defmodule ToweropsWeb.Api.V1.SitesController do @moduledoc """ API controller for managing sites. All endpoints require API token authentication and operations are scoped to the organization associated with the token. """ use ToweropsWeb, :controller alias Towerops.Sites @doc """ GET /api/v1/sites Lists all sites for the authenticated organization. Response: { "sites": [ { "id": "uuid", "name": "Main Office", "location": "New York, NY", "snmp_community": "public", "inserted_at": "2026-01-15T19:44:25Z" } ] } """ def index(conn, _params) do organization_id = conn.assigns.current_organization_id sites = organization_id |> Sites.list_organization_sites() |> Enum.map(&format_site/1) json(conn, %{sites: sites}) end @doc """ POST /api/v1/sites Creates a new site for the authenticated organization. Request body: { "site": { "name": "Main Office", "location": "New York, NY", "snmp_community": "public" # optional } } Response: { "id": "uuid", "name": "Main Office", "location": "New York, NY", "snmp_community": "public", "inserted_at": "2026-01-15T19:44:25Z" } """ def create(conn, %{"site" => site_params}) do organization_id = conn.assigns.current_organization_id attrs = Map.put(site_params, "organization_id", organization_id) case Sites.create_site(attrs) do {:ok, site} -> conn |> put_status(:created) |> json(format_site(site)) {:error, %Ecto.Changeset{} = changeset} -> conn |> put_status(:unprocessable_entity) |> json(%{errors: translate_errors(changeset)}) end end def create(conn, _params) do conn |> put_status(:bad_request) |> json(%{error: "Missing 'site' parameter"}) end @doc """ GET /api/v1/sites/:id Gets a single site by ID. Response: { "id": "uuid", "name": "Main Office", "location": "New York, NY", "snmp_community": "public", "inserted_at": "2026-01-15T19:44:25Z" } """ def show(conn, %{"id" => id}) do organization_id = conn.assigns.current_organization_id site = Sites.get_site!(id) if site.organization_id == organization_id do json(conn, format_site(site)) else conn |> put_status(:forbidden) |> json(%{error: "Access denied to this site"}) end rescue Ecto.NoResultsError -> conn |> put_status(:not_found) |> json(%{error: "Site not found"}) end @doc """ PATCH /api/v1/sites/:id Updates a site. Request body: { "site": { "name": "Updated Name", "location": "Boston, MA" } } Response: { "id": "uuid", "name": "Updated Name", "location": "Boston, MA", "snmp_community": "public", "inserted_at": "2026-01-15T19:44:25Z" } """ def update(conn, %{"id" => id, "site" => site_params}) do organization_id = conn.assigns.current_organization_id site = Sites.get_site!(id) if site.organization_id == organization_id do case Sites.update_site(site, site_params) do {:ok, updated_site} -> json(conn, format_site(updated_site)) {:error, %Ecto.Changeset{} = changeset} -> conn |> put_status(:unprocessable_entity) |> json(%{errors: translate_errors(changeset)}) end else conn |> put_status(:forbidden) |> json(%{error: "Access denied to this site"}) end rescue Ecto.NoResultsError -> conn |> put_status(:not_found) |> json(%{error: "Site not found"}) end def update(conn, _params) do conn |> put_status(:bad_request) |> json(%{error: "Missing 'site' parameter"}) end @doc """ DELETE /api/v1/sites/:id Deletes a site. Response: { "success": true } """ def delete(conn, %{"id" => id}) do organization_id = conn.assigns.current_organization_id site = Sites.get_site!(id) if site.organization_id == organization_id do case Sites.delete_site(site) do {:ok, _site} -> json(conn, %{success: true}) {:error, %Ecto.Changeset{} = changeset} -> conn |> put_status(:unprocessable_entity) |> json(%{errors: translate_errors(changeset)}) end else conn |> put_status(:forbidden) |> json(%{error: "Access denied to this site"}) end rescue Ecto.NoResultsError -> conn |> put_status(:not_found) |> json(%{error: "Site not found"}) end # Private helpers defp format_site(site) do %{ id: site.id, name: site.name, location: site.location, snmp_community: site.snmp_community, inserted_at: site.inserted_at } end defp translate_errors(changeset) do Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> Regex.replace(~r"%{(\w+)}", msg, fn _, key -> safe_translate_key(key, opts) end) end) end # Safely translate error message keys to prevent atom exhaustion defp safe_translate_key(key, opts) do # Validate key length and format before converting to atom to prevent abuse if String.length(key) <= 50 and String.match?(key, ~r/^[a-z_]+$/) do opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() else key end end end