feat(coverage): public REST API + KMZ export + email-on-complete

cnHeat-2.0-style automation hooks. Coverage prediction is now a
first-class API resource that Terraform / Ansible / cron can drive.

REST API (under /api/v1, Bearer-token auth scoped to one org)
- GET    /coverages              list
- POST   /coverages               create + auto-queue compute
- GET    /coverages/:id           read (includes status / progress_pct
                                  for polling-based providers)
- PATCH  /coverages/:id           update editable fields
- DELETE /coverages/:id           delete + cleanup
- POST   /coverages/:id/recompute requeue, returns 202
- GET    /coverages/:id/kmz       Google Earth bundle (KML + PNG)

Each response includes the full resource — Terraform read-after-write
reconciles cleanly; status polling hits a stable JSON shape.

Email-on-complete (Towerops.Coverages.Notifier)
- Worker fires deliver_compute_complete on both :ready and :failed
  paths. Sends a short text email to every member of the owning
  organisation with a link to the show page (or the failure
  reason). Mirrors cnHeat 2.0's per-project email alerts.
- Failures are non-fatal: a stuck SMTP relay never blocks the
  underlying coverage record from transitioning state.

KMZ export
- Builds a flat KMZ in-memory via :zip.create — doc.kml +
  overlay.png, with the LatLonBox set from the coverage's bbox.
  XML special chars are escaped. 409 if not yet ready.

Tests (11 new, all green)
- Bearer-token auth happy path + 401 missing
- index / show / create / update / delete + 404 cross-org
- create returns status:'queued' confirming the auto-enqueue
- recompute returns 202 with status:'queued'

Plus docs/terraform-coverages.md showing how to drive the new API
from a third-party restapi provider until the first-party
terraform-provider-towerops gets a coverage resource.
This commit is contained in:
Graham McIntire 2026-05-06 17:15:57 -05:00
parent 6e4b07026f
commit edb82bcc40
6 changed files with 680 additions and 0 deletions

104
docs/terraform-coverages.md Normal file
View file

