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
+65
View File
@@ -217,6 +217,71 @@ export interface CameraProviderAdmin {
health: { fetched_at: number | null; last_attempt_at: number | null; error: string | null; count: number } | null;
}
// Power outages (server-26#184) — GET /outages, cached server-side, not Firestore.
/**
* One county with active outages from an enabled provider. `id` is
* provider-prefixed ("odin:36067"). Counts are utility-reported meters, used
* as customers. `lat`/`lon` is the centre of the county's bounding box — a
* label point, not an outage location. Times are ISO-8601 UTC.
*/
export interface OutageArea {
id: string;
provider: string;
fips: string;
county: string | null;
state: string | null;
customers_out: number;
customers_served: number | null; // null: no county-level denominator from the source
etr: string | null; // latest estimated restoration across the county's outages
since: string | null; // earliest reported start
updated_at: string | null; // newest time a reporting utility here last sent data
lat: number | null;
lon: number | null;
bbox: [number, number, number, number] | null; // south, west, north, east
utilities: { name: string | null; eia_id: string | null; customers_out: number }[];
}
export interface OutageProviderFeed extends CameraCredit {
id: string;
name: string;
granularity: string;
coverage_note: string | null;
covers_bbox: boolean;
fetched_at: number | null; // epoch s of the provider's last successful fetch
error: string | null;
total_in_bbox: number;
truncated: boolean;
}
export interface OutagesResponse {
areas: OutageArea[];
providers: OutageProviderFeed[];
serving_count: number;
/** Why the list is what it is — an empty layer is never ambiguous. */
status: "ok" | "none_enabled" | "no_source" | "error";
message: string | null;
}
/** Admin → Outage providers (GET /admin/outage-providers). */
export interface OutageProviderAdmin {
id: string;
name: string;
region: string;
granularity: string;
implemented: boolean;
notes: string;
homepage: string | null;
licence: CameraProviderAdmin["licence"];
enabled: boolean;
serving: boolean;
not_serving_reason: string | null;
acknowledgement: CameraProviderAdmin["acknowledgement"];
updated_at: string | null;
updated_by: { uid: string; email: string } | null;
coverage_note: string | null;
health: CameraProviderAdmin["health"];
}
export interface Ny511Event {
id: string;
lat: number;