Outage providers: licence-gated registry, ODIN adapter, /outages API, admin tab (Refs #184)

Part 1 of 2 for server-26#184 (map layer + incident cross-match follow in batch 3).

- app/internal/outages/: same gate as the camera registry -- settings doc
  config/outage_providers, every provider OFF by default, unreadable settings
  fail closed, stubs never enable, gated licences need acknowledge_licence
  stored with who/when plus an audit_log write as a precondition, and the
  acknowledgement is bound to the licence status it was given for.
  Reuses cameras.base Licence/LicenceInfo/get_json/safe_error/require_parsed.
- ODIN adapter (Licence.UNVERIFIED, gated): public county-level API verified
  first-hand against https://odin.ornl.gov/v3/api-docs -- /odi?format=JSON
  (counts, start, ETR), /odi/map?scale=COUNTY (county name, bbox, label
  point), /odi/status (utility freshness). Schema canary on all three.
  metersServed is utility-wide, so customers_served stays None.
- Stubs: poweroutage_us (paid), coned_oru / nyseg_rge / central_hudson
  storm centers and eagle_i_live (agreement required).
- GET /outages (signed-in) returns areas + per-provider credit/health and a
  status (ok / none_enabled / no_source / error) so an empty layer is never
  ambiguous. /admin/outage-providers GET + PUT, admin only, 409 with
  requires_acknowledgement like cameras.
- Frontend: c2api getOutages/listOutageProviders/setOutageProvider, types,
  Admin -> Outage providers tab (deep link /admin#outages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-05 02:38:51 -04:00
co-authored by Claude Opus 5.5
parent a75149b2eb
commit ebeabe342e
12 changed files with 4660 additions and 4 deletions
+19 -1
View File
@@ -1,5 +1,7 @@
import { auth } from "@/lib/firebase";
import type { AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, TalkgroupPending } from "@/lib/types";
import type {
AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, OutageProviderAdmin, OutagesResponse, TalkgroupPending,
} from "@/lib/types";
import type { DiscordAdminOverview, DiscordStream, RadioBot, RadioBotAddResult } from "@/lib/discord";
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
@@ -498,6 +500,22 @@ export const c2api = {
body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }),
}),
// Power outages from every admin-enabled provider inside a bbox (server-26#184).
// `status` explains an empty list: none_enabled / no_source / error / ok.
getOutages: (bbox: { south: number; west: number; north: number; east: number }) =>
request<OutagesResponse>(`/outages?${new URLSearchParams({
south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east),
})}`),
// Admin → Outage providers. Same licence gate as cameras: a gated provider
// answers 409 with `requires_acknowledgement` until `acknowledge_licence` is sent.
listOutageProviders: () => request<{ providers: OutageProviderAdmin[] }>("/admin/outage-providers"),
setOutageProvider: (id: string, enabled: boolean, acknowledgeLicence = false) =>
request<OutageProviderAdmin>(`/admin/outage-providers/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }),
}),
// Public waitlist — no auth, see routers/waitlist.py
joinWaitlist: (body: { email: string; org_name?: string; note?: string }) =>
request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }),