defmodule ToweropsWeb.Api.V1.CoveragesController do @moduledoc """ REST API for RF coverage predictions. All endpoints are scoped to the organization the API token belongs to. Designed for Terraform / IaC consumers as well as ad-hoc scripts: stable UUID resource IDs, predictable JSON shape, status field present on every response so polling-based providers can wait for a `:ready` transition before declaring a resource healthy. Authentication: `Authorization: Bearer towerops_`. Mint a token from /settings → API tokens. """ use ToweropsWeb, :controller import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1] alias Towerops.Coverages @doc """ GET /api/v1/coverages Lists every coverage in the authenticated organization. Response: { "coverages": [ { "id": "uuid", "name": "...", ... } ] } """ def index(conn, _params) do org_id = conn.assigns.current_organization_id coverages = Coverages.list_for_organization(org_id) json(conn, %{coverages: Enum.map(coverages, &format/1)}) end @doc """ POST /api/v1/coverages Creates a coverage and immediately enqueues compute. Body: { "coverage": { "name": "North sector 5 GHz", "site_id": "uuid", "antenna_slug": "rf-elements-tp-sh-30", "frequency_ghz": 5.8, "tx_power_dbm": 22.0, "height_agl_ft": 100, "azimuth_deg": 0, "downtilt_deg": 0, "radius_mi": 4 } } """ def create(conn, %{"coverage" => attrs}) do org_id = conn.assigns.current_organization_id case Coverages.create_coverage(org_id, attrs) do {:ok, coverage} -> case Coverages.queue_compute(coverage) do {:ok, queued} -> conn |> put_status(:created) |> json(format(queued)) {:error, _} -> # Coverage exists but compute couldn't be queued; expose # the row so Terraform's read-after-write reconciles. conn |> put_status(:created) |> json(format(coverage)) end {:error, %Ecto.Changeset{} = cs} -> conn |> put_status(:unprocessable_entity) |> json(%{errors: translate_errors(cs)}) end end def create(conn, _), do: bad_request(conn, "Missing 'coverage' parameter") @doc """ GET /api/v1/coverages/:id Returns a single coverage. 404 if it doesn't belong to the authenticated org. """ def show(conn, %{"id" => id}) do org_id = conn.assigns.current_organization_id case Coverages.get_coverage(org_id, id) do nil -> not_found(conn) coverage -> json(conn, format(coverage)) end end @doc """ PATCH /api/v1/coverages/:id Updates editable fields. Does NOT auto-recompute — callers should POST /api/v1/coverages/:id/recompute when they want fresh outputs. """ def update(conn, %{"id" => id, "coverage" => attrs}) do org_id = conn.assigns.current_organization_id case Coverages.get_coverage(org_id, id) do nil -> not_found(conn) coverage -> case Coverages.update_coverage(coverage, attrs) do {:ok, updated} -> json(conn, format(updated)) {:error, %Ecto.Changeset{} = cs} -> conn |> put_status(:unprocessable_entity) |> json(%{errors: translate_errors(cs)}) end end end def update(conn, _), do: bad_request(conn, "Missing 'coverage' parameter") @doc """ DELETE /api/v1/coverages/:id """ def delete(conn, %{"id" => id}) do org_id = conn.assigns.current_organization_id case Coverages.get_coverage(org_id, id) do nil -> not_found(conn) coverage -> case Coverages.delete_coverage(coverage) do {:ok, _} -> send_resp(conn, :no_content, "") {:error, _} -> bad_request(conn, "Could not delete coverage") end end end @doc """ POST /api/v1/coverages/:id/recompute Re-runs the compute pipeline for an already-existing coverage. Use this from Terraform after `terraform apply` if you want a fresh heatmap without changing parameters. """ def recompute(conn, %{"id" => id}) do org_id = conn.assigns.current_organization_id case Coverages.get_coverage(org_id, id) do nil -> not_found(conn) coverage -> case Coverages.queue_compute(coverage) do {:ok, queued} -> conn |> put_status(:accepted) |> json(format(queued)) {:error, :compute_in_progress} -> conn |> put_status(:conflict) |> json(%{error: "Coverage is already computing"}) {:error, _} -> bad_request(conn, "Could not queue compute") end end end @doc """ GET /api/v1/coverages/:id/kmz Returns the coverage heatmap as a KMZ (KML + PNG) bundle ready to drop into Google Earth or any compatible viewer. """ def kmz(conn, %{"id" => id}) do org_id = conn.assigns.current_organization_id with %{} = coverage <- Coverages.get_coverage(org_id, id), {:ok, kmz_bytes, filename} <- build_kmz(coverage) do conn |> put_resp_content_type("application/vnd.google-earth.kmz") |> put_resp_header("content-disposition", ~s|attachment; filename="#{filename}"|) |> send_resp(200, kmz_bytes) else nil -> not_found(conn) {:error, :not_ready} -> conn |> put_status(:conflict) |> json(%{error: "Coverage is not ready yet"}) {:error, reason} -> require Logger # Log the underlying reason server-side so we can debug, but don't # leak filesystem paths / zip errors / IO details to the caller. Logger.error("Failed to build KMZ for coverage: #{inspect(reason)}") conn |> put_status(:internal_server_error) |> json(%{error: "Failed to build KMZ"}) end end defp build_kmz(%{status: status}) when status != "ready", do: {:error, :not_ready} defp build_kmz(%{png_path: nil}), do: {:error, :not_ready} defp build_kmz(coverage) do static = Application.app_dir(:towerops, "priv/static") abs_png = Path.join(static, Path.relative_to(coverage.png_path, "/")) with true <- File.exists?(abs_png), {:ok, png_bytes} <- File.read(abs_png) do kml = build_kml(coverage) filename = "coverage-#{coverage.id}.kmz" # Build a flat KMZ — a ZIP containing doc.kml + the PNG. ~c"in_memory.kmz" |> :zip.create( [ {~c"doc.kml", kml}, {~c"overlay.png", png_bytes} ], [:memory] ) |> case do {:ok, {_, bin}} -> {:ok, bin, filename} {:error, reason} -> {:error, reason} end else false -> {:error, :png_missing} {:error, reason} -> {:error, reason} end end defp build_kml(c) do """ #{escape(c.name)} Towerops RF coverage prediction #{escape(c.name)} (RSSI) overlay.png #{c.bbox_max_lat} #{c.bbox_min_lat} #{c.bbox_max_lon} #{c.bbox_min_lon} """ end defp escape(nil), do: "" defp escape(s) when is_binary(s) do s |> String.replace("&", "&") |> String.replace("<", "<") |> String.replace(">", ">") end defp format(c) do %{ id: c.id, name: c.name, organization_id: c.organization_id, site_id: c.site_id, device_id: c.device_id, antenna_slug: c.antenna_slug, frequency_mhz: c.frequency_mhz, tx_power_dbm: c.tx_power_dbm, cable_loss_db: c.cable_loss_db, sm_gain_dbi: c.sm_gain_dbi, height_agl_m: c.height_agl_m, height_above_rooftop_m: c.height_above_rooftop_m, azimuth_deg: c.azimuth_deg, downtilt_deg: c.downtilt_deg, radius_m: c.radius_m, cell_size_m: c.cell_size_m, receiver_height_m: c.receiver_height_m, rx_threshold_dbm: c.rx_threshold_dbm, foliage_tuning: c.foliage_tuning, latitude_override: c.latitude_override, longitude_override: c.longitude_override, status: c.status, progress_pct: c.progress_pct, error_message: c.error_message, computed_at: c.computed_at, png_path: c.png_path, raster_path: c.raster_path, height_tiers: c.height_tiers, nlos_tiers: c.nlos_tiers, bbox: %{ min_lat: c.bbox_min_lat, max_lat: c.bbox_max_lat, min_lon: c.bbox_min_lon, max_lon: c.bbox_max_lon }, inserted_at: c.inserted_at, updated_at: c.updated_at } end defp not_found(conn) do conn |> put_status(:not_found) |> json(%{error: "Coverage not found"}) end defp bad_request(conn, msg) do conn |> put_status(:bad_request) |> json(%{error: msg}) end end