towerops/lib/towerops_web/controllers/api/v1/coverages_controller.ex
2026-06-14 08:27:57 -05:00

330 lines
9 KiB
Elixir

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_<token>`. Mint a
token from /settings → API tokens.
"""
use ToweropsWeb, :controller
import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1]
alias Towerops.Coverages
require Logger
@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} ->
# 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
"""
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>#{escape(c.name)}</name>
<description>Towerops RF coverage prediction</description>
<GroundOverlay>
<name>#{escape(c.name)} (RSSI)</name>
<Icon><href>overlay.png</href></Icon>
<LatLonBox>
<north>#{c.bbox_max_lat}</north>
<south>#{c.bbox_min_lat}</south>
<east>#{c.bbox_max_lon}</east>
<west>#{c.bbox_min_lon}</west>
</LatLonBox>
</GroundOverlay>
</Document>
</kml>
"""
end
defp escape(nil), do: ""
defp escape(s) when is_binary(s) do
s
|> String.replace("&", "&amp;")
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
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