@ -0,0 +1,104 @@
# Coverages over the Towerops API (Terraform-friendly)
Towerops exposes a resourceful REST API for RF coverage predictions
that's stable enough to drive from Terraform, Ansible, or any
infrastructure-as-code tool. All endpoints live under `/api/v1/` and
use Bearer-token auth scoped to a single organization.
## Authentication
Mint a token from **Settings → API tokens**. Send it as:
```
Authorization: Bearer towerops_<token>
```
The token is org-scoped — every endpoint reads/writes inside that
org only.
## Resources
| Method | Path | Purpose |
|---------|---------------------------------------|---------|
| GET | `/api/v1/coverages` | List all coverages in the org. |
| POST | `/api/v1/coverages` | Create a coverage. The compute pipeline is queued automatically. |
| GET | `/api/v1/coverages/:id` | Read a single coverage including current `status` / `progress_pct`. |
| PATCH | `/api/v1/coverages/:id` | Update editable fields (does not auto-recompute). |
| DELETE | `/api/v1/coverages/:id` | Delete a coverage and its on-disk rasters. |
| POST | `/api/v1/coverages/:id/recompute` | Re-run the compute pipeline. Returns `202` with `status: "queued"`. |
| GET | `/api/v1/coverages/:id/kmz` | Download a KMZ overlay for Google Earth (returns `409` until `status: "ready"`). |
## Create payload
```json
{
"coverage": {
"name": "North sector 5 GHz",
"site_id": "1f4...uuid",
"antenna_slug": "rf-elements-twistport-symmetrical-horn-30",
"frequency_ghz": 5.8,
"tx_power_dbm": 22.0,
"height_agl_ft": 100,
"azimuth_deg": 0,
"downtilt_deg": 0,
"radius_mi": 4
}
}
```
Imperial inputs are accepted via the virtual fields shown above; the
schema converts them to SI before validation.
## Polling for `:ready`
Compute is asynchronous. After `POST` you'll receive `status:
"queued"`; the worker transitions through `"computing"` to
`"ready"` (or `"failed"`). Terraform providers can poll with
`GET /api/v1/coverages/:id` until `status == "ready"` before
declaring the resource healthy.
## Terraform example
A Terraform provider for Towerops isn't published yet, but a
hand-rolled `restapi` provider works today:
```hcl
terraform {
required_providers {
restapi = { source = "Mastercard/restapi", version = "~> 1.18" }
}
}
provider "restapi" {
uri = "https://app.towerops.com"
write_returns_object = true
headers = {
Authorization = "Bearer ${var.towerops_token}"
Content-Type = "application/json"
}
}
resource "restapi_object" "north_sector" {
path = "/api/v1/coverages"
id_attribute = "id"
data = jsonencode({
coverage = {
name = "North sector 5 GHz"
site_id = var.site_id
antenna_slug = "rf-elements-twistport-symmetrical-horn-30"
frequency_ghz = 5.8
tx_power_dbm = 22.0
height_agl_ft = 100
azimuth_deg = 0
downtilt_deg = 0
radius_mi = 4
}
})
}
```
`terraform apply` creates the coverage; `terraform destroy` deletes
it. Updates flow through `PATCH`. Use `terraform_data` + a
`provisioner` if you need to wait on `status = "ready"` before
proceeding.

View file

@ -0,0 +1,114 @@
defmodule Towerops.Coverages.Notifier do
@moduledoc """
Email notifications for coverage compute lifecycle events.
When the worker finalizes a coverage (`:ready` or `:failed`) it
asks this module to fan out a short email to every member of the
owning organization. Mirrors the cnHeat 2.0 behaviour where each
user subscribed to a project receives an email when a prediction
finishes.
Failures here are non-fatal a stuck SMTP relay must never block
the underlying coverage record from transitioning state. Errors
log and continue.
"""
use Gettext, backend: ToweropsWeb.Gettext
import Swoosh.Email
import ToweropsWeb.GettextHelpers
alias Towerops.Coverages.Coverage
alias Towerops.Mailer
alias Towerops.Organizations
require Logger
@doc """
Sends a compute-complete email to every member of the coverage's
organization. `status` is `:ready` or `:failed`.
"""
@spec deliver_compute_complete(Coverage.t(), :ready | :failed) :: :ok
def deliver_compute_complete(%Coverage{} = coverage, status) when status in [:ready, :failed] do
members = Organizations.list_organization_members(coverage.organization_id)
Enum.each(members, fn member ->
_ = deliver_to(member, coverage, status)
end)
:ok
end
defp deliver_to(member, coverage, status) do
user = member.user
if is_nil(user) or is_nil(user.email) do
:ok
else
from_address = Application.get_env(:towerops, :mailer_from, {"Towerops", "hi@towerops.net"})
subject = subject_for(status, coverage)
body = body_for(status, user, coverage)
email =
new()
|> to(user.email)
|> from(from_address)
|> subject(subject)
|> text_body(body)
case Mailer.deliver(email) do
{:ok, _meta} ->
:ok
{:error, reason} ->
Logger.error(
"Coverages.Notifier: failed to send #{status} email " <>
"to #{user.email} for coverage #{coverage.id}: #{inspect(reason)}"
)
:ok
end
end
end
defp subject_for(:ready, coverage), do: t_email("Coverage prediction ready: %{name}", name: coverage.name)
defp subject_for(:failed, coverage), do: t_email("Coverage prediction failed: %{name}", name: coverage.name)
defp body_for(:ready, user, coverage) do
t_email(
"""
Hi %{name},
Coverage "%{coverage}" finished computing. View it here:
%{url}
Towerops
""",
name: user.first_name || user.email,
coverage: coverage.name,
url: ToweropsWeb.Endpoint.url() <> "/coverage/#{coverage.id}"
)
end
defp body_for(:failed, user, coverage) do
t_email(
"""
Hi %{name},
Coverage "%{coverage}" failed to compute.
Reason: %{reason}
View / retry here: %{url}
Towerops
""",
name: user.first_name || user.email,
coverage: coverage.name,
reason: coverage.error_message || "(no message)",
url: ToweropsWeb.Endpoint.url() <> "/coverage/#{coverage.id}"
)
end
end

View file

@ -33,6 +33,7 @@ defmodule Towerops.Workers.CoverageWorker do
alias Towerops.Coverages.Antenna
alias Towerops.Coverages.Buildings
alias Towerops.Coverages.Coverage
alias Towerops.Coverages.Notifier
alias Towerops.Coverages.Profile
alias Towerops.Coverages.Propagation
alias Towerops.Coverages.Raster
@ -392,6 +393,7 @@ defmodule Towerops.Workers.CoverageWorker do
})
Coverages.broadcast(updated, {:coverage_status, :ready, 100})
_ = Notifier.deliver_compute_complete(updated, :ready)
:ok
end
@ -412,6 +414,7 @@ defmodule Towerops.Workers.CoverageWorker do
})
Coverages.broadcast(updated, {:coverage_status, :failed, message})
_ = Notifier.deliver_compute_complete(updated, :failed)
# Don't let Oban retry — the failure is on the coverage record.
:ok

View file

@ -0,0 +1,324 @@
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
@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} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: "Failed to build KMZ: #{inspect(reason)}"})
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

View file

@ -191,6 +191,11 @@ defmodule ToweropsWeb.Router do
resources "/integrations", IntegrationsController, except: [:new, :edit]
post "/integrations/:id/test", IntegrationsController, :test_connection
# RF coverage prediction (Terraform-friendly resourceful CRUD).
resources "/coverages", CoveragesController, except: [:new, :edit]
post "/coverages/:id/recompute", CoveragesController, :recompute
get "/coverages/:id/kmz", CoveragesController, :kmz
get "/devices/:device_id/checks", CheckResultsController, :checks
get "/devices/:device_id/metrics", CheckResultsController, :metrics
get "/devices/:device_id/interfaces", CheckResultsController, :interfaces

View file

@ -0,0 +1,130 @@
defmodule ToweropsWeb.Api.V1.CoveragesControllerTest do
use ToweropsWeb.ConnCase, async: false
import Towerops.AccountsFixtures
import Towerops.CoveragesFixtures
import Towerops.OrganizationsFixtures
alias Towerops.ApiTokens
setup %{conn: conn} do
user = user_fixture()
org = organization_fixture(user.id)
site = site_fixture(org.id, %{latitude: 30.27, longitude: -97.74})
{:ok, {_token, raw}} =
ApiTokens.create_api_token(%{
organization_id: org.id,
user_id: user.id,
name: "test"
})
conn = put_req_header(conn, "authorization", "Bearer #{raw}")
%{conn: conn, org: org, user: user, site: site}
end
describe "GET /api/v1/coverages" do
test "returns coverages for the authenticated organization", %{conn: conn, org: org, site: site} do
cov = coverage_fixture(org.id, site.id, %{name: "Sector A"})
resp = conn |> get(~p"/api/v1/coverages") |> json_response(200)
assert [%{"id" => id, "name" => "Sector A"}] = resp["coverages"]
assert id == cov.id
end
test "returns empty list when org has no coverages", %{conn: conn} do
assert %{"coverages" => []} = conn |> get(~p"/api/v1/coverages") |> json_response(200)
end
end
describe "POST /api/v1/coverages" do
test "creates and queues a coverage", %{conn: conn, site: site} do
attrs = %{
"name" => "API-created",
"site_id" => site.id,
"antenna_slug" => "simulate-isotropic-omni-0",
"frequency_ghz" => 5.8,
"tx_power_dbm" => 20.0,
"height_agl_ft" => 80,
"azimuth_deg" => 0,
"downtilt_deg" => 0,
"radius_mi" => 3
}
resp = conn |> post(~p"/api/v1/coverages", coverage: attrs) |> json_response(201)
assert resp["name"] == "API-created"
assert resp["status"] == "queued"
assert resp["site_id"] == site.id
assert is_binary(resp["id"])
end
test "returns 422 with field errors on bad input", %{conn: conn} do
resp =
conn
|> post(~p"/api/v1/coverages", coverage: %{"name" => ""})
|> json_response(422)
assert resp["errors"]
end
test "returns 400 when 'coverage' wrapper is missing", %{conn: conn} do
resp = conn |> post(~p"/api/v1/coverages", %{}) |> json_response(400)
assert resp["error"] =~ "Missing"
end
end
describe "GET /api/v1/coverages/:id" do
test "returns a single coverage", %{conn: conn, org: org, site: site} do
cov = coverage_fixture(org.id, site.id)
resp = conn |> get(~p"/api/v1/coverages/#{cov.id}") |> json_response(200)
assert resp["id"] == cov.id
end
test "returns 404 for cross-org coverage", %{conn: conn} do
assert conn
|> get(~p"/api/v1/coverages/#{Ecto.UUID.generate()}")
|> json_response(404)
end
end
describe "PATCH /api/v1/coverages/:id" do
test "updates editable fields", %{conn: conn, org: org, site: site} do
cov = coverage_fixture(org.id, site.id, %{name: "before"})
resp =
conn
|> patch(~p"/api/v1/coverages/#{cov.id}", coverage: %{"name" => "after"})
|> json_response(200)
assert resp["name"] == "after"
end
end
describe "DELETE /api/v1/coverages/:id" do
test "deletes the coverage", %{conn: conn, org: org, site: site} do
cov = coverage_fixture(org.id, site.id)
conn = delete(conn, ~p"/api/v1/coverages/#{cov.id}")
assert response(conn, 204)
assert nil == Towerops.Coverages.get_coverage(org.id, cov.id)
end
end
describe "POST /api/v1/coverages/:id/recompute" do
test "queues a recompute and returns 202", %{conn: conn, org: org, site: site} do
cov = coverage_fixture(org.id, site.id, %{status: "ready"})
resp = conn |> post(~p"/api/v1/coverages/#{cov.id}/recompute") |> json_response(202)
assert resp["status"] == "queued"
end
end
describe "auth" do
test "rejects requests without a Bearer token" do
conn = get(build_conn(), ~p"/api/v1/coverages")
assert response(conn, 401)
end
end
end