Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90a0412066 | ||
|
|
c3fe2a3466 | ||
|
|
1a563c995c | ||
|
|
427d2a9f37 | ||
|
|
83416fe169 | ||
|
|
1b4ed0d09c | ||
|
|
4842725a03 | ||
|
|
2a1d52b7af | ||
|
|
c7f985df42 | ||
|
|
a3681ea698 | ||
|
|
74faa55396 | ||
|
|
c09cb72f66 | ||
|
|
94ce9d48e2 | ||
|
|
7b5258cfdf | ||
|
|
0bd92269d2 | ||
|
|
96625fabd0 | ||
|
|
53965e1a19 | ||
|
|
6d5eb4c5f2 | ||
|
|
97013e1505 | ||
|
|
a2cd2c57ca | ||
|
|
a195563da6 | ||
|
|
55cd1110df | ||
|
|
a0a414ad21 | ||
|
|
518ac46929 | ||
|
|
052dda0b1f | ||
|
|
ee633cbe46 | ||
|
|
1f5f1fede8 | ||
|
|
12c9ad73bb | ||
|
|
971ab74d44 | ||
|
|
6140dd7b9c | ||
|
|
2e3fde2448 | ||
|
|
c42bd1902c | ||
|
|
c6684ea61b | ||
|
|
fa5f91c0fa |
+14
-5
@@ -7,11 +7,20 @@
|
|||||||
# password file. Use different values in production — do NOT reuse defaults.
|
# password file. Use different values in production — do NOT reuse defaults.
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
# C2-core service account (full broker access)
|
# C2-core service account (full broker access via the "c2core" dynsec role)
|
||||||
MQTT_C2_USER=drb-c2-core
|
MQTT_C2_USER=drb-c2-core
|
||||||
MQTT_C2_PASS=change-me-c2
|
MQTT_C2_PASS=change-me-c2
|
||||||
|
|
||||||
# Shared credential for all edge nodes (ACL scopes each node to its own
|
# Seeds mosquitto's built-in dynamic-security plugin's one-time "admin"
|
||||||
# nodes/<NODE_ID>/# namespace via the MQTT client ID)
|
# bootstrap client on first boot (read directly by mosquitto, no entrypoint
|
||||||
MQTT_NODE_USER=drb-node
|
# scripting involved). Must be >=12 chars. c2-core needs this SAME value as
|
||||||
MQTT_NODE_PASS=change-me-node
|
# MQTT_DYNSEC_ADMIN_PASS in drb-c2-core/.env to log in as "admin" and
|
||||||
|
# administer node credentials — see app/internal/dynsec.py.
|
||||||
|
MOSQUITTO_DYNSEC_PASSWORD=change-me-dynsec-admin-min-12-chars
|
||||||
|
|
||||||
|
# There is no shared node credential anymore. Each node authenticates as
|
||||||
|
# username=<node_id>, password=<its node_keys.api_key> — checked by
|
||||||
|
# mosquitto's dynamic-security plugin (not an HTTP backend — that was an
|
||||||
|
# earlier, since-rejected design using the now-archived mosquitto-go-auth).
|
||||||
|
# Nodes obtain that key via the enrollment flow — see ENROLLMENT_TOKEN in
|
||||||
|
# drb-c2-core/.env.example.
|
||||||
|
|||||||
@@ -52,6 +52,15 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/frontend:latest
|
${{ env.REGISTRY }}/frontend:latest
|
||||||
${{ env.REGISTRY }}/frontend:${{ gitea.sha }}
|
${{ env.REGISTRY }}/frontend:${{ gitea.sha }}
|
||||||
|
build-args: |
|
||||||
|
NEXT_PUBLIC_C2_URL=https://api.${{ secrets.DRB_DOMAIN }}
|
||||||
|
NEXT_PUBLIC_FIREBASE_API_KEY=${{ secrets.FIREBASE_API_KEY }}
|
||||||
|
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=${{ secrets.FIREBASE_AUTH_DOMAIN }}
|
||||||
|
NEXT_PUBLIC_FIREBASE_PROJECT_ID=${{ secrets.FIREBASE_PROJECT_ID }}
|
||||||
|
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=${{ secrets.FIREBASE_STORAGE_BUCKET }}
|
||||||
|
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${{ secrets.FIREBASE_MESSAGING_SENDER_ID }}
|
||||||
|
NEXT_PUBLIC_FIREBASE_APP_ID=${{ secrets.FIREBASE_APP_ID }}
|
||||||
|
NEXT_PUBLIC_FIRESTORE_DATABASE=${{ secrets.FIRESTORE_DATABASE }}
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy to VM
|
name: Deploy to VM
|
||||||
@@ -59,15 +68,21 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Check runner outbound IP
|
||||||
|
run: curl -s ifconfig.me
|
||||||
|
|
||||||
- name: Write SSH key
|
- name: Write SSH key
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > /tmp/deploy_key
|
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > /tmp/deploy_key
|
||||||
chmod 600 /tmp/deploy_key
|
chmod 600 /tmp/deploy_key
|
||||||
|
ssh-keygen -l -f /tmp/deploy_key
|
||||||
|
|
||||||
- name: Deploy
|
- name: Deploy
|
||||||
run: |
|
run: |
|
||||||
ssh -o StrictHostKeyChecking=no \
|
ssh -o StrictHostKeyChecking=no \
|
||||||
-o HostKeyAlgorithms=ssh-ed25519,rsa-sha2-256,rsa-sha2-512 \
|
-o HostKeyAlgorithms=ssh-ed25519,rsa-sha2-256,rsa-sha2-512 \
|
||||||
|
-o ConnectTimeout=15 \
|
||||||
|
-v \
|
||||||
-i /tmp/deploy_key \
|
-i /tmp/deploy_key \
|
||||||
drb@${{ secrets.SERVER_IP }} << 'ENDSSH'
|
drb@${{ secrets.SERVER_IP }} << 'ENDSSH'
|
||||||
set -e
|
set -e
|
||||||
|
|||||||
+4
-1
@@ -15,7 +15,10 @@ infra/terraform.tfvars
|
|||||||
infra/tf.log
|
infra/tf.log
|
||||||
infra/ansible/inventory.ini
|
infra/ansible/inventory.ini
|
||||||
infra/ansible/group_vars/all.yml
|
infra/ansible/group_vars/all.yml
|
||||||
infra/ansible/vault.yml
|
# Glob, not the bare filename: ansible-vault edit and manual backups leave
|
||||||
|
# siblings like vault.yml.locked.bak, which the exact-name rule left untracked
|
||||||
|
# but committable.
|
||||||
|
infra/ansible/vault.yml*
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
+26
-3
@@ -8,13 +8,36 @@
|
|||||||
# - restart: always (instead of unless-stopped) for hard reboots.
|
# - restart: always (instead of unless-stopped) for hard reboots.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
# ports AND volumes both need !override here, not !reset/a plain list —
|
||||||
|
# compose merges list-type fields by APPENDING across -f files. A plain
|
||||||
|
# list (or !reset on volumes) would leave dev's mosquitto_certs named
|
||||||
|
# volume mounted at /mosquitto/certs alongside this bind mount, and two
|
||||||
|
# mounts targeting the same path is exactly the "address already in use"-
|
||||||
|
# style footgun the c2-core override below already hit once with ports.
|
||||||
|
# mosquitto-data is now a host bind mount too (not just certs) — it holds
|
||||||
|
# dynamic-security.json, the broker's only record of node credentials
|
||||||
|
# (see app/internal/dynsec.py "TWO-SOURCES-OF-TRUTH"). A named Docker
|
||||||
|
# volume already survives normal redeploys (git pull && compose pull &&
|
||||||
|
# up -d never passes -v), but the bind mount makes it inspectable/
|
||||||
|
# backupable the same way the cert directory already is. NOT read-only —
|
||||||
|
# mosquitto writes dynamic-security.json here.
|
||||||
mosquitto:
|
mosquitto:
|
||||||
restart: always
|
restart: always
|
||||||
ports: !reset [] # Remove the dev 1883:1883 mapping — internal only
|
ports: !override
|
||||||
|
- "8883:8883" # TLS only, published. 1883 stays internal (docker bridge, c2-core's own login).
|
||||||
|
volumes: !override
|
||||||
|
- ./drb-c2-core/mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
|
||||||
|
- /opt/drb/mosquitto-data:/mosquitto/data
|
||||||
|
- /opt/drb/mosquitto-certs:/mosquitto/certs:ro # fed by the cert-sync systemd unit, see infra/ansible
|
||||||
|
|
||||||
|
# !override, not a plain list: compose MERGES `ports` by appending, so a plain
|
||||||
|
# list leaves the base file's "8888:8000" in place alongside this one. The
|
||||||
|
# container then tries to bind 8888 twice — 0.0.0.0 and 127.0.0.1 — and the
|
||||||
|
# second bind fails with "address already in use". It also silently defeated
|
||||||
|
# the whole point of this override, publishing the port on every interface.
|
||||||
c2-core:
|
c2-core:
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports: !override
|
||||||
- "127.0.0.1:8888:8000" # Caddy proxies, not exposed publicly
|
- "127.0.0.1:8888:8000" # Caddy proxies, not exposed publicly
|
||||||
|
|
||||||
discord-bot:
|
discord-bot:
|
||||||
@@ -22,5 +45,5 @@ services:
|
|||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports: !override
|
||||||
- "127.0.0.1:3000:3000" # Caddy proxies, not exposed publicly
|
- "127.0.0.1:3000:3000" # Caddy proxies, not exposed publicly
|
||||||
|
|||||||
+18
-8
@@ -1,20 +1,23 @@
|
|||||||
services:
|
services:
|
||||||
|
# Auth is mosquitto's own built-in dynamic-security plugin (see
|
||||||
|
# mosquitto.conf + app/internal/dynsec.py) — NOT mosquitto-go-auth, that
|
||||||
|
# project is archived upstream (no CVE patches), rejected for a
|
||||||
|
# public-internet broker. Stock official image, pinned to an exact patch
|
||||||
|
# (not the floating `:2` tag). MOSQUITTO_DYNSEC_PASSWORD seeds the
|
||||||
|
# plugin's own one-time "admin" bootstrap client on first boot — read
|
||||||
|
# directly by the plugin's C code, no entrypoint scripting needed for it.
|
||||||
mosquitto:
|
mosquitto:
|
||||||
image: eclipse-mosquitto:2
|
image: eclipse-mosquitto:2.1.2-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "1883:1883"
|
- "1883:1883"
|
||||||
entrypoint: ["/bin/sh", "/mosquitto/config/entrypoint.sh"]
|
- "8883:8883"
|
||||||
environment:
|
environment:
|
||||||
- MQTT_C2_USER=${MQTT_C2_USER}
|
- MOSQUITTO_DYNSEC_PASSWORD=${MOSQUITTO_DYNSEC_PASSWORD}
|
||||||
- MQTT_C2_PASS=${MQTT_C2_PASS}
|
|
||||||
- MQTT_NODE_USER=${MQTT_NODE_USER}
|
|
||||||
- MQTT_NODE_PASS=${MQTT_NODE_PASS}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./drb-c2-core/mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
|
- ./drb-c2-core/mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
|
||||||
- ./drb-c2-core/mosquitto/acl.conf:/mosquitto/config/acl.conf:ro
|
|
||||||
- ./drb-c2-core/mosquitto/entrypoint.sh:/mosquitto/config/entrypoint.sh:ro
|
|
||||||
- mosquitto_data:/mosquitto/data
|
- mosquitto_data:/mosquitto/data
|
||||||
|
- mosquitto_certs:/mosquitto/certs
|
||||||
|
|
||||||
c2-core:
|
c2-core:
|
||||||
image: ${REGISTRY}/c2-core:${TAG:-latest}
|
image: ${REGISTRY}/c2-core:${TAG:-latest}
|
||||||
@@ -45,4 +48,11 @@ services:
|
|||||||
- c2-core
|
- c2-core
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
# Dev only for both. Prod overrides these to host bind mounts
|
||||||
|
# (/opt/drb/mosquitto-data, /opt/drb/mosquitto-certs — the latter fed by
|
||||||
|
# the Caddy cert-sync systemd unit) — see docker-compose.prod.yml and
|
||||||
|
# infra/ansible/roles/deploy/templates/. mosquitto_data holds
|
||||||
|
# dynamic-security.json (node MQTT credentials, see app/internal/dynsec.py)
|
||||||
|
# as well as the usual broker persistence state.
|
||||||
mosquitto_data:
|
mosquitto_data:
|
||||||
|
mosquitto_certs:
|
||||||
|
|||||||
@@ -2,10 +2,15 @@
|
|||||||
MQTT_BROKER=mosquitto
|
MQTT_BROKER=mosquitto
|
||||||
MQTT_PORT=1883
|
MQTT_PORT=1883
|
||||||
# Use the c2-core credential — must match MQTT_C2_USER/MQTT_C2_PASS in the
|
# Use the c2-core credential — must match MQTT_C2_USER/MQTT_C2_PASS in the
|
||||||
# top-level .env (which is passed to the mosquitto entrypoint)
|
# top-level .env
|
||||||
MQTT_USER=drb-c2-core
|
MQTT_USER=drb-c2-core
|
||||||
MQTT_PASS=change-me-c2
|
MQTT_PASS=change-me-c2
|
||||||
|
|
||||||
|
# Same value as the top-level .env's MOSQUITTO_DYNSEC_PASSWORD — lets
|
||||||
|
# c2-core log in as mosquitto's built-in dynsec "admin" client to
|
||||||
|
# administer node MQTT credentials. See app/internal/dynsec.py.
|
||||||
|
MQTT_DYNSEC_ADMIN_PASS=change-me-dynsec-admin-min-12-chars
|
||||||
|
|
||||||
# GCP — path to service account JSON inside the container
|
# GCP — path to service account JSON inside the container
|
||||||
GCP_CREDENTIALS_PATH=/app/gcp-key.json
|
GCP_CREDENTIALS_PATH=/app/gcp-key.json
|
||||||
|
|
||||||
@@ -28,6 +33,7 @@ SUMMARY_INTERVAL_MINUTES=15
|
|||||||
CORRELATION_WINDOW_HOURS=4
|
CORRELATION_WINDOW_HOURS=4
|
||||||
EMBEDDING_SIMILARITY_THRESHOLD=0.82
|
EMBEDDING_SIMILARITY_THRESHOLD=0.82
|
||||||
|
|
||||||
# Auth — static key that edge nodes send as Bearer token on /upload
|
# Fleet-wide token edge nodes present as X-Enrollment-Token on first boot
|
||||||
|
# (POST /nodes/enroll). Shared across every node — NOT a per-node secret.
|
||||||
# Generate with: openssl rand -hex 32
|
# Generate with: openssl rand -hex 32
|
||||||
NODE_API_KEY=
|
ENROLLMENT_TOKEN=
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ class Settings(BaseSettings):
|
|||||||
mqtt_user: Optional[str] = None
|
mqtt_user: Optional[str] = None
|
||||||
mqtt_pass: Optional[str] = None
|
mqtt_pass: Optional[str] = None
|
||||||
|
|
||||||
|
# mosquitto's built-in dynamic-security plugin (see app/internal/dynsec.py).
|
||||||
|
# "admin" is hardcoded by the plugin itself on first boot — not actually
|
||||||
|
# configurable — kept as a named setting rather than a literal for
|
||||||
|
# readability. mqtt_dynsec_admin_pass must equal the mosquitto
|
||||||
|
# container's own MOSQUITTO_DYNSEC_PASSWORD env var (root .env /
|
||||||
|
# root.env.j2) or c2-core can't administer node credentials at all.
|
||||||
|
mqtt_dynsec_admin_user: str = "admin"
|
||||||
|
mqtt_dynsec_admin_pass: Optional[str] = None
|
||||||
|
|
||||||
# GCP
|
# GCP
|
||||||
gcp_credentials_path: Optional[str] = None # None → uses ADC
|
gcp_credentials_path: Optional[str] = None # None → uses ADC
|
||||||
gcs_bucket: Optional[str] = None # None → audio upload disabled
|
gcs_bucket: Optional[str] = None # None → audio upload disabled
|
||||||
@@ -29,8 +38,14 @@ class Settings(BaseSettings):
|
|||||||
# Correlation consensus models
|
# Correlation consensus models
|
||||||
# corr_cheap_model — first-pass LLM correlator (runs on every call)
|
# corr_cheap_model — first-pass LLM correlator (runs on every call)
|
||||||
# corr_smart_model — tiebreaker (only fires when rules and cheap LLM disagree)
|
# corr_smart_model — tiebreaker (only fires when rules and cheap LLM disagree)
|
||||||
corr_cheap_model: str = "gemini-2.0-flash"
|
# Both IDs below were retired by Google and returned 404 on every call from
|
||||||
corr_smart_model: str = "gemini-1.5-pro"
|
# some point before 2026-08-18 until they were corrected. Because a failed
|
||||||
|
# LLM call falls back to the rules decision, nothing broke loudly -- the
|
||||||
|
# entire LLM tier and the consensus tiebreak were simply dead in production
|
||||||
|
# while correlation behaviour was being tuned against rules-only output.
|
||||||
|
# Verify against https://ai.google.dev/gemini-api/docs/models before changing.
|
||||||
|
corr_cheap_model: str = "gemini-3.6-flash" # was gemini-2.0-flash (shut down)
|
||||||
|
corr_smart_model: str = "gemini-2.5-pro" # was gemini-1.5-pro (shut down)
|
||||||
summary_interval_minutes: int = 2 # how often the summary loop runs
|
summary_interval_minutes: int = 2 # how often the summary loop runs
|
||||||
correlation_window_hours: int = 2 # slow/location path: max hours since last call
|
correlation_window_hours: int = 2 # slow/location path: max hours since last call
|
||||||
embedding_similarity_threshold: float = 0.93 # slow-path: requires location corroboration
|
embedding_similarity_threshold: float = 0.93 # slow-path: requires location corroboration
|
||||||
@@ -42,7 +57,14 @@ class Settings(BaseSettings):
|
|||||||
unit_continuity_max_idle_minutes: int = 20 # unit-continuity path: skip if incident idle > this
|
unit_continuity_max_idle_minutes: int = 20 # unit-continuity path: skip if incident idle > this
|
||||||
recorrelation_scan_minutes: int = 60 # re-examine orphaned calls ended within this window
|
recorrelation_scan_minutes: int = 60 # re-examine orphaned calls ended within this window
|
||||||
tg_fast_path_idle_minutes: int = 90 # fast path: max minutes since incident last updated
|
tg_fast_path_idle_minutes: int = 90 # fast path: max minutes since incident last updated
|
||||||
tg_dispatch_thin_idle_minutes: int = 10 # dispatch channels only: thin calls only attach to incidents idle < this many minutes
|
# Dispatch channels only: tier-2 thin calls attach to a lone candidate idle < this.
|
||||||
|
# Was 10, which is long enough for the channel to have moved on to something else:
|
||||||
|
# on 2026-08-16 a "72 at Holland Station" incident absorbed a Grand Central train
|
||||||
|
# meet 9.6 min later, and a status check absorbed a records lookup at 9.7 min.
|
||||||
|
# Across that dump every correct thin attach was <= 3.4 min idle and every wrong
|
||||||
|
# one was >= 8.2, so 5 separates them with room on both sides. Genuine
|
||||||
|
# back-and-forth is handled by the 30-second tier-1 path above this.
|
||||||
|
tg_dispatch_thin_idle_minutes: int = 5
|
||||||
|
|
||||||
# Vocabulary learning
|
# Vocabulary learning
|
||||||
vocabulary_induction_interval_hours: int = 24 # how often the induction loop runs
|
vocabulary_induction_interval_hours: int = 24 # how often the induction loop runs
|
||||||
@@ -51,9 +73,30 @@ class Settings(BaseSettings):
|
|||||||
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase
|
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase
|
||||||
service_key: Optional[str] = None
|
service_key: Optional[str] = None
|
||||||
|
|
||||||
|
# Fleet-wide token edge nodes present to POST /nodes/enroll on first boot.
|
||||||
|
# Not a per-node secret — see routers/enrollment.py for why a leaked copy
|
||||||
|
# of this alone can't steal an already-approved node's key.
|
||||||
|
enrollment_token: Optional[str] = None
|
||||||
|
|
||||||
# Upload size limit — reject audio files larger than this (bytes). Default 100 MB.
|
# Upload size limit — reject audio files larger than this (bytes). Default 100 MB.
|
||||||
upload_max_bytes: int = 100 * 1024 * 1024
|
upload_max_bytes: int = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
# Public origin this API is reachable on, e.g. "https://api.drb.example.com".
|
||||||
|
# Only used to build absolute call-audio playback links: an <audio src> is
|
||||||
|
# fetched by the browser directly, so a relative path would resolve against
|
||||||
|
# the frontend origin, not this one.
|
||||||
|
public_api_url: Optional[str] = None
|
||||||
|
|
||||||
|
# How long a minted call-audio playback link stays valid. Long enough for a
|
||||||
|
# browsing session, short enough that a copied link isn't durable access.
|
||||||
|
audio_link_ttl_seconds: int = 6 * 60 * 60
|
||||||
|
|
||||||
|
# Two nodes hearing the same transmission start recording within about a
|
||||||
|
# second of each other (measured across node-002/node-PI-2 on TG 9048).
|
||||||
|
# 10s is generous against clock skew while staying well under the gap
|
||||||
|
# between genuinely separate transmissions on a busy dispatch channel.
|
||||||
|
duplicate_window_seconds: int = 10
|
||||||
|
|
||||||
# CORS — set to your frontend origin(s) in production, e.g. ["https://app.example.com"]
|
# CORS — set to your frontend origin(s) in production, e.g. ["https://app.example.com"]
|
||||||
# Defaults to "*" for local development only.
|
# Defaults to "*" for local development only.
|
||||||
cors_origins: list[str] = ["*"]
|
cors_origins: list[str] = ["*"]
|
||||||
|
|||||||
@@ -27,6 +27,21 @@ async def check_and_dispatch(
|
|||||||
Check all enabled alert rules and fire events for any that match this call.
|
Check all enabled alert rules and fire events for any that match this call.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Scoped to the call's own org — an unscoped query here would let an
|
||||||
|
# alert rule created by one org fire (and POST its Discord webhook)
|
||||||
|
# on another org's radio traffic. org_id is resolved from the call
|
||||||
|
# doc rather than threaded through as a new parameter, since every
|
||||||
|
# caller of check_and_dispatch already has call_id and the call doc
|
||||||
|
# is the single source of truth for a call's org once mqtt_handler.py
|
||||||
|
# / upload.py have stamped it. None only for a call from a node that
|
||||||
|
# predates tenancy and hasn't been through the backfill script yet —
|
||||||
|
# such calls fall back to the pre-tenancy behaviour of checking
|
||||||
|
# every rule regardless of org.
|
||||||
|
call_doc = await fstore.doc_get("calls", call_id)
|
||||||
|
org_id = (call_doc or {}).get("org_id")
|
||||||
|
if org_id is not None:
|
||||||
|
rules = await fstore.collection_list("alert_rules", enabled=True, org_id=org_id)
|
||||||
|
else:
|
||||||
rules = await fstore.collection_list("alert_rules", enabled=True)
|
rules = await fstore.collection_list("alert_rules", enabled=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Alerter: could not load rules: {e}")
|
logger.warning(f"Alerter: could not load rules: {e}")
|
||||||
@@ -42,6 +57,7 @@ async def check_and_dispatch(
|
|||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
event = {
|
event = {
|
||||||
"alert_id": alert_id,
|
"alert_id": alert_id,
|
||||||
|
"org_id": org_id,
|
||||||
"rule_id": rule.get("rule_id", ""),
|
"rule_id": rule.get("rule_id", ""),
|
||||||
"rule_name": rule.get("name", ""),
|
"rule_name": rule.get("name", ""),
|
||||||
"call_id": call_id,
|
"call_id": call_id,
|
||||||
|
|||||||
@@ -37,6 +37,41 @@ async def require_service_or_firebase_token(
|
|||||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||||
|
|
||||||
|
|
||||||
|
async def require_node_service_or_firebase_token(
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||||
|
) -> dict:
|
||||||
|
"""Accept a node's own API key in addition to a service key / Firebase token.
|
||||||
|
|
||||||
|
Edge nodes need to read ``/systems`` to build their OP25 config, but they
|
||||||
|
hold neither a Firebase token nor the shared service key — only the
|
||||||
|
per-node api_key that ``/upload`` already trusts. Without this they got a
|
||||||
|
flat 401 and silently fell back to their stale offline cache, so a system
|
||||||
|
edited in the UI never reached the node.
|
||||||
|
|
||||||
|
Unlike ``/upload``, the node sends no node_id alongside the bearer token,
|
||||||
|
so the key is matched by querying ``node_keys`` for the value rather than
|
||||||
|
fetching a known document. Mutating routes are unaffected: they carry
|
||||||
|
their own ``require_admin_token`` dependency, so widening the router-level
|
||||||
|
gate grants nodes read access only.
|
||||||
|
"""
|
||||||
|
if not credentials:
|
||||||
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||||
|
token = credentials.credentials
|
||||||
|
if settings.service_key and secrets.compare_digest(token, settings.service_key):
|
||||||
|
return {"service": True}
|
||||||
|
try:
|
||||||
|
return firebase_auth.verify_id_token(token)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Deferred import: app.internal.firestore initialises firebase-admin at
|
||||||
|
# import time, and auth.py is imported from module scope in the routers.
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
matches = await fstore.collection_list("node_keys", api_key=token)
|
||||||
|
if matches:
|
||||||
|
return {"node": True, "node_id": matches[0].get("node_id")}
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||||
|
|
||||||
|
|
||||||
def get_role(decoded: dict) -> str:
|
def get_role(decoded: dict) -> str:
|
||||||
"""Extract the effective role from a decoded Firebase token.
|
"""Extract the effective role from a decoded Firebase token.
|
||||||
|
|
||||||
@@ -49,6 +84,93 @@ def get_role(decoded: dict) -> str:
|
|||||||
return role if role in ("admin", "operator", "viewer") else "viewer"
|
return role if role in ("admin", "operator", "viewer") else "viewer"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tenancy — org_id / org_role claims, set by POST /auth/signup (routers/links.py)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# `role` above is platform-level (admin/operator/viewer — unrelated to which
|
||||||
|
# org a user belongs to). `org_role` is the customer-facing one: "owner" or
|
||||||
|
# "member" of the org named by the `org_id` claim. See SAAS_PLAN.md B2/B4.
|
||||||
|
|
||||||
|
def get_org_role(decoded: dict) -> Optional[str]:
|
||||||
|
org_role = decoded.get("org_role")
|
||||||
|
return org_role if org_role in ("owner", "member") else None
|
||||||
|
|
||||||
|
|
||||||
|
def require_org(decoded: dict) -> str:
|
||||||
|
"""Return the caller's org_id claim, or 403 if they don't have one.
|
||||||
|
|
||||||
|
A Firebase token with no org_id claim is a real, valid session (the user
|
||||||
|
signed in) that is nonetheless provisioned into nothing — see
|
||||||
|
AuthProvider's no-claim guard (SAAS_PLAN.md B3). Every org-scoped route
|
||||||
|
depends on this rather than trusting a client-supplied org_id, so a
|
||||||
|
caller can never read/write outside the org their own token names.
|
||||||
|
"""
|
||||||
|
org_id = decoded.get("org_id")
|
||||||
|
if not org_id:
|
||||||
|
raise HTTPException(403, "This account is not associated with an organization.")
|
||||||
|
return org_id
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_org_scope(decoded: dict, org_id_override: Optional[str] = None) -> str:
|
||||||
|
"""Return the org_id a request should be scoped to.
|
||||||
|
|
||||||
|
Platform admins (role == "admin") may pass ?org_id=<id> to cross into
|
||||||
|
another org's data for support/debugging — the one exception to "you can
|
||||||
|
only ever see your own org's data" called out in SAAS_PLAN.md B2. Every
|
||||||
|
other caller is locked to their own token's org_id claim regardless of
|
||||||
|
what (if anything) they pass.
|
||||||
|
"""
|
||||||
|
if org_id_override and get_role(decoded) == "admin":
|
||||||
|
return org_id_override
|
||||||
|
return require_org(decoded)
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_caller_org_id(decoded: dict) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Resolve the org_id a caller should be scoped to, across every credential
|
||||||
|
shape this file's dependencies can produce (service key, node api_key,
|
||||||
|
Firebase user) — a single helper so read routes gated by
|
||||||
|
require_service_or_firebase_token / require_node_service_or_firebase_token
|
||||||
|
don't each need their own caller-shape switch.
|
||||||
|
|
||||||
|
Returns None for callers that should see across every org: the internal
|
||||||
|
service key (the Discord bot — a single fleet-wide principal, see
|
||||||
|
CLAUDE.md's auth section) and platform admins, matching
|
||||||
|
require_admin_token's existing "admin sees everything" behaviour. A
|
||||||
|
route that wants admins scoped too should check get_role() itself rather
|
||||||
|
than relying on this function to do it.
|
||||||
|
"""
|
||||||
|
if decoded.get("service"):
|
||||||
|
return None
|
||||||
|
if decoded.get("node"):
|
||||||
|
# Deferred import — same reasoning as require_node_service_or_firebase_token
|
||||||
|
# above: app.internal.firestore initialises firebase-admin at import
|
||||||
|
# time, and auth.py is imported from module scope in the routers.
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
node = await fstore.doc_get_cached("nodes", decoded.get("node_id") or "")
|
||||||
|
return (node or {}).get("org_id")
|
||||||
|
if get_role(decoded) == "admin":
|
||||||
|
return None
|
||||||
|
return require_org(decoded)
|
||||||
|
|
||||||
|
|
||||||
|
async def require_org_owner_token(
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||||
|
) -> dict:
|
||||||
|
"""Verify a Firebase ID token AND require org_role == "owner" (or platform admin).
|
||||||
|
|
||||||
|
Used for org-administrative actions a regular member shouldn't be able to
|
||||||
|
do on their own org — minting/revoking enrollment tokens, renaming the
|
||||||
|
org. Platform admins pass through regardless of org_role so support can
|
||||||
|
act on an org that has no reachable owner.
|
||||||
|
"""
|
||||||
|
decoded = await require_firebase_token(credentials)
|
||||||
|
require_org(decoded)
|
||||||
|
if get_org_role(decoded) != "owner" and get_role(decoded) != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Organization owner access required.")
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
|
||||||
async def require_admin_token(
|
async def require_admin_token(
|
||||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -130,3 +252,13 @@ trip_chat_limiter = _RateLimiter(max_calls=20, window_seconds=300)
|
|||||||
summarize_limiter = _RateLimiter(max_calls=5, window_seconds=600)
|
summarize_limiter = _RateLimiter(max_calls=5, window_seconds=600)
|
||||||
# vocabulary bootstrap: 2 per system per hour
|
# vocabulary bootstrap: 2 per system per hour
|
||||||
bootstrap_limiter = _RateLimiter(max_calls=2, window_seconds=3600)
|
bootstrap_limiter = _RateLimiter(max_calls=2, window_seconds=3600)
|
||||||
|
# per-call reprocess: 3 per call per 10 minutes — reprocess re-runs the full
|
||||||
|
# Whisper + Gemini pipeline, which is real spend per call; this is now also
|
||||||
|
# admin-only (see routers/calls.py) but the limiter stays as a second guard
|
||||||
|
# against a compromised/careless admin session looping it. Keyed by call_id,
|
||||||
|
# same pattern as summarize_limiter.
|
||||||
|
reprocess_limiter = _RateLimiter(max_calls=3, window_seconds=600)
|
||||||
|
# public waitlist submissions: 5 per source IP per hour — POST /waitlist has
|
||||||
|
# no auth at all by design (SAAS_PLAN.md B6), so this is the only thing
|
||||||
|
# standing between it and being spammed.
|
||||||
|
waitlist_limiter = _RateLimiter(max_calls=5, window_seconds=3600)
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
Cross-node duplicate detection for call recordings.
|
||||||
|
|
||||||
|
Two edge nodes within range of the same trunked system both decode and upload
|
||||||
|
the same transmission. That is the normal case for a distributed network, not
|
||||||
|
an error — but without this, one transmission is transcribed twice, billed
|
||||||
|
twice, and correlated twice, and the resulting incident shows two "units"
|
||||||
|
where there was one.
|
||||||
|
|
||||||
|
CANONICAL SELECTION IS DELIBERATELY NOT "FIRST UPLOAD WINS". Upload order
|
||||||
|
depends on encode time and network latency, so it varies run to run; picking
|
||||||
|
by it would make which recording is authoritative non-deterministic. The call
|
||||||
|
document is created from the MQTT call_start event *before* the upload
|
||||||
|
arrives, so by upload time every node's document for the transmission already
|
||||||
|
exists and can be ranked. Canonical is the earliest ``started_at``, breaking
|
||||||
|
ties on ``call_id`` so both nodes independently reach the same verdict.
|
||||||
|
|
||||||
|
The loser keeps its audio — it is ~60 KB and may be the cleaner capture if the
|
||||||
|
winner's node had a weak signal — but is excluded from the AI pipeline.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Awaitable, Callable, Optional
|
||||||
|
from app.config import settings
|
||||||
|
from app.internal.logger import logger
|
||||||
|
|
||||||
|
# Firestore is reached through an injected callable rather than a module-level
|
||||||
|
# import. app.internal.firestore initialises firebase-admin at import time,
|
||||||
|
# which needs credentials and the SDK present — so importing it here would make
|
||||||
|
# this module unimportable in a unit test. Same reasoning as the deferred
|
||||||
|
# import in app/internal/auth.py.
|
||||||
|
QueryFn = Callable[[str, list], Awaitable[list[dict]]]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_dt(value) -> Optional[datetime]:
|
||||||
|
"""Firestore hands back Timestamp, datetime, or ISO string depending on writer."""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_canonical(call: dict, others: list[dict]) -> bool:
|
||||||
|
"""True if `call` is the one recording of this transmission that should be processed."""
|
||||||
|
started = _parse_dt(call.get("started_at"))
|
||||||
|
call_id = call.get("call_id") or ""
|
||||||
|
for other in others:
|
||||||
|
other_started = _parse_dt(other.get("started_at"))
|
||||||
|
if not other_started or not started:
|
||||||
|
continue
|
||||||
|
if other_started < started:
|
||||||
|
return False
|
||||||
|
if other_started == started and (other.get("call_id") or "") < call_id:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def find_duplicate_of(call: dict, query: Optional[QueryFn] = None) -> Optional[str]:
|
||||||
|
"""Return the canonical call_id if `call` duplicates another node's recording.
|
||||||
|
|
||||||
|
Returns None when this call is the canonical one, or when there is nothing
|
||||||
|
to compare against (single node in range, or the call lacks the talkgroup
|
||||||
|
and system identifiers the match is keyed on).
|
||||||
|
"""
|
||||||
|
system_id = call.get("system_id")
|
||||||
|
talkgroup_id = call.get("talkgroup_id")
|
||||||
|
call_id = call.get("call_id")
|
||||||
|
started = _parse_dt(call.get("started_at"))
|
||||||
|
if not (system_id and talkgroup_id is not None and call_id and started):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if query is None:
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
query = fstore.collection_where
|
||||||
|
|
||||||
|
window = timedelta(seconds=settings.duplicate_window_seconds)
|
||||||
|
try:
|
||||||
|
# Range-scan on started_at, then filter the rest in Python — Firestore
|
||||||
|
# allows a range on only one field per query.
|
||||||
|
nearby = await query("calls", [
|
||||||
|
("system_id", "==", system_id),
|
||||||
|
("started_at", ">=", started - window),
|
||||||
|
("started_at", "<=", started + window),
|
||||||
|
])
|
||||||
|
except Exception as e:
|
||||||
|
# Never block an upload on dedup — worst case is the pre-existing
|
||||||
|
# behaviour of processing both copies.
|
||||||
|
logger.warning(f"Duplicate check failed for call {call_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
matches = [
|
||||||
|
c for c in nearby
|
||||||
|
if c.get("call_id") != call_id
|
||||||
|
and c.get("talkgroup_id") == talkgroup_id
|
||||||
|
and c.get("node_id") != call.get("node_id") # same node twice is a real repeat
|
||||||
|
and not c.get("duplicate_of") # never point at another duplicate
|
||||||
|
]
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if _is_canonical(call, matches):
|
||||||
|
return None
|
||||||
|
|
||||||
|
canonical = min(
|
||||||
|
matches,
|
||||||
|
key=lambda c: (_parse_dt(c.get("started_at")) or started, c.get("call_id") or ""),
|
||||||
|
)
|
||||||
|
return canonical.get("call_id")
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
"""
|
||||||
|
Client for mosquitto's built-in dynamic-security plugin.
|
||||||
|
|
||||||
|
WHY THIS EXISTS: MQTT-PUBLIC-AUTH-PLAN.md originally specced the
|
||||||
|
mosquitto-go-auth plugin (HTTP backend). That project was archived by its
|
||||||
|
maintainer 2025-08-06 ("no more changes") — unacceptable for a broker that's
|
||||||
|
about to be reachable from the public internet, no way to get a CVE fix.
|
||||||
|
Replaced with mosquitto 2.x's own `dynamic-security` plugin, which ships in
|
||||||
|
and is maintained alongside the official eclipse-mosquitto image itself.
|
||||||
|
|
||||||
|
HOW DYNSEC WORKS (verified against plugin source on
|
||||||
|
github.com/eclipse-mosquitto/mosquitto, 2026-08-16 — see citations inline;
|
||||||
|
NOT verified by running anything, per instruction not to execute/deploy
|
||||||
|
anything from this machine):
|
||||||
|
|
||||||
|
- The broker persists clients/roles/ACLs in a JSON file at
|
||||||
|
`plugin_opt_config_file` (we point this at /mosquitto/data/, the same
|
||||||
|
volume `persistence_location` already uses — one durable volume for all
|
||||||
|
broker state, see docker-compose.yml).
|
||||||
|
- Admin commands are plain MQTT publishes: JSON `{"commands": [...]}` to
|
||||||
|
`$CONTROL/dynamic-security/v1` (source: plugin.c,
|
||||||
|
`mosquitto_callback_register(plg_id, MOSQ_EVT_CONTROL,
|
||||||
|
dynsec_control_callback, "$CONTROL/dynamic-security/v1", ...)`).
|
||||||
|
Replies come back on `$CONTROL/dynamic-security/v1/response`
|
||||||
|
(source: control.c, `#define RESPONSE_TOPIC
|
||||||
|
"$CONTROL/dynamic-security/v1/response"`).
|
||||||
|
- Per-command JSON fields (verified against clients.c / roles.c handlers
|
||||||
|
and the plugin README):
|
||||||
|
createClient: username, password, clientid, textname, textdescription,
|
||||||
|
roles: [{rolename, priority}], groups: [...]
|
||||||
|
modifyClient: same fields, username identifies the existing client
|
||||||
|
deleteClient: username
|
||||||
|
createRole: rolename, textname, textdescription,
|
||||||
|
acls: [{acltype, topic, priority, allow}]
|
||||||
|
acltype values: publishClientSend, publishClientReceive,
|
||||||
|
subscribeLiteral, subscribePattern, unsubscribeLiteral,
|
||||||
|
unsubscribePattern. %u (username) and %c (clientid) are valid
|
||||||
|
substitutions in `topic` for every type except the two *Literal ones.
|
||||||
|
- On first boot, if `plugin_opt_config_file` doesn't exist, the plugin
|
||||||
|
bootstraps itself (config_init.c): reads env var
|
||||||
|
`MOSQUITTO_DYNSEC_PASSWORD` (or `plugin_opt_password_init_file`) and
|
||||||
|
creates a client literally named "admin" (hardcoded string, NOT
|
||||||
|
configurable — verified in config_init.c's `client_add_admin()`) with
|
||||||
|
three roles: `super-admin` (full pub/sub on `$CONTROL/#` — i.e. this is
|
||||||
|
what makes a client capable of issuing further dynsec commands, and
|
||||||
|
it's an ordinary role, nothing hardcoded beyond the initial grant),
|
||||||
|
`sys-observe` ($SYS/# read-only), `topic-observe` (# read-only, NOT
|
||||||
|
read-write). If MOSQUITTO_DYNSEC_PASSWORD is set (we always set it),
|
||||||
|
no `democlient` demo account gets created — that only happens in the
|
||||||
|
"no password provided, generate one randomly" path.
|
||||||
|
- This is a genuine backend swap, not just config: `allow_anonymous
|
||||||
|
false` plus the *absence* of `password_file`/`acl_file` directives
|
||||||
|
means dynsec is the only auth backend registered — nothing else is
|
||||||
|
there to conflict with it. (Inferred from plugin architecture — every
|
||||||
|
mosquitto auth backend, built-in or plugin, registers the same
|
||||||
|
basic-auth/ACL callback hooks; there's no "layering" mechanism, so
|
||||||
|
without password_file/acl_file directives there is nothing else to
|
||||||
|
check credentials or topics.)
|
||||||
|
|
||||||
|
WHAT COULD NOT BE VERIFIED (see also the plan doc + final report):
|
||||||
|
- The exact JSON envelope of a *response* message (only individual
|
||||||
|
command outcomes were confirmed: `mosquitto_control_command_reply(cmd,
|
||||||
|
NULL)` for success, `mosquitto_control_command_reply(cmd, "error
|
||||||
|
string")` for failure — the wrapping object shape, e.g. whether it's
|
||||||
|
`{"responses": [{"command": ..., "error": ...}]}`, was not directly
|
||||||
|
read from source). This client parses defensively: it treats ANY
|
||||||
|
dict containing a non-null "error"/"Error" key anywhere in the
|
||||||
|
top-level response payload as failure, presence of "already exists" in
|
||||||
|
that string as an idempotent success, and a response with no such key
|
||||||
|
within the timeout as success. A response timeout is always a hard
|
||||||
|
failure (never assumed to mean success).
|
||||||
|
- "Client already exists" was confirmed verbatim as createClient's
|
||||||
|
error string; "already exists" for createRole is assumed analogous,
|
||||||
|
not directly confirmed.
|
||||||
|
|
||||||
|
TWO-SOURCES-OF-TRUTH: Firestore's `node_keys` collection is the source of
|
||||||
|
truth for node credentials (nothing changes there); dynamic-security.json
|
||||||
|
is a derived cache the broker uses to authenticate. `reconcile_all()`
|
||||||
|
rebuilds every approved node's dynsec client from Firestore and is called
|
||||||
|
on every c2-core startup — so a lost/corrupted dynamic-security.json (e.g.
|
||||||
|
volume wiped) self-heals on the next restart instead of silently locking
|
||||||
|
out every node. `upsert_node_client()`/`delete_node_client()` are also
|
||||||
|
called synchronously from routers/nodes.py's approve/reissue/delete
|
||||||
|
handlers and raise on failure — those endpoints now fail loudly (502)
|
||||||
|
instead of updating Firestore while dynsec silently didn't get the memo.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
from app.config import settings
|
||||||
|
from app.internal.logger import logger
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
|
||||||
|
CONTROL_TOPIC = "$CONTROL/dynamic-security/v1"
|
||||||
|
RESPONSE_TOPIC = "$CONTROL/dynamic-security/v1/response"
|
||||||
|
_RESPONSE_TIMEOUT_SECONDS = 10
|
||||||
|
|
||||||
|
# Role every approved node's dynsec client is attached to. %u = the
|
||||||
|
# authenticated username (the node_id) — this is the fixed version of the
|
||||||
|
# old `pattern readwrite nodes/%c/#`, where %c was the client-supplied,
|
||||||
|
# spoofable client ID.
|
||||||
|
NODE_ROLE = "node"
|
||||||
|
# Role c2-core's own login gets, in addition to being handed the plugin's
|
||||||
|
# built-in `super-admin` role (see grant_c2core_admin()). Mirrors the old
|
||||||
|
# `topic readwrite #` superuser line.
|
||||||
|
C2CORE_ROLE = "c2core"
|
||||||
|
|
||||||
|
|
||||||
|
class DynsecError(Exception):
|
||||||
|
"""A dynsec command was rejected, or no response arrived in time."""
|
||||||
|
|
||||||
|
|
||||||
|
def _run_commands_sync(commands: list[dict], username: str, password: str) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Blocking: open a short-lived MQTT connection, publish one or more dynsec
|
||||||
|
commands, wait for the matching replies, disconnect. Always called via
|
||||||
|
asyncio.to_thread — see the async wrappers below. A fresh connection per
|
||||||
|
call (rather than reusing mqtt_handler's long-lived client) keeps this
|
||||||
|
request/response exchange simple and isolated from that client's
|
||||||
|
async-callback-driven subscribe state.
|
||||||
|
"""
|
||||||
|
responses: list[dict] = []
|
||||||
|
done = {"got": False, "error": None}
|
||||||
|
|
||||||
|
def _on_connect(client, userdata, flags, reason_code, properties):
|
||||||
|
if reason_code != 0:
|
||||||
|
done["error"] = f"connect refused: {reason_code}"
|
||||||
|
done["got"] = True
|
||||||
|
return
|
||||||
|
client.subscribe(RESPONSE_TOPIC, qos=1)
|
||||||
|
client.publish(CONTROL_TOPIC, json.dumps({"commands": commands}), qos=1)
|
||||||
|
|
||||||
|
def _on_message(client, userdata, msg):
|
||||||
|
try:
|
||||||
|
payload = json.loads(msg.payload.decode())
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
responses.append(payload)
|
||||||
|
done["got"] = True
|
||||||
|
|
||||||
|
client = mqtt.Client(
|
||||||
|
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||||
|
client_id=f"drb-c2-core-dynsec-{uuid.uuid4().hex[:8]}",
|
||||||
|
)
|
||||||
|
client.username_pw_set(username, password)
|
||||||
|
client.on_connect = _on_connect
|
||||||
|
client.on_message = _on_message
|
||||||
|
|
||||||
|
try:
|
||||||
|
client.connect(settings.mqtt_broker, settings.mqtt_port, keepalive=30)
|
||||||
|
except Exception as e:
|
||||||
|
raise DynsecError(f"could not connect to mosquitto for dynsec command: {e}")
|
||||||
|
|
||||||
|
client.loop_start()
|
||||||
|
deadline = time.monotonic() + _RESPONSE_TIMEOUT_SECONDS
|
||||||
|
try:
|
||||||
|
while not done["got"] and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.05)
|
||||||
|
finally:
|
||||||
|
client.loop_stop()
|
||||||
|
client.disconnect()
|
||||||
|
|
||||||
|
if done["error"]:
|
||||||
|
raise DynsecError(str(done["error"]))
|
||||||
|
if not responses:
|
||||||
|
raise DynsecError(
|
||||||
|
f"no response on {RESPONSE_TOPIC} within {_RESPONSE_TIMEOUT_SECONDS}s for commands: "
|
||||||
|
f"{[c.get('command') for c in commands]}"
|
||||||
|
)
|
||||||
|
return responses
|
||||||
|
|
||||||
|
|
||||||
|
def _check_responses_ok(responses: list[dict], tolerate_already_exists: bool = False) -> None:
|
||||||
|
"""Raise DynsecError unless every response payload is error-free (or,
|
||||||
|
when tolerate_already_exists, only contains an 'already exists'-style
|
||||||
|
error — see the module docstring's "could not verify" note on why this
|
||||||
|
is a substring match rather than a structured error code check)."""
|
||||||
|
for payload in responses:
|
||||||
|
# Defensive: walk the payload looking for any *-cased "error" key
|
||||||
|
# with a non-empty value, since the exact envelope shape wasn't
|
||||||
|
# confirmed from source. Covers both a flat {"error": "..."} and a
|
||||||
|
# {"responses": [{"error": "..."}]}-style wrapper.
|
||||||
|
errors = _find_error_strings(payload)
|
||||||
|
for err in errors:
|
||||||
|
if tolerate_already_exists and "already exist" in err.lower():
|
||||||
|
continue
|
||||||
|
raise DynsecError(f"dynsec command failed: {err}")
|
||||||
|
|
||||||
|
|
||||||
|
def _find_error_strings(obj) -> list[str]:
|
||||||
|
found = []
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for k, v in obj.items():
|
||||||
|
if k.lower() == "error" and v:
|
||||||
|
found.append(str(v))
|
||||||
|
else:
|
||||||
|
found.extend(_find_error_strings(v))
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
for item in obj:
|
||||||
|
found.extend(_find_error_strings(item))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Async wrappers (all real work happens in the thread pool)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _admin_publish(commands: list[dict], tolerate_already_exists: bool = False) -> list[dict]:
|
||||||
|
if not settings.mqtt_dynsec_admin_pass:
|
||||||
|
raise DynsecError("MQTT_DYNSEC_ADMIN_PASS / mqtt_dynsec_admin_pass is not configured")
|
||||||
|
responses = await asyncio.to_thread(
|
||||||
|
_run_commands_sync, commands, settings.mqtt_dynsec_admin_user, settings.mqtt_dynsec_admin_pass
|
||||||
|
)
|
||||||
|
_check_responses_ok(responses, tolerate_already_exists=tolerate_already_exists)
|
||||||
|
return responses
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_roles_and_c2core_grant() -> None:
|
||||||
|
"""
|
||||||
|
Idempotent, safe to run on every startup:
|
||||||
|
1. createRole "node" — nodes/%u/# publish+subscribe (both directions)
|
||||||
|
2. createRole "c2core" — full "#" publish+subscribe, same reach the
|
||||||
|
old `topic readwrite #` superuser line gave c2-core
|
||||||
|
3. createClient/modifyClient drb-c2-core (settings.mqtt_user) with
|
||||||
|
BOTH roles above AND the plugin's built-in "super-admin" role —
|
||||||
|
i.e. c2-core's existing login is handed the actual dynsec admin
|
||||||
|
role, not a separate identity, per the design decision.
|
||||||
|
Runs over the dedicated "admin" bootstrap login (step 3 assigns
|
||||||
|
super-admin to c2-core's own login for the record / future use, but
|
||||||
|
THIS module still authenticates its own ongoing calls as "admin" — see
|
||||||
|
the module docstring for why: it's the one identity guaranteed by
|
||||||
|
mosquitto's own source to hold super-admin, so control-plane calls
|
||||||
|
don't depend on step 3's grant having actually landed).
|
||||||
|
"""
|
||||||
|
node_acl_types = ["publishClientSend", "publishClientReceive", "subscribePattern", "unsubscribePattern"]
|
||||||
|
await _admin_publish([{
|
||||||
|
"command": "createRole",
|
||||||
|
"rolename": NODE_ROLE,
|
||||||
|
"textname": "DRB edge node — own namespace only",
|
||||||
|
"acls": [{"acltype": t, "topic": "nodes/%u/#", "priority": 0, "allow": True} for t in node_acl_types],
|
||||||
|
}], tolerate_already_exists=True)
|
||||||
|
|
||||||
|
c2core_acl_types = ["publishClientSend", "publishClientReceive", "subscribePattern", "unsubscribePattern"]
|
||||||
|
await _admin_publish([{
|
||||||
|
"command": "createRole",
|
||||||
|
"rolename": C2CORE_ROLE,
|
||||||
|
"textname": "DRB c2-core — full broker access",
|
||||||
|
"acls": [{"acltype": t, "topic": "#", "priority": 0, "allow": True} for t in c2core_acl_types],
|
||||||
|
}], tolerate_already_exists=True)
|
||||||
|
|
||||||
|
if not settings.mqtt_user or not settings.mqtt_pass:
|
||||||
|
logger.warning("dynsec: MQTT_USER/MQTT_PASS not configured — skipping c2-core client grant")
|
||||||
|
return
|
||||||
|
|
||||||
|
roles = [{"rolename": C2CORE_ROLE, "priority": 1}, {"rolename": "super-admin", "priority": 2}]
|
||||||
|
try:
|
||||||
|
await _admin_publish([{
|
||||||
|
"command": "createClient",
|
||||||
|
"username": settings.mqtt_user,
|
||||||
|
"password": settings.mqtt_pass,
|
||||||
|
"roles": roles,
|
||||||
|
}])
|
||||||
|
logger.info(f"dynsec: created client {settings.mqtt_user!r} with roles {C2CORE_ROLE}, super-admin")
|
||||||
|
except DynsecError as e:
|
||||||
|
if "already exist" in str(e).lower():
|
||||||
|
await _admin_publish([{
|
||||||
|
"command": "modifyClient",
|
||||||
|
"username": settings.mqtt_user,
|
||||||
|
"password": settings.mqtt_pass,
|
||||||
|
"roles": roles,
|
||||||
|
}])
|
||||||
|
logger.info(f"dynsec: updated existing client {settings.mqtt_user!r} with roles {C2CORE_ROLE}, super-admin")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_node_client(node_id: str, api_key: str) -> None:
|
||||||
|
"""Create or update a node's dynsec client — called from
|
||||||
|
routers/nodes.py approve_node()/reissue_node_key(), and from
|
||||||
|
reconcile_all() on startup. Raises DynsecError on failure; callers
|
||||||
|
must not write Firestore as if this succeeded when it didn't."""
|
||||||
|
try:
|
||||||
|
await _admin_publish([{
|
||||||
|
"command": "createClient",
|
||||||
|
"username": node_id,
|
||||||
|
"password": api_key,
|
||||||
|
"roles": [{"rolename": NODE_ROLE, "priority": 1}],
|
||||||
|
}])
|
||||||
|
except DynsecError as e:
|
||||||
|
if "already exist" not in str(e).lower():
|
||||||
|
raise
|
||||||
|
await _admin_publish([{
|
||||||
|
"command": "modifyClient",
|
||||||
|
"username": node_id,
|
||||||
|
"password": api_key,
|
||||||
|
"roles": [{"rolename": NODE_ROLE, "priority": 1}],
|
||||||
|
}])
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_node_client(node_id: str) -> None:
|
||||||
|
"""Best-effort: a node that was never enrolled in dynsec (or already
|
||||||
|
removed) is treated as already-deleted, not an error."""
|
||||||
|
try:
|
||||||
|
await _admin_publish([{"command": "deleteClient", "username": node_id}])
|
||||||
|
except DynsecError as e:
|
||||||
|
if "not found" not in str(e).lower():
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def reconcile_all() -> None:
|
||||||
|
"""
|
||||||
|
Rebuild dynsec state for every approved node from Firestore
|
||||||
|
(node_keys is the source of truth). Called once at c2-core startup,
|
||||||
|
after ensure_roles_and_c2core_grant(). Self-heals a lost/corrupted
|
||||||
|
dynamic-security.json (e.g. volume wiped, or a prior approve/reissue's
|
||||||
|
dynsec publish silently failed to persist for some other reason) —
|
||||||
|
without this, a broker restart with an intact Firestore but an empty
|
||||||
|
dynsec store would lock out every previously-approved node until
|
||||||
|
someone noticed and manually re-approved each one.
|
||||||
|
"""
|
||||||
|
nodes = await fstore.collection_list("nodes", approval_status="approved")
|
||||||
|
if not nodes:
|
||||||
|
return
|
||||||
|
ok, failed = 0, 0
|
||||||
|
for node in nodes:
|
||||||
|
node_id = node.get("node_id")
|
||||||
|
if not node_id:
|
||||||
|
continue
|
||||||
|
key_doc = await fstore.doc_get("node_keys", node_id)
|
||||||
|
if not key_doc or not key_doc.get("api_key"):
|
||||||
|
logger.warning(f"dynsec reconcile: node {node_id!r} is approved but has no node_keys entry — skipping")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await upsert_node_client(node_id, key_doc["api_key"])
|
||||||
|
ok += 1
|
||||||
|
except DynsecError as e:
|
||||||
|
failed += 1
|
||||||
|
logger.error(f"dynsec reconcile: failed to sync node {node_id!r}: {e}")
|
||||||
|
logger.info(f"dynsec reconcile: {ok} node(s) synced, {failed} failed")
|
||||||
@@ -68,16 +68,41 @@ async def collection_list(collection: str, **filters) -> list[dict]:
|
|||||||
async def collection_where(
|
async def collection_where(
|
||||||
collection: str,
|
collection: str,
|
||||||
conditions: list[tuple[str, str, Any]],
|
conditions: list[tuple[str, str, Any]],
|
||||||
|
order_by: Optional[list[tuple[str, str]]] = None,
|
||||||
|
limit_to: Optional[int] = None,
|
||||||
|
start_after: Optional[dict] = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Query a collection with arbitrary where-clauses.
|
Query a collection with arbitrary where-clauses.
|
||||||
conditions: list of (field, op, value) — e.g. [("ended_at", ">=", cutoff_dt)]
|
conditions: list of (field, op, value) — e.g. [("ended_at", ">=", cutoff_dt)]
|
||||||
Supports any Firestore operator: "==", "!=", "<", "<=", ">", ">=".
|
Supports any Firestore operator, including "array_contains" — it's just
|
||||||
|
forwarded straight to FieldFilter, so a condition like
|
||||||
|
("incident_ids", "array_contains", incident_id) already worked before this
|
||||||
|
function grew explicit order_by/limit/cursor params below.
|
||||||
|
|
||||||
|
order_by: list of (field, direction) — direction is "ASCENDING" or
|
||||||
|
"DESCENDING" (Firestore's own constants; passed straight through as
|
||||||
|
strings so this module doesn't need a google.cloud.firestore_v1.Query
|
||||||
|
import). Applied in list order, so multi-field sorts work.
|
||||||
|
limit_to: cap the number of documents returned.
|
||||||
|
start_after: cursor — a dict of the same field values as the *last*
|
||||||
|
document from a previous page's order_by fields (Firestore's
|
||||||
|
`Query.start_after()` takes a field-value mapping, not a document
|
||||||
|
snapshot, when you're not holding one).
|
||||||
|
|
||||||
|
Added for org_id-scoped queries that also need to be ordered/paginated —
|
||||||
|
unscoped equality-only lookups can keep using collection_list().
|
||||||
"""
|
"""
|
||||||
def _query():
|
def _query():
|
||||||
ref = db.collection(collection)
|
ref = db.collection(collection)
|
||||||
for field, op, value in conditions:
|
for field, op, value in conditions:
|
||||||
ref = ref.where(filter=FieldFilter(field, op, value))
|
ref = ref.where(filter=FieldFilter(field, op, value))
|
||||||
|
for field, direction in (order_by or []):
|
||||||
|
ref = ref.order_by(field, direction=direction)
|
||||||
|
if start_after is not None:
|
||||||
|
ref = ref.start_after(start_after)
|
||||||
|
if limit_to is not None:
|
||||||
|
ref = ref.limit(limit_to)
|
||||||
return [doc.to_dict() for doc in ref.stream()]
|
return [doc.to_dict() for doc in ref.stream()]
|
||||||
|
|
||||||
return await asyncio.to_thread(_query)
|
return await asyncio.to_thread(_query)
|
||||||
|
|||||||
@@ -135,6 +135,62 @@ _TAG_TYPE_HINTS: dict[str, str] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Words that describe a unit's role rather than identifying it. Whisper hears
|
||||||
|
# the same officer as "Post 5", "5", and "5 post" within one conversation, so
|
||||||
|
# these carry no distinguishing information and are dropped before comparison.
|
||||||
|
_UNIT_NOISE_TOKENS = frozenset({"post", "unit", "units", "car"})
|
||||||
|
_ORDINAL_RE = re.compile(r"^(\d+)(?:st|nd|rd|th)$")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_unit(unit: str) -> str:
|
||||||
|
"""
|
||||||
|
Reduce a spoken unit ID to a comparison key.
|
||||||
|
|
||||||
|
Dispatch audio names the same unit inconsistently and exact string equality
|
||||||
|
silently drops the follow-ups. Observed on 2026-08-17 in a single hour, each
|
||||||
|
pair being one unit that failed to match itself:
|
||||||
|
|
||||||
|
"K-9A2" vs "K-9-A-2" punctuation
|
||||||
|
"5-1-6" vs "516" digits read out individually
|
||||||
|
"37" vs "37th Post" ordinal + role word
|
||||||
|
"11-Victor" vs "11 Victor"
|
||||||
|
|
||||||
|
Case, punctuation and role words go; digit groups join up. What is
|
||||||
|
deliberately NOT done is matching a bare district letter — "Adam" is not
|
||||||
|
treated as "6-Adam", because every district has an Adam and collapsing them
|
||||||
|
would merge unrelated incidents. That costs a few links and is the right
|
||||||
|
trade: a missed link leaves an orphan the sweep can retry, a false link
|
||||||
|
corrupts an incident permanently.
|
||||||
|
"""
|
||||||
|
tokens = [t for t in re.split(r"[^a-z0-9]+", unit.strip().lower()) if t]
|
||||||
|
cleaned: list[str] = []
|
||||||
|
for tok in tokens:
|
||||||
|
if tok in _UNIT_NOISE_TOKENS:
|
||||||
|
continue
|
||||||
|
ordinal = _ORDINAL_RE.match(tok)
|
||||||
|
cleaned.append(ordinal.group(1) if ordinal else tok)
|
||||||
|
key = "".join(cleaned)
|
||||||
|
# A unit made only of noise words ("Post") would normalise to "" and then
|
||||||
|
# collide with every other such unit, so fall back to the raw text.
|
||||||
|
return key or unit.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _unit_keys(units: Optional[list[str]]) -> set[str]:
|
||||||
|
"""Comparison keys for a unit list, empties dropped."""
|
||||||
|
return {k for k in (_normalize_unit(u) for u in (units or [])) if k}
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_units(call_units: Optional[list[str]], inc_units: Optional[list[str]]) -> list[str]:
|
||||||
|
"""
|
||||||
|
Call-side units that also appear on the incident, compared by normalised key
|
||||||
|
but returned as the original spoken strings so debug output stays readable.
|
||||||
|
"""
|
||||||
|
inc_keys = _unit_keys(inc_units)
|
||||||
|
if not inc_keys:
|
||||||
|
return []
|
||||||
|
return [u for u in (call_units or []) if _normalize_unit(u) in inc_keys]
|
||||||
|
|
||||||
|
|
||||||
def _infer_type_from_tags(tags: list[str]) -> Optional[str]:
|
def _infer_type_from_tags(tags: list[str]) -> Optional[str]:
|
||||||
"""Return an incident type inferred from tags, or None if ambiguous."""
|
"""Return an incident type inferred from tags, or None if ambiguous."""
|
||||||
for tag in tags:
|
for tag in tags:
|
||||||
@@ -279,20 +335,33 @@ async def _build_context(
|
|||||||
now = reference_time or datetime.now(timezone.utc)
|
now = reference_time or datetime.now(timezone.utc)
|
||||||
window = timedelta(hours=settings.correlation_window_hours)
|
window = timedelta(hours=settings.correlation_window_hours)
|
||||||
|
|
||||||
|
call_doc = await fstore.doc_get("calls", call_id) or {}
|
||||||
|
org_id = call_doc.get("org_id")
|
||||||
|
|
||||||
|
# Candidate incidents MUST be scoped to the call's own org — without this,
|
||||||
|
# a call from org A could match/link into an incident belonging to org B
|
||||||
|
# (fast-path talkgroup match, unit-continuity, disambiguation all pull
|
||||||
|
# from all_active/recent below), which is a cross-tenant data merge, not
|
||||||
|
# just an over-broad read. org_id is None for a call from a node that
|
||||||
|
# predates tenancy and hasn't been through scripts/backfill_org_id.py yet
|
||||||
|
# — such calls fall back to the pre-tenancy behaviour of matching across
|
||||||
|
# the whole collection rather than being unable to correlate at all.
|
||||||
|
if org_id is not None:
|
||||||
|
all_active = await fstore.collection_list("incidents", status="active", org_id=org_id)
|
||||||
|
else:
|
||||||
all_active = await fstore.collection_list("incidents", status="active")
|
all_active = await fstore.collection_list("incidents", status="active")
|
||||||
recent = [inc for inc in all_active if _within_window_of(inc, now, window)]
|
recent = [inc for inc in all_active if _within_window_of(inc, now, window)]
|
||||||
|
|
||||||
call_doc = await fstore.doc_get("calls", call_id) or {}
|
|
||||||
call_embedding = call_doc.get("embedding")
|
call_embedding = call_doc.get("embedding")
|
||||||
call_units = units if units is not None else (call_doc.get("units") or [])
|
call_units = units if units is not None else (call_doc.get("units") or [])
|
||||||
call_vehicles = vehicles if vehicles is not None else (call_doc.get("vehicles") or [])
|
call_vehicles = vehicles if vehicles is not None else (call_doc.get("vehicles") or [])
|
||||||
call_cleared = cleared_units if cleared_units is not None else (call_doc.get("cleared_units") or [])
|
call_cleared = cleared_units if cleared_units is not None else (call_doc.get("cleared_units") or [])
|
||||||
call_severity = call_doc.get("severity") or "unknown"
|
call_severity = call_doc.get("severity") or "routine"
|
||||||
coords = location_coords or call_doc.get("location_coords")
|
coords = location_coords or call_doc.get("location_coords")
|
||||||
is_thin_call = not call_units and not call_vehicles and not coords
|
is_thin_call = not call_units and not call_vehicles and not coords
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"call_id": call_id, "all_active": all_active, "recent": recent,
|
"call_id": call_id, "org_id": org_id, "all_active": all_active, "recent": recent,
|
||||||
"call_doc": call_doc, "call_embedding": call_embedding,
|
"call_doc": call_doc, "call_embedding": call_embedding,
|
||||||
"call_units": call_units, "call_vehicles": call_vehicles,
|
"call_units": call_units, "call_vehicles": call_vehicles,
|
||||||
"call_cleared": call_cleared, "call_severity": call_severity,
|
"call_cleared": call_cleared, "call_severity": call_severity,
|
||||||
@@ -326,6 +395,7 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
call_embedding = ctx["call_embedding"]
|
call_embedding = ctx["call_embedding"]
|
||||||
call_units = ctx["call_units"]
|
call_units = ctx["call_units"]
|
||||||
call_vehicles = ctx["call_vehicles"]
|
call_vehicles = ctx["call_vehicles"]
|
||||||
|
call_severity = ctx["call_severity"]
|
||||||
coords = ctx["coords"]
|
coords = ctx["coords"]
|
||||||
is_thin_call = ctx["is_thin_call"]
|
is_thin_call = ctx["is_thin_call"]
|
||||||
now = ctx["now"]
|
now = ctx["now"]
|
||||||
@@ -460,8 +530,7 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
"corr_is_dispatch": is_dispatch,
|
"corr_is_dispatch": is_dispatch,
|
||||||
}
|
}
|
||||||
if fit_signal == "unit_overlap" and call_units:
|
if fit_signal == "unit_overlap" and call_units:
|
||||||
inc_unit_set = set(candidate.get("units") or [])
|
corr_debug["corr_matched_units"] = _matching_units(call_units, candidate.get("units"))
|
||||||
corr_debug["corr_matched_units"] = [u for u in call_units if u in inc_unit_set]
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Correlator fast-path: call {call_id} → {candidate['incident_id']} "
|
f"Correlator fast-path: call {call_id} → {candidate['incident_id']} "
|
||||||
f"(signal={fit_signal}, is_dispatch={is_dispatch})"
|
f"(signal={fit_signal}, is_dispatch={is_dispatch})"
|
||||||
@@ -501,8 +570,7 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
"corr_is_dispatch": is_dispatch,
|
"corr_is_dispatch": is_dispatch,
|
||||||
}
|
}
|
||||||
if fit_signal == "unit_overlap" and call_units:
|
if fit_signal == "unit_overlap" and call_units:
|
||||||
inc_unit_set = set(candidate.get("units") or [])
|
corr_debug["corr_matched_units"] = _matching_units(call_units, candidate.get("units"))
|
||||||
corr_debug["corr_matched_units"] = [u for u in call_units if u in inc_unit_set]
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Correlator fast-path (disambig {len(tg_recent)} candidates): "
|
f"Correlator fast-path (disambig {len(tg_recent)} candidates): "
|
||||||
f"call {call_id} → {candidate['incident_id']} (signal={fit_signal})"
|
f"call {call_id} → {candidate['incident_id']} (signal={fit_signal})"
|
||||||
@@ -523,11 +591,11 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
# incident, the officer has moved on and we don't link back to the old one.
|
# incident, the officer has moved on and we don't link back to the old one.
|
||||||
# This correctly handles officers dispatched to a second call mid-shift.
|
# This correctly handles officers dispatched to a second call mid-shift.
|
||||||
if not matched_incident and call_units and system_id and not reassignment:
|
if not matched_incident and call_units and system_id and not reassignment:
|
||||||
call_unit_set = set(call_units)
|
call_unit_set = _unit_keys(call_units)
|
||||||
unit_candidates = [
|
unit_candidates = [
|
||||||
inc for inc in all_active
|
inc for inc in all_active
|
||||||
if system_id in (inc.get("system_ids") or [])
|
if system_id in (inc.get("system_ids") or [])
|
||||||
and call_unit_set & set(inc.get("units") or [])
|
and call_unit_set & _unit_keys(inc.get("units"))
|
||||||
]
|
]
|
||||||
# Apply idle cap: units get reassigned; a 20+ min gap means the officer
|
# Apply idle cap: units get reassigned; a 20+ min gap means the officer
|
||||||
# has almost certainly moved on or the incident closed.
|
# has almost certainly moved on or the incident closed.
|
||||||
@@ -539,7 +607,7 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
best_unit_inc = max(unit_candidates, key=lambda i: i.get("updated_at", ""))
|
best_unit_inc = max(unit_candidates, key=lambda i: i.get("updated_at", ""))
|
||||||
reassigned_away = any(
|
reassigned_away = any(
|
||||||
inc["incident_id"] != best_unit_inc["incident_id"]
|
inc["incident_id"] != best_unit_inc["incident_id"]
|
||||||
and call_unit_set & set(inc.get("units") or [])
|
and call_unit_set & _unit_keys(inc.get("units"))
|
||||||
and inc.get("updated_at", "") > best_unit_inc.get("updated_at", "")
|
and inc.get("updated_at", "") > best_unit_inc.get("updated_at", "")
|
||||||
for inc in all_active
|
for inc in all_active
|
||||||
)
|
)
|
||||||
@@ -614,7 +682,7 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
# • embedding similarity >= cross-TG threshold (same subject matter)
|
# • embedding similarity >= cross-TG threshold (same subject matter)
|
||||||
# Requiring 2+ shared units prevents single-officer false positives.
|
# Requiring 2+ shared units prevents single-officer false positives.
|
||||||
if not matched_incident and call_embedding and incident_type and call_units and system_id:
|
if not matched_incident and call_embedding and incident_type and call_units and system_id:
|
||||||
call_unit_set = set(call_units)
|
call_unit_set = _unit_keys(call_units)
|
||||||
best_cross_score = 0.0
|
best_cross_score = 0.0
|
||||||
best_cross_inc: Optional[dict] = None
|
best_cross_inc: Optional[dict] = None
|
||||||
for inc in recent:
|
for inc in recent:
|
||||||
@@ -622,7 +690,7 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
continue
|
continue
|
||||||
if system_id not in (inc.get("system_ids") or []):
|
if system_id not in (inc.get("system_ids") or []):
|
||||||
continue
|
continue
|
||||||
inc_units_set = set(inc.get("units") or [])
|
inc_units_set = _unit_keys(inc.get("units"))
|
||||||
if len(call_unit_set & inc_units_set) < 2:
|
if len(call_unit_set & inc_units_set) < 2:
|
||||||
continue
|
continue
|
||||||
inc_embedding = inc.get("embedding")
|
inc_embedding = inc.get("embedding")
|
||||||
@@ -634,7 +702,7 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
best_cross_inc = inc
|
best_cross_inc = inc
|
||||||
if best_cross_inc and best_cross_score >= settings.embedding_cross_tg_threshold:
|
if best_cross_inc and best_cross_score >= settings.embedding_cross_tg_threshold:
|
||||||
matched_incident = best_cross_inc
|
matched_incident = best_cross_inc
|
||||||
shared = len(call_unit_set & set(best_cross_inc.get("units") or []))
|
shared = len(call_unit_set & _unit_keys(best_cross_inc.get("units")))
|
||||||
corr_debug = {
|
corr_debug = {
|
||||||
"corr_path": "cross-tg",
|
"corr_path": "cross-tg",
|
||||||
"corr_score": round(best_cross_score, 4),
|
"corr_score": round(best_cross_score, 4),
|
||||||
@@ -718,6 +786,35 @@ def _run_decision(ctx: dict) -> dict:
|
|||||||
f"Correlator: inferred incident_type={resolved_type!r} from tags {tags} for call {call_id}"
|
f"Correlator: inferred incident_type={resolved_type!r} from tags {tags} for call {call_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Severity, not type, decides whether a call is incident-worthy.
|
||||||
|
#
|
||||||
|
# Requiring a concrete incident_type here meant a channel whose traffic never
|
||||||
|
# classifies — transit/rail administration, records lookups, prisoner
|
||||||
|
# transports — could never open a SECOND incident. Every later call on that
|
||||||
|
# talkgroup then funnelled into whichever incident happened to be created
|
||||||
|
# first, producing hour-long incidents made of unrelated transmissions
|
||||||
|
# (2026-08-16: TG 9048, 28 calls / 49 min) alongside 30+ permanent orphans.
|
||||||
|
#
|
||||||
|
# Anything the extractor judged a real event, or that carries any concrete
|
||||||
|
# content, now opens an incident under the neutral "other" type. Only
|
||||||
|
# content-free routine traffic is still left for the thin path to attach.
|
||||||
|
#
|
||||||
|
# `call_units` and `location` are NOT substance. Radio protocol puts a unit
|
||||||
|
# ID in essentially every transmission and a place name in most of them, so
|
||||||
|
# including them made has_substance true almost always and the severity check
|
||||||
|
# dead code — the first version of this gate turned "11-Victor, 72 at Holland
|
||||||
|
# Station" into its own incident and left 37 of 50 incidents open, one call
|
||||||
|
# each. A vehicle, a geocode, or a tag means the extractor found something
|
||||||
|
# beyond who was speaking and where they stood.
|
||||||
|
if not resolved_type:
|
||||||
|
has_substance = bool(call_vehicles or coords or tags)
|
||||||
|
if call_severity in ("minor", "moderate", "major") or has_substance:
|
||||||
|
resolved_type = "other"
|
||||||
|
logger.info(
|
||||||
|
f"Correlator: call {call_id} has no incident_type — opening an 'other' "
|
||||||
|
f"incident (severity={call_severity}, substance={has_substance})"
|
||||||
|
)
|
||||||
|
|
||||||
if not resolved_type:
|
if not resolved_type:
|
||||||
return {"action": "orphan", "matched_incident": None, "incident_type": None, "corr_debug": corr_debug}
|
return {"action": "orphan", "matched_incident": None, "incident_type": None, "corr_debug": corr_debug}
|
||||||
|
|
||||||
@@ -755,6 +852,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
call_id = ctx["call_id"]
|
call_id = ctx["call_id"]
|
||||||
|
org_id = ctx["org_id"]
|
||||||
talkgroup_id = ctx["talkgroup_id"]
|
talkgroup_id = ctx["talkgroup_id"]
|
||||||
talkgroup_name = ctx["talkgroup_name"]
|
talkgroup_name = ctx["talkgroup_name"]
|
||||||
system_id = ctx["system_id"]
|
system_id = ctx["system_id"]
|
||||||
@@ -772,11 +870,13 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
|
|||||||
|
|
||||||
if action == "link":
|
if action == "link":
|
||||||
matched_incident = decision["matched_incident"]
|
matched_incident = decision["matched_incident"]
|
||||||
|
# A thin call attaches for context but does not count as incident activity.
|
||||||
|
thin_link = (decision.get("corr_debug") or {}).get("corr_path") == "fast/thin"
|
||||||
await _update_incident(
|
await _update_incident(
|
||||||
matched_incident, call_id, talkgroup_id, system_id, tags,
|
matched_incident, call_id, talkgroup_id, system_id, tags,
|
||||||
location, location_coords, call_units, call_vehicles, call_embedding, now,
|
location, location_coords, call_units, call_vehicles, call_embedding, now,
|
||||||
talkgroup_name=talkgroup_name, incident_type=incident_type,
|
talkgroup_name=talkgroup_name, incident_type=incident_type,
|
||||||
cleared_units=call_cleared,
|
cleared_units=call_cleared, refresh_activity=not thin_link,
|
||||||
)
|
)
|
||||||
return matched_incident["incident_id"]
|
return matched_incident["incident_id"]
|
||||||
|
|
||||||
@@ -805,7 +905,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
|
|||||||
|
|
||||||
# Create the new agency's child incident first
|
# Create the new agency's child incident first
|
||||||
incident_id = await _create_incident(
|
incident_id = await _create_incident(
|
||||||
call_id, incident_type, talkgroup_id, talkgroup_name, system_id,
|
call_id, org_id, incident_type, talkgroup_id, talkgroup_name, system_id,
|
||||||
tags, location, location_coords,
|
tags, location, location_coords,
|
||||||
call_units, call_vehicles, call_embedding, call_severity, now,
|
call_units, call_vehicles, call_embedding, call_severity, now,
|
||||||
)
|
)
|
||||||
@@ -824,6 +924,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
|
|||||||
master_id = await _create_master_incident(
|
master_id = await _create_master_incident(
|
||||||
first_child_id=existing_child_id,
|
first_child_id=existing_child_id,
|
||||||
second_child_id=incident_id,
|
second_child_id=incident_id,
|
||||||
|
org_id=org_id,
|
||||||
operational_type=incident_type,
|
operational_type=incident_type,
|
||||||
location=cross_parent.get("location") or location,
|
location=cross_parent.get("location") or location,
|
||||||
location_coords=cross_parent.get("location_coords") or coords,
|
location_coords=cross_parent.get("location_coords") or coords,
|
||||||
@@ -839,7 +940,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
|
|||||||
else:
|
else:
|
||||||
# Normal single-agency incident creation
|
# Normal single-agency incident creation
|
||||||
incident_id = await _create_incident(
|
incident_id = await _create_incident(
|
||||||
call_id, incident_type, talkgroup_id, talkgroup_name, system_id,
|
call_id, org_id, incident_type, talkgroup_id, talkgroup_name, system_id,
|
||||||
tags, location, location_coords,
|
tags, location, location_coords,
|
||||||
call_units, call_vehicles, call_embedding, call_severity, now,
|
call_units, call_vehicles, call_embedding, call_severity, now,
|
||||||
)
|
)
|
||||||
@@ -919,8 +1020,8 @@ def _disambiguate(
|
|||||||
elif idle_min < 15: score += 1.0
|
elif idle_min < 15: score += 1.0
|
||||||
# > 15 min: no bonus — older incidents compete on content/units only
|
# > 15 min: no bonus — older incidents compete on content/units only
|
||||||
|
|
||||||
inc_units = set(inc.get("units") or [])
|
inc_units = _unit_keys(inc.get("units"))
|
||||||
if inc_units and call_units and any(u in inc_units for u in call_units):
|
if inc_units and call_units and (inc_units & _unit_keys(call_units)):
|
||||||
score += 10.0
|
score += 10.0
|
||||||
|
|
||||||
inc_vehicles = set(inc.get("vehicles") or [])
|
inc_vehicles = set(inc.get("vehicles") or [])
|
||||||
@@ -1015,8 +1116,8 @@ def _call_fits_incident(
|
|||||||
inc_id = inc.get("incident_id", "?")
|
inc_id = inc.get("incident_id", "?")
|
||||||
|
|
||||||
# ── 1. Unit overlap ───────────────────────────────────────────────────────
|
# ── 1. Unit overlap ───────────────────────────────────────────────────────
|
||||||
inc_units = set(inc.get("units") or [])
|
inc_units = _unit_keys(inc.get("units"))
|
||||||
matched_units = [u for u in call_units if u in inc_units] if (inc_units and call_units) else []
|
matched_units = _matching_units(call_units, inc.get("units"))
|
||||||
if matched_units:
|
if matched_units:
|
||||||
if is_dispatch:
|
if is_dispatch:
|
||||||
if call_coords:
|
if call_coords:
|
||||||
@@ -1149,6 +1250,7 @@ async def _update_incident(
|
|||||||
talkgroup_name: Optional[str] = None,
|
talkgroup_name: Optional[str] = None,
|
||||||
incident_type: Optional[str] = None,
|
incident_type: Optional[str] = None,
|
||||||
cleared_units: Optional[list[str]] = None,
|
cleared_units: Optional[list[str]] = None,
|
||||||
|
refresh_activity: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
incident_id = inc["incident_id"]
|
incident_id = inc["incident_id"]
|
||||||
|
|
||||||
@@ -1200,10 +1302,20 @@ async def _update_incident(
|
|||||||
"units_active": units_active,
|
"units_active": units_active,
|
||||||
"units_cleared": units_cleared,
|
"units_cleared": units_cleared,
|
||||||
"location_mentions": location_mentions,
|
"location_mentions": location_mentions,
|
||||||
"updated_at": now.isoformat(),
|
|
||||||
"summary_stale": True,
|
"summary_stale": True,
|
||||||
**embedding_updates,
|
**embedding_updates,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# `updated_at` drives every recency gate in the fast path, so a content-free
|
||||||
|
# status call must NOT refresh it. When it did, each "10-4" reset the idle
|
||||||
|
# clock on the incident it attached to, which kept that incident permanently
|
||||||
|
# "recent" and made it absorb the entire channel for as long as anyone kept
|
||||||
|
# acknowledging. The incident now ages from its last SUBSTANTIVE call, and
|
||||||
|
# thin traffic rides along without extending its life.
|
||||||
|
if refresh_activity:
|
||||||
|
updates["updated_at"] = now.isoformat()
|
||||||
|
else:
|
||||||
|
updates["last_thin_at"] = now.isoformat()
|
||||||
if best_location:
|
if best_location:
|
||||||
updates["location"] = best_location
|
updates["location"] = best_location
|
||||||
if best_coords:
|
if best_coords:
|
||||||
@@ -1249,6 +1361,7 @@ async def _update_incident(
|
|||||||
|
|
||||||
async def _create_incident(
|
async def _create_incident(
|
||||||
call_id: str,
|
call_id: str,
|
||||||
|
org_id: Optional[str],
|
||||||
incident_type: str,
|
incident_type: str,
|
||||||
talkgroup_id: Optional[int],
|
talkgroup_id: Optional[int],
|
||||||
talkgroup_name: Optional[str],
|
talkgroup_name: Optional[str],
|
||||||
@@ -1280,6 +1393,7 @@ async def _create_incident(
|
|||||||
|
|
||||||
doc = {
|
doc = {
|
||||||
"incident_id": incident_id,
|
"incident_id": incident_id,
|
||||||
|
"org_id": org_id,
|
||||||
"title": title,
|
"title": title,
|
||||||
"incident_type": "master", # structural role; "child" set on demotion
|
"incident_type": "master", # structural role; "child" set on demotion
|
||||||
"type": incident_type,
|
"type": incident_type,
|
||||||
@@ -1327,6 +1441,7 @@ def _merge_embedding_vecs(inc: dict, call_embedding: list[float]) -> dict:
|
|||||||
async def _create_master_incident(
|
async def _create_master_incident(
|
||||||
first_child_id: str,
|
first_child_id: str,
|
||||||
second_child_id: str,
|
second_child_id: str,
|
||||||
|
org_id: Optional[str],
|
||||||
operational_type: str,
|
operational_type: str,
|
||||||
location: Optional[str],
|
location: Optional[str],
|
||||||
location_coords: Optional[dict],
|
location_coords: Optional[dict],
|
||||||
@@ -1340,6 +1455,7 @@ async def _create_master_incident(
|
|||||||
master_id = str(uuid.uuid4())
|
master_id = str(uuid.uuid4())
|
||||||
doc = {
|
doc = {
|
||||||
"incident_id": master_id,
|
"incident_id": master_id,
|
||||||
|
"org_id": org_id,
|
||||||
"title": f"Multi-agency {operational_type} incident",
|
"title": f"Multi-agency {operational_type} incident",
|
||||||
"incident_type": "master",
|
"incident_type": "master",
|
||||||
"type": operational_type,
|
"type": operational_type,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ Response format — a JSON object with a "scenes" array. Each scene:
|
|||||||
vehicles: list of vehicle descriptions mentioned
|
vehicles: list of vehicle descriptions mentioned
|
||||||
units: list of unit IDs or officer numbers explicitly mentioned
|
units: list of unit IDs or officer numbers explicitly mentioned
|
||||||
cleared_units: list of unit IDs that explicitly signal back-in-service or available in this recording
|
cleared_units: list of unit IDs that explicitly signal back-in-service or available in this recording
|
||||||
severity: one of "minor" | "moderate" | "major" | "unknown"
|
severity: one of "routine" | "minor" | "moderate" | "major"
|
||||||
resolved: true if this scene explicitly signals incident closure, false otherwise
|
resolved: true if this scene explicitly signals incident closure, false otherwise
|
||||||
reassignment: true if a unit is breaking from their current scene to respond to a completely different call — whether dispatch-initiated ("Baker, can you clear and respond to...", "Adam, break from that and go to...") OR unit-initiated ("Show me headed to the vehicle complaint", "Can you show me to that call", a unit going 10-8 and self-requesting a new assignment). False if the unit is reporting in on their current scene, giving a status update, or requesting information about their existing call.
|
reassignment: true if a unit is breaking from their current scene to respond to a completely different call — whether dispatch-initiated ("Baker, can you clear and respond to...", "Adam, break from that and go to...") OR unit-initiated ("Show me headed to the vehicle complaint", "Can you show me to that call", a unit going 10-8 and self-requesting a new assignment). False if the unit is reporting in on their current scene, giving a status update, or requesting information about their existing call.
|
||||||
transcript_corrected: corrected text for this scene's transmissions only, or null
|
transcript_corrected: corrected text for this scene's transmissions only, or null
|
||||||
@@ -52,7 +52,12 @@ Rules:
|
|||||||
- tags: describe WHAT happened, not WHERE. Specific, lowercase, hyphenated. Do not use location names, road names, talkgroup names, or place names as tags (wrong: "lower-macy's", "canvas-route-6", "route-202"; right: "suspect-search", "shoplifting", "vehicle-pursuit"). Do not repeat incident_type as a tag.
|
- tags: describe WHAT happened, not WHERE. Specific, lowercase, hyphenated. Do not use location names, road names, talkgroup names, or place names as tags (wrong: "lower-macy's", "canvas-route-6", "route-202"; right: "suspect-search", "shoplifting", "vehicle-pursuit"). Do not repeat incident_type as a tag.
|
||||||
- units: ONLY identifiers that appear verbatim in the transcript. Use speaker role inference to distinguish units being dispatched from units acknowledging — both should be included. Never infer or guess unit IDs not present in the text.
|
- units: ONLY identifiers that appear verbatim in the transcript. Use speaker role inference to distinguish units being dispatched from units acknowledging — both should be included. Never infer or guess unit IDs not present in the text.
|
||||||
- Do not invent details not present in the transcript.
|
- Do not invent details not present in the transcript.
|
||||||
- incident_type: let the talkgroup channel be your primary signal. Use "fire" ONLY if the talkgroup is clearly a fire/rescue channel OR the transcript explicitly describes active fire, smoke, flames, or structure fire activation. Police or EMS referencing a fire scene → use "police" or "ems". When uncertain, prefer "other" over "fire".
|
- incident_type: let the talkgroup channel be your primary signal. Use "fire" ONLY if the talkgroup is clearly a fire/rescue channel OR the transcript explicitly describes active fire, smoke, flames, or structure fire activation. Police or EMS referencing a fire scene → use "police" or "ems". When the channel is a police channel and nothing in the transcript contradicts it, return "police" — do NOT fall back to "other" merely because the transmission is administrative. Reserve "other" for traffic that genuinely belongs to no emergency service (rail operations, public works, utility coordination). Reserve "unknown" for transcripts too garbled to place at all.
|
||||||
|
- severity: ALWAYS return one of the four values. Judge the underlying event, not how dramatic the words sound.
|
||||||
|
"routine" — administrative/status traffic with no incident behind it: mileage and transport logging, radio checks, acknowledgements, shift changes, track block/power requests, records lookups.
|
||||||
|
"minor" — a real but low-stakes call: lift assist, parking complaint, past-tense larceny report, noise complaint, welfare check.
|
||||||
|
"moderate" — an active call needing a response now: MVA, alarm activation, disturbance in progress, medical call, suspicious person, road closure.
|
||||||
|
"major" — life safety or major property loss: structure fire, vehicle pursuit, shots fired, entrapment, cardiac arrest, officer needing assistance.
|
||||||
- ten_codes: interpret radio codes using the department reference provided below. Do not guess codes not listed.
|
- ten_codes: interpret radio codes using the department reference provided below. Do not guess codes not listed.
|
||||||
- resolved: true only when the scene explicitly signals "Code 4", "all clear", "10-42", "in custody", "patient transported", "fire out", "GOA", "negative contact", "scene clear".
|
- resolved: true only when the scene explicitly signals "Code 4", "all clear", "10-42", "in custody", "patient transported", "fire out", "GOA", "negative contact", "scene clear".
|
||||||
- cleared_units: only include units that explicitly stated their own back-in-service status in this recording (e.g. "Unit 7, 10-8", "Baker-1 available", "E-14 back in service", or the department ten-code for available/back-in-service listed above). Silence or absence of a unit is NOT clearance. A scene-wide Code 4 belongs in resolved=true, not here — cleared_units is for individual unit availability signals only.
|
- cleared_units: only include units that explicitly stated their own back-in-service status in this recording (e.g. "Unit 7, 10-8", "Baker-1 available", "E-14 back in service", or the department ten-code for available/back-in-service listed above). Silence or absence of a unit is NOT clearance. A scene-wide Code 4 belongs in resolved=true, not here — cleared_units is for individual unit availability signals only.
|
||||||
@@ -63,6 +68,13 @@ System: {system_id}
|
|||||||
Talkgroup: {talkgroup_name}
|
Talkgroup: {talkgroup_name}
|
||||||
{ten_codes_block}{vocabulary_block}{transcript_block}"""
|
{ten_codes_block}{vocabulary_block}{transcript_block}"""
|
||||||
|
|
||||||
|
# The incident_type enum offered to the model in EXTRACTION_PROMPT. Kept here
|
||||||
|
# rather than only in the prompt so a model that invents a value cannot write it
|
||||||
|
# into incident.type. "unknown" is deliberately absent — it is a real answer
|
||||||
|
# from the model but not a usable type, and is normalised to None alongside
|
||||||
|
# anything unrecognised.
|
||||||
|
_VALID_INCIDENT_TYPES = frozenset({"fire", "ems", "police", "accident", "other"})
|
||||||
|
|
||||||
# Geographic bias radius for geocoding — half-width in degrees (~55 km)
|
# Geographic bias radius for geocoding — half-width in degrees (~55 km)
|
||||||
_GEO_DELTA = 0.5
|
_GEO_DELTA = 0.5
|
||||||
|
|
||||||
@@ -183,7 +195,13 @@ async def extract_scenes(
|
|||||||
f"({len(transcript.split())} words), skipping"
|
f"({len(transcript.split())} words), skipping"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await fstore.doc_set("calls", call_id, {"skip_reason": "transcript_too_short"})
|
# Severity is still recorded: a five-word acknowledgement is genuinely
|
||||||
|
# routine traffic, and downstream code treats a missing severity as
|
||||||
|
# "not yet processed" rather than "nothing happened".
|
||||||
|
await fstore.doc_set("calls", call_id, {
|
||||||
|
"skip_reason": "transcript_too_short",
|
||||||
|
"severity": "routine",
|
||||||
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return []
|
return []
|
||||||
@@ -213,13 +231,35 @@ async def extract_scenes(
|
|||||||
vehicles: list[str] = scene.get("vehicles") or []
|
vehicles: list[str] = scene.get("vehicles") or []
|
||||||
units: list[str] = scene.get("units") or []
|
units: list[str] = scene.get("units") or []
|
||||||
cleared_units: list[str] = scene.get("cleared_units") or []
|
cleared_units: list[str] = scene.get("cleared_units") or []
|
||||||
severity: str = scene.get("severity") or "unknown"
|
# Every call carries a severity — it is the signal the correlator uses to
|
||||||
|
# decide whether a call is incident-worthy at all, so it must never be
|
||||||
|
# absent. "unknown" is a legacy value from before the prompt guaranteed
|
||||||
|
# one of the four levels; normalise it to the bottom rung.
|
||||||
|
severity: str = scene.get("severity") or "routine"
|
||||||
|
if severity == "unknown":
|
||||||
|
severity = "routine"
|
||||||
resolved: bool = bool(scene.get("resolved", False))
|
resolved: bool = bool(scene.get("resolved", False))
|
||||||
reassignment: bool = bool(scene.get("reassignment", False))
|
reassignment: bool = bool(scene.get("reassignment", False))
|
||||||
transcript_corrected: Optional[str]= scene.get("transcript_corrected") or None
|
transcript_corrected: Optional[str]= scene.get("transcript_corrected") or None
|
||||||
segment_indices: Optional[list] = scene.get("segment_indices")
|
segment_indices: Optional[list] = scene.get("segment_indices")
|
||||||
|
|
||||||
if incident_type in ("unknown", "other", ""):
|
# "other" is a real classification (rail ops, public works, utility work)
|
||||||
|
# and is kept. Collapsing it to None used to make the call untypeable,
|
||||||
|
# and an untypeable call could never open an incident — see the creation
|
||||||
|
# gate in incident_correlator._run_decision().
|
||||||
|
#
|
||||||
|
# Anything outside the enum is a model error, not a new category. The
|
||||||
|
# value is written straight through to incident.type and rendered as the
|
||||||
|
# incident title, so on 2026-08-16 a model that answered the severity
|
||||||
|
# question in the type field produced an incident literally titled
|
||||||
|
# "Routine — TGID 9563". Unrecognised values become None and fall to the
|
||||||
|
# tag/severity path, which is the same treatment "unknown" already got.
|
||||||
|
if incident_type not in _VALID_INCIDENT_TYPES:
|
||||||
|
if incident_type and incident_type != "unknown":
|
||||||
|
logger.warning(
|
||||||
|
f"Intelligence: discarding invalid incident_type {incident_type!r} "
|
||||||
|
f"(not in {sorted(_VALID_INCIDENT_TYPES)})"
|
||||||
|
)
|
||||||
incident_type = None
|
incident_type = None
|
||||||
|
|
||||||
# Geocode this scene's location.
|
# Geocode this scene's location.
|
||||||
@@ -399,11 +439,17 @@ async def _geocode_location(
|
|||||||
return None
|
return None
|
||||||
result = data["results"][0]
|
result = data["results"][0]
|
||||||
location_type = result.get("geometry", {}).get("location_type", "")
|
location_type = result.get("geometry", {}).get("location_type", "")
|
||||||
# Only accept address-level precision. GEOMETRIC_CENTER (city/neighborhood
|
# Reject only APPROXIMATE — a region/city boundary centroid, which is
|
||||||
# centroid) and APPROXIMATE (region boundary) produce coordinates that look
|
# what an ungeocodable string degrades to and is genuinely useless.
|
||||||
# valid but are too vague for 0.5km proximity matching — they often resolve
|
#
|
||||||
# to the same point as the node's position and create false proximity matches.
|
# ROOFTOP-only was too strict and emptied the map: dispatch names
|
||||||
if location_type not in ("ROOFTOP", "RANGE_INTERPOLATED"):
|
# places the way people speak, and Google returns GEOMETRIC_CENTER for
|
||||||
|
# exactly those forms — intersections ("Lake Street and Veterans
|
||||||
|
# Memorial Drive") and named POIs ("Brewster Station"). Both are
|
||||||
|
# precise enough to plot and to proximity-match; requiring a street
|
||||||
|
# address threw away nearly every real dispatch location, leaving only
|
||||||
|
# numbered addresses geocoded.
|
||||||
|
if location_type not in ("ROOFTOP", "RANGE_INTERPOLATED", "GEOMETRIC_CENTER"):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Geocoding rejected '{location_str}' — imprecise result "
|
f"Geocoding rejected '{location_str}' — imprecise result "
|
||||||
f"(location_type={location_type!r}), returning None"
|
f"(location_type={location_type!r}), returning None"
|
||||||
|
|||||||
@@ -241,10 +241,55 @@ async def decide(call_id: str, ctx: dict) -> Optional[dict]:
|
|||||||
)
|
)
|
||||||
return decision
|
return decision
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"LLM correlator failed for call {call_id}: {e}")
|
_log_llm_failure("LLM correlator", call_id, settings.corr_cheap_model, e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_dead_models: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _log_llm_failure(where: str, call_id: str, model: str, exc: Exception) -> None:
|
||||||
|
"""
|
||||||
|
Log an LLM failure, escalating a dead model ID to ERROR once per model.
|
||||||
|
|
||||||
|
A per-call WARNING was the only signal that gemini-2.0-flash had been shut
|
||||||
|
down, and since every failure falls back to the rules decision the pipeline
|
||||||
|
kept running normally -- the LLM tier was dead for an unknown number of days
|
||||||
|
while correlation was being tuned against rules-only output. A transient API
|
||||||
|
error is genuinely a warning; a model that does not exist is a config bug
|
||||||
|
that will never fix itself, so it gets ERROR and says what to do.
|
||||||
|
"""
|
||||||
|
text = str(exc)
|
||||||
|
low = text.lower()
|
||||||
|
|
||||||
|
if "404" in text or "not found" in low or "no longer available" in low:
|
||||||
|
_log_tier_down(where, model, "model is unavailable",
|
||||||
|
"Update CORR_CHEAP_MODEL/CORR_SMART_MODEL in config.py", text)
|
||||||
|
return
|
||||||
|
|
||||||
|
# A depleted balance reads as 429, the same status as an ordinary rate limit,
|
||||||
|
# but it is the opposite kind of problem: a rate limit clears on its own and a
|
||||||
|
# dead account never does. Matching on the billing wording keeps a burst of
|
||||||
|
# rate limits at WARNING while an empty account escalates like a bad model ID.
|
||||||
|
if "credits are depleted" in low or "prepayment" in low or "billing" in low:
|
||||||
|
_log_tier_down(where, model, "the Gemini account is out of credit",
|
||||||
|
"Top up billing at https://ai.studio/projects", text)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.warning(f"{where} failed for call {call_id}: {text}")
|
||||||
|
|
||||||
|
|
||||||
|
def _log_tier_down(where: str, model: str, problem: str, fix: str, text: str) -> None:
|
||||||
|
"""ERROR once per model, not once per call — this runs at radio-traffic volume."""
|
||||||
|
if model in _dead_models:
|
||||||
|
return
|
||||||
|
_dead_models.add(model)
|
||||||
|
logger.error(
|
||||||
|
f"{where}: {problem} ({model!r}) -- the LLM correlation tier is DISABLED "
|
||||||
|
f"and every call is falling back to rules-only. {fix}. API said: {text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def tiebreak(rules_decision: dict, llm_decision: dict, ctx: dict) -> dict:
|
async def tiebreak(rules_decision: dict, llm_decision: dict, ctx: dict) -> dict:
|
||||||
"""
|
"""
|
||||||
Run the smart tiebreaker (corr_smart_model) when rules and LLM disagree.
|
Run the smart tiebreaker (corr_smart_model) when rules and LLM disagree.
|
||||||
@@ -263,7 +308,7 @@ async def tiebreak(rules_decision: dict, llm_decision: dict, ctx: dict) -> dict:
|
|||||||
)
|
)
|
||||||
return decision
|
return decision
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"LLM tiebreak failed for call {call_id}: {e} — using rules decision")
|
_log_llm_failure("LLM tiebreak", call_id, settings.corr_smart_model, e)
|
||||||
return rules_decision
|
return rules_decision
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,4 +7,12 @@ logging.basicConfig(
|
|||||||
handlers=[logging.StreamHandler(sys.stdout)],
|
handlers=[logging.StreamHandler(sys.stdout)],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# httpx logs every request at INFO as a full URL *including the query string*,
|
||||||
|
# which puts API keys in plaintext in container logs — the Google Maps key was
|
||||||
|
# leaking on every geocode call (`?address=...&key=AIza...`). Nothing here needs
|
||||||
|
# per-request client logging, so drop httpx to WARNING; failures still surface
|
||||||
|
# because the callers log their own errors.
|
||||||
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||||
|
|
||||||
logger = logging.getLogger("drb-c2-core")
|
logger = logging.getLogger("drb-c2-core")
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import paho.mqtt.client as mqtt
|
import paho.mqtt.client as mqtt
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.tenancy import FOUNDING_ORG_ID
|
||||||
|
|
||||||
|
|
||||||
class MQTTHandler:
|
class MQTTHandler:
|
||||||
@@ -33,6 +34,10 @@ class MQTTHandler:
|
|||||||
client.subscribe("nodes/+/checkin", qos=1)
|
client.subscribe("nodes/+/checkin", qos=1)
|
||||||
client.subscribe("nodes/+/status", qos=1)
|
client.subscribe("nodes/+/status", qos=1)
|
||||||
client.subscribe("nodes/+/metadata", qos=1)
|
client.subscribe("nodes/+/metadata", qos=1)
|
||||||
|
# TODO(mqtt-cutover): drop this subscribe once the enrollment/HTTP
|
||||||
|
# credentials flow (routers/enrollment.py) is stable in prod and
|
||||||
|
# node-26 (the one live node) has been migrated. See
|
||||||
|
# MQTT-PUBLIC-AUTH-PLAN.md "Rollout order" step 6.
|
||||||
client.subscribe("nodes/+/key_request", qos=1)
|
client.subscribe("nodes/+/key_request", qos=1)
|
||||||
logger.info("MQTT connected — subscribed to node topics.")
|
logger.info("MQTT connected — subscribed to node topics.")
|
||||||
else:
|
else:
|
||||||
@@ -83,9 +88,19 @@ class MQTTHandler:
|
|||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
if not existing:
|
if not existing:
|
||||||
# First time we've seen this node — create it as unconfigured, pending approval
|
# First time we've seen this node — create it as unconfigured, pending approval.
|
||||||
|
# This branch only fires for a node_id that has never gone through
|
||||||
|
# POST /nodes/enroll (routers/enrollment.py) — a properly-enrolled
|
||||||
|
# node already has a Firestore doc, with its real org_id, by the time
|
||||||
|
# its first checkin arrives, so `existing` would be truthy and this
|
||||||
|
# branch wouldn't run. What's left is the legacy shared-MQTT-password
|
||||||
|
# path (node-26 — see the TODO(mqtt-cutover) notes in this file),
|
||||||
|
# which has no enrollment token to resolve org_id from at all.
|
||||||
|
# Default it to FOUNDING_ORG_ID, same as enrollment.py's own
|
||||||
|
# legacy-token fallback.
|
||||||
doc = {
|
doc = {
|
||||||
"node_id": node_id,
|
"node_id": node_id,
|
||||||
|
"org_id": FOUNDING_ORG_ID,
|
||||||
"name": payload.get("name", node_id),
|
"name": payload.get("name", node_id),
|
||||||
"lat": payload.get("lat", 0.0),
|
"lat": payload.get("lat", 0.0),
|
||||||
"lon": payload.get("lon", 0.0),
|
"lon": payload.get("lon", 0.0),
|
||||||
@@ -94,6 +109,11 @@ class MQTTHandler:
|
|||||||
"last_seen": now.isoformat(),
|
"last_seen": now.isoformat(),
|
||||||
"assigned_system_id": None,
|
"assigned_system_id": None,
|
||||||
"approval_status": "pending",
|
"approval_status": "pending",
|
||||||
|
"node_type": payload.get("node_type", "fixed"),
|
||||||
|
"enforce_override_timeout": payload.get("enforce_override_timeout", True),
|
||||||
|
"is_overridden": False,
|
||||||
|
"override_system_id": None,
|
||||||
|
"override_timeout_at": None,
|
||||||
}
|
}
|
||||||
await fstore.doc_set("nodes", node_id, doc, merge=False)
|
await fstore.doc_set("nodes", node_id, doc, merge=False)
|
||||||
logger.info(f"New node registered: {node_id} — pending admin approval.")
|
logger.info(f"New node registered: {node_id} — pending admin approval.")
|
||||||
@@ -111,6 +131,34 @@ class MQTTHandler:
|
|||||||
elif existing.get("approval_status") == "approved":
|
elif existing.get("approval_status") == "approved":
|
||||||
# Approved but not yet configured — restore reachable status after reboot
|
# Approved but not yet configured — restore reachable status after reboot
|
||||||
updates["status"] = "unconfigured"
|
updates["status"] = "unconfigured"
|
||||||
|
|
||||||
|
node_type = payload.get("node_type", existing.get("node_type", "fixed"))
|
||||||
|
enforce_timeout = payload.get("enforce_override_timeout", existing.get("enforce_override_timeout", True))
|
||||||
|
is_overridden = payload.get("is_overridden", False)
|
||||||
|
override_system_id = payload.get("override_system_id")
|
||||||
|
|
||||||
|
updates["node_type"] = node_type
|
||||||
|
updates["enforce_override_timeout"] = enforce_timeout
|
||||||
|
|
||||||
|
if node_type == "portable":
|
||||||
|
updates["is_overridden"] = False
|
||||||
|
updates["override_system_id"] = None
|
||||||
|
updates["override_timeout_at"] = None
|
||||||
|
else:
|
||||||
|
updates["is_overridden"] = is_overridden
|
||||||
|
updates["override_system_id"] = override_system_id
|
||||||
|
|
||||||
|
if is_overridden:
|
||||||
|
existing_timeout = existing.get("override_timeout_at")
|
||||||
|
existing_override_id = existing.get("override_system_id")
|
||||||
|
if enforce_timeout:
|
||||||
|
if not existing_timeout or existing_override_id != override_system_id:
|
||||||
|
updates["override_timeout_at"] = (now + timedelta(hours=24)).isoformat()
|
||||||
|
else:
|
||||||
|
updates["override_timeout_at"] = None
|
||||||
|
else:
|
||||||
|
updates["override_timeout_at"] = None
|
||||||
|
|
||||||
await fstore.doc_update("nodes", node_id, updates)
|
await fstore.doc_update("nodes", node_id, updates)
|
||||||
|
|
||||||
# NOTE: discord_connected in checkins is informational only — do NOT release the
|
# NOTE: discord_connected in checkins is informational only — do NOT release the
|
||||||
@@ -157,6 +205,12 @@ class MQTTHandler:
|
|||||||
# Look up assigned system for this node (cached — assignment rarely changes)
|
# Look up assigned system for this node (cached — assignment rarely changes)
|
||||||
node = await fstore.doc_get_cached("nodes", node_id)
|
node = await fstore.doc_get_cached("nodes", node_id)
|
||||||
system_id = node.get("assigned_system_id") if node else None
|
system_id = node.get("assigned_system_id") if node else None
|
||||||
|
# org_id is inherited from the node, not carried in the MQTT payload —
|
||||||
|
# this is the load-bearing tenancy stamp (SAAS_PLAN.md B2b): every call
|
||||||
|
# and, downstream, every incident correlated from it, traces back to
|
||||||
|
# this. None only for a call from a node that predates tenancy and
|
||||||
|
# hasn't been through scripts/backfill_org_id.py yet.
|
||||||
|
org_id = node.get("org_id") if node else None
|
||||||
|
|
||||||
started_at_raw = payload.get("started_at")
|
started_at_raw = payload.get("started_at")
|
||||||
started_at = (
|
started_at = (
|
||||||
@@ -179,6 +233,7 @@ class MQTTHandler:
|
|||||||
doc = {
|
doc = {
|
||||||
"call_id": call_id,
|
"call_id": call_id,
|
||||||
"node_id": node_id,
|
"node_id": node_id,
|
||||||
|
"org_id": org_id,
|
||||||
"system_id": system_id,
|
"system_id": system_id,
|
||||||
"talkgroup_id": payload.get("tgid"),
|
"talkgroup_id": payload.get("tgid"),
|
||||||
"talkgroup_name": tgid_name,
|
"talkgroup_name": tgid_name,
|
||||||
@@ -212,6 +267,15 @@ class MQTTHandler:
|
|||||||
"ended_at": ended_at,
|
"ended_at": ended_at,
|
||||||
"status": "ended",
|
"status": "ended",
|
||||||
}
|
}
|
||||||
|
# doc_set below is a merge, so if call_start already wrote org_id this
|
||||||
|
# is a no-op write of the same value. But DEFERRED.md notes call_end
|
||||||
|
# can in principle arrive before call_start (ordering relies on MQTT
|
||||||
|
# preserving per-topic order, which holds in practice but isn't
|
||||||
|
# guaranteed) — in that case doc_set would CREATE the calls doc here
|
||||||
|
# with no org_id at all unless it's resolved independently.
|
||||||
|
node = await fstore.doc_get_cached("nodes", node_id)
|
||||||
|
if node and node.get("org_id"):
|
||||||
|
updates["org_id"] = node["org_id"]
|
||||||
if payload.get("audio_url"):
|
if payload.get("audio_url"):
|
||||||
updates["audio_url"] = payload["audio_url"]
|
updates["audio_url"] = payload["audio_url"]
|
||||||
|
|
||||||
@@ -221,6 +285,11 @@ class MQTTHandler:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Key request — re-deliver an existing approved key to a node that
|
# Key request — re-deliver an existing approved key to a node that
|
||||||
# lost its credentials (e.g. after a directory move / fresh volume)
|
# lost its credentials (e.g. after a directory move / fresh volume)
|
||||||
|
# TODO(mqtt-cutover): remove this handler + publish_node_key() below,
|
||||||
|
# and the key_request subscribe above, in the separate post-cutover
|
||||||
|
# pass called out in MQTT-PUBLIC-AUTH-PLAN.md. Left in place for now so
|
||||||
|
# node-26 (currently live, using the shared-password MQTT path) keeps
|
||||||
|
# working until the enrollment flow has replaced it in prod.
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def _handle_key_request(self, node_id: str):
|
async def _handle_key_request(self, node_id: str):
|
||||||
@@ -253,7 +322,10 @@ class MQTTHandler:
|
|||||||
logger.warning(f"MQTT not connected — could not push config to {node_id}")
|
logger.warning(f"MQTT not connected — could not push config to {node_id}")
|
||||||
|
|
||||||
def publish_node_key(self, node_id: str, api_key: str):
|
def publish_node_key(self, node_id: str, api_key: str):
|
||||||
"""Publish the provisioned API key to the node (retained so it survives reconnects)."""
|
"""Publish the provisioned API key to the node (retained so it survives reconnects).
|
||||||
|
TODO(mqtt-cutover): dead once nodes.py's callers switch to the HTTP
|
||||||
|
credentials poll (routers/enrollment.py) exclusively. See note above
|
||||||
|
_handle_key_request."""
|
||||||
topic = f"nodes/{node_id}/api_key"
|
topic = f"nodes/{node_id}/api_key"
|
||||||
if self._client and self._connected:
|
if self._client and self._connected:
|
||||||
self._client.publish(topic, json.dumps({"api_key": api_key}), qos=2, retain=True)
|
self._client.publish(topic, json.dumps({"api_key": api_key}), qos=2, retain=True)
|
||||||
|
|||||||
@@ -55,3 +55,35 @@ async def _sweep():
|
|||||||
logger.info(f"Node {node_id} marked offline (last seen: {last_seen.isoformat()})")
|
logger.info(f"Node {node_id} marked offline (last seen: {last_seen.isoformat()})")
|
||||||
from app.routers.tokens import release_token
|
from app.routers.tokens import release_token
|
||||||
await release_token(node_id)
|
await release_token(node_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for expired system overrides (only for fixed nodes with timeout enforced)
|
||||||
|
override_timeout_raw = node.get("override_timeout_at")
|
||||||
|
enforce_timeout = node.get("enforce_override_timeout", True)
|
||||||
|
node_type = node.get("node_type", "fixed")
|
||||||
|
|
||||||
|
if override_timeout_raw and enforce_timeout and node_type != "portable":
|
||||||
|
if isinstance(override_timeout_raw, str):
|
||||||
|
override_timeout = datetime.fromisoformat(override_timeout_raw)
|
||||||
|
else:
|
||||||
|
override_timeout = override_timeout_raw
|
||||||
|
if override_timeout.tzinfo is None:
|
||||||
|
override_timeout = override_timeout.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
if datetime.now(timezone.utc) > override_timeout:
|
||||||
|
node_id = node.get("node_id")
|
||||||
|
assigned_system_id = node.get("assigned_system_id")
|
||||||
|
logger.info(f"Node {node_id} override has expired. Reverting to system {assigned_system_id}.")
|
||||||
|
|
||||||
|
# Push the original assigned config if it exists
|
||||||
|
if assigned_system_id:
|
||||||
|
system_doc = await fstore.doc_get("systems", assigned_system_id)
|
||||||
|
if system_doc:
|
||||||
|
from app.internal.mqtt_handler import mqtt_handler
|
||||||
|
mqtt_handler.push_config(node_id, system_doc)
|
||||||
|
|
||||||
|
await fstore.doc_update("nodes", node_id, {
|
||||||
|
"is_overridden": False,
|
||||||
|
"override_system_id": None,
|
||||||
|
"override_timeout_at": None,
|
||||||
|
})
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ async def _run_sweep_pass() -> None:
|
|||||||
c for c in recent_ended
|
c for c in recent_ended
|
||||||
if not c.get("incident_ids") and not c.get("incident_id")
|
if not c.get("incident_ids") and not c.get("incident_id")
|
||||||
and not c.get("corr_path") # skip calls already exhausted
|
and not c.get("corr_path") # skip calls already exhausted
|
||||||
|
and not c.get("duplicate_of") # another node's copy — never processed by design
|
||||||
|
# /upload deliberately skips correlation for garbage and too-short
|
||||||
|
# transcripts (routers/upload.py) because they carry no signal. The sweep
|
||||||
|
# was not applying the same guard, so those fragments came back in through
|
||||||
|
# the thin path minutes later and attached to whatever was most recent —
|
||||||
|
# a second route into the over-merge the thin fix above addresses.
|
||||||
|
and not c.get("skip_reason")
|
||||||
and c.get("corr_sweep_count", 0) < MAX_SWEEP_ATTEMPTS
|
and c.get("corr_sweep_count", 0) < MAX_SWEEP_ATTEMPTS
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,40 @@
|
|||||||
|
"""
|
||||||
|
Call-audio storage and playback links.
|
||||||
|
|
||||||
|
TWO THINGS THIS MODULE DELIBERATELY DOES NOT DO ANY MORE:
|
||||||
|
|
||||||
|
1. It does not return a GCS *signed* URL from the upload path. Signing needs a
|
||||||
|
service-account private key, and the deployed VM runs on Application Default
|
||||||
|
Credentials with no key file (see ansible c2-core.env.j2). The old code
|
||||||
|
silently fell back to returning a bare ``gs://`` URI, which broke two things
|
||||||
|
at once: browsers can't fetch a gs:// URI, so no recording was ever
|
||||||
|
playable, and ``_public_url_to_gcs_uri`` in upload.py returned None for it,
|
||||||
|
so the transcription step was skipped without logging anything at all.
|
||||||
|
|
||||||
|
2. It does not store a long-lived URL on the call document. What gets persisted
|
||||||
|
is the canonical ``gs://`` object location; a short-lived playback link is
|
||||||
|
minted on read instead. Nothing durable and nothing loggable is a credential.
|
||||||
|
|
||||||
|
Playback goes through c2-core's own /media route rather than GCS directly,
|
||||||
|
because an <audio src> cannot carry an Authorization header — so the link
|
||||||
|
itself has to be the credential. It is a plain HMAC over (call_id, expiry)
|
||||||
|
keyed by SERVICE_KEY, which costs no network round-trip, keeps the bucket
|
||||||
|
fully private, and needs no IAM change on the VM's service account.
|
||||||
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
import hashlib
|
||||||
from typing import Optional
|
import hmac
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Optional, Tuple
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
|
|
||||||
|
# Domain separation: the audio-link key is derived from SERVICE_KEY rather than
|
||||||
|
# being SERVICE_KEY itself, so a leaked playback link can never be replayed as
|
||||||
|
# a service-key bearer token against the rest of the API.
|
||||||
|
_KEY_CONTEXT = b"drb-audio-link-v1"
|
||||||
|
|
||||||
|
|
||||||
def _safe_audio_filename(filename: str, call_id: str) -> str:
|
def _safe_audio_filename(filename: str, call_id: str) -> str:
|
||||||
"""Return a safe GCS object name derived from the call_id.
|
"""Return a safe GCS object name derived from the call_id.
|
||||||
@@ -12,7 +43,6 @@ def _safe_audio_filename(filename: str, call_id: str) -> str:
|
|||||||
call_id (which we control) to prevent path traversal via crafted filenames.
|
call_id (which we control) to prevent path traversal via crafted filenames.
|
||||||
The original extension is preserved only if it's a known audio type.
|
The original extension is preserved only if it's a known audio type.
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
ext = os.path.splitext(filename)[-1].lower() if filename else ""
|
ext = os.path.splitext(filename)[-1].lower() if filename else ""
|
||||||
if ext not in (".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"):
|
if ext not in (".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"):
|
||||||
ext = ".mp3"
|
ext = ".mp3"
|
||||||
@@ -20,38 +50,140 @@ def _safe_audio_filename(filename: str, call_id: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optional[str]:
|
async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optional[str]:
|
||||||
"""Upload audio bytes to GCS and return a signed URL, or None if disabled."""
|
"""Upload audio bytes to GCS and return the canonical gs:// URI, or None if disabled."""
|
||||||
if not settings.gcs_bucket:
|
if not settings.gcs_bucket:
|
||||||
logger.info("GCS_BUCKET not configured — skipping audio upload.")
|
logger.info("GCS_BUCKET not configured — skipping audio upload.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _upload() -> str:
|
safe_name = _safe_audio_filename(filename, call_id)
|
||||||
|
blob_path = f"calls/{safe_name}"
|
||||||
|
|
||||||
|
def _upload() -> None:
|
||||||
from google.cloud import storage
|
from google.cloud import storage
|
||||||
from google.oauth2 import service_account as sa
|
|
||||||
if settings.gcp_credentials_path:
|
if settings.gcp_credentials_path:
|
||||||
client = storage.Client.from_service_account_json(settings.gcp_credentials_path)
|
client = storage.Client.from_service_account_json(settings.gcp_credentials_path)
|
||||||
signing_creds = sa.Credentials.from_service_account_file(settings.gcp_credentials_path)
|
|
||||||
else:
|
else:
|
||||||
client = storage.Client()
|
client = storage.Client()
|
||||||
signing_creds = None
|
blob = client.bucket(settings.gcs_bucket).blob(blob_path)
|
||||||
bucket = client.bucket(settings.gcs_bucket)
|
|
||||||
safe_name = _safe_audio_filename(filename, call_id)
|
|
||||||
blob = bucket.blob(f"calls/{safe_name}")
|
|
||||||
blob.upload_from_string(data, content_type="audio/mpeg")
|
blob.upload_from_string(data, content_type="audio/mpeg")
|
||||||
if signing_creds:
|
|
||||||
return blob.generate_signed_url(
|
|
||||||
version="v2",
|
|
||||||
expiration=datetime.timedelta(days=365),
|
|
||||||
method="GET",
|
|
||||||
credentials=signing_creds,
|
|
||||||
)
|
|
||||||
# Fallback: return the gs:// URI (no public access)
|
|
||||||
return f"gs://{settings.gcs_bucket}/calls/{filename}"
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
url = await asyncio.to_thread(_upload)
|
await asyncio.to_thread(_upload)
|
||||||
logger.info(f"Audio uploaded: {url}")
|
|
||||||
return url
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"GCS upload failed: {e}")
|
logger.error(f"GCS upload failed: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
gcs_uri = f"gs://{settings.gcs_bucket}/{blob_path}"
|
||||||
|
logger.info(f"Audio uploaded: {gcs_uri}")
|
||||||
|
return gcs_uri
|
||||||
|
|
||||||
|
|
||||||
|
async def download_audio(gcs_uri: str) -> Optional[bytes]:
|
||||||
|
"""Fetch an object back out of GCS. Server-side read — no signing involved."""
|
||||||
|
bucket_name, blob_path = split_gcs_uri(gcs_uri)
|
||||||
|
if not bucket_name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _download() -> bytes:
|
||||||
|
from google.cloud import storage
|
||||||
|
if settings.gcp_credentials_path:
|
||||||
|
client = storage.Client.from_service_account_json(settings.gcp_credentials_path)
|
||||||
|
else:
|
||||||
|
client = storage.Client()
|
||||||
|
return client.bucket(bucket_name).blob(blob_path).download_as_bytes()
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(_download)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"GCS download failed for {gcs_uri}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def split_gcs_uri(gcs_uri: str) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
"""``gs://bucket/path/to.mp3`` → ``("bucket", "path/to.mp3")``."""
|
||||||
|
if not gcs_uri or not gcs_uri.startswith("gs://"):
|
||||||
|
return None, None
|
||||||
|
without_scheme = gcs_uri[len("gs://"):]
|
||||||
|
if "/" not in without_scheme:
|
||||||
|
return None, None
|
||||||
|
bucket_name, blob_path = without_scheme.split("/", 1)
|
||||||
|
return bucket_name, blob_path
|
||||||
|
|
||||||
|
|
||||||
|
def gcs_uri_for_call(call: dict) -> Optional[str]:
|
||||||
|
"""Resolve the audio object for a call document.
|
||||||
|
|
||||||
|
Prefers the canonical ``audio_gcs_uri`` written by /upload. Falls back to
|
||||||
|
reconstructing the object name from the call_id for documents written
|
||||||
|
before this module was fixed: those stored a gs:// URI built from the
|
||||||
|
*client-supplied* filename, which never matched the object actually
|
||||||
|
written (always ``calls/{call_id}.mp3``). Reconstructing rather than
|
||||||
|
trusting the stored value is what makes every pre-existing recording
|
||||||
|
playable again without a data migration.
|
||||||
|
"""
|
||||||
|
uri = call.get("audio_gcs_uri")
|
||||||
|
if uri:
|
||||||
|
return uri
|
||||||
|
call_id = call.get("call_id")
|
||||||
|
if call.get("audio_url") and call_id and settings.gcs_bucket:
|
||||||
|
return f"gs://{settings.gcs_bucket}/calls/{call_id}.mp3"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _link_key() -> Optional[bytes]:
|
||||||
|
if not settings.service_key:
|
||||||
|
return None
|
||||||
|
return hmac.new(settings.service_key.encode("utf-8"), _KEY_CONTEXT, hashlib.sha256).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def sign_audio_link(call_id: str, expires_at: int) -> Optional[str]:
|
||||||
|
key = _link_key()
|
||||||
|
if not key:
|
||||||
|
return None
|
||||||
|
msg = f"{call_id}:{expires_at}".encode("utf-8")
|
||||||
|
return hmac.new(key, msg, hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_audio_link(call_id: str, expires_at: int, signature: str) -> bool:
|
||||||
|
if expires_at < int(time.time()):
|
||||||
|
return False
|
||||||
|
expected = sign_audio_link(call_id, expires_at)
|
||||||
|
if not expected:
|
||||||
|
return False
|
||||||
|
return hmac.compare_digest(expected, signature)
|
||||||
|
|
||||||
|
|
||||||
|
_warned_no_service_key = False
|
||||||
|
_warned_no_public_url = False
|
||||||
|
|
||||||
|
|
||||||
|
def playback_url(call: dict) -> Optional[str]:
|
||||||
|
"""Mint a short-lived playback URL for a call, or None if it has no audio."""
|
||||||
|
global _warned_no_service_key, _warned_no_public_url
|
||||||
|
|
||||||
|
call_id = call.get("call_id")
|
||||||
|
if not call_id or not gcs_uri_for_call(call):
|
||||||
|
return None
|
||||||
|
|
||||||
|
expires_at = int(time.time()) + settings.audio_link_ttl_seconds
|
||||||
|
signature = sign_audio_link(call_id, expires_at)
|
||||||
|
if not signature:
|
||||||
|
if not _warned_no_service_key:
|
||||||
|
logger.error("SERVICE_KEY not set — call audio cannot be served.")
|
||||||
|
_warned_no_service_key = True
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Loud rather than silent: a relative link here would 404 against the
|
||||||
|
# frontend origin, which is the exact failure mode this module exists to
|
||||||
|
# stop repeating. Deploy via ansible so c2-core.env.j2 sets PUBLIC_API_URL.
|
||||||
|
if not settings.public_api_url and not _warned_no_public_url:
|
||||||
|
logger.error("PUBLIC_API_URL not set — call audio links will be relative and will not resolve.")
|
||||||
|
_warned_no_public_url = True
|
||||||
|
|
||||||
|
base = (settings.public_api_url or "").rstrip("/")
|
||||||
|
return f"{base}/media/calls/{call_id}/audio?exp={expires_at}&sig={signature}"
|
||||||
|
|
||||||
|
|
||||||
|
def with_playback_url(call: dict) -> dict:
|
||||||
|
"""Return the call dict with a freshly minted ``audio_url``."""
|
||||||
|
return {**call, "audio_url": playback_url(call)}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""
|
||||||
|
Shared tenancy constants used across auth, enrollment, org provisioning,
|
||||||
|
trip gating, and scripts/backfill_org_id.py.
|
||||||
|
|
||||||
|
FOUNDING_ORG_ID is the org every pre-tenancy document (nodes, systems,
|
||||||
|
calls, incidents, alert_rules created before this pass) gets stamped with by
|
||||||
|
scripts/backfill_org_id.py, and the org the legacy fleet-wide
|
||||||
|
settings.enrollment_token still resolves to in routers/enrollment.py so an
|
||||||
|
already-deployed field node doesn't break the day these rules deploy — see
|
||||||
|
that file's enroll_node() for the fallback path.
|
||||||
|
|
||||||
|
NOTE ON MODEL: per the owner's correction mid-build, DRB's access model is
|
||||||
|
participation-based (you run a node feeding the network, you get access to
|
||||||
|
the network's data), not per-seat SaaS — org_role is "owner"/"member" with
|
||||||
|
no tier axis, and organizations.plan_id/seat_limit/node_limit are inert
|
||||||
|
placeholders (see models.py OrganizationRecord) until a business-strategy
|
||||||
|
pass defines what, if anything, gates on them. FOUNDING_ORG_ID plays no
|
||||||
|
special role in that model beyond being the backfill target and the legacy
|
||||||
|
token's org — it is not a "free tier" or a privileged org in code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
FOUNDING_ORG_ID = "founding"
|
||||||
@@ -5,6 +5,7 @@ Audio is downloaded from GCS then sent to the Whisper API. Falls back to
|
|||||||
returning None on any failure so the intelligence pipeline can still run.
|
returning None on any failure so the intelligence pipeline can still run.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import os
|
import os
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -14,15 +15,119 @@ from app.internal import firestore as fstore
|
|||||||
# Whisper treats `prompt` as preceding transcript text, not instructions.
|
# Whisper treats `prompt` as preceding transcript text, not instructions.
|
||||||
# Writing it as actual radio speech primes the vocabulary toward P25 codes
|
# Writing it as actual radio speech primes the vocabulary toward P25 codes
|
||||||
# and phrasing before the model hears the audio.
|
# and phrasing before the model hears the audio.
|
||||||
|
#
|
||||||
|
# DO NOT put an enumerated run of ten-codes in here. The original version of
|
||||||
|
# this prompt opened with "10-4. 10-23. 10-20. 10-97. 10-8. ..." and Whisper,
|
||||||
|
# treating that as text it should continue, filled noisy or silent audio with
|
||||||
|
# sequences like "10-4. 10-5. 10-6. ... 10-99." Those hallucinations sailed
|
||||||
|
# straight past the no_speech_prob filter below, because the model is highly
|
||||||
|
# confident the continuation it invented is speech. Codes appear here only
|
||||||
|
# singly and inside a sentence, where there is no series to extend.
|
||||||
_WHISPER_PROMPT = (
|
_WHISPER_PROMPT = (
|
||||||
"10-4. 10-23. 10-20. 10-97. 10-8. 10-7. 10-34. 10-50. 10-52. "
|
"Dispatch, go ahead. Copy that, en route. Show me on scene. "
|
||||||
"Post 4, I'm out. Post 3. En route. On scene. In route. "
|
"Be advised, units responding. Negative, stand by. "
|
||||||
"Copy. Negative. Stand by. Be advised. Go ahead. "
|
"Post 4, I'm out. Received, thank you. "
|
||||||
"Units responding. Dispatch. Talkgroup. "
|
"Engine and ladder responding to a structure fire. "
|
||||||
"Engine. Ladder. Medic. Rescue. Car. Unit. "
|
"Medic on scene with one patient. "
|
||||||
"MVA. MVC. Structure fire. Working fire."
|
"Vehicle accident with injuries, MVA. "
|
||||||
|
"Show me 10-8 and clear."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Degenerate-output detection (see _is_degenerate). Tuned to catch Whisper's
|
||||||
|
# repetition failure mode without discarding terse but real radio traffic.
|
||||||
|
_MIN_CODES_FOR_RUN = 6 # ten-codes needed before a run is even considered
|
||||||
|
_RUN_RATIO = 0.7 # share of consecutive pairs that must step by +1
|
||||||
|
_MIN_SEGMENTS_FOR_REPEAT = 6 # segments needed before repetition is considered
|
||||||
|
_UNIQUE_RATIO = 0.25 # unique/total segment texts at or below this is degenerate
|
||||||
|
_MAX_PHRASE_REPEATS = 8 # identical consecutive phrase repeats allowed in one blob
|
||||||
|
|
||||||
|
|
||||||
|
def _ten_code_run(text: str) -> bool:
|
||||||
|
"""True if the text is mostly a counting run of ten-codes.
|
||||||
|
|
||||||
|
Real traffic uses ten-codes constantly, but never in ascending order — a
|
||||||
|
dispatcher does not say "10-4, 10-5, 10-6". An arithmetic series is the
|
||||||
|
signature of Whisper continuing a pattern rather than hearing one.
|
||||||
|
"""
|
||||||
|
numbers = [int(n) for n in re.findall(r"\b10-(\d{1,2})\b", text)]
|
||||||
|
if len(numbers) < _MIN_CODES_FOR_RUN:
|
||||||
|
return False
|
||||||
|
steps = [b - a for a, b in zip(numbers, numbers[1:])]
|
||||||
|
ascending = sum(1 for s in steps if s == 1)
|
||||||
|
return steps and (ascending / len(steps)) >= _RUN_RATIO
|
||||||
|
|
||||||
|
|
||||||
|
def _phrase_loop(text: str) -> bool:
|
||||||
|
"""True if one short phrase repeats far more than speech plausibly would.
|
||||||
|
|
||||||
|
Catches the other repetition mode, e.g. "Dispatch, do you copy?" emitted
|
||||||
|
a dozen times over static.
|
||||||
|
"""
|
||||||
|
parts = [p.strip().lower() for p in re.split(r"[.!?]", text) if p.strip()]
|
||||||
|
if len(parts) <= _MAX_PHRASE_REPEATS:
|
||||||
|
return False
|
||||||
|
repeats = 1
|
||||||
|
for prev, cur in zip(parts, parts[1:]):
|
||||||
|
repeats = repeats + 1 if cur == prev else 1
|
||||||
|
if repeats > _MAX_PHRASE_REPEATS:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_degenerate(text: str, segments: list[dict]) -> bool:
|
||||||
|
"""True if a transcript looks like Whisper output rather than radio traffic.
|
||||||
|
|
||||||
|
Applied AFTER the per-segment no_speech_prob filter, which does not catch
|
||||||
|
these: the model reports high confidence in text it invented by continuing
|
||||||
|
a pattern, so the only tell is the shape of the output itself.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
if _ten_code_run(text) or _phrase_loop(text):
|
||||||
|
return True
|
||||||
|
# Near-identical segments repeated across the whole recording.
|
||||||
|
if len(segments) >= _MIN_SEGMENTS_FOR_REPEAT:
|
||||||
|
normalised = {s["text"].strip().lower() for s in segments}
|
||||||
|
if len(normalised) / len(segments) <= _UNIQUE_RATIO:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
_billing_reported = False
|
||||||
|
|
||||||
|
|
||||||
|
def _log_transcribe_failure(call_id: str, exc: Exception) -> None:
|
||||||
|
"""
|
||||||
|
Log a transcription failure, escalating an unpayable account to ERROR once.
|
||||||
|
|
||||||
|
Transcription failing returns None and the pipeline carries on by design, so
|
||||||
|
a per-call WARNING is invisible: no transcript means no extraction, which
|
||||||
|
means no incident, and the only symptom is calls quietly arriving empty. A
|
||||||
|
network blip is genuinely a warning. An exhausted balance is not -- it will
|
||||||
|
not fix itself and it takes the whole pipeline down with it, so it says so
|
||||||
|
once, loudly, and names the fix.
|
||||||
|
|
||||||
|
The same failure mode already bit the Gemini correlator twice (a retired
|
||||||
|
model ID, then a depleted balance), which is why this is worth the code.
|
||||||
|
"""
|
||||||
|
global _billing_reported
|
||||||
|
text = str(exc)
|
||||||
|
low = text.lower()
|
||||||
|
|
||||||
|
if ("insufficient_quota" in low or "billing" in low
|
||||||
|
or "credit" in low or "exceeded your current quota" in low):
|
||||||
|
if not _billing_reported:
|
||||||
|
_billing_reported = True
|
||||||
|
logger.error(
|
||||||
|
"Transcription: the OpenAI account cannot be billed -- EVERY call is "
|
||||||
|
"now stored with no transcript, so extraction, correlation and "
|
||||||
|
"incidents are all dead downstream. Top up at "
|
||||||
|
f"https://platform.openai.com/settings/organization/billing. API said: {text}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.warning(f"Transcription failed for call {call_id}: {text}")
|
||||||
|
|
||||||
|
|
||||||
async def transcribe_call(
|
async def transcribe_call(
|
||||||
call_id: str,
|
call_id: str,
|
||||||
@@ -45,7 +150,7 @@ async def transcribe_call(
|
|||||||
_sync_transcribe, gcs_uri, talkgroup_name
|
_sync_transcribe, gcs_uri, talkgroup_name
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Transcription failed for call {call_id}: {e}")
|
_log_transcribe_failure(call_id, e)
|
||||||
return None, []
|
return None, []
|
||||||
|
|
||||||
if transcript:
|
if transcript:
|
||||||
@@ -76,7 +181,10 @@ def _sync_transcribe(
|
|||||||
|
|
||||||
if not settings.openai_api_key:
|
if not settings.openai_api_key:
|
||||||
logger.warning("OPENAI_API_KEY not set — transcription disabled.")
|
logger.warning("OPENAI_API_KEY not set — transcription disabled.")
|
||||||
return None
|
# Tuple, not a bare None: the caller unpacks two values, so returning
|
||||||
|
# None here raised a TypeError that surfaced as a misleading
|
||||||
|
# "Transcription failed" instead of the real missing-key warning.
|
||||||
|
return None, []
|
||||||
|
|
||||||
without_scheme = gcs_uri[len("gs://"):]
|
without_scheme = gcs_uri[len("gs://"):]
|
||||||
bucket_name, blob_path = without_scheme.split("/", 1)
|
bucket_name, blob_path = without_scheme.split("/", 1)
|
||||||
@@ -145,11 +253,17 @@ def _sync_transcribe(
|
|||||||
# in sync. If every segment was filtered, text becomes None which prevents
|
# in sync. If every segment was filtered, text becomes None which prevents
|
||||||
# the intelligence pipeline from running on hallucinated content.
|
# the intelligence pipeline from running on hallucinated content.
|
||||||
text = " ".join(s["text"] for s in segments) or None
|
text = " ".join(s["text"] for s in segments) or None
|
||||||
|
if _is_degenerate(text or "", segments):
|
||||||
|
logger.info(f"Discarded hallucinated transcript for {gcs_uri}: {(text or '')[:80]!r}")
|
||||||
|
return None, []
|
||||||
return text, segments
|
return text, segments
|
||||||
else:
|
else:
|
||||||
# json format returns just {"text": "..."} — no segments or timestamps.
|
# json format returns just {"text": "..."} — no segments or timestamps.
|
||||||
# Intelligence extraction falls back to treating the whole transcript as one block.
|
# Intelligence extraction falls back to treating the whole transcript as one block.
|
||||||
text = (response.text or "").strip() or None
|
text = (response.text or "").strip() or None
|
||||||
|
if _is_degenerate(text or "", []):
|
||||||
|
logger.info(f"Discarded hallucinated transcript for {gcs_uri}: {(text or '')[:80]!r}")
|
||||||
|
return None, []
|
||||||
return text, []
|
return text, []
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
|
|||||||
+43
-2
@@ -9,8 +9,14 @@ from app.internal.summarizer import summarizer_loop
|
|||||||
from app.internal.vocabulary_learner import vocabulary_induction_loop
|
from app.internal.vocabulary_learner import vocabulary_induction_loop
|
||||||
from app.internal.recorrelation_sweep import recorrelation_loop
|
from app.internal.recorrelation_sweep import recorrelation_loop
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.internal.auth import require_firebase_token, require_service_or_firebase_token
|
from app.internal.auth import (
|
||||||
|
require_firebase_token,
|
||||||
|
require_service_or_firebase_token,
|
||||||
|
require_node_service_or_firebase_token,
|
||||||
|
)
|
||||||
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
|
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
|
||||||
|
from app.routers import enrollment, media, org, waitlist
|
||||||
|
from app.internal import dynsec
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +42,22 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info("DRB C2 Core starting.")
|
logger.info("DRB C2 Core starting.")
|
||||||
await _release_orphaned_tokens()
|
await _release_orphaned_tokens()
|
||||||
|
|
||||||
|
# dynsec bootstrap + reconcile — must happen before mqtt_handler.connect()
|
||||||
|
# so that by the time the app is serving requests, c2-core's own dynsec
|
||||||
|
# client/roles exist and every already-approved node's dynsec client
|
||||||
|
# matches Firestore (see app/internal/dynsec.py "TWO-SOURCES-OF-TRUTH").
|
||||||
|
# Non-fatal by design: if the broker or MQTT_DYNSEC_ADMIN_PASS isn't
|
||||||
|
# reachable/configured yet (e.g. first-ever deploy, mosquitto still
|
||||||
|
# starting), log loudly and keep booting rather than crash-looping
|
||||||
|
# c2-core itself — mqtt_handler.connect() below has its own retry loop
|
||||||
|
# and node approval/reissue endpoints fail loudly on their own if dynsec
|
||||||
|
# calls fail later, so nothing here is silently swallowed forever.
|
||||||
|
try:
|
||||||
|
await dynsec.ensure_roles_and_c2core_grant()
|
||||||
|
await dynsec.reconcile_all()
|
||||||
|
except dynsec.DynsecError as e:
|
||||||
|
logger.error(f"dynsec bootstrap/reconcile failed — node approval/reissue will fail until this is resolved: {e}")
|
||||||
|
|
||||||
await mqtt_handler.connect()
|
await mqtt_handler.connect()
|
||||||
sweeper_task = asyncio.create_task(sweeper_loop())
|
sweeper_task = asyncio.create_task(sweeper_loop())
|
||||||
summarizer_task = asyncio.create_task(summarizer_loop())
|
summarizer_task = asyncio.create_task(summarizer_loop())
|
||||||
@@ -63,7 +85,11 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(nodes.router, dependencies=[Depends(require_service_or_firebase_token)])
|
app.include_router(nodes.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
app.include_router(systems.router, dependencies=[Depends(require_service_or_firebase_token)])
|
# systems is the one router edge nodes read directly (system_cacher.py builds
|
||||||
|
# the OP25 config from it), so its gate also accepts a per-node api_key. The
|
||||||
|
# write routes inside carry their own require_admin_token, so nodes get read
|
||||||
|
# access only.
|
||||||
|
app.include_router(systems.router, dependencies=[Depends(require_node_service_or_firebase_token)])
|
||||||
app.include_router(calls.router, dependencies=[Depends(require_service_or_firebase_token)])
|
app.include_router(calls.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
app.include_router(tokens.router, dependencies=[Depends(require_service_or_firebase_token)])
|
app.include_router(tokens.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)])
|
app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
@@ -74,6 +100,21 @@ app.include_router(upload.router) # auth is per-node, handled inline
|
|||||||
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
|
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
|
||||||
app.include_router(users.router) # auth: admin only
|
app.include_router(users.router) # auth: admin only
|
||||||
app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
|
app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
|
||||||
|
app.include_router(enrollment.router) # public; auth is the enrollment/pickup-secret tokens, checked inline
|
||||||
|
app.include_router(org.router) # auth is per-endpoint (read: firebase, write: org owner)
|
||||||
|
app.include_router(waitlist.router) # public — no auth, source-IP rate limited inline
|
||||||
|
# public by necessity — an <audio src> can't send a bearer token, so the
|
||||||
|
# short-lived HMAC in the URL is the credential. Checked inline in media.py.
|
||||||
|
app.include_router(media.router)
|
||||||
|
# NOTE: there used to be an app.routers.mqtt_auth router here (an HTTP
|
||||||
|
# backend for the mosquitto-go-auth plugin). That plugin's upstream project
|
||||||
|
# is archived (no CVE patches) and was rejected for an internet-facing
|
||||||
|
# broker — see MQTT-PUBLIC-AUTH-PLAN.md. MQTT auth is now mosquitto's own
|
||||||
|
# built-in dynamic-security plugin (app/internal/dynsec.py talks to it over
|
||||||
|
# MQTT control topics, not HTTP), so there is nothing at /internal/mqtt/*
|
||||||
|
# anymore. Caddy's Caddyfile.j2 still 404s /internal/* on api.<domain> as
|
||||||
|
# defence in depth even though nothing calls it today — cheap insurance
|
||||||
|
# against a future /internal/* route being added and forgotten there.
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -3,6 +3,50 @@ from typing import Optional, List, Dict, Any
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Organizations — the tenant boundary. See SAAS_PLAN.md B2 and
|
||||||
|
# app/internal/auth.py's require_org(). plan_id/subscription_status and the
|
||||||
|
# stripe_* fields are deliberately inert (None) here: no billing model has
|
||||||
|
# been decided yet (participation-based / reciprocal access, not per-seat
|
||||||
|
# SaaS — see the note on FOUNDING_ORG_ID in app/internal/tenancy.py), so this
|
||||||
|
# is just the seam a future billing pass would write into, not a promise
|
||||||
|
# about what that pass looks like.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class OrganizationRecord(BaseModel):
|
||||||
|
org_id: str
|
||||||
|
name: str
|
||||||
|
created_at: datetime
|
||||||
|
created_by_uid: str
|
||||||
|
plan_id: Optional[str] = None
|
||||||
|
subscription_status: Optional[str] = None
|
||||||
|
stripe_customer_id: Optional[str] = None
|
||||||
|
stripe_subscription_id: Optional[str] = None
|
||||||
|
current_period_end: Optional[datetime] = None
|
||||||
|
seat_limit: Optional[int] = None
|
||||||
|
node_limit: Optional[int] = None
|
||||||
|
retention_days: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrgMember(BaseModel):
|
||||||
|
uid: str
|
||||||
|
org_id: str
|
||||||
|
org_role: str # "owner" | "member"
|
||||||
|
email: Optional[str] = None
|
||||||
|
added_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class EnrollmentTokenRecord(BaseModel):
|
||||||
|
"""Firestore doc id is the SHA-256 hash of the raw token — see
|
||||||
|
routers/enrollment.py's _hash_secret pattern (pickup_secret_hash)."""
|
||||||
|
org_id: str
|
||||||
|
label: str
|
||||||
|
created_at: datetime
|
||||||
|
created_by_uid: str
|
||||||
|
revoked: bool = False
|
||||||
|
uses: int = 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Nodes
|
# Nodes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -10,12 +54,18 @@ from datetime import datetime
|
|||||||
class NodeRecord(BaseModel):
|
class NodeRecord(BaseModel):
|
||||||
node_id: str
|
node_id: str
|
||||||
name: str
|
name: str
|
||||||
|
org_id: Optional[str] = None # stamped at enrollment; None only on pre-tenancy docs awaiting backfill
|
||||||
lat: float = 0.0
|
lat: float = 0.0
|
||||||
lon: float = 0.0
|
lon: float = 0.0
|
||||||
status: str = "offline" # online / offline / recording / unconfigured
|
status: str = "offline" # online / offline / recording / unconfigured
|
||||||
configured: bool = False
|
configured: bool = False
|
||||||
last_seen: Optional[datetime] = None
|
last_seen: Optional[datetime] = None
|
||||||
assigned_system_id: Optional[str] = None
|
assigned_system_id: Optional[str] = None
|
||||||
|
node_type: str = "fixed" # fixed or portable
|
||||||
|
enforce_override_timeout: bool = True
|
||||||
|
is_overridden: bool = False
|
||||||
|
override_system_id: Optional[str] = None
|
||||||
|
override_timeout_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
class CommandPayload(BaseModel):
|
class CommandPayload(BaseModel):
|
||||||
@@ -30,6 +80,7 @@ class CommandPayload(BaseModel):
|
|||||||
|
|
||||||
class SystemRecord(BaseModel):
|
class SystemRecord(BaseModel):
|
||||||
system_id: str
|
system_id: str
|
||||||
|
org_id: Optional[str] = None
|
||||||
name: str
|
name: str
|
||||||
type: str # P25 / DMR / NBFM
|
type: str # P25 / DMR / NBFM
|
||||||
config: Dict[str, Any] = {} # OP25-compatible config blob
|
config: Dict[str, Any] = {} # OP25-compatible config blob
|
||||||
@@ -50,6 +101,7 @@ class SystemCreate(BaseModel):
|
|||||||
class CallRecord(BaseModel):
|
class CallRecord(BaseModel):
|
||||||
call_id: str
|
call_id: str
|
||||||
node_id: str
|
node_id: str
|
||||||
|
org_id: Optional[str] = None # inherited from the node at call_start/upload — see internal/mqtt_handler.py
|
||||||
system_id: Optional[str] = None
|
system_id: Optional[str] = None
|
||||||
talkgroup_id: Optional[int] = None
|
talkgroup_id: Optional[int] = None
|
||||||
talkgroup_name: Optional[str] = None
|
talkgroup_name: Optional[str] = None
|
||||||
@@ -57,7 +109,9 @@ class CallRecord(BaseModel):
|
|||||||
srcaddr: Optional[str] = None
|
srcaddr: Optional[str] = None
|
||||||
started_at: datetime
|
started_at: datetime
|
||||||
ended_at: Optional[datetime] = None
|
ended_at: Optional[datetime] = None
|
||||||
audio_url: Optional[str] = None
|
audio_gcs_uri: Optional[str] = None # canonical gs:// object location
|
||||||
|
audio_url: Optional[str] = None # NOT stored — minted per read, see internal/storage.py
|
||||||
|
duplicate_of: Optional[str] = None # another node recorded this same transmission first
|
||||||
transcript: Optional[str] = None # populated later by STT
|
transcript: Optional[str] = None # populated later by STT
|
||||||
incident_ids: List[str] = [] # one per scene detected in the recording
|
incident_ids: List[str] = [] # one per scene detected in the recording
|
||||||
location: Optional[Dict[str, float]] = None # {lat, lng}
|
location: Optional[Dict[str, float]] = None # {lat, lng}
|
||||||
@@ -71,6 +125,7 @@ class CallRecord(BaseModel):
|
|||||||
|
|
||||||
class IncidentRecord(BaseModel):
|
class IncidentRecord(BaseModel):
|
||||||
incident_id: str
|
incident_id: str
|
||||||
|
org_id: Optional[str] = None # inherited from the calls that created it — see internal/incident_correlator.py
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
type: Optional[str] = None # fire / police / ems / etc.
|
type: Optional[str] = None # fire / police / ems / etc.
|
||||||
status: str = "active" # active / resolved
|
status: str = "active" # active / resolved
|
||||||
@@ -107,6 +162,7 @@ class IncidentUpdate(BaseModel):
|
|||||||
|
|
||||||
class AlertRule(BaseModel):
|
class AlertRule(BaseModel):
|
||||||
rule_id: Optional[str] = None
|
rule_id: Optional[str] = None
|
||||||
|
org_id: Optional[str] = None
|
||||||
name: str
|
name: str
|
||||||
keywords: List[str] = []
|
keywords: List[str] = []
|
||||||
talkgroup_ids: List[int] = []
|
talkgroup_ids: List[int] = []
|
||||||
@@ -124,6 +180,7 @@ class AlertRuleUpdate(BaseModel):
|
|||||||
|
|
||||||
class AlertEvent(BaseModel):
|
class AlertEvent(BaseModel):
|
||||||
alert_id: Optional[str] = None
|
alert_id: Optional[str] = None
|
||||||
|
org_id: Optional[str] = None
|
||||||
rule_id: str
|
rule_id: str
|
||||||
rule_name: str
|
rule_name: str
|
||||||
call_id: str
|
call_id: str
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from app.internal.auth import require_admin_token, require_firebase_token
|
from app.internal.auth import require_admin_token
|
||||||
from app.internal.feature_flags import get_flags, set_flags
|
from app.internal.feature_flags import get_flags, set_flags
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
|
||||||
@@ -24,8 +24,13 @@ router = APIRouter(prefix="/admin", tags=["admin"])
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/features")
|
@router.get("/features")
|
||||||
async def get_feature_flags(_=Depends(require_firebase_token)):
|
async def get_feature_flags(_=Depends(require_admin_token)):
|
||||||
"""Return the current AI feature flag state. Any authenticated user can read."""
|
"""
|
||||||
|
Return the current AI feature flag state. Admin-only (SAAS_PLAN.md B2c) —
|
||||||
|
was previously any authenticated user via require_firebase_token, which
|
||||||
|
handed platform-wide AI configuration state to every signed-in viewer
|
||||||
|
regardless of org.
|
||||||
|
"""
|
||||||
return await get_flags()
|
return await get_flags()
|
||||||
|
|
||||||
|
|
||||||
@@ -93,13 +98,29 @@ async def debug_correlation(
|
|||||||
ai_systems = await _get_ai_enabled_system_ids(global_flags)
|
ai_systems = await _get_ai_enabled_system_ids(global_flags)
|
||||||
|
|
||||||
# ── Fetch recent incidents (AI-enabled systems only) ──────────────────────
|
# ── Fetch recent incidents (AI-enabled systems only) ──────────────────────
|
||||||
all_incidents = await fstore.collection_list("incidents")
|
# Read a bounded, already-sorted window rather than the whole collection.
|
||||||
all_incidents.sort(key=lambda i: i.get("updated_at", ""), reverse=True)
|
# This route used to pull every incident ever created and sort in Python,
|
||||||
|
# which stopped returning at all once the collection grew — Firestore kills
|
||||||
|
# an unbounded scan with a 503 and the request just hangs. Ordering on the
|
||||||
|
# single field updated_at needs no composite index.
|
||||||
|
#
|
||||||
|
# The AI-system filter runs in Python (it's a membership test against a set
|
||||||
|
# the flags decide), so the window has to be wider than `limit` or filtering
|
||||||
|
# could empty it. 10x with a floor of 200 covers a debug view; if a fetch
|
||||||
|
# still comes back short, incidents_window_exhausted says so in the payload
|
||||||
|
# rather than quietly looking like "no incidents".
|
||||||
|
window = max(limit * 10, 200)
|
||||||
|
all_incidents = await fstore.collection_where(
|
||||||
|
"incidents", [],
|
||||||
|
order_by=[("updated_at", "DESCENDING")],
|
||||||
|
limit_to=window,
|
||||||
|
)
|
||||||
ai_incidents = [
|
ai_incidents = [
|
||||||
i for i in all_incidents
|
i for i in all_incidents
|
||||||
if any(sid in ai_systems for sid in (i.get("system_ids") or []))
|
if any(sid in ai_systems for sid in (i.get("system_ids") or []))
|
||||||
]
|
]
|
||||||
incidents = ai_incidents[:limit]
|
incidents = ai_incidents[:limit]
|
||||||
|
incidents_window_exhausted = len(all_incidents) >= window and len(ai_incidents) < limit
|
||||||
|
|
||||||
# ── Fetch all linked call docs in parallel ────────────────────────────────
|
# ── Fetch all linked call docs in parallel ────────────────────────────────
|
||||||
all_call_ids: list[str] = []
|
all_call_ids: list[str] = []
|
||||||
@@ -125,13 +146,22 @@ async def debug_correlation(
|
|||||||
# Use a single-field range query to avoid requiring a composite Firestore index;
|
# Use a single-field range query to avoid requiring a composite Firestore index;
|
||||||
# filter status and system in Python.
|
# filter status and system in Python.
|
||||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=orphan_hours)
|
cutoff = datetime.now(timezone.utc) - timedelta(hours=orphan_hours)
|
||||||
recent_calls = await fstore.collection_where("calls", [
|
# Bounded for the same reason as the incident read above. The range and the
|
||||||
("ended_at", ">=", cutoff),
|
# sort are both on ended_at, which is what keeps this a single-field query
|
||||||
])
|
# needing no composite index.
|
||||||
|
_ORPHAN_SCAN_CAP = 3000
|
||||||
|
recent_calls = await fstore.collection_where(
|
||||||
|
"calls",
|
||||||
|
[("ended_at", ">=", cutoff)],
|
||||||
|
order_by=[("ended_at", "DESCENDING")],
|
||||||
|
limit_to=_ORPHAN_SCAN_CAP,
|
||||||
|
)
|
||||||
|
orphan_scan_truncated = len(recent_calls) >= _ORPHAN_SCAN_CAP
|
||||||
orphans = [
|
orphans = [
|
||||||
_call_summary(c) for c in recent_calls
|
_call_summary(c) for c in recent_calls
|
||||||
if c.get("status") == "ended"
|
if c.get("status") == "ended"
|
||||||
and not c.get("incident_ids") and not c.get("incident_id")
|
and not c.get("incident_ids") and not c.get("incident_id")
|
||||||
|
and not c.get("duplicate_of") # another node's copy — never meant to correlate
|
||||||
and c.get("system_id") in ai_systems
|
and c.get("system_id") in ai_systems
|
||||||
]
|
]
|
||||||
orphans.sort(key=lambda c: c.get("started_at", ""), reverse=True)
|
orphans.sort(key=lambda c: c.get("started_at", ""), reverse=True)
|
||||||
@@ -156,6 +186,11 @@ async def debug_correlation(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
# Both reads are capped, so say plainly when a cap was hit — otherwise a
|
||||||
|
# truncated window is indistinguishable from a quiet night.
|
||||||
|
"incidents_window_exhausted": incidents_window_exhausted,
|
||||||
|
"orphan_scan_truncated": orphan_scan_truncated,
|
||||||
|
"orphan_scan_cap": _ORPHAN_SCAN_CAP,
|
||||||
"incident_count": len(incident_records),
|
"incident_count": len(incident_records),
|
||||||
"orphaned_call_count": len(orphans),
|
"orphaned_call_count": len(orphans),
|
||||||
"orphans_by_talkgroup": sorted(orphans_by_tg.values(), key=lambda x: x["count"], reverse=True),
|
"orphans_by_talkgroup": sorted(orphans_by_tg.values(), key=lambda x: x["count"], reverse=True),
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||||
from app.models import AlertRule, AlertRuleUpdate
|
from app.models import AlertRule, AlertRuleUpdate
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.auth import require_admin_token
|
from app.internal.auth import require_admin_token, require_service_or_firebase_token, resolve_caller_org_id
|
||||||
|
from app.internal.tenancy import FOUNDING_ORG_ID
|
||||||
|
|
||||||
router = APIRouter(tags=["alerts"])
|
router = APIRouter(tags=["alerts"])
|
||||||
|
|
||||||
@@ -14,18 +15,31 @@ router = APIRouter(tags=["alerts"])
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/alerts")
|
@router.get("/alerts")
|
||||||
async def list_alerts(acknowledged: Optional[bool] = None):
|
async def list_alerts(
|
||||||
|
acknowledged: Optional[bool] = None,
|
||||||
|
decoded: dict = Depends(require_service_or_firebase_token),
|
||||||
|
):
|
||||||
filters = {}
|
filters = {}
|
||||||
if acknowledged is not None:
|
if acknowledged is not None:
|
||||||
filters["acknowledged"] = acknowledged
|
filters["acknowledged"] = acknowledged
|
||||||
|
org_id = await resolve_caller_org_id(decoded)
|
||||||
|
if org_id is not None:
|
||||||
|
filters["org_id"] = org_id
|
||||||
return await fstore.collection_list("alert_events", **filters)
|
return await fstore.collection_list("alert_events", **filters)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/alerts/{alert_id}/acknowledge")
|
@router.post("/alerts/{alert_id}/acknowledge")
|
||||||
async def acknowledge_alert(alert_id: str):
|
async def acknowledge_alert(alert_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
|
||||||
doc = await fstore.doc_get("alert_events", alert_id)
|
doc = await fstore.doc_get("alert_events", alert_id)
|
||||||
if not doc:
|
if not doc:
|
||||||
raise HTTPException(404, f"Alert '{alert_id}' not found.")
|
raise HTTPException(404, f"Alert '{alert_id}' not found.")
|
||||||
|
# SAAS_PLAN.md B2c: previously any authenticated viewer could acknowledge
|
||||||
|
# any org's alerts. org_id may be absent on a pre-tenancy alert_event
|
||||||
|
# that hasn't been through scripts/backfill_org_id.py yet — allow those
|
||||||
|
# through rather than making them permanently unacknowledgeable.
|
||||||
|
org_id = await resolve_caller_org_id(decoded)
|
||||||
|
if org_id is not None and doc.get("org_id") and doc.get("org_id") != org_id:
|
||||||
|
raise HTTPException(404, f"Alert '{alert_id}' not found.")
|
||||||
await fstore.doc_update("alert_events", alert_id, {"acknowledged": True})
|
await fstore.doc_update("alert_events", alert_id, {"acknowledged": True})
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@@ -35,15 +49,23 @@ async def acknowledge_alert(alert_id: str):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/alert-rules")
|
@router.get("/alert-rules")
|
||||||
async def list_alert_rules():
|
async def list_alert_rules(decoded: dict = Depends(require_service_or_firebase_token)):
|
||||||
|
org_id = await resolve_caller_org_id(decoded)
|
||||||
|
if org_id is not None:
|
||||||
|
return await fstore.collection_list("alert_rules", org_id=org_id)
|
||||||
return await fstore.collection_list("alert_rules")
|
return await fstore.collection_list("alert_rules")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/alert-rules")
|
@router.post("/alert-rules")
|
||||||
async def create_alert_rule(body: AlertRule, _: dict = Depends(require_admin_token)):
|
async def create_alert_rule(
|
||||||
|
body: AlertRule,
|
||||||
|
org_id: Optional[str] = Query(None, description="Platform-admin only — defaults to the founding org."),
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
rule_id = str(uuid.uuid4())
|
rule_id = str(uuid.uuid4())
|
||||||
doc = {
|
doc = {
|
||||||
"rule_id": rule_id,
|
"rule_id": rule_id,
|
||||||
|
"org_id": org_id or FOUNDING_ORG_ID,
|
||||||
"name": body.name,
|
"name": body.name,
|
||||||
"keywords": body.keywords,
|
"keywords": body.keywords,
|
||||||
"talkgroup_ids": body.talkgroup_ids,
|
"talkgroup_ids": body.talkgroup_ids,
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Depends
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.auth import require_admin_token
|
from app.internal.auth import (
|
||||||
|
require_admin_token,
|
||||||
|
require_service_or_firebase_token,
|
||||||
|
resolve_caller_org_id,
|
||||||
|
reprocess_limiter,
|
||||||
|
)
|
||||||
|
from app.internal.storage import gcs_uri_for_call, with_playback_url
|
||||||
|
|
||||||
|
|
||||||
class TranscriptUpdate(BaseModel):
|
class TranscriptUpdate(BaseModel):
|
||||||
@@ -17,6 +23,7 @@ async def list_calls(
|
|||||||
node_id: Optional[str] = Query(None),
|
node_id: Optional[str] = Query(None),
|
||||||
status: Optional[str] = Query(None),
|
status: Optional[str] = Query(None),
|
||||||
system_id: Optional[str] = Query(None),
|
system_id: Optional[str] = Query(None),
|
||||||
|
decoded: dict = Depends(require_service_or_firebase_token),
|
||||||
):
|
):
|
||||||
filters = {}
|
filters = {}
|
||||||
if node_id:
|
if node_id:
|
||||||
@@ -25,28 +32,48 @@ async def list_calls(
|
|||||||
filters["status"] = status
|
filters["status"] = status
|
||||||
if system_id:
|
if system_id:
|
||||||
filters["system_id"] = system_id
|
filters["system_id"] = system_id
|
||||||
return await fstore.collection_list("calls", **filters)
|
org_id = await resolve_caller_org_id(decoded)
|
||||||
|
if org_id is not None: # service key / platform admin stay unrestricted
|
||||||
|
filters["org_id"] = org_id
|
||||||
|
calls = await fstore.collection_list("calls", **filters)
|
||||||
|
# audio_url is not stored — it's a short-lived signed link minted per read.
|
||||||
|
return [with_playback_url(c) for c in calls]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{call_id}")
|
@router.get("/{call_id}")
|
||||||
async def get_call(call_id: str):
|
async def get_call(call_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
|
||||||
call = await fstore.doc_get("calls", call_id)
|
call = await fstore.doc_get("calls", call_id)
|
||||||
if not call:
|
if not call:
|
||||||
raise HTTPException(404, f"Call '{call_id}' not found.")
|
raise HTTPException(404, f"Call '{call_id}' not found.")
|
||||||
return call
|
org_id = await resolve_caller_org_id(decoded)
|
||||||
|
if org_id is not None and call.get("org_id") != org_id:
|
||||||
|
raise HTTPException(404, f"Call '{call_id}' not found.")
|
||||||
|
return with_playback_url(call)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{call_id}/reprocess")
|
@router.post("/{call_id}/reprocess")
|
||||||
async def reprocess_call(call_id: str, background_tasks: BackgroundTasks):
|
async def reprocess_call(
|
||||||
"""Re-run the full intelligence pipeline (transcription → extraction → correlation) for a call."""
|
call_id: str,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Re-run the full intelligence pipeline (transcription -> extraction ->
|
||||||
|
correlation) for a call. Admin-only (SAAS_PLAN.md B2c) — this was
|
||||||
|
previously gated only by "any valid Firebase token", which meant any
|
||||||
|
signed-in viewer could loop it and burn the owner's OpenAI/Gemini
|
||||||
|
credits (DEFERRED.md, calls.py:42). The rate limiter below is a second
|
||||||
|
guard against the same thing happening from a compromised/careless
|
||||||
|
admin session, not the primary fix.
|
||||||
|
"""
|
||||||
call = await fstore.doc_get("calls", call_id)
|
call = await fstore.doc_get("calls", call_id)
|
||||||
if not call:
|
if not call:
|
||||||
raise HTTPException(404, f"Call '{call_id}' not found.")
|
raise HTTPException(404, f"Call '{call_id}' not found.")
|
||||||
|
reprocess_limiter.check(call_id)
|
||||||
|
|
||||||
from app.routers.upload import _run_intelligence_pipeline, _public_url_to_gcs_uri
|
from app.routers.upload import _run_intelligence_pipeline
|
||||||
|
|
||||||
audio_url = call.get("audio_url")
|
gcs_uri = gcs_uri_for_call(call)
|
||||||
gcs_uri = _public_url_to_gcs_uri(audio_url) if audio_url else None
|
|
||||||
|
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
_run_intelligence_pipeline,
|
_run_intelligence_pipeline,
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""
|
||||||
|
Node self-enrollment — the public replacement for the old shared-MQTT-
|
||||||
|
password flow (see MQTT-PUBLIC-AUTH-PLAN.md "Enrollment flow").
|
||||||
|
|
||||||
|
1. POST /nodes/enroll (X-Enrollment-Token: <fleet-wide token>)
|
||||||
|
First-boot node upserts itself as `approval_status: pending` and gets
|
||||||
|
back a one-time pickup_secret. Only its hash is persisted.
|
||||||
|
2. GET /nodes/{id}/credentials (X-Pickup-Secret: <secret from step 1>)
|
||||||
|
Node polls this with backoff until an admin approves it in the
|
||||||
|
frontend (existing nodes.py approve_node() flow — unchanged, still
|
||||||
|
writes node_keys/{id}.api_key) and then reads its api_key back.
|
||||||
|
|
||||||
|
These two endpoints are meant to be public (unlike the dynsec control-plane
|
||||||
|
traffic in app/internal/dynsec.py, which never leaves the docker-internal
|
||||||
|
MQTT bridge) — that's the whole point of moving off WireGuard-per-node.
|
||||||
|
Auth is the token headers checked inline below, not the app-wide
|
||||||
|
Firebase/service-key dependency the rest of routers/nodes.py uses.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, HTTPException, Header, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from app.config import settings
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.logger import logger
|
||||||
|
from app.internal.tenancy import FOUNDING_ORG_ID
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/nodes", tags=["enrollment"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Per-source-IP token bucket for /nodes/enroll.
|
||||||
|
#
|
||||||
|
# c2-core has no rate-limiting dependency anywhere today (see
|
||||||
|
# MQTT-PUBLIC-AUTH-PLAN.md); this is a deliberately small (~30 line)
|
||||||
|
# in-memory limiter rather than a new library. Known limitations:
|
||||||
|
# - per-process: with more than one c2-core instance, each has its own
|
||||||
|
# bucket, so real throughput is (limit x instance count). Fine today —
|
||||||
|
# there is exactly one instance.
|
||||||
|
# - resets on every restart/redeploy — not persisted anywhere.
|
||||||
|
# Good enough to blunt casual guessing of node_ids against the fleet token;
|
||||||
|
# not a substitute for a real edge/WAF rate limiter if this endpoint is
|
||||||
|
# ever seriously targeted.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class _TokenBucket:
|
||||||
|
def __init__(self, capacity: int, refill_per_sec: float):
|
||||||
|
self.capacity = capacity
|
||||||
|
self.refill_per_sec = refill_per_sec
|
||||||
|
self._buckets: dict[str, tuple[float, float]] = {} # key -> (tokens, last_refill_ts)
|
||||||
|
|
||||||
|
def allow(self, key: str) -> bool:
|
||||||
|
now = time.monotonic()
|
||||||
|
tokens, last_ts = self._buckets.get(key, (float(self.capacity), now))
|
||||||
|
tokens = min(self.capacity, tokens + (now - last_ts) * self.refill_per_sec)
|
||||||
|
if tokens < 1:
|
||||||
|
self._buckets[key] = (tokens, now)
|
||||||
|
return False
|
||||||
|
self._buckets[key] = (tokens - 1, now)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Burst of 5, refilling 1/minute — enrollment is a first-boot, once-per-node
|
||||||
|
# event, so a legitimate node never needs more than a handful of attempts.
|
||||||
|
_enroll_limiter = _TokenBucket(capacity=5, refill_per_sec=1 / 60)
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_secret(secret: str) -> str:
|
||||||
|
return hashlib.sha256(secret.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_org_for_token(token: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Resolve an X-Enrollment-Token to the org_id it enrolls a node into.
|
||||||
|
|
||||||
|
Tries the per-org enrollment_tokens collection first (SAAS_PLAN.md B2b —
|
||||||
|
minted/listed/revoked via routers/org.py), then falls back to the legacy
|
||||||
|
fleet-wide settings.enrollment_token so an already-deployed field node's
|
||||||
|
.env doesn't start failing the day per-org tokens ship. The fallback
|
||||||
|
always resolves to FOUNDING_ORG_ID — see app/internal/tenancy.py.
|
||||||
|
"""
|
||||||
|
token_hash = _hash_secret(token)
|
||||||
|
doc = await fstore.doc_get("enrollment_tokens", token_hash)
|
||||||
|
if doc and not doc.get("revoked"):
|
||||||
|
try:
|
||||||
|
await fstore.doc_update("enrollment_tokens", token_hash, {"uses": (doc.get("uses") or 0) + 1})
|
||||||
|
except Exception:
|
||||||
|
pass # use-counter is informational only — never block enrollment on it
|
||||||
|
return doc.get("org_id")
|
||||||
|
|
||||||
|
if settings.enrollment_token and secrets.compare_digest(token, settings.enrollment_token):
|
||||||
|
return FOUNDING_ORG_ID
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class EnrollRequest(BaseModel):
|
||||||
|
node_id: str
|
||||||
|
name: Optional[str] = None
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class EnrollResponse(BaseModel):
|
||||||
|
node_id: str
|
||||||
|
pickup_secret: str
|
||||||
|
approval_status: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/enroll", response_model=EnrollResponse)
|
||||||
|
async def enroll_node(
|
||||||
|
body: EnrollRequest,
|
||||||
|
request: Request,
|
||||||
|
x_enrollment_token: Optional[str] = Header(None),
|
||||||
|
):
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
if not _enroll_limiter.allow(client_ip):
|
||||||
|
raise HTTPException(429, "Too many enrollment attempts. Try again later.")
|
||||||
|
|
||||||
|
if not x_enrollment_token:
|
||||||
|
logger.warning(f"Enroll 401: missing X-Enrollment-Token from {client_ip} for node_id={body.node_id!r}")
|
||||||
|
raise HTTPException(401, "Invalid or missing X-Enrollment-Token")
|
||||||
|
|
||||||
|
org_id = await _resolve_org_for_token(x_enrollment_token)
|
||||||
|
if not org_id:
|
||||||
|
logger.warning(f"Enroll 401: bad enrollment token from {client_ip} for node_id={body.node_id!r}")
|
||||||
|
raise HTTPException(401, "Invalid or missing X-Enrollment-Token")
|
||||||
|
|
||||||
|
node_id = body.node_id.strip()
|
||||||
|
if not node_id:
|
||||||
|
raise HTTPException(400, "node_id is required")
|
||||||
|
|
||||||
|
existing = await fstore.doc_get("nodes", node_id)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# CRITICAL GUARD — do not remove or weaken this check.
|
||||||
|
#
|
||||||
|
# An already-approved node_id must NEVER get a fresh pickup_secret off
|
||||||
|
# the fleet-wide enrollment token alone. The fleet token is shared by
|
||||||
|
# every node (it ships in every node's .env / setup.sh prompt), so it
|
||||||
|
# is the credential most likely to leak. Without this guard, a leaked
|
||||||
|
# fleet token plus a guessable node_id (node-001, node-002, ...) would
|
||||||
|
# let an attacker "re-enroll" a live, already-approved node and race
|
||||||
|
# the real node to GET /nodes/{id}/credentials — stealing its actual
|
||||||
|
# api_key before the legitimate device ever asks.
|
||||||
|
#
|
||||||
|
# Recovery for an approved node goes through the existing admin-only
|
||||||
|
# POST /nodes/{id}/reissue-key instead (routers/nodes.py), which
|
||||||
|
# requires a Firebase admin token, not the fleet token.
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
if existing and existing.get("approval_status") == "approved":
|
||||||
|
logger.warning(
|
||||||
|
f"Enroll refused: node_id={node_id!r} is already approved — "
|
||||||
|
f"refusing to issue a new pickup_secret from the fleet token alone "
|
||||||
|
f"(source_ip={client_ip})"
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
403,
|
||||||
|
"Node is already approved. This endpoint cannot re-issue credentials "
|
||||||
|
"for an approved node from the enrollment token alone — use admin "
|
||||||
|
"key reissue.",
|
||||||
|
)
|
||||||
|
|
||||||
|
pickup_secret = secrets.token_hex(24)
|
||||||
|
doc = {
|
||||||
|
"node_id": node_id,
|
||||||
|
# A node re-enrolling keeps whatever org_id it already has rather
|
||||||
|
# than letting a differently-scoped token silently reassign its
|
||||||
|
# tenancy — only a brand-new node_id (or one that predates tenancy
|
||||||
|
# entirely) picks up org_id from the token used here.
|
||||||
|
"org_id": (existing or {}).get("org_id") or org_id,
|
||||||
|
"name": body.name or (existing or {}).get("name") or node_id,
|
||||||
|
"lat": body.lat or (existing or {}).get("lat", 0.0),
|
||||||
|
"lon": body.lon or (existing or {}).get("lon", 0.0),
|
||||||
|
"approval_status": (existing or {}).get("approval_status", "pending"),
|
||||||
|
"pickup_secret_hash": _hash_secret(pickup_secret),
|
||||||
|
}
|
||||||
|
# approval_status stays "pending" for a brand-new node; if it's an
|
||||||
|
# existing "pending" or "rejected" node re-enrolling (e.g. lost its
|
||||||
|
# pickup_secret before an admin ever approved it), leave whatever
|
||||||
|
# status it already has rather than silently flipping "rejected" back
|
||||||
|
# to "pending" — that decision belongs to an admin, not this endpoint.
|
||||||
|
await fstore.doc_set("nodes", node_id, doc, merge=True)
|
||||||
|
logger.info(f"Node enrolled: {node_id} (status={doc['approval_status']}, source_ip={client_ip})")
|
||||||
|
|
||||||
|
return EnrollResponse(node_id=node_id, pickup_secret=pickup_secret, approval_status=doc["approval_status"])
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialsResponse(BaseModel):
|
||||||
|
approval_status: str
|
||||||
|
api_key: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{node_id}/credentials", response_model=CredentialsResponse)
|
||||||
|
async def get_node_credentials(node_id: str, x_pickup_secret: Optional[str] = Header(None)):
|
||||||
|
if not x_pickup_secret:
|
||||||
|
raise HTTPException(401, "Missing X-Pickup-Secret header")
|
||||||
|
|
||||||
|
node = await fstore.doc_get("nodes", node_id)
|
||||||
|
if not node or not node.get("pickup_secret_hash"):
|
||||||
|
raise HTTPException(404, "Unknown node, or node was never enrolled via POST /nodes/enroll")
|
||||||
|
|
||||||
|
if not secrets.compare_digest(_hash_secret(x_pickup_secret), node["pickup_secret_hash"]):
|
||||||
|
raise HTTPException(401, "Invalid pickup secret")
|
||||||
|
|
||||||
|
approval_status = node.get("approval_status", "pending")
|
||||||
|
if approval_status != "approved":
|
||||||
|
return CredentialsResponse(approval_status=approval_status)
|
||||||
|
|
||||||
|
key_doc = await fstore.doc_get("node_keys", node_id)
|
||||||
|
if not key_doc or not key_doc.get("api_key"):
|
||||||
|
# Approved but no key yet — shouldn't normally happen, approve_node()
|
||||||
|
# always writes node_keys in the same call that sets approved. Treat
|
||||||
|
# it as "keep polling" rather than erroring the node's retry loop.
|
||||||
|
return CredentialsResponse(approval_status=approval_status)
|
||||||
|
|
||||||
|
return CredentialsResponse(approval_status=approval_status, api_key=key_doc["api_key"])
|
||||||
@@ -4,18 +4,30 @@ from typing import Optional
|
|||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
|
||||||
from app.models import IncidentCreate, IncidentUpdate
|
from app.models import IncidentCreate, IncidentUpdate
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.auth import require_admin_token, require_service_or_firebase_token, summarize_limiter
|
from app.internal.auth import (
|
||||||
|
require_admin_token,
|
||||||
|
require_service_or_firebase_token,
|
||||||
|
resolve_caller_org_id,
|
||||||
|
summarize_limiter,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/incidents", tags=["incidents"])
|
router = APIRouter(prefix="/incidents", tags=["incidents"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_incidents(status: Optional[str] = None, type: Optional[str] = None):
|
async def list_incidents(
|
||||||
|
status: Optional[str] = None,
|
||||||
|
type: Optional[str] = None,
|
||||||
|
decoded: dict = Depends(require_service_or_firebase_token),
|
||||||
|
):
|
||||||
filters = {}
|
filters = {}
|
||||||
if status:
|
if status:
|
||||||
filters["status"] = status
|
filters["status"] = status
|
||||||
if type:
|
if type:
|
||||||
filters["type"] = type
|
filters["type"] = type
|
||||||
|
org_id = await resolve_caller_org_id(decoded)
|
||||||
|
if org_id is not None:
|
||||||
|
filters["org_id"] = org_id
|
||||||
return await fstore.collection_list("incidents", **filters)
|
return await fstore.collection_list("incidents", **filters)
|
||||||
|
|
||||||
|
|
||||||
@@ -31,10 +43,13 @@ async def summarize_all_stale(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{incident_id}")
|
@router.get("/{incident_id}")
|
||||||
async def get_incident(incident_id: str):
|
async def get_incident(incident_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
|
||||||
doc = await fstore.doc_get("incidents", incident_id)
|
doc = await fstore.doc_get("incidents", incident_id)
|
||||||
if not doc:
|
if not doc:
|
||||||
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
||||||
|
org_id = await resolve_caller_org_id(decoded)
|
||||||
|
if org_id is not None and doc.get("org_id") != org_id:
|
||||||
|
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
||||||
return doc
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
import asyncio
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||||
|
from firebase_admin import auth as firebase_auth
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.auth import require_firebase_token, require_service_key
|
from app.internal.auth import require_firebase_token, require_service_key, get_role
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
@@ -129,6 +131,91 @@ async def unlink(decoded: dict = Depends(require_firebase_token)):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Org provisioning — SAAS_PLAN.md B4. The client creates the Firebase user
|
||||||
|
# first (email/password or Google) and calls this with that user's fresh ID
|
||||||
|
# token, which carries no org_id/org_role claim yet. This is the only route
|
||||||
|
# that turns "has a Firebase account" into "can read anything" — see
|
||||||
|
# infra/firestore/firestore.rules and AuthProvider's no-claim guard.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class SignupBody(BaseModel):
|
||||||
|
org_name: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/signup")
|
||||||
|
async def signup(body: SignupBody, decoded: dict = Depends(require_firebase_token)):
|
||||||
|
"""
|
||||||
|
Provision a new organization owned by the calling user, or return their
|
||||||
|
existing one. Idempotent by design: the frontend calls this right after
|
||||||
|
account creation, and a user who double-submits (or re-runs it after a
|
||||||
|
refresh) must not end up with two orgs.
|
||||||
|
"""
|
||||||
|
uid = decoded["uid"]
|
||||||
|
|
||||||
|
existing_org_id = decoded.get("org_id")
|
||||||
|
if existing_org_id:
|
||||||
|
org = await fstore.doc_get("organizations", existing_org_id)
|
||||||
|
if org:
|
||||||
|
return {"org_id": existing_org_id, "org_name": org.get("name"), "already_provisioned": True}
|
||||||
|
# Claim points at a deleted/missing org doc — fall through and
|
||||||
|
# provision a fresh one rather than leaving the account stranded.
|
||||||
|
|
||||||
|
org_name = body.org_name.strip()
|
||||||
|
if not org_name:
|
||||||
|
raise HTTPException(400, "org_name is required.")
|
||||||
|
if len(org_name) > 200:
|
||||||
|
raise HTTPException(400, "org_name is too long.")
|
||||||
|
|
||||||
|
org_id = str(uuid4())
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
# plan_id/subscription_status/stripe_*/seat_limit/node_limit/retention_days
|
||||||
|
# are all deliberately None — no billing model exists yet (see
|
||||||
|
# app/internal/tenancy.py). This is the seam a future billing pass writes
|
||||||
|
# into; nothing today reads or enforces these fields.
|
||||||
|
await fstore.doc_set("organizations", org_id, {
|
||||||
|
"org_id": org_id,
|
||||||
|
"name": org_name,
|
||||||
|
"created_at": now,
|
||||||
|
"created_by_uid": uid,
|
||||||
|
"plan_id": None,
|
||||||
|
"subscription_status": None,
|
||||||
|
"stripe_customer_id": None,
|
||||||
|
"stripe_subscription_id": None,
|
||||||
|
"current_period_end": None,
|
||||||
|
"seat_limit": None,
|
||||||
|
"node_limit": None,
|
||||||
|
"retention_days": None,
|
||||||
|
}, merge=False)
|
||||||
|
|
||||||
|
await fstore.doc_set("org_members", uid, {
|
||||||
|
"uid": uid,
|
||||||
|
"org_id": org_id,
|
||||||
|
"org_role": "owner",
|
||||||
|
"email": decoded.get("email"),
|
||||||
|
"added_at": now,
|
||||||
|
}, merge=False)
|
||||||
|
|
||||||
|
# set_custom_user_claims() replaces the whole claim set, so preserve any
|
||||||
|
# existing custom claims (owned_node_ids, a platform `role` if this
|
||||||
|
# account was created via the admin-only POST /admin/users flow, etc.)
|
||||||
|
# rather than clobbering them. Firebase's own reserved JWT fields are
|
||||||
|
# stripped out — they aren't settable as custom claims and would raise.
|
||||||
|
_RESERVED = {
|
||||||
|
"iss", "aud", "auth_time", "user_id", "sub", "iat", "exp", "uid",
|
||||||
|
"email", "email_verified", "firebase", "name", "picture",
|
||||||
|
}
|
||||||
|
existing_claims = {k: v for k, v in decoded.items() if k not in _RESERVED}
|
||||||
|
# role: platform-level, orthogonal to org ownership. get_role() falls
|
||||||
|
# back to "viewer" for a brand-new self-serve signup with no claims yet.
|
||||||
|
claims = {**existing_claims, "org_id": org_id, "org_role": "owner", "role": get_role(decoded)}
|
||||||
|
await asyncio.to_thread(firebase_auth.set_custom_user_claims, uid, claims)
|
||||||
|
|
||||||
|
logger.info(f"Org provisioned: org_id={org_id} name={org_name!r} owner_uid={uid}")
|
||||||
|
return {"org_id": org_id, "org_name": org_name, "already_provisioned": False}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Session recording — called by the frontend on each successful sign-in
|
# Session recording — called by the frontend on each successful sign-in
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
Call-audio playback.
|
||||||
|
|
||||||
|
Public router by necessity: a browser's <audio src="..."> cannot attach an
|
||||||
|
Authorization header, so the link itself carries the credential — a short-lived
|
||||||
|
HMAC over (call_id, expiry) minted by app/internal/storage.py. That is why this
|
||||||
|
router is included in main.py WITHOUT a router-level auth dependency; the check
|
||||||
|
happens inline below, in the same spirit as routers/enrollment.py.
|
||||||
|
|
||||||
|
The bucket stays fully private and c2-core reads the object server-side with
|
||||||
|
Application Default Credentials, so no GCS signed URL — and therefore no
|
||||||
|
service-account private key on the VM — is involved anywhere in this path.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, Response
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.storage import verify_audio_link, gcs_uri_for_call, download_audio
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/media", tags=["media"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/calls/{call_id}/audio")
|
||||||
|
async def get_call_audio(
|
||||||
|
call_id: str,
|
||||||
|
exp: int = Query(..., description="Link expiry, unix seconds."),
|
||||||
|
sig: str = Query(..., description="HMAC over call_id and expiry."),
|
||||||
|
):
|
||||||
|
# Verify before touching Firestore so an invalid link costs nothing.
|
||||||
|
if not verify_audio_link(call_id, exp, sig):
|
||||||
|
raise HTTPException(403, "Invalid or expired audio link")
|
||||||
|
|
||||||
|
call = await fstore.doc_get("calls", call_id)
|
||||||
|
if not call:
|
||||||
|
raise HTTPException(404, f"Call '{call_id}' not found.")
|
||||||
|
|
||||||
|
gcs_uri = gcs_uri_for_call(call)
|
||||||
|
if not gcs_uri:
|
||||||
|
raise HTTPException(404, "No audio for this call.")
|
||||||
|
|
||||||
|
data = await download_audio(gcs_uri)
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(404, "Audio object missing from storage.")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=data,
|
||||||
|
media_type="audio/mpeg",
|
||||||
|
headers={
|
||||||
|
"Content-Length": str(len(data)),
|
||||||
|
# Recordings are small (16 kbps mono — a 30s call is ~60 KB), so the
|
||||||
|
# whole body is sent at once and the browser seeks within its own
|
||||||
|
# buffer. Range support would only matter for long files.
|
||||||
|
"Accept-Ranges": "none",
|
||||||
|
# Immutable content, but the URL expires — cache privately only.
|
||||||
|
"Cache-Control": "private, max-age=3600",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -1,25 +1,39 @@
|
|||||||
import secrets
|
import secrets
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
from app.models import CommandPayload
|
from app.models import CommandPayload
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.mqtt_handler import mqtt_handler
|
from app.internal.mqtt_handler import mqtt_handler
|
||||||
from app.internal.auth import require_admin_token, require_service_key_or_admin
|
from app.internal import dynsec
|
||||||
|
from app.internal.logger import logger
|
||||||
|
from app.internal.auth import (
|
||||||
|
require_admin_token,
|
||||||
|
require_service_key_or_admin,
|
||||||
|
require_service_or_firebase_token,
|
||||||
|
resolve_caller_org_id,
|
||||||
|
)
|
||||||
from app.routers.tokens import assign_token, release_token
|
from app.routers.tokens import assign_token, release_token
|
||||||
|
|
||||||
router = APIRouter(prefix="/nodes", tags=["nodes"])
|
router = APIRouter(prefix="/nodes", tags=["nodes"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_nodes():
|
async def list_nodes(decoded: dict = Depends(require_service_or_firebase_token)):
|
||||||
|
org_id = await resolve_caller_org_id(decoded)
|
||||||
|
if org_id is None: # service key or platform admin — unrestricted, matches prior behaviour
|
||||||
return await fstore.collection_list("nodes")
|
return await fstore.collection_list("nodes")
|
||||||
|
return await fstore.collection_list("nodes", org_id=org_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{node_id}")
|
@router.get("/{node_id}")
|
||||||
async def get_node(node_id: str):
|
async def get_node(node_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
|
||||||
node = await fstore.doc_get("nodes", node_id)
|
node = await fstore.doc_get("nodes", node_id)
|
||||||
if not node:
|
if not node:
|
||||||
raise HTTPException(404, f"Node '{node_id}' not found.")
|
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||||
|
org_id = await resolve_caller_org_id(decoded)
|
||||||
|
if org_id is not None and node.get("org_id") != org_id:
|
||||||
|
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||||
return node
|
return node
|
||||||
|
|
||||||
|
|
||||||
@@ -30,8 +44,23 @@ async def approve_node(node_id: str, _: dict = Depends(require_admin_token)):
|
|||||||
raise HTTPException(404, f"Node '{node_id}' not found.")
|
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||||
|
|
||||||
api_key = secrets.token_hex(32)
|
api_key = secrets.token_hex(32)
|
||||||
|
|
||||||
|
# dynsec FIRST, Firestore second: if the broker rejects/never confirms
|
||||||
|
# the new client, we must not tell Firestore (and the admin UI) the
|
||||||
|
# node is approved with a key mosquitto doesn't actually recognise —
|
||||||
|
# that's exactly the silent-drift the two-sources-of-truth problem
|
||||||
|
# warns about. See app/internal/dynsec.py.
|
||||||
|
try:
|
||||||
|
await dynsec.upsert_node_client(node_id, api_key)
|
||||||
|
except dynsec.DynsecError as e:
|
||||||
|
logger.error(f"Approve {node_id!r}: dynsec upsert failed, NOT writing Firestore: {e}")
|
||||||
|
raise HTTPException(502, f"Could not provision MQTT credentials for node: {e}")
|
||||||
|
|
||||||
await fstore.doc_set("node_keys", node_id, {"node_id": node_id, "api_key": api_key}, merge=False)
|
await fstore.doc_set("node_keys", node_id, {"node_id": node_id, "api_key": api_key}, merge=False)
|
||||||
await fstore.doc_update("nodes", node_id, {"approval_status": "approved"})
|
await fstore.doc_update("nodes", node_id, {"approval_status": "approved"})
|
||||||
|
# TODO(mqtt-cutover): drop this MQTT push once nodes pull their key via
|
||||||
|
# GET /nodes/{id}/credentials (routers/enrollment.py) exclusively — see
|
||||||
|
# MQTT-PUBLIC-AUTH-PLAN.md "Rollout order" step 6. Kept for node-26.
|
||||||
mqtt_handler.publish_node_key(node_id, api_key)
|
mqtt_handler.publish_node_key(node_id, api_key)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@@ -41,6 +70,11 @@ async def delete_node(node_id: str, _: dict = Depends(require_admin_token)):
|
|||||||
node = await fstore.doc_get("nodes", node_id)
|
node = await fstore.doc_get("nodes", node_id)
|
||||||
if not node:
|
if not node:
|
||||||
raise HTTPException(404, f"Node '{node_id}' not found.")
|
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||||
|
try:
|
||||||
|
await dynsec.delete_node_client(node_id)
|
||||||
|
except dynsec.DynsecError as e:
|
||||||
|
logger.error(f"Delete {node_id!r}: dynsec deleteClient failed, NOT deleting Firestore docs: {e}")
|
||||||
|
raise HTTPException(502, f"Could not revoke MQTT credentials for node: {e}")
|
||||||
await fstore.doc_delete("node_keys", node_id)
|
await fstore.doc_delete("node_keys", node_id)
|
||||||
await fstore.doc_delete("nodes", node_id)
|
await fstore.doc_delete("nodes", node_id)
|
||||||
|
|
||||||
@@ -101,7 +135,15 @@ async def reissue_node_key(node_id: str, _: dict = Depends(require_admin_token))
|
|||||||
if not node:
|
if not node:
|
||||||
raise HTTPException(404, f"Node '{node_id}' not found.")
|
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||||
api_key = secrets.token_hex(32)
|
api_key = secrets.token_hex(32)
|
||||||
|
try:
|
||||||
|
await dynsec.upsert_node_client(node_id, api_key)
|
||||||
|
except dynsec.DynsecError as e:
|
||||||
|
logger.error(f"Reissue {node_id!r}: dynsec upsert failed, NOT writing Firestore: {e}")
|
||||||
|
raise HTTPException(502, f"Could not update MQTT credentials for node: {e}")
|
||||||
await fstore.doc_set("node_keys", node_id, {"node_id": node_id, "api_key": api_key}, merge=False)
|
await fstore.doc_set("node_keys", node_id, {"node_id": node_id, "api_key": api_key}, merge=False)
|
||||||
|
# TODO(mqtt-cutover): drop this MQTT push once nodes pull their key via
|
||||||
|
# GET /nodes/{id}/credentials (routers/enrollment.py) exclusively — see
|
||||||
|
# MQTT-PUBLIC-AUTH-PLAN.md "Rollout order" step 6. Kept for node-26.
|
||||||
mqtt_handler.publish_node_key(node_id, api_key)
|
mqtt_handler.publish_node_key(node_id, api_key)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@@ -126,10 +168,13 @@ async def assign_system(
|
|||||||
if not system:
|
if not system:
|
||||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||||
|
|
||||||
# Include hardware preset in the push so the edge node applies it when
|
# Include hardware preset, node type, and enforce timeout in the push
|
||||||
# generating the OP25 config. Strip it from the system doc first so it
|
push_payload = {
|
||||||
# doesn't collide with SystemConfig field validation on the node side.
|
**system,
|
||||||
push_payload = {**system, "hardware_preset": hardware_preset}
|
"hardware_preset": hardware_preset,
|
||||||
|
"node_type": node.get("node_type", "fixed"),
|
||||||
|
"enforce_override_timeout": node.get("enforce_override_timeout", True),
|
||||||
|
}
|
||||||
if ppm_override is not None:
|
if ppm_override is not None:
|
||||||
push_payload["ppm_override"] = ppm_override
|
push_payload["ppm_override"] = ppm_override
|
||||||
mqtt_handler.push_config(node_id, push_payload)
|
mqtt_handler.push_config(node_id, push_payload)
|
||||||
@@ -145,3 +190,89 @@ async def assign_system(
|
|||||||
await fstore.doc_update("nodes", node_id, node_updates)
|
await fstore.doc_update("nodes", node_id, node_updates)
|
||||||
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
class NodeUpdateBody(BaseModel):
|
||||||
|
node_type: Optional[str] = None
|
||||||
|
enforce_override_timeout: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{node_id}")
|
||||||
|
async def update_node(
|
||||||
|
node_id: str,
|
||||||
|
body: NodeUpdateBody,
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
|
node = await fstore.doc_get("nodes", node_id)
|
||||||
|
if not node:
|
||||||
|
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||||
|
|
||||||
|
updates = body.model_dump(exclude_unset=True)
|
||||||
|
if not updates:
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
await fstore.doc_update("nodes", node_id, updates)
|
||||||
|
|
||||||
|
# Re-push config to apply new node settings locally
|
||||||
|
updated_node = await fstore.doc_get("nodes", node_id)
|
||||||
|
assigned_system_id = updated_node.get("assigned_system_id")
|
||||||
|
if assigned_system_id:
|
||||||
|
system = await fstore.doc_get("systems", assigned_system_id)
|
||||||
|
if system:
|
||||||
|
push_payload = {
|
||||||
|
**system,
|
||||||
|
"hardware_preset": updated_node.get("hardware_preset", "rtl-sdr-v3"),
|
||||||
|
"node_type": updated_node.get("node_type", "fixed"),
|
||||||
|
"enforce_override_timeout": updated_node.get("enforce_override_timeout", True),
|
||||||
|
}
|
||||||
|
if updated_node.get("ppm_override") is not None:
|
||||||
|
push_payload["ppm_override"] = updated_node["ppm_override"]
|
||||||
|
mqtt_handler.push_config(node_id, push_payload)
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
class AckOverrideBody(BaseModel):
|
||||||
|
timeout_minutes: int = 1440
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{node_id}/override/ack")
|
||||||
|
async def ack_override(
|
||||||
|
node_id: str,
|
||||||
|
body: AckOverrideBody,
|
||||||
|
_: dict = Depends(require_service_key_or_admin),
|
||||||
|
):
|
||||||
|
node = await fstore.doc_get("nodes", node_id)
|
||||||
|
if not node:
|
||||||
|
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||||
|
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
new_timeout = datetime.now(timezone.utc) + timedelta(minutes=body.timeout_minutes)
|
||||||
|
|
||||||
|
await fstore.doc_update("nodes", node_id, {
|
||||||
|
"override_timeout_at": new_timeout.isoformat()
|
||||||
|
})
|
||||||
|
return {"ok": True, "override_timeout_at": new_timeout.isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{node_id}/override/reset")
|
||||||
|
async def reset_override(
|
||||||
|
node_id: str,
|
||||||
|
_: dict = Depends(require_service_key_or_admin),
|
||||||
|
):
|
||||||
|
node = await fstore.doc_get("nodes", node_id)
|
||||||
|
if not node:
|
||||||
|
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||||
|
|
||||||
|
assigned_system_id = node.get("assigned_system_id")
|
||||||
|
if assigned_system_id:
|
||||||
|
system = await fstore.doc_get("systems", assigned_system_id)
|
||||||
|
if system:
|
||||||
|
mqtt_handler.push_config(node_id, system)
|
||||||
|
|
||||||
|
await fstore.doc_update("nodes", node_id, {
|
||||||
|
"is_overridden": False,
|
||||||
|
"override_system_id": None,
|
||||||
|
"override_timeout_at": None,
|
||||||
|
})
|
||||||
|
return {"ok": True}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
Organization-scoped routes.
|
||||||
|
|
||||||
|
Two things live here:
|
||||||
|
1. Org profile (name) — closes the "Save changes" button that's been
|
||||||
|
disabled in app/settings/organization since there was no organizations
|
||||||
|
concept server-side to save into (see DEFERRED.md, now resolved).
|
||||||
|
2. Per-org enrollment tokens (SAAS_PLAN.md B2b) — the credential that lets
|
||||||
|
a customer's own node join THEIR org specifically. Before this, every
|
||||||
|
node enrolled with the same fleet-wide ENROLLMENT_TOKEN
|
||||||
|
(routers/enrollment.py), which had no way to say which org a newly
|
||||||
|
enrolled node belonged to — every node landed in the same pool.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.auth import require_firebase_token, require_org, require_org_owner_token
|
||||||
|
from app.internal.logger import logger
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/org", tags=["org"])
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_token(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Org profile
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def get_org(decoded: dict = Depends(require_firebase_token)):
|
||||||
|
"""Any member of the org (owner or member) can read the org profile."""
|
||||||
|
org_id = require_org(decoded)
|
||||||
|
org = await fstore.doc_get("organizations", org_id)
|
||||||
|
if not org:
|
||||||
|
raise HTTPException(404, "Organization not found.")
|
||||||
|
return org
|
||||||
|
|
||||||
|
|
||||||
|
class OrgUpdateBody(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("")
|
||||||
|
async def update_org(body: OrgUpdateBody, decoded: dict = Depends(require_org_owner_token)):
|
||||||
|
org_id = require_org(decoded)
|
||||||
|
name = body.name.strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(400, "name must not be empty.")
|
||||||
|
await fstore.doc_update("organizations", org_id, {"name": name})
|
||||||
|
return {"ok": True, "name": name}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Enrollment tokens — mint/list/revoke. Minting and revoking are owner-only
|
||||||
|
# (this is fleet-security-sensitive, same tier as node approval); any org
|
||||||
|
# member can list them (metadata only, never the raw value) since anyone on
|
||||||
|
# the team might be the one physically standing up the next node.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class MintTokenBody(BaseModel):
|
||||||
|
label: str
|
||||||
|
|
||||||
|
|
||||||
|
class MintTokenResponse(BaseModel):
|
||||||
|
token_id: str
|
||||||
|
token: str # raw value — returned exactly once, never again, never stored
|
||||||
|
label: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/enrollment-tokens", response_model=MintTokenResponse)
|
||||||
|
async def mint_enrollment_token(body: MintTokenBody, decoded: dict = Depends(require_org_owner_token)):
|
||||||
|
org_id = require_org(decoded)
|
||||||
|
label = body.label.strip() or "Unnamed token"
|
||||||
|
raw = secrets.token_hex(24)
|
||||||
|
token_hash = _hash_token(raw)
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
# Doc id IS the hash (matches enrollment.py's pickup_secret_hash pattern) —
|
||||||
|
# also stored as a field so list/delete below don't need a second lookup.
|
||||||
|
await fstore.doc_set("enrollment_tokens", token_hash, {
|
||||||
|
"token_hash": token_hash,
|
||||||
|
"org_id": org_id,
|
||||||
|
"label": label,
|
||||||
|
"created_at": now,
|
||||||
|
"created_by_uid": decoded.get("uid"),
|
||||||
|
"revoked": False,
|
||||||
|
"uses": 0,
|
||||||
|
}, merge=False)
|
||||||
|
logger.info(f"Enrollment token minted for org={org_id!r} label={label!r} by uid={decoded.get('uid')}")
|
||||||
|
return MintTokenResponse(token_id=token_hash, token=raw, label=label)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/enrollment-tokens")
|
||||||
|
async def list_enrollment_tokens(decoded: dict = Depends(require_firebase_token)):
|
||||||
|
org_id = require_org(decoded)
|
||||||
|
tokens = await fstore.collection_list("enrollment_tokens", org_id=org_id)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"token_id": t.get("token_hash"),
|
||||||
|
"label": t.get("label"),
|
||||||
|
"created_at": t.get("created_at"),
|
||||||
|
"revoked": t.get("revoked", False),
|
||||||
|
"uses": t.get("uses", 0),
|
||||||
|
}
|
||||||
|
for t in tokens
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/enrollment-tokens/{token_id}")
|
||||||
|
async def revoke_enrollment_token(token_id: str, decoded: dict = Depends(require_org_owner_token)):
|
||||||
|
org_id = require_org(decoded)
|
||||||
|
doc = await fstore.doc_get("enrollment_tokens", token_id)
|
||||||
|
if not doc or doc.get("org_id") != org_id:
|
||||||
|
raise HTTPException(404, "Enrollment token not found.")
|
||||||
|
await fstore.doc_update("enrollment_tokens", token_id, {"revoked": True})
|
||||||
|
logger.info(f"Enrollment token revoked: org={org_id!r} token_id={token_id}")
|
||||||
|
return {"ok": True}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
from app.models import SystemCreate, SystemRecord
|
from app.models import SystemCreate, SystemRecord
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.auth import require_admin_token, bootstrap_limiter
|
from app.internal.auth import (
|
||||||
|
require_admin_token,
|
||||||
|
require_node_service_or_firebase_token,
|
||||||
|
resolve_caller_org_id,
|
||||||
|
bootstrap_limiter,
|
||||||
|
)
|
||||||
|
from app.internal.tenancy import FOUNDING_ORG_ID
|
||||||
|
|
||||||
router = APIRouter(prefix="/systems", tags=["systems"])
|
router = APIRouter(prefix="/systems", tags=["systems"])
|
||||||
|
|
||||||
@@ -23,22 +29,32 @@ class AiFlagsBody(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_systems():
|
async def list_systems(decoded: dict = Depends(require_node_service_or_firebase_token)):
|
||||||
|
org_id = await resolve_caller_org_id(decoded)
|
||||||
|
if org_id is None: # service key or platform admin — unrestricted, matches prior behaviour
|
||||||
return await fstore.collection_list("systems")
|
return await fstore.collection_list("systems")
|
||||||
|
return await fstore.collection_list("systems", org_id=org_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{system_id}")
|
@router.get("/{system_id}")
|
||||||
async def get_system(system_id: str):
|
async def get_system(system_id: str, decoded: dict = Depends(require_node_service_or_firebase_token)):
|
||||||
system = await fstore.doc_get("systems", system_id)
|
system = await fstore.doc_get("systems", system_id)
|
||||||
if not system:
|
if not system:
|
||||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||||
|
org_id = await resolve_caller_org_id(decoded)
|
||||||
|
if org_id is not None and system.get("org_id") != org_id:
|
||||||
|
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||||
return system
|
return system
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_system(body: SystemCreate, _: dict = Depends(require_admin_token)):
|
async def create_system(
|
||||||
|
body: SystemCreate,
|
||||||
|
org_id: Optional[str] = Query(None, description="Platform-admin only — defaults to the founding org."),
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
system_id = str(uuid.uuid4())
|
system_id = str(uuid.uuid4())
|
||||||
doc = SystemRecord(system_id=system_id, **body.model_dump())
|
doc = SystemRecord(system_id=system_id, org_id=org_id or FOUNDING_ORG_ID, **body.model_dump())
|
||||||
await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False)
|
await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False)
|
||||||
return doc
|
return doc
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ from app.internal.auth import (
|
|||||||
require_service_or_firebase_token,
|
require_service_or_firebase_token,
|
||||||
require_service_key,
|
require_service_key,
|
||||||
require_service_key_or_admin,
|
require_service_key_or_admin,
|
||||||
|
get_role,
|
||||||
trip_chat_limiter,
|
trip_chat_limiter,
|
||||||
)
|
)
|
||||||
|
from app.internal.tenancy import FOUNDING_ORG_ID
|
||||||
|
|
||||||
router = APIRouter(prefix="/trips", tags=["trips"])
|
router = APIRouter(prefix="/trips", tags=["trips"])
|
||||||
|
|
||||||
@@ -23,6 +25,22 @@ router = APIRouter(prefix="/trips", tags=["trips"])
|
|||||||
# Access control helpers
|
# Access control helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _require_founding_org(decoded: dict) -> None:
|
||||||
|
"""
|
||||||
|
Trips is an internal utility feature riding along on this stack, not a
|
||||||
|
tenant-scoped product surface (see [[trips-feature-intentional]] and
|
||||||
|
SAAS_PLAN.md B7/B2c) — it has no org_id on its documents and isn't
|
||||||
|
getting one in this pass. Restricting mutations to the founding org (plus
|
||||||
|
the bot's service key, and platform admins for support) is how it stays
|
||||||
|
usable for its original purpose without becoming a write surface every
|
||||||
|
new customer org can reach into.
|
||||||
|
"""
|
||||||
|
if decoded.get("service") or get_role(decoded) == "admin":
|
||||||
|
return
|
||||||
|
if decoded.get("org_id") != FOUNDING_ORG_ID:
|
||||||
|
raise HTTPException(403, "Trip planning is available to the founding org only.")
|
||||||
|
|
||||||
|
|
||||||
async def _discord_id_for_firebase(firebase_uid: str) -> Optional[str]:
|
async def _discord_id_for_firebase(firebase_uid: str) -> Optional[str]:
|
||||||
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
|
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
|
||||||
return (link or {}).get("discord_user_id")
|
return (link or {}).get("discord_user_id")
|
||||||
@@ -224,7 +242,8 @@ async def list_trips(decoded: dict = Depends(require_service_or_firebase_token))
|
|||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
async def create_trip(body: TripCreate):
|
async def create_trip(body: TripCreate, decoded: dict = Depends(require_service_or_firebase_token)):
|
||||||
|
_require_founding_org(decoded)
|
||||||
if body.end_date < body.start_date:
|
if body.end_date < body.start_date:
|
||||||
raise HTTPException(400, "end_date must be on or after start_date.")
|
raise HTTPException(400, "end_date must be on or after start_date.")
|
||||||
trip_id = str(uuid.uuid4())
|
trip_id = str(uuid.uuid4())
|
||||||
@@ -263,8 +282,9 @@ async def get_trip(trip_id: str, decoded: dict = Depends(require_service_or_fire
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{trip_id}/tags")
|
@router.put("/{trip_id}/tags")
|
||||||
async def update_trip_tags(trip_id: str, body: dict):
|
async def update_trip_tags(trip_id: str, body: dict, decoded: dict = Depends(require_service_or_firebase_token)):
|
||||||
"""Replace the trip's available tag list and overlap-allowed tag list."""
|
"""Replace the trip's available tag list and overlap-allowed tag list."""
|
||||||
|
_require_founding_org(decoded)
|
||||||
trip = await fstore.doc_get("trips", trip_id)
|
trip = await fstore.doc_get("trips", trip_id)
|
||||||
if not trip:
|
if not trip:
|
||||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||||
@@ -363,7 +383,8 @@ async def leave_trip(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{trip_id}/events")
|
@router.post("/{trip_id}/events")
|
||||||
async def create_event(trip_id: str, body: TripEventCreate):
|
async def create_event(trip_id: str, body: TripEventCreate, decoded: dict = Depends(require_service_or_firebase_token)):
|
||||||
|
_require_founding_org(decoded)
|
||||||
trip = await fstore.doc_get("trips", trip_id)
|
trip = await fstore.doc_get("trips", trip_id)
|
||||||
if not trip:
|
if not trip:
|
||||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||||
@@ -396,7 +417,8 @@ async def create_event(trip_id: str, body: TripEventCreate):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{trip_id}/events/{event_id}")
|
@router.patch("/{trip_id}/events/{event_id}")
|
||||||
async def update_event(trip_id: str, event_id: str, body: TripEventUpdate):
|
async def update_event(trip_id: str, event_id: str, body: TripEventUpdate, decoded: dict = Depends(require_service_or_firebase_token)):
|
||||||
|
_require_founding_org(decoded)
|
||||||
event = await fstore.doc_get("trip_events", event_id)
|
event = await fstore.doc_get("trip_events", event_id)
|
||||||
if not event or event.get("trip_id") != trip_id:
|
if not event or event.get("trip_id") != trip_id:
|
||||||
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
|
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from typing import Optional
|
|||||||
from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPException, Security
|
from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPException, Security
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from app.internal.storage import upload_audio
|
from app.internal.storage import upload_audio
|
||||||
|
from app.internal import dedup
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -47,16 +48,38 @@ async def upload_call_audio(
|
|||||||
if len(data) > settings.upload_max_bytes:
|
if len(data) > settings.upload_max_bytes:
|
||||||
raise HTTPException(413, f"File too large (max {settings.upload_max_bytes // (1024*1024)} MB).")
|
raise HTTPException(413, f"File too large (max {settings.upload_max_bytes // (1024*1024)} MB).")
|
||||||
|
|
||||||
audio_url = await upload_audio(data, file.filename or "", call_id=call_id)
|
gcs_uri = await upload_audio(data, file.filename or "", call_id=call_id)
|
||||||
|
|
||||||
if audio_url:
|
if gcs_uri:
|
||||||
try:
|
try:
|
||||||
await fstore.doc_set("calls", call_id, {"audio_url": audio_url})
|
# Canonical object location only. The playback link is minted per
|
||||||
|
# read in storage.playback_url() — nothing durable is stored here.
|
||||||
|
# org_id is stamped defensively here too (not just in
|
||||||
|
# mqtt_handler.py's call_start/call_end): key_doc above proves this
|
||||||
|
# node_id is real and authenticated, so resolving org_id from the
|
||||||
|
# node doc here covers a call whose Firestore doc was somehow
|
||||||
|
# never written by call_start (the upload is otherwise the
|
||||||
|
# authoritative record of which node this audio came from).
|
||||||
|
node = await fstore.doc_get_cached("nodes", node_id)
|
||||||
|
updates = {"audio_gcs_uri": gcs_uri}
|
||||||
|
if node and node.get("org_id"):
|
||||||
|
updates["org_id"] = node["org_id"]
|
||||||
|
await fstore.doc_set("calls", call_id, updates)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not update call {call_id} with audio_url: {e}")
|
logger.warning(f"Could not update call {call_id} with audio_gcs_uri: {e}")
|
||||||
|
|
||||||
# Convert public GCS URL to gs:// URI for Speech-to-Text
|
# Another node in range recorded the same transmission. Keep the audio
|
||||||
gcs_uri = _public_url_to_gcs_uri(audio_url)
|
# (it may be the cleaner capture) but don't transcribe or correlate it
|
||||||
|
# a second time — see app/internal/dedup.py.
|
||||||
|
call_doc = await fstore.doc_get("calls", call_id)
|
||||||
|
duplicate_of = await dedup.find_duplicate_of(call_doc) if call_doc else None
|
||||||
|
if duplicate_of:
|
||||||
|
await fstore.doc_set("calls", call_id, {"duplicate_of": duplicate_of})
|
||||||
|
logger.info(
|
||||||
|
f"Call {call_id} from {node_id} duplicates {duplicate_of} "
|
||||||
|
f"— audio kept, AI pipeline skipped."
|
||||||
|
)
|
||||||
|
return {"url": gcs_uri, "duplicate_of": duplicate_of}
|
||||||
|
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
_run_intelligence_pipeline,
|
_run_intelligence_pipeline,
|
||||||
@@ -68,21 +91,7 @@ async def upload_call_audio(
|
|||||||
gcs_uri=gcs_uri,
|
gcs_uri=gcs_uri,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"url": audio_url}
|
return {"url": gcs_uri}
|
||||||
|
|
||||||
|
|
||||||
def _public_url_to_gcs_uri(url: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Convert a public GCS URL (possibly signed) like
|
|
||||||
https://storage.googleapis.com/bucket/calls/file.mp3?Expires=...
|
|
||||||
to a gs:// URI usable by Speech-to-Text.
|
|
||||||
Returns None if the URL doesn't look like a GCS URL.
|
|
||||||
"""
|
|
||||||
prefix = "https://storage.googleapis.com/"
|
|
||||||
if url and url.startswith(prefix):
|
|
||||||
path = url[len(prefix):].split("?")[0] # strip signed-URL query params
|
|
||||||
return "gs://" + path
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _correlate_with_consensus(
|
async def _correlate_with_consensus(
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
Public waitlist submission — no self-serve org creation is promised here,
|
||||||
|
just "we'll get back to you". Not coupled to any plan/tier: the commercial
|
||||||
|
model (participation-based access, not per-seat SaaS — see
|
||||||
|
app/internal/tenancy.py) is still being defined separately, so this route
|
||||||
|
only ever writes {email, org_name, note} and never a plan_id.
|
||||||
|
|
||||||
|
Unauthenticated by design (a prospect has no account yet), so the only
|
||||||
|
protection against abuse is source-IP rate limiting via the shared
|
||||||
|
_RateLimiter (app/internal/auth.py).
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import uuid4
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.auth import waitlist_limiter
|
||||||
|
from app.internal.logger import logger
|
||||||
|
|
||||||
|
router = APIRouter(tags=["waitlist"])
|
||||||
|
|
||||||
|
|
||||||
|
class WaitlistBody(BaseModel):
|
||||||
|
# Plain str, not pydantic.EmailStr — EmailStr needs the email-validator
|
||||||
|
# package, which isn't in requirements.txt, and adding a dependency for
|
||||||
|
# one light check wasn't worth it. Good-enough sanity check only; this
|
||||||
|
# is a marketing capture form, not an auth path.
|
||||||
|
email: str
|
||||||
|
org_name: Optional[str] = None
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
@field_validator("email")
|
||||||
|
@classmethod
|
||||||
|
def _basic_email_shape(cls, v: str) -> str:
|
||||||
|
v = v.strip()
|
||||||
|
if "@" not in v or " " in v or len(v) > 254:
|
||||||
|
raise ValueError("Enter a valid email address.")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/waitlist", status_code=201)
|
||||||
|
async def join_waitlist(body: WaitlistBody, request: Request):
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
waitlist_limiter.check(client_ip)
|
||||||
|
|
||||||
|
entry_id = str(uuid4())
|
||||||
|
await fstore.doc_set("waitlist", entry_id, {
|
||||||
|
"entry_id": entry_id,
|
||||||
|
"email": body.email.lower(),
|
||||||
|
"org_name": (body.org_name or "").strip() or None,
|
||||||
|
"note": (body.note or "").strip()[:2000] or None,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"source_ip": client_ip,
|
||||||
|
}, merge=False)
|
||||||
|
logger.info(f"Waitlist signup: {body.email!r} (org_name={body.org_name!r})")
|
||||||
|
return {"ok": True}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# -----------------------------------------------------------------------
|
|
||||||
# Mosquitto ACL — DRB C2 Server
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Two principals:
|
|
||||||
# drb-c2-core — the backend service; needs full broker access
|
|
||||||
# drb-node — shared credential for all edge nodes; scoped to their
|
|
||||||
# own namespace via MQTT client ID (%c = NODE_ID)
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
# C2-core service — full read/write on every topic
|
|
||||||
user drb-c2-core
|
|
||||||
topic readwrite #
|
|
||||||
|
|
||||||
# Edge nodes — each node may only read/write topics under nodes/<its-own-ID>/
|
|
||||||
# Mosquitto substitutes %c with the connecting client's MQTT client ID at
|
|
||||||
# runtime. Edge nodes set client_id = NODE_ID in mqtt_manager.py, so this
|
|
||||||
# cryptographically prevents node-A from publishing to nodes/node-B/api_key
|
|
||||||
# or any other node's namespace.
|
|
||||||
pattern readwrite nodes/%c/#
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Mosquitto entrypoint — generates /mosquitto/config/passwd from env vars
|
|
||||||
# before handing off to the broker process.
|
|
||||||
#
|
|
||||||
# Required environment variables (set in docker-compose.yml):
|
|
||||||
# MQTT_C2_USER — username for the drb-c2-core service
|
|
||||||
# MQTT_C2_PASS — password for the drb-c2-core service
|
|
||||||
# MQTT_NODE_USER — shared username for all edge nodes
|
|
||||||
# MQTT_NODE_PASS — shared password for all edge nodes
|
|
||||||
set -e
|
|
||||||
|
|
||||||
PASSWD_FILE=/tmp/passwd
|
|
||||||
|
|
||||||
# Remove any stale file so we start clean on every container start
|
|
||||||
rm -f "$PASSWD_FILE"
|
|
||||||
|
|
||||||
if [ -z "$MQTT_C2_USER" ] || [ -z "$MQTT_C2_PASS" ]; then
|
|
||||||
echo "ERROR: MQTT_C2_USER and MQTT_C2_PASS must be set" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$MQTT_NODE_USER" ] || [ -z "$MQTT_NODE_PASS" ]; then
|
|
||||||
echo "ERROR: MQTT_NODE_USER and MQTT_NODE_PASS must be set" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# -c creates/overwrites the file; subsequent calls append without -c
|
|
||||||
mosquitto_passwd -c -b "$PASSWD_FILE" "$MQTT_C2_USER" "$MQTT_C2_PASS"
|
|
||||||
mosquitto_passwd -b "$PASSWD_FILE" "$MQTT_NODE_USER" "$MQTT_NODE_PASS"
|
|
||||||
|
|
||||||
# mosquitto_passwd creates the file 0600 (root-only); mosquitto drops to
|
|
||||||
# the mosquitto user before reading it, so make it world-readable.
|
|
||||||
chmod 644 "$PASSWD_FILE"
|
|
||||||
|
|
||||||
echo "Mosquitto: password file written for users: $MQTT_C2_USER, $MQTT_NODE_USER"
|
|
||||||
|
|
||||||
exec /usr/sbin/mosquitto -c /mosquitto/config/mosquitto.conf
|
|
||||||
@@ -1,11 +1,43 @@
|
|||||||
listener 1883
|
# Auth: mosquitto's own built-in dynamic-security plugin — NOT
|
||||||
|
# mosquitto-go-auth (that project is archived upstream, no CVE patches;
|
||||||
|
# rejected for an internet-facing broker). This plugin ships in and is
|
||||||
|
# maintained alongside the official eclipse-mosquitto image itself.
|
||||||
|
# See MQTT-PUBLIC-AUTH-PLAN.md and app/internal/dynsec.py for the full
|
||||||
|
# design (bootstrap, roles, the two-sources-of-truth reconcile).
|
||||||
|
#
|
||||||
|
# Plugin path is DERIVED FROM SOURCE (docker/2.1-alpine/Dockerfile in
|
||||||
|
# eclipse-mosquitto/mosquitto), not observed by running the image —
|
||||||
|
# nothing in this project executes/pulls images from this machine. Verify
|
||||||
|
# it on first real deploy: `docker compose logs mosquitto` will say
|
||||||
|
# "Error: Unable to load plugin" at the exact path below if it's wrong for
|
||||||
|
# whatever patch tag ends up pinned.
|
||||||
|
plugin /usr/lib/mosquitto_dynamic_security.so
|
||||||
|
# Lives on the same persistent volume as `persistence_location` below —
|
||||||
|
# one durable volume for all broker state, survives redeploys.
|
||||||
|
plugin_opt_config_file /mosquitto/data/dynamic-security.json
|
||||||
|
|
||||||
allow_anonymous false
|
allow_anonymous false
|
||||||
|
# No password_file/acl_file directive anywhere in this file — the plugin
|
||||||
|
# above is the only registered auth backend. There is no "coexist" mode:
|
||||||
|
# nothing else is registered to conflict with it.
|
||||||
|
|
||||||
# Credentials and ACLs are generated/mounted at container startup
|
# Internal, plaintext — c2-core's own connection only (its dynsec-admin
|
||||||
password_file /tmp/passwd
|
# control-plane calls AND its regular data-plane pub/sub both use this).
|
||||||
acl_file /mosquitto/config/acl.conf
|
# Never published to the host in prod (docker-compose.prod.yml removes the
|
||||||
|
# port mapping); external nodes use the TLS listener below instead.
|
||||||
|
listener 1883
|
||||||
|
|
||||||
|
# Public, TLS — edge nodes connect here as username=node_id, password=api_key
|
||||||
|
# (the same credential /upload already trusts via node_keys), authorized by
|
||||||
|
# the "node" dynsec role (nodes/%u/# — %u is the dynsec-authenticated
|
||||||
|
# username, fixing the old %c-based ACL's client-ID-spoofing hole). Cert/key
|
||||||
|
# come from infra/ansible's Caddy cert-sync unit; see
|
||||||
|
# MQTT-PUBLIC-AUTH-PLAN.md "Infra" and the "Rollout order" cert-verification
|
||||||
|
# step for what happens before that cert exists.
|
||||||
|
listener 8883
|
||||||
|
certfile /mosquitto/certs/mqtt.crt
|
||||||
|
keyfile /mosquitto/certs/mqtt.key
|
||||||
|
|
||||||
# Persist retained messages (e.g. api_key, node status) across broker restarts
|
|
||||||
persistence true
|
persistence true
|
||||||
persistence_location /mosquitto/data/
|
persistence_location /mosquitto/data/
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Backfill org_id onto every pre-tenancy document and create the founding org.
|
||||||
|
|
||||||
|
*** WRITE-ONLY REFERENCE — NOT RUN AS PART OF THIS CHANGE. ***
|
||||||
|
SAAS_PLAN.md B2 explicitly says "write it; do not run it" — this script
|
||||||
|
touches production Firestore (organizations, org_members, nodes, systems,
|
||||||
|
calls, incidents, alert_rules, alert_events) and Firebase Auth custom
|
||||||
|
claims. Read this whole docstring before ever running it anywhere.
|
||||||
|
|
||||||
|
WHY IT'S NEEDED: as of this pass, every doc in the six tenant collections
|
||||||
|
below predates the org_id field entirely (org_id is Optional[...] = None on
|
||||||
|
every model in app/models.py specifically to allow this). c2-core's read
|
||||||
|
routes and infra/firestore/firestore.rules now filter/require org_id, so
|
||||||
|
until this runs, pre-existing docs are invisible through the org-scoped
|
||||||
|
paths — they still exist, they're just unreachable by a caller whose token
|
||||||
|
carries an org_id claim. New docs created going forward (enrollment.py,
|
||||||
|
mqtt_handler.py, upload.py, incident_correlator.py) already stamp org_id
|
||||||
|
themselves; this script only needs to run ONCE, retroactively, and is safe
|
||||||
|
to re-run after that (idempotent — see below).
|
||||||
|
|
||||||
|
WHAT IT DOES:
|
||||||
|
1. Creates organizations/{FOUNDING_ORG_ID} if it doesn't already exist.
|
||||||
|
FOUNDING_ORG_ID ("founding") is the same id app/internal/tenancy.py
|
||||||
|
defines and the same id enrollment.py's legacy fleet-wide
|
||||||
|
ENROLLMENT_TOKEN fallback and mqtt_handler.py's legacy MQTT-checkin
|
||||||
|
path both already resolve brand-new nodes into — so a node that
|
||||||
|
enrolled the old way and a doc backfilled by this script end up in the
|
||||||
|
same org.
|
||||||
|
2. Sets --owner-email's org_id/org_role Firebase custom claims and writes
|
||||||
|
their org_members doc — the same shape POST /auth/signup writes for a
|
||||||
|
self-serve org, so this person becomes the founding org's owner in the
|
||||||
|
UI exactly as if they'd signed up normally. Their platform `role`
|
||||||
|
claim is left alone if already set, else defaults to "admin" (the
|
||||||
|
backfill owner is presumed to be today's single-tenant deployment's
|
||||||
|
admin).
|
||||||
|
3. Walks nodes / systems / calls / incidents / alert_rules / alert_events
|
||||||
|
and stamps org_id = FOUNDING_ORG_ID onto every document that doesn't
|
||||||
|
already have one. A document that already has org_id (from the
|
||||||
|
post-tenancy code paths that shipped alongside this script) is left
|
||||||
|
untouched — this is what makes a second run a no-op rather than a
|
||||||
|
re-stamp, so running it twice by accident is harmless.
|
||||||
|
|
||||||
|
USAGE (run from the drb-c2-core directory, with GCP_CREDENTIALS_PATH set or
|
||||||
|
Application Default Credentials available — same auth as set_admin.py):
|
||||||
|
|
||||||
|
python scripts/backfill_org_id.py --owner-email you@example.com --dry-run
|
||||||
|
python scripts/backfill_org_id.py --owner-email you@example.com
|
||||||
|
|
||||||
|
ALWAYS run with --dry-run first and read every line of its output — it
|
||||||
|
prints exactly what would be created/changed, with no writes, before you
|
||||||
|
run it for real. --dry-run performs full collection scans (read-only) to
|
||||||
|
produce accurate counts; on a large calls/incidents collection this is not
|
||||||
|
free, but it is the only way to know the real backfill count in advance.
|
||||||
|
|
||||||
|
NOT HANDLED: org_api_keys (collection doesn't exist server-side yet — see
|
||||||
|
DEFERRED.md) and node_keys (deliberately never gets an org_id column; it's
|
||||||
|
looked up by node_id / api_key value, not read as an org-scoped list).
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import firebase_admin
|
||||||
|
from firebase_admin import auth, credentials, firestore
|
||||||
|
|
||||||
|
# Mirrors app/internal/tenancy.py — duplicated rather than imported so this
|
||||||
|
# script has no dependency on the app package (or its settings/env) being
|
||||||
|
# importable from wherever it's actually run.
|
||||||
|
FOUNDING_ORG_ID = "founding"
|
||||||
|
|
||||||
|
TENANT_COLLECTIONS = ["nodes", "systems", "calls", "incidents", "alert_rules", "alert_events"]
|
||||||
|
|
||||||
|
# Firestore batched writes cap at 500 operations; stay comfortably under it.
|
||||||
|
_BATCH_SIZE = 400
|
||||||
|
|
||||||
|
|
||||||
|
_PAGE_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_collection(db, collection: str, page_size: int = _PAGE_SIZE):
|
||||||
|
"""
|
||||||
|
Walk a collection in key-ordered pages instead of one open stream.
|
||||||
|
|
||||||
|
A bare .stream() over calls/ died with "503 Query timed out. Please try
|
||||||
|
either limiting the entities scanned" -- the collection has outgrown what
|
||||||
|
Firestore will serve as a single scan, and the failure surfaced as an
|
||||||
|
unrelated-looking AttributeError from the client's own retry path. Paging by
|
||||||
|
document id needs no composite index and keeps each request small; it also
|
||||||
|
stops the counting pass holding every document in memory at once.
|
||||||
|
|
||||||
|
Documents written while this runs may be missed or seen twice. Neither
|
||||||
|
matters: post-tenancy code stamps org_id itself, and the update below is
|
||||||
|
idempotent.
|
||||||
|
"""
|
||||||
|
base = db.collection(collection).order_by("__name__").limit(page_size)
|
||||||
|
cursor = None
|
||||||
|
while True:
|
||||||
|
query = base.start_after(cursor) if cursor is not None else base
|
||||||
|
page = list(query.stream())
|
||||||
|
if not page:
|
||||||
|
return
|
||||||
|
for doc in page:
|
||||||
|
yield doc
|
||||||
|
if len(page) < page_size:
|
||||||
|
return
|
||||||
|
cursor = page[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
parser.add_argument("--owner-email", required=True, help="Firebase user who becomes the founding org's owner")
|
||||||
|
parser.add_argument("--org-name", default="Founding Org", help="Display name for the founding org")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Print what would change; write nothing")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Match app/internal/firestore.py exactly. Two ways this script diverged
|
||||||
|
# from the app and would have failed or, worse, half-succeeded:
|
||||||
|
# * credentials — GCP_CREDENTIALS_PATH is unset in production (the server
|
||||||
|
# is a GCE instance and the app uses Application Default Credentials via
|
||||||
|
# the metadata server). Defaulting to a "gcp-key.json" that does not
|
||||||
|
# exist made the script unrunnable there.
|
||||||
|
# * database — the app talks to FIRESTORE_DATABASE (c2-server in prod),
|
||||||
|
# while a bare firestore.client() talks to "(default)". That one is the
|
||||||
|
# dangerous half: the script would have scanned an empty database, found
|
||||||
|
# nothing to backfill, created the founding org in the wrong place and
|
||||||
|
# printed a clean success.
|
||||||
|
creds_path = os.getenv("GCP_CREDENTIALS_PATH")
|
||||||
|
cred = credentials.Certificate(creds_path) if creds_path else credentials.ApplicationDefault()
|
||||||
|
firebase_admin.initialize_app(cred)
|
||||||
|
|
||||||
|
database_id = os.getenv("FIRESTORE_DATABASE", "(default)")
|
||||||
|
print(f"Using Firestore database: {database_id}")
|
||||||
|
db = firestore.client(database_id=database_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
owner = auth.get_user_by_email(args.owner_email)
|
||||||
|
except auth.UserNotFoundError:
|
||||||
|
print(f"No Firebase user found for {args.owner_email!r}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
existing_claims = owner.custom_claims or {}
|
||||||
|
|
||||||
|
org_ref = db.collection("organizations").document(FOUNDING_ORG_ID)
|
||||||
|
org_exists = org_ref.get().exists
|
||||||
|
print(f"organizations/{FOUNDING_ORG_ID}: {'exists — left alone' if org_exists else 'WILL CREATE'}")
|
||||||
|
print(f"org_members/{owner.uid}: WILL SET org_role='owner' (email={args.owner_email})")
|
||||||
|
print(
|
||||||
|
f"Firebase custom claims for {args.owner_email}: WILL SET org_id={FOUNDING_ORG_ID!r} org_role='owner', "
|
||||||
|
f"role={existing_claims.get('role', 'admin (default)')!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
counts: dict[str, tuple[int, int]] = {}
|
||||||
|
total_missing = 0
|
||||||
|
for collection in TENANT_COLLECTIONS:
|
||||||
|
total = 0
|
||||||
|
missing = 0
|
||||||
|
for doc in _iter_collection(db, collection):
|
||||||
|
total += 1
|
||||||
|
if not (doc.to_dict() or {}).get("org_id"):
|
||||||
|
missing += 1
|
||||||
|
counts[collection] = (total, missing)
|
||||||
|
total_missing += missing
|
||||||
|
print(f"{collection}: {total} docs total, {missing} missing org_id")
|
||||||
|
|
||||||
|
print(f"\nTotal documents to backfill: {total_missing}")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("\n--dry-run: no writes performed.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not org_exists:
|
||||||
|
org_ref.set({
|
||||||
|
"org_id": FOUNDING_ORG_ID,
|
||||||
|
"name": args.org_name,
|
||||||
|
"created_at": now,
|
||||||
|
"created_by_uid": owner.uid,
|
||||||
|
# Inert placeholders — no billing model exists yet, see
|
||||||
|
# app/internal/tenancy.py and models.py's OrganizationRecord.
|
||||||
|
"plan_id": None,
|
||||||
|
"subscription_status": None,
|
||||||
|
"stripe_customer_id": None,
|
||||||
|
"stripe_subscription_id": None,
|
||||||
|
"current_period_end": None,
|
||||||
|
"seat_limit": None,
|
||||||
|
"node_limit": None,
|
||||||
|
"retention_days": None,
|
||||||
|
})
|
||||||
|
print(f"Created organizations/{FOUNDING_ORG_ID}.")
|
||||||
|
|
||||||
|
db.collection("org_members").document(owner.uid).set({
|
||||||
|
"uid": owner.uid,
|
||||||
|
"org_id": FOUNDING_ORG_ID,
|
||||||
|
"org_role": "owner",
|
||||||
|
"email": args.owner_email,
|
||||||
|
"added_at": now,
|
||||||
|
}, merge=True)
|
||||||
|
print(f"Set org_members/{owner.uid}.")
|
||||||
|
|
||||||
|
new_claims = {**existing_claims, "org_id": FOUNDING_ORG_ID, "org_role": "owner"}
|
||||||
|
new_claims.setdefault("role", "admin")
|
||||||
|
auth.set_custom_user_claims(owner.uid, new_claims)
|
||||||
|
print(f"Set custom claims for {args.owner_email}.")
|
||||||
|
|
||||||
|
for collection in TENANT_COLLECTIONS:
|
||||||
|
_, missing_count = counts[collection]
|
||||||
|
if not missing_count:
|
||||||
|
print(f"{collection}: nothing to backfill.")
|
||||||
|
continue
|
||||||
|
batch = db.batch()
|
||||||
|
batch_count = 0
|
||||||
|
written = 0
|
||||||
|
for doc in _iter_collection(db, collection):
|
||||||
|
if (doc.to_dict() or {}).get("org_id"):
|
||||||
|
continue
|
||||||
|
batch.update(doc.reference, {"org_id": FOUNDING_ORG_ID})
|
||||||
|
batch_count += 1
|
||||||
|
written += 1
|
||||||
|
if batch_count >= _BATCH_SIZE:
|
||||||
|
batch.commit()
|
||||||
|
batch = db.batch()
|
||||||
|
batch_count = 0
|
||||||
|
if batch_count:
|
||||||
|
batch.commit()
|
||||||
|
print(f"{collection}: backfilled {written} document(s).")
|
||||||
|
|
||||||
|
print("\nDone. The owner must sign out and back in (or wait up to 1 hour) for the new claims to take effect.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,2 +1,61 @@
|
|||||||
# All C2 core settings have defaults — no env setup needed.
|
# All C2 core settings have defaults — no env setup needed.
|
||||||
# Add any shared fixtures here if required in the future.
|
#
|
||||||
|
# firebase-admin and google-cloud-firestore are runtime-only dependencies: they
|
||||||
|
# are installed in the container but not in the local dev venv, and
|
||||||
|
# app/internal/firestore.py calls _init_firebase() at import time. Without the
|
||||||
|
# stubs below, importing ANY module that reaches Firestore fails at collection
|
||||||
|
# time, which is why test_mqtt_handler and test_node_sweeper could not be run
|
||||||
|
# outside the container.
|
||||||
|
#
|
||||||
|
# The stubs are installed only when the real packages are absent, so the
|
||||||
|
# container's real SDK is never shadowed.
|
||||||
|
import sys
|
||||||
|
from types import ModuleType
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
try: # pragma: no cover - exercised only by which packages are installed
|
||||||
|
import firebase_admin # noqa: F401
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
_firebase = ModuleType("firebase_admin")
|
||||||
|
# Falsy so _init_firebase() takes the initialize_app() branch rather than the
|
||||||
|
# already-initialised branch, which is itself broken (see DEFERRED.md).
|
||||||
|
_firebase._apps = {}
|
||||||
|
_firebase.initialize_app = MagicMock()
|
||||||
|
_firebase.credentials = MagicMock()
|
||||||
|
_firebase.firestore = MagicMock()
|
||||||
|
|
||||||
|
_credentials = ModuleType("firebase_admin.credentials")
|
||||||
|
_credentials.Certificate = MagicMock()
|
||||||
|
_credentials.ApplicationDefault = MagicMock()
|
||||||
|
|
||||||
|
_fs = ModuleType("firebase_admin.firestore")
|
||||||
|
_fs.client = MagicMock()
|
||||||
|
# A distinct sentinel rather than a MagicMock: production code writes this
|
||||||
|
# into dicts that tests compare against, and a MagicMock compares unequal
|
||||||
|
# to itself across attribute accesses.
|
||||||
|
_fs.SERVER_TIMESTAMP = "__SERVER_TIMESTAMP__"
|
||||||
|
|
||||||
|
_auth = ModuleType("firebase_admin.auth")
|
||||||
|
_auth.verify_id_token = MagicMock()
|
||||||
|
_auth.set_custom_user_claims = MagicMock()
|
||||||
|
_auth.get_user_by_email = MagicMock()
|
||||||
|
_auth.get_user = MagicMock()
|
||||||
|
|
||||||
|
_firebase.auth = _auth
|
||||||
|
_firebase.credentials = _credentials
|
||||||
|
_firebase.firestore = _fs
|
||||||
|
|
||||||
|
sys.modules["firebase_admin"] = _firebase
|
||||||
|
sys.modules["firebase_admin.credentials"] = _credentials
|
||||||
|
sys.modules["firebase_admin.firestore"] = _fs
|
||||||
|
sys.modules["firebase_admin.auth"] = _auth
|
||||||
|
|
||||||
|
try: # pragma: no cover
|
||||||
|
from google.cloud.firestore_v1.base_query import FieldFilter # noqa: F401
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
for _name in (
|
||||||
|
"google", "google.cloud", "google.cloud.firestore_v1",
|
||||||
|
"google.cloud.firestore_v1.base_query",
|
||||||
|
):
|
||||||
|
sys.modules.setdefault(_name, ModuleType(_name))
|
||||||
|
sys.modules["google.cloud.firestore_v1.base_query"].FieldFilter = MagicMock()
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the incident-creation gate and the thin-call activity rule.
|
||||||
|
|
||||||
|
Both behaviours come from the 2026-08-16 correlation dump, where TG 9048
|
||||||
|
produced one 28-call / 49-minute incident alongside 32 permanent orphans:
|
||||||
|
|
||||||
|
* Requiring a concrete incident_type to create an incident meant a channel
|
||||||
|
whose traffic never classifies could never open a second incident, so every
|
||||||
|
later call funnelled into whichever incident existed first.
|
||||||
|
* Thin ("10-4") calls refreshed updated_at, which kept that incident
|
||||||
|
permanently inside the fast-path recency gate.
|
||||||
|
|
||||||
|
_run_decision is pure — it reads only the context dict — so these cases need no
|
||||||
|
Firestore. _update_incident writes, so its test patches fstore.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
from app.internal.incident_correlator import (
|
||||||
|
_run_decision, _update_incident, _normalize_unit, _matching_units,
|
||||||
|
)
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 16, 21, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(**overrides) -> dict:
|
||||||
|
"""Context with no active incidents, so the decision reaches the creation gate."""
|
||||||
|
base = {
|
||||||
|
"call_id": "call-1",
|
||||||
|
"all_active": [],
|
||||||
|
"recent": [],
|
||||||
|
"call_doc": {},
|
||||||
|
"call_embedding": None,
|
||||||
|
"call_units": [],
|
||||||
|
"call_vehicles": [],
|
||||||
|
"call_cleared": [],
|
||||||
|
"call_severity": "routine",
|
||||||
|
"coords": None,
|
||||||
|
"is_thin_call": True,
|
||||||
|
"now": NOW,
|
||||||
|
"system_id": "sys-1",
|
||||||
|
"talkgroup_id": 9048,
|
||||||
|
"talkgroup_name": "MTA PD Districts 6/7/11 - Police Dispatch",
|
||||||
|
"tags": [],
|
||||||
|
"incident_type": None,
|
||||||
|
"location": None,
|
||||||
|
"location_coords": None,
|
||||||
|
"reassignment": False,
|
||||||
|
"create_if_new": True,
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Creation gate — severity decides incident-worthiness, not incident_type
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_routine_status_traffic_stays_orphaned():
|
||||||
|
"""A content-free acknowledgement must not open an incident of its own."""
|
||||||
|
assert _run_decision(_ctx())["action"] == "orphan"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("severity", ["minor", "moderate", "major"])
|
||||||
|
def test_any_real_severity_opens_an_untyped_incident(severity):
|
||||||
|
decision = _run_decision(_ctx(call_severity=severity))
|
||||||
|
assert decision["action"] == "new"
|
||||||
|
assert decision["incident_type"] == "other"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field,value", [
|
||||||
|
("call_vehicles", ["RMP 22146"]),
|
||||||
|
("coords", {"lat": 41.0, "lng": -73.8}),
|
||||||
|
("tags", ["prisoner-transport"]),
|
||||||
|
])
|
||||||
|
def test_concrete_content_opens_an_untyped_incident(field, value):
|
||||||
|
"""Routine severity is overridden by anything the extractor actually found."""
|
||||||
|
decision = _run_decision(_ctx(**{field: value}))
|
||||||
|
assert decision["action"] == "new"
|
||||||
|
assert decision["incident_type"] == "other"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field,value", [
|
||||||
|
("call_units", ["11-Victor"]),
|
||||||
|
("location", "Holland Station"),
|
||||||
|
])
|
||||||
|
def test_ambient_radio_fields_are_not_substance(field, value):
|
||||||
|
"""
|
||||||
|
A unit ID and a place name appear in nearly every transmission, so treating
|
||||||
|
them as substance made the severity check dead code: "11-Victor, 72 at
|
||||||
|
Holland Station" opened its own incident, and 37 of 50 incidents were single
|
||||||
|
routine calls left permanently active.
|
||||||
|
"""
|
||||||
|
assert _run_decision(_ctx(**{field: value}))["action"] == "orphan"
|
||||||
|
|
||||||
|
|
||||||
|
def test_units_and_location_together_still_orphan():
|
||||||
|
decision = _run_decision(_ctx(call_units=["11-Victor"], location="Holland Station"))
|
||||||
|
assert decision["action"] == "orphan"
|
||||||
|
|
||||||
|
|
||||||
|
def test_units_with_real_severity_still_open_an_incident():
|
||||||
|
"""Severity is the gate — ambient fields don't block it, they just can't open it alone."""
|
||||||
|
decision = _run_decision(_ctx(call_units=["11-Victor"], call_severity="moderate"))
|
||||||
|
assert decision["action"] == "new"
|
||||||
|
assert decision["incident_type"] == "other"
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_type_is_never_downgraded_to_other():
|
||||||
|
decision = _run_decision(_ctx(incident_type="police", call_severity="moderate"))
|
||||||
|
assert decision["action"] == "new"
|
||||||
|
assert decision["incident_type"] == "police"
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_survives_extraction_and_creates_an_incident():
|
||||||
|
""""other" is a real classification now, not a synonym for unclassifiable."""
|
||||||
|
decision = _run_decision(_ctx(incident_type="other"))
|
||||||
|
assert decision["action"] == "new"
|
||||||
|
assert decision["incident_type"] == "other"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sweep_never_creates_incidents():
|
||||||
|
"""The re-correlation sweep passes create_if_new=False — it may only link."""
|
||||||
|
decision = _run_decision(_ctx(call_severity="major", create_if_new=False))
|
||||||
|
assert decision["action"] == "orphan"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Thin calls attach for context but do not count as incident activity
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _incident(idle_minutes: float) -> dict:
|
||||||
|
updated = NOW - timedelta(minutes=idle_minutes)
|
||||||
|
return {
|
||||||
|
"incident_id": "inc-1",
|
||||||
|
"system_ids": ["sys-1"],
|
||||||
|
"talkgroup_ids": ["9048"],
|
||||||
|
"updated_at": updated.isoformat(),
|
||||||
|
"started_at": updated.isoformat(),
|
||||||
|
"status": "active",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_thin_call_links_to_the_active_incident_on_its_talkgroup():
|
||||||
|
inc = _incident(0.2)
|
||||||
|
decision = _run_decision(_ctx(all_active=[inc], recent=[inc]))
|
||||||
|
assert decision["action"] == "link"
|
||||||
|
assert decision["corr_debug"]["corr_path"] == "fast/thin"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("idle_min", [1.0, 3.4, 4.9])
|
||||||
|
def test_thin_call_still_attaches_inside_the_tier2_window(idle_min):
|
||||||
|
inc = _incident(idle_min)
|
||||||
|
assert _run_decision(_ctx(all_active=[inc], recent=[inc]))["action"] == "link"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("idle_min", [5.1, 8.2, 9.7])
|
||||||
|
def test_thin_call_does_not_attach_after_the_channel_has_moved_on(idle_min):
|
||||||
|
"""
|
||||||
|
A '10-4' arriving many minutes into silence is new traffic, not a reply. The
|
||||||
|
old 10-minute window let one incident swallow an unrelated event 9.6 min
|
||||||
|
later; being the *only* candidate is not evidence, it just means the channel
|
||||||
|
was quiet, which is when the guess is weakest.
|
||||||
|
"""
|
||||||
|
inc = _incident(idle_min)
|
||||||
|
assert _run_decision(_ctx(all_active=[inc], recent=[inc]))["action"] == "orphan"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_thin_link_does_not_refresh_updated_at():
|
||||||
|
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||||
|
mock_fstore.doc_set = AsyncMock()
|
||||||
|
await _update_incident(
|
||||||
|
_incident(5), "call-1", 9048, "sys-1", [], None, None, [], [], None, NOW,
|
||||||
|
refresh_activity=False,
|
||||||
|
)
|
||||||
|
updates = mock_fstore.doc_set.await_args.args[2]
|
||||||
|
assert "updated_at" not in updates, "a '10-4' must not reset the incident idle clock"
|
||||||
|
assert updates["last_thin_at"] == NOW.isoformat()
|
||||||
|
assert updates["summary_stale"] is True, "the call still belongs in the summary"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_substantive_link_does_refresh_updated_at():
|
||||||
|
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||||
|
mock_fstore.doc_set = AsyncMock()
|
||||||
|
await _update_incident(
|
||||||
|
_incident(5), "call-1", 9048, "sys-1", [], None, None, ["6 Adam"], [], None, NOW,
|
||||||
|
)
|
||||||
|
updates = mock_fstore.doc_set.await_args.args[2]
|
||||||
|
assert updates["updated_at"] == NOW.isoformat()
|
||||||
|
assert "last_thin_at" not in updates
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unit-ID normalisation — dispatch names the same unit several ways
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("spoken,other", [
|
||||||
|
("K-9A2", "K-9-A-2"), # punctuation only
|
||||||
|
("5-1-6", "516"), # digits read out individually
|
||||||
|
("37", "37th Post"), # ordinal + role word
|
||||||
|
("11-Victor", "11 Victor"), # hyphen vs space
|
||||||
|
("Post 5", "5"), # bare role word
|
||||||
|
("post 1-2", "Post 1-2"), # case
|
||||||
|
])
|
||||||
|
def test_same_unit_spoken_differently_normalises_alike(spoken, other):
|
||||||
|
"""Every pair here was observed as one real unit failing to match itself."""
|
||||||
|
assert _normalize_unit(spoken) == _normalize_unit(other)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("a,b", [
|
||||||
|
("6-Adam", "Adam"), # every district has an Adam — must stay distinct
|
||||||
|
("6-Adam", "7-Adam"),
|
||||||
|
("11-Victor", "11-Xray"),
|
||||||
|
("516", "517"),
|
||||||
|
("3", "39"),
|
||||||
|
])
|
||||||
|
def test_genuinely_different_units_stay_distinct(a, b):
|
||||||
|
assert _normalize_unit(a) != _normalize_unit(b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_role_only_unit_does_not_collapse_to_empty():
|
||||||
|
"""
|
||||||
|
"Post" is all noise words. Normalising it to "" would make every such unit
|
||||||
|
equal to every other, so it falls back to the raw text instead.
|
||||||
|
"""
|
||||||
|
assert _normalize_unit("Post") != ""
|
||||||
|
assert _normalize_unit("Post") != _normalize_unit("Unit")
|
||||||
|
|
||||||
|
|
||||||
|
def test_matching_units_reports_the_original_spoken_strings():
|
||||||
|
"""Debug output has to stay readable, so matches come back un-normalised."""
|
||||||
|
assert _matching_units(["K-9A2", "6-Adam"], ["K-9-A-2"]) == ["K-9A2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_matching_units_empty_when_nothing_overlaps():
|
||||||
|
assert _matching_units(["6-Adam"], ["7-Adam", "516"]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalised_units_link_a_call_that_exact_match_would_orphan():
|
||||||
|
"""End-to-end: the K-9A2 case that orphaned in the 2026-08-17 01:05Z dump."""
|
||||||
|
inc = _incident(2.0)
|
||||||
|
inc["units"] = ["K-9-A-2"]
|
||||||
|
decision = _run_decision(_ctx(
|
||||||
|
all_active=[inc], recent=[inc],
|
||||||
|
call_units=["K-9A2"], is_thin_call=False, call_severity="routine",
|
||||||
|
))
|
||||||
|
assert decision["action"] == "link"
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for cross-node duplicate detection.
|
||||||
|
|
||||||
|
Fixture timings come from real production data: node-002 and node-PI-2 both
|
||||||
|
recorded TG 9048 on 2026-08-16, starting ~1.1s apart.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from app.internal.dedup import _parse_dt, _is_canonical, find_duplicate_of
|
||||||
|
|
||||||
|
BASE = datetime(2026, 8, 16, 19, 31, 46, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _query_returning(*calls):
|
||||||
|
"""Stand-in for fstore.collection_where."""
|
||||||
|
async def _q(_collection, _conditions):
|
||||||
|
return list(calls)
|
||||||
|
return _q
|
||||||
|
|
||||||
|
|
||||||
|
def _query_raising(exc):
|
||||||
|
async def _q(_collection, _conditions):
|
||||||
|
raise exc
|
||||||
|
return _q
|
||||||
|
|
||||||
|
|
||||||
|
def _call(call_id, node_id, offset_seconds=0.0, talkgroup_id=9048, **extra):
|
||||||
|
return {
|
||||||
|
"call_id": call_id,
|
||||||
|
"node_id": node_id,
|
||||||
|
"system_id": "sys-1",
|
||||||
|
"talkgroup_id": talkgroup_id,
|
||||||
|
"started_at": BASE + timedelta(seconds=offset_seconds),
|
||||||
|
**extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Timestamp parsing — Firestore returns three different shapes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_parse_dt_accepts_aware_datetime():
|
||||||
|
assert _parse_dt(BASE) == BASE
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_dt_assumes_utc_for_naive_datetime():
|
||||||
|
naive = datetime(2026, 8, 16, 19, 31, 46)
|
||||||
|
assert _parse_dt(naive) == BASE
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_dt_accepts_iso_string_with_z():
|
||||||
|
assert _parse_dt("2026-08-16T19:31:46Z") == BASE
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_dt_returns_none_for_junk():
|
||||||
|
assert _parse_dt("not a date") is None
|
||||||
|
assert _parse_dt(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Canonical selection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_earlier_start_wins():
|
||||||
|
early = _call("a", "node-002", 0.0)
|
||||||
|
late = _call("b", "node-PI-2", 1.1)
|
||||||
|
assert _is_canonical(early, [late]) is True
|
||||||
|
assert _is_canonical(late, [early]) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_identical_starts_break_tie_on_call_id():
|
||||||
|
first = _call("aaa", "node-002", 0.0)
|
||||||
|
second = _call("bbb", "node-PI-2", 0.0)
|
||||||
|
assert _is_canonical(first, [second]) is True
|
||||||
|
assert _is_canonical(second, [first]) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_nodes_reach_the_same_verdict():
|
||||||
|
"""The whole point: the decision must not depend on upload order."""
|
||||||
|
a = _call("a", "node-002", 0.0)
|
||||||
|
b = _call("b", "node-PI-2", 1.1)
|
||||||
|
verdicts = [_is_canonical(a, [b]), _is_canonical(b, [a])]
|
||||||
|
assert verdicts.count(True) == 1, "exactly one recording must be canonical"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# find_duplicate_of
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_canonical_id_for_later_recording():
|
||||||
|
canonical = _call("canon", "node-002", 0.0)
|
||||||
|
later = _call("later", "node-PI-2", 1.1)
|
||||||
|
q = _query_returning(canonical, later)
|
||||||
|
assert await find_duplicate_of(later, query=q) == "canon"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_none_for_the_canonical_recording():
|
||||||
|
canonical = _call("canon", "node-002", 0.0)
|
||||||
|
later = _call("later", "node-PI-2", 1.1)
|
||||||
|
q = _query_returning(canonical, later)
|
||||||
|
assert await find_duplicate_of(canonical, query=q) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_same_node_is_never_a_duplicate():
|
||||||
|
"""Back-to-back transmissions from one node are real, separate calls."""
|
||||||
|
first = _call("a", "node-002", 0.0)
|
||||||
|
second = _call("b", "node-002", 2.0)
|
||||||
|
q = _query_returning(first, second)
|
||||||
|
assert await find_duplicate_of(second, query=q) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_different_talkgroup_is_not_a_duplicate():
|
||||||
|
other_tg = _call("a", "node-002", 0.0, talkgroup_id=9600)
|
||||||
|
mine = _call("b", "node-PI-2", 1.0, talkgroup_id=9048)
|
||||||
|
q = _query_returning(other_tg, mine)
|
||||||
|
assert await find_duplicate_of(mine, query=q) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_never_chains_onto_another_duplicate():
|
||||||
|
"""A third node must point at the original, not at a duplicate of it."""
|
||||||
|
canonical = _call("canon", "node-002", 0.0)
|
||||||
|
already_dupe = _call("dupe", "node-PI-2", 0.5, duplicate_of="canon")
|
||||||
|
third = _call("third", "node-003", 1.0)
|
||||||
|
q = _query_returning(canonical, already_dupe, third)
|
||||||
|
assert await find_duplicate_of(third, query=q) == "canon"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_match_returns_none():
|
||||||
|
lonely = _call("only", "node-002", 0.0)
|
||||||
|
q = _query_returning(lonely)
|
||||||
|
assert await find_duplicate_of(lonely, query=q) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_identifiers_skip_the_check():
|
||||||
|
called = False
|
||||||
|
|
||||||
|
async def _q(_collection, _conditions):
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
return []
|
||||||
|
|
||||||
|
incomplete = {"call_id": "x", "node_id": "node-002", "started_at": BASE}
|
||||||
|
assert await find_duplicate_of(incomplete, query=_q) is None
|
||||||
|
assert called is False, "must bail out before querying"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_query_failure_never_blocks_the_upload():
|
||||||
|
call = _call("a", "node-002", 0.0)
|
||||||
|
q = _query_raising(RuntimeError("firestore down"))
|
||||||
|
assert await find_duplicate_of(call, query=q) is None
|
||||||
@@ -4,6 +4,17 @@ WORKDIR /app
|
|||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Build-time public vars — baked into the Next.js bundle by the CI workflow
|
||||||
|
ARG NEXT_PUBLIC_C2_URL
|
||||||
|
ARG NEXT_PUBLIC_FIREBASE_API_KEY
|
||||||
|
ARG NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN
|
||||||
|
ARG NEXT_PUBLIC_FIREBASE_PROJECT_ID
|
||||||
|
ARG NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET
|
||||||
|
ARG NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID
|
||||||
|
ARG NEXT_PUBLIC_FIREBASE_APP_ID
|
||||||
|
ARG NEXT_PUBLIC_FIRESTORE_DATABASE
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:20-slim AS runner
|
FROM node:20-slim AS runner
|
||||||
|
|||||||
@@ -1,21 +1,52 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useNodes, useUnconfiguredNodes } from "@/lib/useNodes";
|
import { useNodes, useUnconfiguredNodes } from "@/lib/useNodes";
|
||||||
import { useCalls, useActiveCalls } from "@/lib/useCalls";
|
import { useCalls, useActiveCalls } from "@/lib/useCalls";
|
||||||
import { useSystems } from "@/lib/useSystems";
|
import { useSystems } from "@/lib/useSystems";
|
||||||
|
import { useActiveIncidents } from "@/lib/useIncidents";
|
||||||
import { NodeCard } from "@/components/NodeCard";
|
import { NodeCard } from "@/components/NodeCard";
|
||||||
import { CallRow } from "@/components/CallRow";
|
import { CallRow } from "@/components/CallRow";
|
||||||
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
||||||
|
import { TypeBadge } from "@/components/IncidentBadges";
|
||||||
|
import { severityBadge, severityRank } from "@/lib/severity";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { NodeRecord } from "@/lib/types";
|
import type { NodeRecord, IncidentRecord } from "@/lib/types";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
import { PageHeader } from "@/components/ui/PageHeader";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
||||||
|
|
||||||
function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
|
function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
|
<Card>
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">{label}</p>
|
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">{label}</p>
|
||||||
<p className={`text-3xl font-bold font-mono ${accent ?? "text-white"}`}>{value}</p>
|
<p className={`text-3xl font-bold font-mono ${accent ?? "text-white"}`}>{value}</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(iso: string) {
|
||||||
|
try { return new Date(iso).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); }
|
||||||
|
catch { return iso; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function IncidentSummaryCard({ incident }: { incident: IncidentRecord }) {
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<Card hover className="cursor-pointer" onClick={() => router.push(`/incidents/${incident.incident_id}`)}>
|
||||||
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<TypeBadge type={incident.type} />
|
||||||
|
{severityBadge(incident.severity)}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-white text-sm font-semibold leading-snug line-clamp-2">{incident.title ?? "Untitled incident"}</p>
|
||||||
|
<p className="text-gray-500 text-xs font-mono mt-2">
|
||||||
|
{fmtTime(incident.started_at)} · {incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +56,7 @@ export default function DashboardPage() {
|
|||||||
const { calls, error: callsError } = useCalls(20);
|
const { calls, error: callsError } = useCalls(20);
|
||||||
const activeCalls = useActiveCalls();
|
const activeCalls = useActiveCalls();
|
||||||
const { systems, error: systemsError } = useSystems();
|
const { systems, error: systemsError } = useSystems();
|
||||||
|
const activeIncidents = useActiveIncidents();
|
||||||
const [configNode, setConfigNode] = useState<NodeRecord | null>(null);
|
const [configNode, setConfigNode] = useState<NodeRecord | null>(null);
|
||||||
|
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
@@ -33,44 +65,66 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
const fsError = nodesError ?? callsError ?? systemsError;
|
const fsError = nodesError ?? callsError ?? systemsError;
|
||||||
|
|
||||||
return (
|
// Worst-first: the incident that most needs a human's attention leads the panel.
|
||||||
<div className="space-y-6">
|
const sortedIncidents = [...activeIncidents].sort(
|
||||||
<h1 className="text-xl font-bold text-white font-mono">Dashboard</h1>
|
(a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at)
|
||||||
|
);
|
||||||
|
const notableIncidentCount = activeIncidents.filter((i) => severityRank(i.severity) >= 2).length;
|
||||||
|
|
||||||
{fsError && (
|
return (
|
||||||
<div className="bg-red-950 border border-red-800 rounded-lg p-4">
|
<div className="space-y-8">
|
||||||
<p className="text-red-400 text-sm font-mono">Firestore error: {fsError}</p>
|
<PageHeader
|
||||||
</div>
|
title="Dashboard"
|
||||||
)}
|
badge={notableIncidentCount > 0 && <Badge tone="danger">{notableIncidentCount} moderate+ active</Badge>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{fsError && <ErrorBanner message={`Firestore error: ${fsError}`} />}
|
||||||
|
|
||||||
{/* Pending config banner */}
|
{/* Pending config banner */}
|
||||||
{pending.length > 0 && (
|
{pending.length > 0 && (
|
||||||
<div className="bg-indigo-950 border border-indigo-800 rounded-lg p-4 flex items-center justify-between">
|
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-lg p-4 flex items-center justify-between gap-3 flex-wrap">
|
||||||
<p className="text-indigo-300 text-sm font-mono">
|
<p className="text-indigo-300 text-sm font-mono">
|
||||||
{pending.length} new node{pending.length > 1 ? "s" : ""} connected and need{pending.length === 1 ? "s" : ""} configuration.
|
{pending.length} new node{pending.length > 1 ? "s" : ""} connected and need{pending.length === 1 ? "s" : ""} configuration.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button size="sm" onClick={() => setConfigNode(pending[0])}>Configure now</Button>
|
||||||
onClick={() => setConfigNode(pending[0])}
|
|
||||||
className="text-xs bg-indigo-700 hover:bg-indigo-600 text-white px-3 py-1.5 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Configure now
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<StatCard label="Active Incidents" value={activeIncidents.length} accent={activeIncidents.length > 0 ? "text-orange-400" : undefined} />
|
||||||
<StatCard label="Nodes Online" value={onlineCount} accent="text-green-400" />
|
<StatCard label="Nodes Online" value={onlineCount} accent="text-green-400" />
|
||||||
<StatCard label="Active Calls" value={activeCalls.length} accent={activeCalls.length > 0 ? "text-orange-400" : undefined} />
|
<StatCard label="Active Calls" value={activeCalls.length} accent={activeCalls.length > 0 ? "text-orange-400" : undefined} />
|
||||||
<StatCard label="Total Nodes" value={nodes.length} />
|
|
||||||
<StatCard label="Systems" value={systems.length} />
|
<StatCard label="Systems" value={systems.length} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Active incidents — the primary "what's happening" view */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">Active Incidents</h2>
|
||||||
|
<Link href="/incidents" className="text-xs text-indigo-400 hover:text-indigo-300 font-mono transition-colors">
|
||||||
|
View all →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{sortedIncidents.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No active incidents"
|
||||||
|
description="Incidents appear here automatically as calls correlate into events."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{sortedIncidents.slice(0, 6).map((inc) => (
|
||||||
|
<IncidentSummaryCard key={inc.incident_id} incident={inc} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Nodes */}
|
{/* Nodes */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Nodes</h2>
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Nodes</h2>
|
||||||
{nodes.length === 0 ? (
|
{nodes.length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">No nodes registered yet.</p>
|
<EmptyState title="No nodes registered yet" description="Deploy a field SDR node and it will show up here automatically." />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{nodes.map((n) => (
|
{nodes.map((n) => (
|
||||||
@@ -84,9 +138,9 @@ export default function DashboardPage() {
|
|||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
|
||||||
{calls.length === 0 ? (
|
{calls.length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">No calls recorded yet.</p>
|
<EmptyState title="No calls recorded yet" />
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
<Card padding="none" className="overflow-hidden overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||||
@@ -104,16 +158,12 @@ export default function DashboardPage() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{configNode && (
|
{configNode && (
|
||||||
<NodeConfigModal
|
<NodeConfigModal node={configNode} systems={systems} onClose={() => setConfigNode(null)} />
|
||||||
node={configNode}
|
|
||||||
systems={systems}
|
|
||||||
onClose={() => setConfigNode(null)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { LinkButton } from "@/components/ui/Button";
|
||||||
|
|
||||||
|
const FAQS: { q: string; a: string }[] = [
|
||||||
|
{
|
||||||
|
q: "What hardware do I need to run a node?",
|
||||||
|
a: "A node is a small field SDR device running our edge-node software — it needs an SDR dongle capable of receiving your local P25 or analog trunked system, and a network connection to reach your DRB account. Full setup instructions are provided once you add a node.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "What's the difference between a 'call' and an 'incident'?",
|
||||||
|
a: "A call is a single radio transmission. An incident is the thing you actually care about — a pursuit, a fire, an accident — built by correlating related calls together, sometimes across multiple talkgroups or nodes. Incidents are the primary view; calls are the evidence behind them.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Does DRB do the transcription and AI work itself, or is that a separate cost?",
|
||||||
|
a: "Transcription and incident correlation are included in every paid plan and run automatically on every recorded call. The Community plan includes AI features on a limited call volume; Pro and Enterprise scale with your node count.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Can I listen to live radio traffic without opening the dashboard?",
|
||||||
|
a: "Yes — the Discord bot can join a voice channel and relay live audio from any of your nodes, so your team can listen without a separate scanner app.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "How does node ownership and team access work?",
|
||||||
|
a: "Admins have full access. Operators are scoped to a specific list of nodes they own — they see and manage only those. Viewers get read-only access to everything the org exposes. You manage all of this from Settings → Members.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "What happens if I go over my plan's node or seat limit?",
|
||||||
|
a: "You'll see a plan-limit notice in Settings → Billing before anything is blocked. In this demo build there's no live enforcement wired up yet — see the Billing settings page for what's stubbed vs. real.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "How long is call and incident history kept?",
|
||||||
|
a: "Retention depends on plan — 7 days on Community, 90 days on Pro, and a year or more on Enterprise (negotiable). Historical calls remain searchable and linked to their incidents for the full retention window.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Is DMR supported?",
|
||||||
|
a: "Not yet — DMR is on the roadmap but the current release only decodes P25 and analog trunked systems.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function ChevronIcon({ open }: { open: boolean }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
className={`text-gray-500 shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FaqPage() {
|
||||||
|
const [openIndex, setOpenIndex] = useState<number | null>(0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-screen-md mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||||
|
<div className="text-center">
|
||||||
|
<Badge tone="brand">FAQ</Badge>
|
||||||
|
<h1 className="text-display-sm md:text-display text-white mt-5">Frequently asked questions</h1>
|
||||||
|
<p className="text-gray-400 mt-4">Can't find what you're looking for? Sign in and reach out from your account.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 divide-y divide-gray-800 border-t border-b border-gray-800">
|
||||||
|
{FAQS.map((item, i) => {
|
||||||
|
const open = openIndex === i;
|
||||||
|
return (
|
||||||
|
<div key={item.q}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpenIndex(open ? null : i)}
|
||||||
|
className="w-full flex items-center justify-between gap-4 py-5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 rounded-lg"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span className="text-white font-semibold text-sm md:text-base">{item.q}</span>
|
||||||
|
<ChevronIcon open={open} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<p className="text-gray-400 text-sm leading-relaxed pb-5 pr-8 animate-fade-in">{item.a}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mt-16">
|
||||||
|
<LinkButton href="/login" size="lg">Get started</LinkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { LinkButton } from "@/components/ui/Button";
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{
|
||||||
|
eyebrow: "Correlation",
|
||||||
|
title: "Calls become incidents",
|
||||||
|
body:
|
||||||
|
"The correlation engine groups related transmissions — across talkgroups and even across nodes — into a single incident. A pursuit renders as a path through every checkin point heard while it moved; a structure fire or accident renders as a pin at the location dispatch gave.",
|
||||||
|
points: [
|
||||||
|
"Hybrid rule + LLM correlation with a cheap/smart consensus tiebreak",
|
||||||
|
"Distance, timing, shared units, and talkgroup signals all feed the match",
|
||||||
|
"Every call keeps its correlation debug trail for admins to audit",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: "AI pipeline",
|
||||||
|
title: "Transcription and entity extraction",
|
||||||
|
body:
|
||||||
|
"Every recorded call is transcribed and scanned for the details that matter — units on scene, vehicles, and locations — so an incident reads like a dispatch briefing instead of a stack of raw audio.",
|
||||||
|
points: [
|
||||||
|
"Automatic speech-to-text on every call",
|
||||||
|
"Scene & entity extraction feeds the correlator and the incident summary",
|
||||||
|
"AI-generated incident summaries, regenerable on demand",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: "Situational awareness",
|
||||||
|
title: "Live map, full history",
|
||||||
|
body:
|
||||||
|
"Glance at the map to see what's active right now, or scrub back through history to review how a specific incident unfolded — every linked call, in order, with playback.",
|
||||||
|
points: [
|
||||||
|
"Real-time node and incident map",
|
||||||
|
"Per-incident call timeline with audio playback",
|
||||||
|
"Configurable alert rules that post to Discord on keyword or talkgroup match",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: "Field hardware",
|
||||||
|
title: "Field SDR nodes",
|
||||||
|
body:
|
||||||
|
"Lightweight edge nodes run OP25/GNU Radio against a P25 or analog trunked system and stream decoded audio to your account. Deploy one node to cover a town, or a whole network across a region.",
|
||||||
|
points: [
|
||||||
|
"P25 and analog trunked systems supported",
|
||||||
|
"Per-node hardware tuning (gain, PPM, antenna) persists independently of system assignment",
|
||||||
|
"Node health, call activity, and configuration all visible from the dashboard",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: "Team",
|
||||||
|
title: "Discord voice relay & role-scoped access",
|
||||||
|
body:
|
||||||
|
"The Discord bot relays live radio audio into a voice channel so your team can listen along without a separate app, and doubles as a lightweight utility bot for team coordination.",
|
||||||
|
points: [
|
||||||
|
"Live audio relay per node, on demand",
|
||||||
|
"Admin / operator / viewer roles, with operators scoped to the nodes they own",
|
||||||
|
"Discord account linking for in-Discord commands",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function FeaturesPage() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<Badge tone="brand">Features</Badge>
|
||||||
|
<h1 className="text-display-sm md:text-display text-white mt-5">Everything between the radio and the map</h1>
|
||||||
|
<p className="text-gray-400 mt-4 leading-relaxed">
|
||||||
|
DRB is the pipeline from decoded radio traffic to a picture your team can act on: transcription,
|
||||||
|
correlation, mapping, and a live relay — end to end.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-16 space-y-16">
|
||||||
|
{SECTIONS.map((s) => (
|
||||||
|
<div key={s.title} className="grid grid-cols-1 lg:grid-cols-5 gap-8 items-start">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<p className="text-indigo-400 text-xs font-mono uppercase tracking-wider font-semibold">{s.eyebrow}</p>
|
||||||
|
<h2 className="text-white text-2xl font-bold mt-2">{s.title}</h2>
|
||||||
|
<p className="text-gray-400 mt-3 leading-relaxed">{s.body}</p>
|
||||||
|
</div>
|
||||||
|
<Card padding="lg" className="lg:col-span-3">
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{s.points.map((p) => (
|
||||||
|
<li key={p} className="flex items-start gap-3 text-sm text-gray-300">
|
||||||
|
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-indigo-500 shrink-0" />
|
||||||
|
{p}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mt-20 pt-16 border-t border-gray-800">
|
||||||
|
<h2 className="text-display-sm text-white">See it running on your own traffic</h2>
|
||||||
|
<div className="mt-6">
|
||||||
|
<LinkButton href="/login" size="lg">Get started</LinkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -115,3 +115,36 @@ html:not(.dark) input::placeholder,
|
|||||||
html:not(.dark) textarea::placeholder {
|
html:not(.dark) textarea::placeholder {
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Marketing/product surface additions (2026-08 overhaul) ─────────────────
|
||||||
|
* Same pattern as above: components use hardcoded dark-palette Tailwind
|
||||||
|
* classes, remapped here for light mode instead of dark: prefixes.
|
||||||
|
* Only new classes introduced by the marketing pages / settings shell live
|
||||||
|
* below — everything else reuses the palette already mapped above.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Tinted accent surfaces (plan highlight cards, "included" checks, danger zones) */
|
||||||
|
html:not(.dark) .bg-indigo-600\/10 { background-color: rgba(79,70,229,0.08) !important; }
|
||||||
|
html:not(.dark) .border-indigo-600\/40 { border-color: rgba(79,70,229,0.35) !important; }
|
||||||
|
html:not(.dark) .bg-green-600\/10 { background-color: rgba(22,163,74,0.08) !important; }
|
||||||
|
html:not(.dark) .bg-red-600\/10 { background-color: rgba(220,38,38,0.08) !important; }
|
||||||
|
html:not(.dark) .border-red-600\/40 { border-color: rgba(220,38,38,0.35) !important; }
|
||||||
|
html:not(.dark) .bg-yellow-600\/10 { background-color: rgba(202,138,4,0.08) !important; }
|
||||||
|
html:not(.dark) .border-yellow-600\/40 { border-color: rgba(202,138,4,0.35) !important; }
|
||||||
|
|
||||||
|
/* Marketing hero background — subtle radial glow, brand-neutral in both themes */
|
||||||
|
.marketing-hero-bg {
|
||||||
|
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.25), transparent 60%);
|
||||||
|
}
|
||||||
|
html:not(.dark) .marketing-hero-bg {
|
||||||
|
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.12), transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton loading shimmer */
|
||||||
|
@keyframes skeleton-pulse {
|
||||||
|
0%, 100% { opacity: 0.5; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
.skeleton {
|
||||||
|
animation: skeleton-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,26 +10,11 @@ import { useAuth } from "@/components/AuthProvider";
|
|||||||
import { CallRow } from "@/components/CallRow";
|
import { CallRow } from "@/components/CallRow";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
import type { IncidentRecord } from "@/lib/types";
|
import type { IncidentRecord } from "@/lib/types";
|
||||||
|
import { TypeBadge } from "@/components/IncidentBadges";
|
||||||
|
import { severityBadge } from "@/lib/severity";
|
||||||
|
|
||||||
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
||||||
|
|
||||||
const TYPE_COLORS: Record<string, string> = {
|
|
||||||
fire: "bg-red-900 text-red-300",
|
|
||||||
police: "bg-blue-900 text-blue-300",
|
|
||||||
ems: "bg-yellow-900 text-yellow-300",
|
|
||||||
accident: "bg-orange-900 text-orange-300",
|
|
||||||
other: "bg-gray-800 text-gray-300",
|
|
||||||
};
|
|
||||||
|
|
||||||
function TypeBadge({ type }: { type: string | null }) {
|
|
||||||
const cls = TYPE_COLORS[type ?? "other"] ?? TYPE_COLORS.other;
|
|
||||||
return (
|
|
||||||
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
|
|
||||||
{type ?? "other"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: IncidentRecord["status"] }) {
|
function StatusBadge({ status }: { status: IncidentRecord["status"] }) {
|
||||||
return (
|
return (
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full font-mono ${
|
<span className={`text-xs px-2 py-0.5 rounded-full font-mono ${
|
||||||
@@ -93,6 +78,7 @@ export default function IncidentDetailPage() {
|
|||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<TypeBadge type={incident.type} />
|
<TypeBadge type={incident.type} />
|
||||||
<StatusBadge status={incident.status} />
|
<StatusBadge status={incident.status} />
|
||||||
|
{severityBadge(incident.severity)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-lg sm:text-xl font-bold text-white font-mono leading-snug">
|
<h1 className="text-lg sm:text-xl font-bold text-white font-mono leading-snug">
|
||||||
{incident.title ?? "Incident"}
|
{incident.title ?? "Incident"}
|
||||||
|
|||||||
+179
-165
@@ -1,49 +1,42 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { useIncidents } from "@/lib/useIncidents";
|
import { useIncidents } from "@/lib/useIncidents";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
import type { IncidentRecord } from "@/lib/types";
|
import type { IncidentRecord } from "@/lib/types";
|
||||||
import { useState } from "react";
|
import { PageHeader } from "@/components/ui/PageHeader";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { SkeletonCard } from "@/components/ui/Skeleton";
|
||||||
|
import { severityBadge, severityRank } from "@/lib/severity";
|
||||||
|
import { TypeBadge } from "@/components/IncidentBadges";
|
||||||
|
|
||||||
const TYPE_COLORS: Record<string, string> = {
|
// Severity badge/ordering now lives in lib/severity.ts (shared with CallRow).
|
||||||
fire: "bg-red-900 text-red-300",
|
// `severityBadge()` already returns null for the legacy "unknown" value.
|
||||||
police: "bg-blue-900 text-blue-300",
|
|
||||||
ems: "bg-yellow-900 text-yellow-300",
|
|
||||||
accident: "bg-orange-900 text-orange-300",
|
|
||||||
other: "bg-gray-800 text-gray-300",
|
|
||||||
};
|
|
||||||
|
|
||||||
const SEVERITY_COLORS: Record<string, string> = {
|
type SeverityFilter = "all" | "minor" | "moderate" | "major";
|
||||||
major: "bg-red-950 text-red-400",
|
const SEVERITY_FILTERS: { key: SeverityFilter; label: string }[] = [
|
||||||
moderate: "bg-orange-950 text-orange-400",
|
{ key: "all", label: "All" },
|
||||||
minor: "bg-gray-800 text-gray-400",
|
{ key: "minor", label: "Minor+" },
|
||||||
};
|
{ key: "moderate", label: "Moderate+" },
|
||||||
|
{ key: "major", label: "Major only" },
|
||||||
|
];
|
||||||
|
const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, moderate: 2, major: 3 };
|
||||||
|
|
||||||
function severityBadge(severity: string | null | undefined) {
|
type SortMode = "recent" | "severity";
|
||||||
if (!severity || severity === "unknown") return null;
|
|
||||||
const cls = SEVERITY_COLORS[severity] ?? "bg-gray-800 text-gray-400";
|
|
||||||
return (
|
|
||||||
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
|
|
||||||
{severity}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function typeBadge(type: string | null) {
|
|
||||||
const cls = TYPE_COLORS[type ?? "other"] ?? TYPE_COLORS.other;
|
|
||||||
return (
|
|
||||||
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
|
|
||||||
{type ?? "other"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtTime(iso: string) {
|
function fmtTime(iso: string) {
|
||||||
try { return new Date(iso).toLocaleString(); } catch { return iso; }
|
try { return new Date(iso).toLocaleString(); } catch { return iso; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rows / cards
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function IncidentRow({ incident, isAdmin, onResolve }: {
|
function IncidentRow({ incident, isAdmin, onResolve }: {
|
||||||
incident: IncidentRecord;
|
incident: IncidentRecord;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
@@ -53,19 +46,13 @@ function IncidentRow({ incident, isAdmin, onResolve }: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
className="border-b border-gray-800 hover:bg-gray-900 cursor-pointer"
|
className="border-b border-gray-800 last:border-0 hover:bg-gray-900/60 cursor-pointer transition-colors"
|
||||||
onClick={() => router.push(`/incidents/${incident.incident_id}`)}
|
onClick={() => router.push(`/incidents/${incident.incident_id}`)}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3">{typeBadge(incident.type)}</td>
|
<td className="px-4 py-3"><TypeBadge type={incident.type} /></td>
|
||||||
<td className="px-4 py-3 text-white text-sm">{incident.title ?? "—"}</td>
|
<td className="px-4 py-3 text-white text-sm">{incident.title ?? "—"}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
<Badge tone={incident.status === "active" ? "success" : "neutral"}>{incident.status}</Badge>
|
||||||
incident.status === "active"
|
|
||||||
? "bg-green-900 text-green-300"
|
|
||||||
: "bg-gray-800 text-gray-400"
|
|
||||||
}`}>
|
|
||||||
{incident.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">{severityBadge(incident.severity)}</td>
|
<td className="px-4 py-3">{severityBadge(incident.severity)}</td>
|
||||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{incident.call_ids.length}</td>
|
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{incident.call_ids.length}</td>
|
||||||
@@ -73,22 +60,94 @@ function IncidentRow({ incident, isAdmin, onResolve }: {
|
|||||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(incident.updated_at)}</td>
|
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(incident.updated_at)}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{isAdmin && incident.status === "active" && (
|
{isAdmin && incident.status === "active" && (
|
||||||
<button
|
<Button
|
||||||
|
size="sm" variant="secondary"
|
||||||
onClick={(e) => { e.stopPropagation(); onResolve(incident.incident_id); }}
|
onClick={(e) => { e.stopPropagation(); onResolve(incident.incident_id); }}
|
||||||
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded transition-colors"
|
|
||||||
>
|
>
|
||||||
Resolve
|
Resolve
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateModal({ onClose, onCreate }: {
|
function IncidentCards({ incidents, isAdmin, onResolve }: {
|
||||||
onClose: () => void;
|
incidents: IncidentRecord[];
|
||||||
onCreate: (body: object) => Promise<void>;
|
isAdmin: boolean;
|
||||||
|
onResolve: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{incidents.map((inc) => (
|
||||||
|
<Card
|
||||||
|
key={inc.incident_id}
|
||||||
|
padding="sm"
|
||||||
|
hover
|
||||||
|
className="cursor-pointer active:bg-gray-800"
|
||||||
|
onClick={() => router.push(`/incidents/${inc.incident_id}`)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TypeBadge type={inc.type} />
|
||||||
|
<Badge tone={inc.status === "active" ? "success" : "neutral"}>{inc.status}</Badge>
|
||||||
|
</div>
|
||||||
|
{isAdmin && inc.status === "active" && (
|
||||||
|
<Button size="sm" variant="secondary" onClick={(e) => { e.stopPropagation(); onResolve(inc.incident_id); }}>
|
||||||
|
Resolve
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-white text-sm font-semibold leading-snug">{inc.title ?? "—"}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
{severityBadge(inc.severity)}
|
||||||
|
<p className="text-gray-500 text-xs font-mono">
|
||||||
|
{fmtTime(inc.started_at)} · {inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IncidentTable({ incidents, isAdmin, onResolve }: {
|
||||||
|
incidents: IncidentRecord[];
|
||||||
|
isAdmin: boolean;
|
||||||
|
onResolve: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<IncidentCards incidents={incidents} isAdmin={isAdmin} onResolve={onResolve} />
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:block bg-gray-900 border border-gray-800 rounded-xl overflow-hidden overflow-x-auto">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
|
||||||
|
<th className="px-4 py-3">Type</th>
|
||||||
|
<th className="px-4 py-3">Title</th>
|
||||||
|
<th className="px-4 py-3">Status</th>
|
||||||
|
<th className="px-4 py-3">Severity</th>
|
||||||
|
<th className="px-4 py-3">Calls</th>
|
||||||
|
<th className="px-4 py-3">Started</th>
|
||||||
|
<th className="px-4 py-3">Updated</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{incidents.map((inc) => (
|
||||||
|
<IncidentRow key={inc.incident_id} incident={inc} isAdmin={isAdmin} onResolve={onResolve} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (body: object) => Promise<void> }) {
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [type, setType] = useState("other");
|
const [type, setType] = useState("other");
|
||||||
const [summary, setSummary] = useState("");
|
const [summary, setSummary] = useState("");
|
||||||
@@ -106,11 +165,8 @@ function CreateModal({ onClose, onCreate }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||||
<form
|
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4">
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4"
|
|
||||||
>
|
|
||||||
<h2 className="text-white font-bold">Create Incident</h2>
|
<h2 className="text-white font-bold">Create Incident</h2>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Title</label>
|
<label className="text-xs text-gray-400 block mb-1">Title</label>
|
||||||
@@ -138,114 +194,37 @@ function CreateModal({ onClose, onCreate }: {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 justify-end">
|
<div className="flex gap-3 justify-end">
|
||||||
<button type="button" onClick={onClose} className="text-sm text-gray-400 hover:text-gray-200 px-4 py-2">
|
<Button type="button" variant="ghost" onClick={onClose}>Cancel</Button>
|
||||||
Cancel
|
<Button type="submit" disabled={saving}>{saving ? "Creating…" : "Create"}</Button>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit" disabled={saving}
|
|
||||||
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm rounded-lg px-4 py-2"
|
|
||||||
>
|
|
||||||
{saving ? "Creating…" : "Create"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function IncidentCards({ incidents, isAdmin, onResolve }: {
|
// ---------------------------------------------------------------------------
|
||||||
incidents: IncidentRecord[];
|
// Page
|
||||||
isAdmin: boolean;
|
// ---------------------------------------------------------------------------
|
||||||
onResolve: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{incidents.map((inc) => (
|
|
||||||
<div
|
|
||||||
key={inc.incident_id}
|
|
||||||
className="bg-gray-900 border border-gray-800 rounded-xl p-4 cursor-pointer active:bg-gray-800"
|
|
||||||
onClick={() => router.push(`/incidents/${inc.incident_id}`)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{typeBadge(inc.type)}
|
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
|
||||||
inc.status === "active" ? "bg-green-900 text-green-300" : "bg-gray-800 text-gray-400"
|
|
||||||
}`}>{inc.status}</span>
|
|
||||||
</div>
|
|
||||||
{isAdmin && inc.status === "active" && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onResolve(inc.incident_id); }}
|
|
||||||
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded transition-colors"
|
|
||||||
>
|
|
||||||
Resolve
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-white text-sm font-semibold leading-snug">{inc.title ?? "—"}</p>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
{severityBadge(inc.severity)}
|
|
||||||
<p className="text-gray-500 text-xs font-mono">
|
|
||||||
{fmtTime(inc.started_at)} · {inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function IncidentTable({ incidents, isAdmin, onResolve }: {
|
|
||||||
incidents: IncidentRecord[];
|
|
||||||
isAdmin: boolean;
|
|
||||||
onResolve: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Mobile card view */}
|
|
||||||
<div className="sm:hidden">
|
|
||||||
<IncidentCards incidents={incidents} isAdmin={isAdmin} onResolve={onResolve} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop table view */}
|
|
||||||
<div className="hidden sm:block bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
|
||||||
<table className="w-full text-left">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
|
|
||||||
<th className="px-4 py-3">Type</th>
|
|
||||||
<th className="px-4 py-3">Title</th>
|
|
||||||
<th className="px-4 py-3">Status</th>
|
|
||||||
<th className="px-4 py-3">Severity</th>
|
|
||||||
<th className="px-4 py-3">Calls</th>
|
|
||||||
<th className="px-4 py-3">Started</th>
|
|
||||||
<th className="px-4 py-3">Updated</th>
|
|
||||||
<th className="px-4 py-3"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{incidents.map((inc) => (
|
|
||||||
<IncidentRow
|
|
||||||
key={inc.incident_id}
|
|
||||||
incident={inc}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
onResolve={onResolve}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function IncidentsPage() {
|
export default function IncidentsPage() {
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const { incidents, loading } = useIncidents();
|
const { incidents, loading } = useIncidents();
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
|
||||||
|
const [sortMode, setSortMode] = useState<SortMode>("recent");
|
||||||
|
|
||||||
const active = incidents.filter((i) => i.status === "active");
|
const filtered = useMemo(() => {
|
||||||
const resolved = incidents.filter((i) => i.status === "resolved");
|
const threshold = FILTER_THRESHOLD[severityFilter];
|
||||||
|
const list = incidents.filter((i) => severityRank(i.severity) >= threshold);
|
||||||
|
if (sortMode === "severity") {
|
||||||
|
return [...list].sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at));
|
||||||
|
}
|
||||||
|
return list; // useIncidents() already orders by started_at desc
|
||||||
|
}, [incidents, severityFilter, sortMode]);
|
||||||
|
|
||||||
|
const active = filtered.filter((i) => i.status === "active");
|
||||||
|
const resolved = filtered.filter((i) => i.status === "resolved");
|
||||||
|
const hiddenCount = incidents.length - filtered.length;
|
||||||
|
|
||||||
async function handleResolve(id: string) {
|
async function handleResolve(id: string) {
|
||||||
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
||||||
@@ -253,30 +232,53 @@ export default function IncidentsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<PageHeader
|
||||||
<div className="flex items-center gap-3">
|
title="Incidents"
|
||||||
<h1 className="text-white text-xl font-bold font-mono">Incidents</h1>
|
badge={active.length > 0 && <Badge tone="danger">{active.length} active</Badge>}
|
||||||
{active.length > 0 && (
|
action={isAdmin && <Button onClick={() => setShowCreate(true)}>+ Create Incident</Button>}
|
||||||
<span className="text-xs bg-red-900 text-red-300 px-2 py-0.5 rounded-full font-mono">
|
/>
|
||||||
{active.length} active
|
|
||||||
</span>
|
{/* Severity filter + sort — severity is a filter dimension, not decoration */}
|
||||||
)}
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
</div>
|
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
|
||||||
{isAdmin && (
|
{SEVERITY_FILTERS.map(({ key, label }) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreate(true)}
|
key={key}
|
||||||
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded-lg px-4 py-2 transition-colors"
|
onClick={() => setSeverityFilter(key)}
|
||||||
|
className={`text-sm font-mono px-3.5 py-1.5 rounded-md transition-colors ${
|
||||||
|
severityFilter === key ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
+ Create Incident
|
{label}
|
||||||
</button>
|
</button>
|
||||||
)}
|
))}
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-xs font-mono text-gray-500">
|
||||||
|
Sort
|
||||||
|
<select
|
||||||
|
value={sortMode}
|
||||||
|
onChange={(e) => setSortMode(e.target.value as SortMode)}
|
||||||
|
className="bg-gray-900 border border-gray-800 rounded-lg px-2 py-1.5 text-gray-200 focus:outline-none focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="recent">Most recent</option>
|
||||||
|
<option value="severity">Highest severity</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<SkeletonCard /><SkeletonCard />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{hiddenCount > 0 && (
|
||||||
|
<p className="text-xs text-gray-600 font-mono">
|
||||||
|
{hiddenCount} incident{hiddenCount !== 1 ? "s" : ""} hidden by the severity filter.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{active.length > 0 && (
|
{active.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Active</h2>
|
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Active</h2>
|
||||||
@@ -291,8 +293,20 @@ export default function IncidentsPage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{incidents.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<p className="text-gray-600 text-sm font-mono">No incidents recorded yet.</p>
|
<EmptyState
|
||||||
|
title={incidents.length === 0 ? "No incidents recorded yet" : "No incidents match this filter"}
|
||||||
|
description={
|
||||||
|
incidents.length === 0
|
||||||
|
? "Incidents appear automatically once calls start correlating."
|
||||||
|
: "Try a lower severity threshold."
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
incidents.length > 0 && severityFilter !== "all" ? (
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setSeverityFilter("all")}>Clear filter</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Nav } from "@/components/Nav";
|
|
||||||
import { AuthProvider } from "@/components/AuthProvider";
|
import { AuthProvider } from "@/components/AuthProvider";
|
||||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||||
|
import { ChromeSwitcher } from "@/components/ChromeSwitcher";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "DRB Portal",
|
title: "DRB — Public-Safety Radio Intelligence",
|
||||||
description: "Distributed Radio Bot — Control & Monitoring",
|
description: "Live incident awareness from field SDR nodes — transcribed, correlated, and mapped in real time.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
@@ -19,8 +19,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<body className="min-h-screen bg-gray-950">
|
<body className="min-h-screen bg-gray-950">
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Nav />
|
<ChromeSwitcher>{children}</ChromeSwitcher>
|
||||||
<main className="max-w-screen-2xl mx-auto px-4 md:px-6 py-6">{children}</main>
|
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
|
import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
|
||||||
import { auth } from "@/lib/firebase";
|
import { auth } from "@/lib/firebase";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
@@ -44,8 +45,12 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-sm mx-auto pt-16">
|
<div className="max-w-sm mx-auto pt-16">
|
||||||
|
<Link href="/" className="flex items-center justify-center gap-2 mb-6 font-mono font-bold text-white">
|
||||||
|
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
|
||||||
|
DRB
|
||||||
|
</Link>
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono">
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono">
|
||||||
<h1 className="text-white text-lg font-bold">DRB Portal</h1>
|
<h1 className="text-white text-lg font-bold">Sign in</h1>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -100,6 +105,11 @@ export default function LoginPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
Continue with Google
|
Continue with Google
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-gray-500">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link href="/signup" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign up</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -192,6 +192,54 @@ export default function NodeDetailPage() {
|
|||||||
<StatusBadge status={node.status} />
|
<StatusBadge status={node.status} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Override Warning */}
|
||||||
|
{node.is_overridden && (
|
||||||
|
<div className="bg-yellow-950/40 border border-yellow-800/60 rounded-lg p-4 font-mono text-sm space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-yellow-400 font-semibold">
|
||||||
|
<span className="text-base">⚠</span>
|
||||||
|
<span>Local System Override Active</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 text-xs leading-relaxed">
|
||||||
|
This node is operating on a local system override.
|
||||||
|
{node.override_timeout_at ? (
|
||||||
|
<> Resets automatically on: <span className="text-white font-bold">{new Date(node.override_timeout_at).toLocaleString()}</span>.</>
|
||||||
|
) : (
|
||||||
|
<> No timeout is currently enforced (permanent override).</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{node.override_timeout_at && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await c2api.ackOverride(id, 1440);
|
||||||
|
} catch (e) {
|
||||||
|
alert("Failed to extend timer.");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 bg-yellow-800 hover:bg-yellow-700 text-white rounded text-xs transition-colors"
|
||||||
|
>
|
||||||
|
Ack (Reset 24h Timer)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (confirm("Force this node to revert back to its assigned system config?")) {
|
||||||
|
try {
|
||||||
|
await c2api.resetOverride(id);
|
||||||
|
} catch (e) {
|
||||||
|
alert("Failed to reset override.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 bg-red-900 hover:bg-red-800 text-red-200 rounded text-xs transition-colors"
|
||||||
|
>
|
||||||
|
Force Revert Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm">
|
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm">
|
||||||
{[
|
{[
|
||||||
@@ -199,6 +247,8 @@ export default function NodeDetailPage() {
|
|||||||
["Location", `${node.lat}, ${node.lon}`],
|
["Location", `${node.lat}, ${node.lon}`],
|
||||||
["Last Seen", node.last_seen ? new Date(node.last_seen).toLocaleString() : "never"],
|
["Last Seen", node.last_seen ? new Date(node.last_seen).toLocaleString() : "never"],
|
||||||
["Configured", node.configured ? "Yes" : "No"],
|
["Configured", node.configured ? "Yes" : "No"],
|
||||||
|
["Node Type", node.node_type ?? "fixed"],
|
||||||
|
...(node.node_type !== "portable" ? [["Enforce Timeout", node.enforce_override_timeout ? "Yes" : "No"]] : []),
|
||||||
].map(([label, value]) => (
|
].map(([label, value]) => (
|
||||||
<div key={label} className="flex justify-between px-4 py-2.5">
|
<div key={label} className="flex justify-between px-4 py-2.5">
|
||||||
<span className="text-gray-500">{label}</span>
|
<span className="text-gray-500">{label}</span>
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown to any signed-in user with no org_id claim — see ChromeSwitcher's
|
||||||
|
* no-claim guard (SAAS_PLAN.md B3). Two ways to land here:
|
||||||
|
* 1. Just created an account via /signup, org name not collected yet.
|
||||||
|
* 2. Signed in via Google on /login (which auto-creates a Firebase account
|
||||||
|
* on first use) and was never provisioned into anything.
|
||||||
|
* Either way, this is the one screen an unprovisioned account can reach,
|
||||||
|
* and completing it is what POST /auth/signup uses to grant org_id/org_role.
|
||||||
|
*/
|
||||||
|
export default function OnboardingPage() {
|
||||||
|
const { user, loading, orgId, refreshClaims } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const [orgName, setOrgName] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
if (!user) {
|
||||||
|
router.replace("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (orgId) {
|
||||||
|
router.replace("/dashboard");
|
||||||
|
}
|
||||||
|
}, [loading, user, orgId, router]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!orgName.trim()) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await c2api.signup(orgName.trim());
|
||||||
|
// Firebase custom claims only show up in a *freshly fetched* ID token —
|
||||||
|
// getIdTokenResult(true) inside refreshClaims forces that fetch, then
|
||||||
|
// AuthProvider's own state (orgId) updates and the effect above
|
||||||
|
// redirects to /dashboard.
|
||||||
|
await refreshClaims();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Could not set up your organization. Try again.");
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || !user || orgId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-sm mx-auto pt-16">
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-white text-lg font-bold">Set up your organization</h1>
|
||||||
|
<p className="text-gray-400 text-xs mt-2 leading-relaxed">
|
||||||
|
One more step — name the organization your nodes, calls, and incidents will belong to. You can change this later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Organization name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={orgName}
|
||||||
|
onChange={(e) => setOrgName(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
placeholder="e.g. Riverside County Scanner"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
<Button type="submit" disabled={submitting || !orgName.trim()} fullWidth>
|
||||||
|
{submitting ? "Setting up…" : "Continue"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+139
-3
@@ -1,5 +1,141 @@
|
|||||||
import { redirect } from "next/navigation";
|
import Link from "next/link";
|
||||||
|
import { LinkButton } from "@/components/ui/Button";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { PLANS } from "@/lib/billing";
|
||||||
|
|
||||||
export default function Home() {
|
const CAPABILITIES = [
|
||||||
redirect("/dashboard");
|
{
|
||||||
|
title: "Incidents, not raw calls",
|
||||||
|
body: "Individual transmissions are correlated into a single incident — a pursuit becomes a path through every checkin point, a structure fire becomes a pin at the dispatched address.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "AI transcription & extraction",
|
||||||
|
body: "Every call is transcribed and scanned for units, vehicles, and locations, so an incident page reads like a briefing instead of a call log.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Live map, full history",
|
||||||
|
body: "Watch what's happening right now, or scrub back through history to see how an incident unfolded call by call.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Field SDR nodes",
|
||||||
|
body: "Lightweight edge nodes decode P25 and analog police/fire traffic and stream it to your account — deploy one node or a whole regional network.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Discord voice relay",
|
||||||
|
body: "Pipe live radio audio into a Discord channel so your team can listen along in real time, no separate scanner app required.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Role-scoped access",
|
||||||
|
body: "Admins, operators scoped to the nodes they own, and read-only viewers — invite your team with the access level that fits.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ n: "01", title: "Deploy a node", body: "Point a field SDR node at your local P25 or analog system. It streams decoded audio to your DRB account over the network." },
|
||||||
|
{ n: "02", title: "We transcribe & correlate", body: "Calls are transcribed, entities are extracted, and related calls are correlated into incidents automatically." },
|
||||||
|
{ n: "03", title: "Your team watches", body: "Incidents show up on the live map and dashboard with an AI summary, units on scene, and every related recording." },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MarketingHomePage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="marketing-hero-bg">
|
||||||
|
<div className="max-w-screen-xl mx-auto px-4 md:px-6 pt-20 pb-24 md:pt-28 md:pb-32">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<Badge tone="brand">Public-safety radio intelligence</Badge>
|
||||||
|
<h1 className="text-display-sm md:text-display mt-5 text-white">
|
||||||
|
See what's happening on the radio, as an incident — not a wall of calls.
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 text-base md:text-lg mt-5 max-w-2xl leading-relaxed">
|
||||||
|
DRB turns field SDR nodes into a live public-safety picture: police/fire radio is decoded, transcribed,
|
||||||
|
and correlated into incidents you can watch on a map or scrub back through in history — with a Discord
|
||||||
|
bot to relay the audio live to your team.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mt-8">
|
||||||
|
<LinkButton href="/login" size="lg">Get started</LinkButton>
|
||||||
|
<LinkButton href="/pricing" variant="secondary" size="lg">View pricing</LinkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Capabilities */}
|
||||||
|
<section className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||||
|
<div className="max-w-2xl mb-10">
|
||||||
|
<h2 className="text-display-sm text-white">The unit of value is the incident</h2>
|
||||||
|
<p className="text-gray-400 mt-3">
|
||||||
|
A scanner feed is noise. DRB's job is to turn that noise into a small number of things you actually care
|
||||||
|
about — and let you click into any one of them for the full picture.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
|
{CAPABILITIES.map((c) => (
|
||||||
|
<Card key={c.title} padding="lg" hover>
|
||||||
|
<h3 className="text-white font-semibold">{c.title}</h3>
|
||||||
|
<p className="text-gray-400 text-sm mt-2 leading-relaxed">{c.body}</p>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* How it works */}
|
||||||
|
<section className="border-t border-gray-800">
|
||||||
|
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||||
|
<h2 className="text-display-sm text-white mb-10">How it works</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
{STEPS.map((s) => (
|
||||||
|
<div key={s.n}>
|
||||||
|
<p className="text-indigo-400 font-mono text-sm font-bold">{s.n}</p>
|
||||||
|
<h3 className="text-white font-semibold mt-2">{s.title}</h3>
|
||||||
|
<p className="text-gray-400 text-sm mt-2 leading-relaxed">{s.body}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Pricing teaser */}
|
||||||
|
<section className="border-t border-gray-800">
|
||||||
|
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 mb-10">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-display-sm text-white">Plans for one node or a whole region</h2>
|
||||||
|
<p className="text-gray-400 mt-2">Start free. Upgrade when you add nodes or need longer retention.</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/pricing" className="text-indigo-400 hover:text-indigo-300 text-sm font-mono transition-colors shrink-0">
|
||||||
|
See full plan comparison →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
|
||||||
|
{PLANS.map((plan) => (
|
||||||
|
<Card key={plan.id} padding="lg" highlighted={plan.highlighted}>
|
||||||
|
{plan.highlighted && <Badge tone="brand" className="mb-3">Most popular</Badge>}
|
||||||
|
<h3 className="text-white font-semibold">{plan.name}</h3>
|
||||||
|
<p className="text-gray-500 text-xs mt-1">{plan.tagline}</p>
|
||||||
|
<p className="text-white text-2xl font-bold font-mono mt-4">
|
||||||
|
{plan.priceMonthlyUsd === null ? "Custom" : plan.priceMonthlyUsd === 0 ? "Free" : `$${plan.priceMonthlyUsd}`}
|
||||||
|
{plan.priceMonthlyUsd !== null && plan.priceMonthlyUsd > 0 && <span className="text-gray-500 text-sm font-normal">/mo</span>}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Final CTA */}
|
||||||
|
<section className="border-t border-gray-800">
|
||||||
|
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20 text-center">
|
||||||
|
<h2 className="text-display-sm text-white">Bring your first node online</h2>
|
||||||
|
<p className="text-gray-400 mt-3 max-w-xl mx-auto">
|
||||||
|
Sign in to create an account, add a node, and start seeing incidents within minutes of your first call.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8">
|
||||||
|
<LinkButton href="/login" size="lg">Get started</LinkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { PLANS, type BillingInterval } from "@/lib/billing";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { LinkButton } from "@/components/ui/Button";
|
||||||
|
|
||||||
|
function CheckIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" className="text-green-400 shrink-0 mt-0.5">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PricingPage() {
|
||||||
|
const [interval, setInterval] = useState<BillingInterval>("monthly");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||||
|
<div className="text-center max-w-2xl mx-auto">
|
||||||
|
<h1 className="text-display-sm md:text-display text-white">Simple, node-based pricing</h1>
|
||||||
|
<p className="text-gray-400 mt-4">
|
||||||
|
Every plan includes the full incident pipeline — transcription, correlation, mapping, and the Discord relay.
|
||||||
|
Plans differ in how many nodes and seats you get, and how far back your history goes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Interval toggle */}
|
||||||
|
<div className="flex items-center justify-center gap-1 mt-10 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit mx-auto">
|
||||||
|
{(["monthly", "annual"] as BillingInterval[]).map((i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setInterval(i)}
|
||||||
|
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors capitalize ${
|
||||||
|
interval === i ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{i}
|
||||||
|
{i === "annual" && <span className="ml-1.5 text-green-400 text-xs">save ~17%</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plan cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-10 items-stretch">
|
||||||
|
{PLANS.map((plan) => {
|
||||||
|
const price = interval === "annual" ? plan.priceAnnualUsd : plan.priceMonthlyUsd;
|
||||||
|
const priceLabel =
|
||||||
|
price === null ? "Custom" : price === 0 ? "Free" : `$${interval === "annual" ? Math.round(price / 12) : price}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={plan.id} padding="lg" highlighted={plan.highlighted} className="flex flex-col">
|
||||||
|
{plan.highlighted && <Badge tone="brand" className="mb-3 w-fit">Most popular</Badge>}
|
||||||
|
<h2 className="text-white text-lg font-bold">{plan.name}</h2>
|
||||||
|
<p className="text-gray-500 text-sm mt-1.5 leading-relaxed">{plan.tagline}</p>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<span className="text-white text-3xl font-bold font-mono">{priceLabel}</span>
|
||||||
|
{price !== null && price > 0 && <span className="text-gray-500 text-sm">/mo</span>}
|
||||||
|
{interval === "annual" && price !== null && price > 0 && (
|
||||||
|
<p className="text-gray-600 text-xs mt-1">billed ${plan.priceAnnualUsd}/year</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<LinkButton href="/login" variant={plan.highlighted ? "primary" : "secondary"} fullWidth>
|
||||||
|
{plan.priceMonthlyUsd === null ? "Contact sales" : "Get started"}
|
||||||
|
</LinkButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="mt-6 space-y-2.5 flex-1">
|
||||||
|
{plan.features.map((f) => (
|
||||||
|
<li key={f} className="flex items-start gap-2 text-sm text-gray-300">
|
||||||
|
<CheckIcon />
|
||||||
|
{f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-gray-600 text-xs font-mono mt-8">
|
||||||
|
Prices shown are sample figures for this demo build — nothing here is connected to a live payment processor.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="text-center mt-16">
|
||||||
|
<p className="text-gray-400">
|
||||||
|
Questions about a plan?{" "}
|
||||||
|
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link>
|
||||||
|
{" "}or{" "}
|
||||||
|
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to talk to us</Link>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SAAS_PLAN.md B5: page structure only — see app/terms/page.tsx for why the
|
||||||
|
* agent building this did not write real legal text. Privacy Policy needs
|
||||||
|
* the same jurisdiction-aware legal review as Terms, plus specifics this
|
||||||
|
* agent cannot respond for on the owner's behalf: what a real DPA/CCPA/GDPR
|
||||||
|
* posture looks like, and what third-party processors (OpenAI, Gemini,
|
||||||
|
* Google Maps, Firebase/GCP, Stripe once chosen) actually receive and why.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SECTIONS: { heading: string; note: string }[] = [
|
||||||
|
{
|
||||||
|
heading: "1. What data this collects",
|
||||||
|
note: "TODO(legal): account data (email, org membership), field node telemetry (location, status), radio call audio and AI-generated transcripts/entities/incident data, and usage/session logs (drb-c2-core's audit_log and user_sessions collections already exist and hold some of this today).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "2. Third parties this data is sent to, and why",
|
||||||
|
note: "TODO(legal): OpenAI (Whisper transcription), Google Gemini (incident extraction/summarization/embeddings), Google Maps (geocoding location strings extracted from transcripts), Google Cloud (Firestore + GCS storage, Firebase Auth), and — once a payment processor is chosen (SAAS_PLAN.md section 6.5, not yet decided) — that processor. Each of these is a real, already-integrated dependency, not a hypothetical one; this section needs to name them accurately, not generically.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "3. Recorded radio traffic specifically",
|
||||||
|
note: "TODO(legal): this product's core function is recording, transcribing, and storing monitored radio audio — including public-safety traffic that may name individuals, locations, and in-progress incidents. This needs explicit treatment distinct from generic 'we collect usage data' privacy boilerplate, and needs to be read alongside the same legal review flagged in Terms section 3.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "4. How long data is kept",
|
||||||
|
note: "TODO(legal): no retention enforcement exists in the product yet (no TTL, no sweep, no deletion job — see DEFERRED.md) — this section cannot promise a retention/deletion window the system doesn't actually implement.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "5. Customer and end-user rights",
|
||||||
|
note: "TODO(legal): access/export/deletion requests, and who they're directed to — org owner vs. platform operator.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "6. Cookies and session data",
|
||||||
|
note: "TODO(legal): drb_session is a client-set, non-httpOnly cookie used only for UI redirect logic (not an auth boundary — see CLAUDE.md); Firebase Auth sets its own session storage. No analytics/tracking cookies are set today.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "7. Security practices",
|
||||||
|
note: "TODO(legal): at a level appropriate for public disclosure — Firestore security rules, per-node credentials, encrypted transport. Should be reviewed against SAAS_PLAN.md's actual findings before publishing any specific claim.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "8. Changes to this policy",
|
||||||
|
note: "TODO(legal): how customers are notified.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "9. Contact",
|
||||||
|
note: "TODO(legal): real company legal identity and contact address — not yet decided (SAAS_PLAN.md section 6.6).",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PrivacyPage() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-screen-md mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||||
|
<div className="bg-yellow-900/30 border border-yellow-700/50 rounded-lg px-4 py-3 mb-10">
|
||||||
|
<p className="text-yellow-200 text-sm font-mono font-semibold">
|
||||||
|
Draft — not yet in force
|
||||||
|
</p>
|
||||||
|
<p className="text-yellow-200/80 text-xs mt-1 leading-relaxed">
|
||||||
|
This page is a structural placeholder, not a real Privacy Policy. Every section below is a{" "}
|
||||||
|
<code className="text-yellow-100">TODO(legal)</code> marker, not actual legal text. Nothing on this page
|
||||||
|
describes a binding commitment about how data is handled.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-display-sm text-white">Privacy Policy</h1>
|
||||||
|
<p className="text-gray-500 text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p>
|
||||||
|
|
||||||
|
<div className="mt-10 space-y-8">
|
||||||
|
{SECTIONS.map((s) => (
|
||||||
|
<section key={s.heading}>
|
||||||
|
<h2 className="text-white font-semibold">{s.heading}</h2>
|
||||||
|
<p className="text-gray-500 text-sm mt-2 leading-relaxed italic">{s.note}</p>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-600 text-xs font-mono mt-16">
|
||||||
|
Questions in the meantime? <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> or{" "}
|
||||||
|
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { listApiKeys, createApiKey, revokeApiKey, type ApiKeyRecord } from "@/lib/apiKeys";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
|
||||||
|
function fmtDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateKeyModal({ onClose, onCreated }: { onClose: () => void; onCreated: (r: ApiKeyRecord) => void }) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [rawKey, setRawKey] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const { record, rawKey } = await createApiKey(name);
|
||||||
|
onCreated(record);
|
||||||
|
setRawKey(rawKey);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copy() {
|
||||||
|
if (!rawKey) return;
|
||||||
|
navigator.clipboard?.writeText(rawKey).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawKey) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||||
|
<Card padding="lg" className="w-full max-w-lg space-y-4">
|
||||||
|
<h2 className="text-white font-semibold">Key created</h2>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Copy this key now — it won't be shown again. This is a sample key from the demo module in{" "}
|
||||||
|
<code className="text-gray-300">lib/apiKeys.ts</code>; it doesn't authenticate against anything.
|
||||||
|
</p>
|
||||||
|
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-indigo-300 break-all font-mono">{rawKey}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="secondary" onClick={copy} fullWidth>{copied ? "Copied!" : "Copy key"}</Button>
|
||||||
|
<Button onClick={onClose} fullWidth>Done</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||||
|
<Card padding="lg" className="w-full max-w-md">
|
||||||
|
<h2 className="text-white font-semibold mb-4">New API key</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Label</label>
|
||||||
|
<input
|
||||||
|
required value={name} onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g. Ops dashboard integration"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button type="submit" disabled={saving} fullWidth>{saving ? "Creating…" : "Create key"}</Button>
|
||||||
|
<Button type="button" variant="secondary" onClick={onClose} fullWidth>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ApiKeysSettingsPage() {
|
||||||
|
const [keys, setKeys] = useState<ApiKeyRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { listApiKeys().then(setKeys).finally(() => setLoading(false)); }, []);
|
||||||
|
|
||||||
|
async function handleRevoke(id: string) {
|
||||||
|
await revokeApiKey(id);
|
||||||
|
setKeys((prev) => prev.map((k) => (k.key_id === id ? { ...k, revoked: true } : k)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = keys.filter((k) => !k.revoked);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-xl p-4">
|
||||||
|
<p className="text-indigo-300 text-sm font-semibold">Preview feature</p>
|
||||||
|
<p className="text-gray-400 text-xs mt-1 leading-relaxed">
|
||||||
|
Organization API keys aren't backed by a real endpoint yet — this screen runs against an in-memory
|
||||||
|
demo module (<code className="text-gray-300">lib/apiKeys.ts</code>) so the flow can be reviewed end to
|
||||||
|
end. See that file for the exact backend routes a real integration needs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<CreateKeyModal onClose={() => setShowCreate(false)} onCreated={(r) => setKeys((prev) => [...prev, r])} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-gray-500">{loading ? "Loading…" : `${active.length} active key${active.length !== 1 ? "s" : ""}`}</p>
|
||||||
|
<Button size="sm" onClick={() => setShowCreate(true)}>+ Create key</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && keys.length === 0 ? (
|
||||||
|
<EmptyState title="No API keys yet" description="Create one to authenticate external integrations against the DRB API." />
|
||||||
|
) : (
|
||||||
|
<Card padding="none" className="overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
|
||||||
|
<th className="px-4 py-3 text-left">Label</th>
|
||||||
|
<th className="px-4 py-3 text-left">Key</th>
|
||||||
|
<th className="px-4 py-3 text-left hidden sm:table-cell">Created</th>
|
||||||
|
<th className="px-4 py-3 text-left hidden sm:table-cell">Last used</th>
|
||||||
|
<th className="px-4 py-3 text-left">Status</th>
|
||||||
|
<th className="px-4 py-3 w-20"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{keys.map((k) => (
|
||||||
|
<tr key={k.key_id} className="border-b border-gray-800 last:border-0">
|
||||||
|
<td className="px-4 py-3 text-white">{k.name}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500 font-mono text-xs">{k.key_prefix}…</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">{fmtDate(k.created_at)}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">{k.last_used_at ? fmtDate(k.last_used_at) : "Never"}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{k.revoked ? <Badge tone="danger">Revoked</Badge> : <Badge tone="success">Active</Badge>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{!k.revoked && (
|
||||||
|
<button onClick={() => handleRevoke(k.key_id)} className="text-xs text-red-500 hover:text-red-400 transition-colors">
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
PLANS, getPlan, getCurrentSubscription, getUsageSummary, getInvoices,
|
||||||
|
createCheckoutSession, createBillingPortalSession,
|
||||||
|
type Subscription, type UsageSummary, type Invoice, type PlanId,
|
||||||
|
} from "@/lib/billing";
|
||||||
|
import { Card, CardHeader } from "@/components/ui/Card";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { SkeletonCard } from "@/components/ui/Skeleton";
|
||||||
|
|
||||||
|
function fmtDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBanner({ sub }: { sub: Subscription }) {
|
||||||
|
if (sub.status === "trialing" && sub.trialEndsAt) {
|
||||||
|
return (
|
||||||
|
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-xl p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<p className="text-sm text-indigo-300">
|
||||||
|
Trial active — ends {fmtDate(sub.trialEndsAt)}. Add a payment method to keep your plan after that.
|
||||||
|
</p>
|
||||||
|
<Badge tone="brand">Trial</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (sub.status === "past_due") {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-600/10 border border-red-600/40 rounded-xl p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<p className="text-sm text-red-400">
|
||||||
|
Payment failed on your last invoice. Update your payment method to avoid losing access.
|
||||||
|
</p>
|
||||||
|
<Badge tone="danger">Past due</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (sub.status === "canceled") {
|
||||||
|
return (
|
||||||
|
<div className="bg-yellow-600/10 border border-yellow-600/40 rounded-xl p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<p className="text-sm text-yellow-400">Your subscription is canceled. Reactivate to restore full access.</p>
|
||||||
|
<Badge tone="warning">Canceled</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsageBar({ label, used, limit }: { label: string; used: number; limit: number | "unlimited" }) {
|
||||||
|
const pct = limit === "unlimited" ? 0 : Math.min(100, Math.round((used / Math.max(limit, 1)) * 100));
|
||||||
|
const nearLimit = limit !== "unlimited" && used / limit >= 0.9;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between mb-1.5">
|
||||||
|
<span className="text-xs text-gray-400 font-mono">{label}</span>
|
||||||
|
<span className="text-xs font-mono text-gray-300">
|
||||||
|
{used} / {limit === "unlimited" ? "∞" : limit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-gray-800 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${nearLimit ? "bg-orange-500" : "bg-indigo-500"}`}
|
||||||
|
style={{ width: limit === "unlimited" ? "8%" : `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const INVOICE_TONE: Record<Invoice["status"], "success" | "warning" | "neutral" | "danger"> = {
|
||||||
|
paid: "success",
|
||||||
|
open: "warning",
|
||||||
|
void: "neutral",
|
||||||
|
uncollectible: "danger",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BillingSettingsPage() {
|
||||||
|
const [sub, setSub] = useState<Subscription | null>(null);
|
||||||
|
const [usage, setUsage] = useState<UsageSummary | null>(null);
|
||||||
|
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
const [busyPlan, setBusyPlan] = useState<PlanId | null>(null);
|
||||||
|
const [portalBusy, setPortalBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([getCurrentSubscription(), getUsageSummary(), getInvoices()])
|
||||||
|
.then(([s, u, i]) => { setSub(s); setUsage(u); setInvoices(i); })
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleChoosePlan(planId: PlanId) {
|
||||||
|
setBusyPlan(planId);
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const { url } = await createCheckoutSession(planId, sub?.interval ?? "monthly");
|
||||||
|
window.location.href = url;
|
||||||
|
} catch (e) {
|
||||||
|
setActionError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusyPlan(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleManageBilling() {
|
||||||
|
setPortalBusy(true);
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const { url } = await createBillingPortalSession();
|
||||||
|
window.location.href = url;
|
||||||
|
} catch (e) {
|
||||||
|
setActionError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setPortalBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || !sub || !usage) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<SkeletonCard /><SkeletonCard />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = getPlan(sub.planId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-4xl">
|
||||||
|
<p className="text-xs text-gray-600 font-mono">
|
||||||
|
Demo data — this page isn't connected to a live payment processor. See lib/billing.ts for the integration plan.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<StatusBanner sub={sub} />
|
||||||
|
|
||||||
|
{actionError && (
|
||||||
|
<div className="bg-red-950 border border-red-800 rounded-lg p-4">
|
||||||
|
<p className="text-red-400 text-sm">{actionError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Current plan"
|
||||||
|
subtitle={sub.cancelAtPeriodEnd ? `Cancels ${sub.currentPeriodEnd ? fmtDate(sub.currentPeriodEnd) : "at period end"}` : sub.currentPeriodEnd ? `Renews ${fmtDate(sub.currentPeriodEnd)}` : undefined}
|
||||||
|
action={<Badge tone={plan.id === "free" ? "neutral" : "brand"}>{plan.name}</Badge>}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<UsageBar label="Seats" used={usage.seatsUsed} limit={usage.seatsLimit} />
|
||||||
|
<UsageBar label="Nodes" used={usage.nodesUsed} limit={usage.nodesLimit} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 pt-5 border-t border-gray-800">
|
||||||
|
<Button variant="secondary" size="sm" onClick={handleManageBilling} disabled={portalBusy}>
|
||||||
|
{portalBusy ? "Opening…" : "Manage payment method & invoices"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="Change plan" subtitle="Upgrading takes effect immediately; downgrading takes effect at the end of the current period." />
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
{PLANS.map((p) => {
|
||||||
|
const isCurrent = p.id === sub.planId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className={`rounded-xl border p-4 flex flex-col ${p.highlighted ? "border-indigo-600/40" : "border-gray-800"}`}
|
||||||
|
>
|
||||||
|
<p className="text-white font-semibold text-sm">{p.name}</p>
|
||||||
|
<p className="text-gray-500 text-xs mt-1 flex-1">{p.tagline}</p>
|
||||||
|
<p className="text-white text-lg font-bold font-mono mt-3">
|
||||||
|
{p.priceMonthlyUsd === null ? "Custom" : p.priceMonthlyUsd === 0 ? "Free" : `$${p.priceMonthlyUsd}/mo`}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
className="mt-3"
|
||||||
|
size="sm"
|
||||||
|
variant={isCurrent ? "secondary" : "primary"}
|
||||||
|
disabled={isCurrent || busyPlan === p.id}
|
||||||
|
onClick={() => handleChoosePlan(p.id)}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{isCurrent ? "Current plan" : busyPlan === p.id ? "Redirecting…" : p.priceMonthlyUsd === null ? "Contact sales" : "Switch"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="Invoice history" />
|
||||||
|
{invoices.length === 0 ? (
|
||||||
|
<p className="text-gray-600 text-sm">No invoices yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-800">
|
||||||
|
{invoices.map((inv) => (
|
||||||
|
<div key={inv.id} className="flex items-center justify-between gap-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-gray-200 text-sm">{inv.description}</p>
|
||||||
|
<p className="text-gray-600 text-xs font-mono">{fmtDate(inv.date)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
<span className="text-gray-300 text-sm font-mono">${inv.amountUsd.toFixed(2)}</span>
|
||||||
|
<Badge tone={INVOICE_TONE[inv.status]}>{inv.status}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
import { PageHeader } from "@/components/ui/PageHeader";
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ href: "/settings/organization", label: "Organization" },
|
||||||
|
{ href: "/settings/members", label: "Members" },
|
||||||
|
{ href: "/settings/nodes", label: "Node Ownership" },
|
||||||
|
{ href: "/settings/api-keys", label: "API Keys" },
|
||||||
|
{ href: "/settings/billing", label: "Billing" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
// SAAS_PLAN.md B7: this used to gate on isAdmin (platform admin) alone,
|
||||||
|
// which meant a paying customer who is their own org's owner couldn't
|
||||||
|
// reach their own billing/members/node-ownership settings — "admin" here
|
||||||
|
// conflated "platform operator" with "org owner". isAdmin still passes
|
||||||
|
// (support/debugging access to any org's settings), but org_role ===
|
||||||
|
// "owner" is now sufficient on its own.
|
||||||
|
const { isAdmin, isOrgOwner, loading } = useAuth();
|
||||||
|
const canAccess = isAdmin || isOrgOwner;
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && !canAccess) router.replace("/dashboard");
|
||||||
|
}, [loading, canAccess, router]);
|
||||||
|
|
||||||
|
if (loading || !canAccess) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Settings"
|
||||||
|
description="Organization profile, team access, node ownership, API keys, and billing."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit max-w-full overflow-x-auto">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<Link
|
||||||
|
key={t.href}
|
||||||
|
href={t.href}
|
||||||
|
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors whitespace-nowrap ${
|
||||||
|
pathname === t.href || pathname.startsWith(t.href + "/")
|
||||||
|
? "bg-gray-800 text-white"
|
||||||
|
: "text-gray-500 hover:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
import type { UserRecord, UserRole } from "@/lib/types";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
||||||
|
import { SkeletonRow } from "@/components/ui/Skeleton";
|
||||||
|
|
||||||
|
const ROLE_TONE: Record<UserRole, "brand" | "success" | "neutral"> = {
|
||||||
|
admin: "brand",
|
||||||
|
operator: "success",
|
||||||
|
viewer: "neutral",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLE_LABEL: Record<UserRole, string> = { admin: "Admin", operator: "Operator", viewer: "Viewer" };
|
||||||
|
|
||||||
|
function InviteModal({ onClose, onCreated }: { onClose: () => void; onCreated: (u: UserRecord) => void }) {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [role, setRole] = useState<UserRole>("viewer");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const created = await c2api.createUser({ email, role });
|
||||||
|
onCreated(created);
|
||||||
|
if (created.invite_link) setInviteLink(created.invite_link);
|
||||||
|
else onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inviteLink) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||||
|
<Card padding="lg" className="w-full max-w-md space-y-4">
|
||||||
|
<h2 className="text-white font-semibold">Member invited</h2>
|
||||||
|
<p className="text-xs text-gray-400">Share this one-time invite link so they can set their password. It expires after use.</p>
|
||||||
|
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={onClose} fullWidth>Done</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||||
|
<Card padding="lg" className="w-full max-w-md">
|
||||||
|
<h2 className="text-white font-semibold mb-4">Invite a member</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email" required value={email} onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="teammate@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Role</label>
|
||||||
|
<select
|
||||||
|
value={role} onChange={(e) => setRole(e.target.value as UserRole)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="admin">Admin — full access</option>
|
||||||
|
<option value="operator">Operator — owns nodes</option>
|
||||||
|
<option value="viewer">Viewer — read-only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
<div className="flex gap-3 pt-1">
|
||||||
|
<Button type="submit" disabled={saving} fullWidth>{saving ? "Sending…" : "Send invite"}</Button>
|
||||||
|
<Button type="button" variant="secondary" onClick={onClose} fullWidth>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MembersSettingsPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [users, setUsers] = useState<UserRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showInvite, setShowInvite] = useState(false);
|
||||||
|
const [savingUid, setSavingUid] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setUsers(await c2api.listUsers());
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
async function handleRoleChange(u: UserRecord, role: UserRole) {
|
||||||
|
setSavingUid(u.uid);
|
||||||
|
try {
|
||||||
|
const updated = await c2api.updateUser(u.uid, { role, owned_node_ids: role === "operator" ? u.owned_node_ids : [] });
|
||||||
|
setUsers((prev) => prev.map((x) => (x.uid === u.uid ? { ...x, ...updated } : x)));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setSavingUid(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{showInvite && (
|
||||||
|
<InviteModal onClose={() => setShowInvite(false)} onCreated={(u) => setUsers((prev) => [...prev, u])} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{loading ? "Loading members…" : `${users.length} member${users.length !== 1 ? "s" : ""}`}
|
||||||
|
</p>
|
||||||
|
<Button size="sm" onClick={() => setShowInvite(true)}>+ Invite member</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <ErrorBanner message={error} />}
|
||||||
|
|
||||||
|
{!loading && users.length === 0 ? (
|
||||||
|
<EmptyState title="No members yet" description="Invite your team to give them dashboard access." />
|
||||||
|
) : (
|
||||||
|
<Card padding="none" className="overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
|
||||||
|
<th className="px-4 py-3 text-left">Member</th>
|
||||||
|
<th className="px-4 py-3 text-left">Role</th>
|
||||||
|
<th className="px-4 py-3 text-left hidden sm:table-cell">Owned nodes</th>
|
||||||
|
<th className="px-4 py-3 text-left hidden md:table-cell">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading
|
||||||
|
? Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={4} />)
|
||||||
|
: users.map((u) => (
|
||||||
|
<tr key={u.uid} className="border-b border-gray-800 last:border-0">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="text-white">{u.display_name || u.email}</p>
|
||||||
|
{u.display_name && <p className="text-gray-600 text-xs">{u.email}</p>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{u.uid === user?.uid ? (
|
||||||
|
<Badge tone={ROLE_TONE[u.role]}>{ROLE_LABEL[u.role]}</Badge>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={u.role}
|
||||||
|
disabled={savingUid === u.uid}
|
||||||
|
onChange={(e) => handleRoleChange(u, e.target.value as UserRole)}
|
||||||
|
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="operator">Operator</option>
|
||||||
|
<option value="viewer">Viewer</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">
|
||||||
|
{u.role === "operator" ? u.owned_node_ids.length : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 hidden md:table-cell">
|
||||||
|
{u.disabled ? <Badge tone="danger">Disabled</Badge> : <Badge tone="success">Active</Badge>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-600 font-mono">
|
||||||
|
Need to disable or delete a member? Use the full user admin panel under Admin → Users.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useNodes } from "@/lib/useNodes";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import type { UserRecord } from "@/lib/types";
|
||||||
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
|
import { Card, CardHeader } from "@/components/ui/Card";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { ErrorBanner } from "@/components/ui/EmptyState";
|
||||||
|
import { SkeletonRow } from "@/components/ui/Skeleton";
|
||||||
|
|
||||||
|
const UNASSIGNED = "__unassigned__";
|
||||||
|
|
||||||
|
interface EnrollmentToken {
|
||||||
|
token_id: string;
|
||||||
|
label: string;
|
||||||
|
created_at: string;
|
||||||
|
revoked: boolean;
|
||||||
|
uses: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SAAS_PLAN.md B2b — per-org enrollment tokens (routers/org.py). This is the
|
||||||
|
* credential a customer's field node presents to POST /nodes/enroll
|
||||||
|
* (X-Enrollment-Token) so it lands in THIS org instead of the legacy
|
||||||
|
* fleet-wide pool. Minting/revoking is owner-only server-side; any org
|
||||||
|
* member can list (metadata only, the raw token is shown exactly once at
|
||||||
|
* mint time and never again).
|
||||||
|
*/
|
||||||
|
function EnrollmentTokensPanel() {
|
||||||
|
const { isOrgOwner, isAdmin } = useAuth();
|
||||||
|
const canManage = isOrgOwner || isAdmin;
|
||||||
|
const [tokens, setTokens] = useState<EnrollmentToken[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [minting, setMinting] = useState(false);
|
||||||
|
const [justMinted, setJustMinted] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
c2api.listEnrollmentTokens()
|
||||||
|
.then(setTokens)
|
||||||
|
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
async function handleMint(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!label.trim()) return;
|
||||||
|
setMinting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await c2api.mintEnrollmentToken(label.trim());
|
||||||
|
setJustMinted(result.token);
|
||||||
|
setLabel("");
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setMinting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke(tokenId: string) {
|
||||||
|
try {
|
||||||
|
await c2api.revokeEnrollmentToken(tokenId);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Enrollment tokens"
|
||||||
|
subtitle="Give a new field node one of these instead of an admin-issued key — it enrolls straight into this org."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{justMinted && (
|
||||||
|
<div className="bg-indigo-900/30 border border-indigo-700/50 rounded-lg p-3 mb-4">
|
||||||
|
<p className="text-xs text-indigo-200 font-mono mb-1">
|
||||||
|
New token — copy it now, it won't be shown again:
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-indigo-100 font-mono break-all bg-gray-900 rounded px-2 py-1.5">{justMinted}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setJustMinted(null)}
|
||||||
|
className="text-xs text-indigo-300 hover:text-indigo-200 mt-2 transition-colors"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <ErrorBanner message={error} />}
|
||||||
|
|
||||||
|
{canManage && (
|
||||||
|
<form onSubmit={handleMint} className="flex flex-wrap gap-2 mb-4">
|
||||||
|
<input
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
placeholder="Label, e.g. 'node-003 field kit'"
|
||||||
|
className="flex-1 min-w-[12rem] bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="sm" disabled={minting || !label.trim()}>
|
||||||
|
{minting ? "Minting…" : "New token"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SkeletonRow cols={1} />
|
||||||
|
</div>
|
||||||
|
) : tokens.length === 0 ? (
|
||||||
|
<p className="text-gray-600 text-xs">No enrollment tokens yet.</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||||
|
<th className="py-2 text-left">Label</th>
|
||||||
|
<th className="py-2 text-left hidden sm:table-cell">Created</th>
|
||||||
|
<th className="py-2 text-left">Status</th>
|
||||||
|
{canManage && <th className="py-2 text-right">Actions</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tokens.map((t) => (
|
||||||
|
<tr key={t.token_id} className="border-b border-gray-800 last:border-0">
|
||||||
|
<td className="py-2 text-white">{t.label}</td>
|
||||||
|
<td className="py-2 text-gray-500 text-xs hidden sm:table-cell">
|
||||||
|
{new Date(t.created_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-xs">
|
||||||
|
{t.revoked ? (
|
||||||
|
<span className="text-gray-600">Revoked</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-green-400">Active · {t.uses} use{t.uses !== 1 ? "s" : ""}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{canManage && (
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
{!t.revoked && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRevoke(t.token_id)}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300 transition-colors"
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NodeOwnershipSettingsPage() {
|
||||||
|
const { nodes, loading: nodesLoading } = useNodes();
|
||||||
|
const [users, setUsers] = useState<UserRecord[]>([]);
|
||||||
|
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [savingNodeId, setSavingNodeId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
c2api.listUsers()
|
||||||
|
.then(setUsers)
|
||||||
|
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||||
|
.finally(() => setLoadingUsers(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Ownership (owned_node_ids) is only meaningful for operators elsewhere in the
|
||||||
|
// app (see Admin → Users); admins already have full access regardless.
|
||||||
|
const assignable = useMemo(() => users.filter((u) => u.role === "operator"), [users]);
|
||||||
|
|
||||||
|
const ownerByNode = useMemo(() => {
|
||||||
|
const map = new Map<string, UserRecord>();
|
||||||
|
for (const u of users) {
|
||||||
|
if (u.role !== "operator") continue;
|
||||||
|
for (const nodeId of u.owned_node_ids) map.set(nodeId, u);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [users]);
|
||||||
|
|
||||||
|
const reassign = useCallback(async (nodeId: string, newUid: string) => {
|
||||||
|
setSavingNodeId(nodeId);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const prevOwner = ownerByNode.get(nodeId);
|
||||||
|
// Remove from previous owner, if any and different from the new one.
|
||||||
|
if (prevOwner && prevOwner.uid !== newUid) {
|
||||||
|
const next = prevOwner.owned_node_ids.filter((id) => id !== nodeId);
|
||||||
|
await c2api.updateUser(prevOwner.uid, { owned_node_ids: next });
|
||||||
|
setUsers((all) => all.map((u) => (u.uid === prevOwner.uid ? { ...u, owned_node_ids: next } : u)));
|
||||||
|
}
|
||||||
|
// Add to new owner, if one was selected.
|
||||||
|
if (newUid !== UNASSIGNED) {
|
||||||
|
const newOwner = users.find((u) => u.uid === newUid);
|
||||||
|
if (newOwner && !newOwner.owned_node_ids.includes(nodeId)) {
|
||||||
|
const next = [...newOwner.owned_node_ids, nodeId];
|
||||||
|
await c2api.updateUser(newOwner.uid, { owned_node_ids: next });
|
||||||
|
setUsers((all) => all.map((u) => (u.uid === newOwner.uid ? { ...u, owned_node_ids: next } : u)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setSavingNodeId(null);
|
||||||
|
}
|
||||||
|
}, [ownerByNode, users]);
|
||||||
|
|
||||||
|
const loading = nodesLoading || loadingUsers;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<EnrollmentTokensPanel />
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Assign each node to the operator responsible for it. Operators only see and manage the nodes assigned to them here.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <ErrorBanner message={error} />}
|
||||||
|
|
||||||
|
<Card padding="none" className="overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
|
||||||
|
<th className="px-4 py-3 text-left">Node</th>
|
||||||
|
<th className="px-4 py-3 text-left hidden sm:table-cell">Status</th>
|
||||||
|
<th className="px-4 py-3 text-left">Owner</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={3} />)
|
||||||
|
) : nodes.length === 0 ? (
|
||||||
|
<tr><td colSpan={3} className="px-4 py-8 text-center text-gray-600 text-sm">No nodes registered yet.</td></tr>
|
||||||
|
) : (
|
||||||
|
nodes.map((n) => {
|
||||||
|
const owner = ownerByNode.get(n.node_id);
|
||||||
|
return (
|
||||||
|
<tr key={n.node_id} className="border-b border-gray-800 last:border-0">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link href={`/nodes/${n.node_id}`} className="text-white hover:text-indigo-300 transition-colors">
|
||||||
|
{n.name}
|
||||||
|
</Link>
|
||||||
|
<p className="text-gray-600 text-xs font-mono">{n.node_id}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 hidden sm:table-cell"><StatusBadge status={n.status} /></td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<select
|
||||||
|
value={owner?.uid ?? UNASSIGNED}
|
||||||
|
disabled={savingNodeId === n.node_id}
|
||||||
|
onChange={(e) => reassign(n.node_id, e.target.value)}
|
||||||
|
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50 max-w-[14rem]"
|
||||||
|
>
|
||||||
|
<option value={UNASSIGNED}>Unassigned</option>
|
||||||
|
{assignable.map((u) => (
|
||||||
|
<option key={u.uid} value={u.uid}>{u.display_name || u.email}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
import { useNodes } from "@/lib/useNodes";
|
||||||
|
import { getCurrentSubscription, getPlan, type Subscription } from "@/lib/billing";
|
||||||
|
import { Card, CardHeader } from "@/components/ui/Card";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Skeleton } from "@/components/ui/Skeleton";
|
||||||
|
|
||||||
|
function StatTile({ label, value }: { label: string; value: string | number }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono">{label}</p>
|
||||||
|
<p className="text-2xl font-bold text-white font-mono mt-1">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrganizationSettingsPage() {
|
||||||
|
const { nodes } = useNodes();
|
||||||
|
const { isOrgOwner, isAdmin } = useAuth();
|
||||||
|
const [memberCount, setMemberCount] = useState<number | null>(null);
|
||||||
|
const [sub, setSub] = useState<Subscription | null>(null);
|
||||||
|
const [orgName, setOrgName] = useState("");
|
||||||
|
const [savedName, setSavedName] = useState("");
|
||||||
|
const [orgLoading, setOrgLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const canEdit = isOrgOwner || isAdmin;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
c2api.listUsers().then((u) => setMemberCount(u.length)).catch(() => setMemberCount(null));
|
||||||
|
getCurrentSubscription().then(setSub);
|
||||||
|
c2api.getOrg()
|
||||||
|
.then((org) => { setOrgName(org.name); setSavedName(org.name); })
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setOrgLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError(null);
|
||||||
|
try {
|
||||||
|
const res = await c2api.updateOrg(orgName.trim());
|
||||||
|
setSavedName(res.name);
|
||||||
|
setOrgName(res.name);
|
||||||
|
} catch (err) {
|
||||||
|
setSaveError(err instanceof Error ? err.message : "Could not save.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = sub ? getPlan(sub.planId) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-3xl">
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Organization profile"
|
||||||
|
subtitle="Basic identity for this DRB account."
|
||||||
|
/>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Organization name</label>
|
||||||
|
<input
|
||||||
|
value={orgName}
|
||||||
|
disabled={orgLoading || !canEdit}
|
||||||
|
onChange={(e) => setOrgName(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{saveError && <p className="text-red-400 text-xs">{saveError}</p>}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={orgLoading || !canEdit || saving || !orgName.trim() || orgName.trim() === savedName}
|
||||||
|
onClick={handleSave}
|
||||||
|
title={!canEdit ? "Only the organization owner can change this" : undefined}
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : "Save changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Overview"
|
||||||
|
action={
|
||||||
|
plan ? (
|
||||||
|
<Badge tone={plan.id === "free" ? "neutral" : "brand"}>{plan.name} plan</Badge>
|
||||||
|
) : (
|
||||||
|
<Skeleton className="h-5 w-16" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-6">
|
||||||
|
<StatTile label="Nodes" value={nodes.length} />
|
||||||
|
<StatTile label="Members" value={memberCount ?? "—"} />
|
||||||
|
<StatTile
|
||||||
|
label="Status"
|
||||||
|
value={sub ? sub.status.replace("_", " ") : "—"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 pt-5 border-t border-gray-800 flex items-center gap-4 text-sm">
|
||||||
|
<Link href="/settings/billing" className="text-indigo-400 hover:text-indigo-300 transition-colors">
|
||||||
|
Manage plan & billing →
|
||||||
|
</Link>
|
||||||
|
<Link href="/settings/members" className="text-indigo-400 hover:text-indigo-300 transition-colors">
|
||||||
|
Manage members →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 border border-red-800/60 rounded-xl p-5">
|
||||||
|
<CardHeader title="Danger zone" subtitle="Destructive organization-level actions." />
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Button variant="danger" size="sm" disabled title="Not available in this build — contact support">
|
||||||
|
Delete organization
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" size="sm" disabled title="Not available in this build — contact support">
|
||||||
|
Transfer ownership
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function SettingsIndexPage() {
|
||||||
|
redirect("/settings/organization");
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { createUserWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
|
||||||
|
import { auth } from "@/lib/firebase";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-serve account creation (SAAS_PLAN.md B4). Only creates the Firebase
|
||||||
|
* user — org naming happens on the next screen, /onboarding, which is also
|
||||||
|
* where every other no-org-yet path (Google sign-in via /login, etc.) ends
|
||||||
|
* up. Keeping that step in one shared place means there's exactly one route
|
||||||
|
* that calls POST /auth/signup.
|
||||||
|
*/
|
||||||
|
export default function SignupPage() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await createUserWithEmailAndPassword(auth, email, password);
|
||||||
|
router.push("/onboarding");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const code = (err as { code?: string })?.code;
|
||||||
|
if (code === "auth/email-already-in-use") {
|
||||||
|
setError("An account with this email already exists. Try signing in instead.");
|
||||||
|
} else if (code === "auth/weak-password") {
|
||||||
|
setError("Password is too weak — use at least 6 characters.");
|
||||||
|
} else {
|
||||||
|
setError("Could not create your account. Check your details and try again.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGoogle() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await signInWithPopup(auth, new GoogleAuthProvider());
|
||||||
|
router.push("/onboarding");
|
||||||
|
} catch {
|
||||||
|
setError("Google sign-up failed. Try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-sm mx-auto pt-16">
|
||||||
|
<Link href="/" className="flex items-center justify-center gap-2 mb-6 font-mono font-bold text-white">
|
||||||
|
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
|
||||||
|
DRB
|
||||||
|
</Link>
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono">
|
||||||
|
<h1 className="text-white text-lg font-bold">Create your account</h1>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? "Creating account…" : "Create account"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1 h-px bg-gray-700" />
|
||||||
|
<span className="text-xs text-gray-500">or</span>
|
||||||
|
<div className="flex-1 h-px bg-gray-700" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGoogle}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full flex items-center justify-center gap-3 bg-white hover:bg-gray-100 disabled:opacity-50 text-gray-900 rounded-lg py-2 text-sm font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/>
|
||||||
|
<path d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.859-3.048.859-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z" fill="#34A853"/>
|
||||||
|
<path d="M3.964 10.706A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.706V4.962H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.038l3.007-2.332z" fill="#FBBC05"/>
|
||||||
|
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.962L3.964 6.294C4.672 4.169 6.656 3.58 9 3.58z" fill="#EA4335"/>
|
||||||
|
</svg>
|
||||||
|
Continue with Google
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-gray-500">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign in</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SAAS_PLAN.md B5: page structure only. The agent building this is
|
||||||
|
* explicitly instructed not to invent legal text — DRB records, stores, and
|
||||||
|
* transcribes public-safety radio traffic, and recording/rebroadcast
|
||||||
|
* legality varies by state (see SAAS_PLAN.md section 6.3), so this needs a
|
||||||
|
* human, and probably a lawyer, not a generated draft. Every section below
|
||||||
|
* is a placeholder marking what a real Terms of Service needs to cover, not
|
||||||
|
* actual terms — see the banner and every TODO(legal) marker.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SECTIONS: { heading: string; note: string }[] = [
|
||||||
|
{
|
||||||
|
heading: "1. Acceptance of terms",
|
||||||
|
note: "TODO(legal): standard acceptance clause — using the service means agreeing to these terms.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "2. What the service does and does not do",
|
||||||
|
note: "TODO(legal): describe the product (SDR ingestion, transcription, AI correlation, Discord relay) and, importantly, disclaim accuracy — AI-generated transcripts and incident summaries are not guaranteed accurate and must not be relied on as the sole source for dispatch or safety decisions.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "3. Radio recording and rebroadcast — the part that needs a lawyer",
|
||||||
|
note: "TODO(legal): this is the section that actually matters. Recording, storing, and rebroadcasting monitored radio traffic (including public-safety frequencies) has different legal treatment by state and by traffic type (encrypted vs. clear, dispatch vs. tactical). Needs jurisdiction-aware legal review before this product can be sold across state lines — do not ship this page live without it.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "4. Customer responsibilities and acceptable use",
|
||||||
|
note: "TODO(legal): who owns the hardware, who's responsible for lawful operation of the field node, prohibited uses.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "5. Data ownership and retention",
|
||||||
|
note: "TODO(legal): who owns the recorded audio/transcripts/incidents, how long they're kept, what happens on cancellation or account deletion. Note: no retention enforcement exists in the product yet either (see DEFERRED.md) — this section can't promise a retention window the system doesn't yet enforce.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "6. Payment, billing, and cancellation",
|
||||||
|
note: "TODO(legal): once a billing model and pricing exist (SAAS_PLAN.md section 6, still undecided) — refunds, proration, what happens to data on non-payment.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "7. Service availability and support",
|
||||||
|
note: "TODO(legal): whether any uptime/SLA commitment is made (today: none).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "8. Limitation of liability",
|
||||||
|
note: "TODO(legal): standard limitation-of-liability language, reviewed against the fact that this product touches public-safety-adjacent data.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "9. Changes to these terms",
|
||||||
|
note: "TODO(legal): how customers are notified of material changes.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "10. Governing law and contact",
|
||||||
|
note: "TODO(legal): governing jurisdiction, and a real company legal identity and contact address (SAAS_PLAN.md section 6.6 — not yet decided).",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TermsPage() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-screen-md mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||||
|
<div className="bg-yellow-900/30 border border-yellow-700/50 rounded-lg px-4 py-3 mb-10">
|
||||||
|
<p className="text-yellow-200 text-sm font-mono font-semibold">
|
||||||
|
Draft — not yet in force
|
||||||
|
</p>
|
||||||
|
<p className="text-yellow-200/80 text-xs mt-1 leading-relaxed">
|
||||||
|
This page is a structural placeholder, not a real Terms of Service. Every section below is a{" "}
|
||||||
|
<code className="text-yellow-100">TODO(legal)</code> marker, not actual legal text. Nothing on this page is
|
||||||
|
binding, and no self-serve signup should be considered subject to it until an actual attorney-reviewed
|
||||||
|
version replaces this content.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-display-sm text-white">Terms of Service</h1>
|
||||||
|
<p className="text-gray-500 text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p>
|
||||||
|
|
||||||
|
<div className="mt-10 space-y-8">
|
||||||
|
{SECTIONS.map((s) => (
|
||||||
|
<section key={s.heading}>
|
||||||
|
<h2 className="text-white font-semibold">{s.heading}</h2>
|
||||||
|
<p className="text-gray-500 text-sm mt-2 leading-relaxed italic">{s.note}</p>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-600 text-xs font-mono mt-16">
|
||||||
|
Questions in the meantime? <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> or{" "}
|
||||||
|
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SAAS_PLAN.md B6's waitlist form — POST /waitlist (routers/waitlist.py) is
|
||||||
|
* public, source-IP rate-limited, and deliberately not coupled to any plan
|
||||||
|
* or tier: the commercial model (who runs the node, what a customer
|
||||||
|
* actually buys) is still an open decision (SAAS_PLAN.md section 6.1), so
|
||||||
|
* this collects only {email, org_name, note} and makes no promise about
|
||||||
|
* price or plan.
|
||||||
|
*/
|
||||||
|
export default function WaitlistPage() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [orgName, setOrgName] = useState("");
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await c2api.joinWaitlist({ email, org_name: orgName || undefined, note: note || undefined });
|
||||||
|
setDone(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Could not submit — try again in a moment.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-sm mx-auto pt-16">
|
||||||
|
<Link href="/" className="flex items-center justify-center gap-2 mb-6 font-mono font-bold text-white">
|
||||||
|
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
|
||||||
|
DRB
|
||||||
|
</Link>
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono">
|
||||||
|
{done ? (
|
||||||
|
<>
|
||||||
|
<h1 className="text-white text-lg font-bold">You're on the list</h1>
|
||||||
|
<p className="text-gray-400 text-sm leading-relaxed">
|
||||||
|
Thanks — we'll reach out at the email you gave us. In the meantime, feel free to{" "}
|
||||||
|
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">read the FAQ</Link>.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-white text-lg font-bold">Request access</h1>
|
||||||
|
<p className="text-gray-400 text-xs mt-2 leading-relaxed">
|
||||||
|
Self-serve signup isn't open yet. Leave your details and we'll follow up to get your organization set up.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Organization (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={orgName}
|
||||||
|
onChange={(e) => setOrgName(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Anything else? (optional)</label>
|
||||||
|
<textarea
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
maxLength={2000}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
{submitting ? "Submitting…" : "Request access"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/a
|
|||||||
import { auth } from "@/lib/firebase";
|
import { auth } from "@/lib/firebase";
|
||||||
import type { UserRole } from "@/lib/types";
|
import type { UserRole } from "@/lib/types";
|
||||||
|
|
||||||
|
export type OrgRole = "owner" | "member";
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -12,7 +14,13 @@ interface AuthContextType {
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
isOperator: boolean;
|
isOperator: boolean;
|
||||||
ownedNodeIds: string[];
|
ownedNodeIds: string[];
|
||||||
|
/** Tenant claim — null means this account isn't provisioned into an org yet. */
|
||||||
|
orgId: string | null;
|
||||||
|
orgRole: OrgRole | null;
|
||||||
|
isOrgOwner: boolean;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
|
/** Force-refetch the ID token's claims — call after POST /auth/signup so orgId picks up immediately. */
|
||||||
|
refreshClaims: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType>({
|
const AuthContext = createContext<AuthContextType>({
|
||||||
@@ -22,7 +30,11 @@ const AuthContext = createContext<AuthContextType>({
|
|||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
isOperator: false,
|
isOperator: false,
|
||||||
ownedNodeIds: [],
|
ownedNodeIds: [],
|
||||||
|
orgId: null,
|
||||||
|
orgRole: null,
|
||||||
|
isOrgOwner: false,
|
||||||
signOut: async () => {},
|
signOut: async () => {},
|
||||||
|
refreshClaims: async () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
@@ -30,15 +42,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [role, setRole] = useState<UserRole | null>(null);
|
const [role, setRole] = useState<UserRole | null>(null);
|
||||||
const [ownedNodeIds, setOwnedNodeIds] = useState<string[]>([]);
|
const [ownedNodeIds, setOwnedNodeIds] = useState<string[]>([]);
|
||||||
|
const [orgId, setOrgId] = useState<string | null>(null);
|
||||||
|
const [orgRole, setOrgRole] = useState<OrgRole | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
async function applyClaims(u: User, forceRefresh: boolean) {
|
||||||
return onAuthStateChanged(auth, async (u) => {
|
const result = await u.getIdTokenResult(forceRefresh);
|
||||||
setUser(u);
|
|
||||||
setLoading(false);
|
|
||||||
|
|
||||||
if (u) {
|
|
||||||
document.cookie = "drb_session=1; path=/; SameSite=Strict";
|
|
||||||
const result = await u.getIdTokenResult(true);
|
|
||||||
const claims = result.claims;
|
const claims = result.claims;
|
||||||
|
|
||||||
// Derive role: prefer granular "role" claim, fall back to legacy "admin" boolean
|
// Derive role: prefer granular "role" claim, fall back to legacy "admin" boolean
|
||||||
@@ -53,11 +61,41 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
setRole(effectiveRole);
|
setRole(effectiveRole);
|
||||||
setOwnedNodeIds((claims.owned_node_ids as string[]) ?? []);
|
setOwnedNodeIds((claims.owned_node_ids as string[]) ?? []);
|
||||||
|
|
||||||
|
// org_id/org_role are set by POST /auth/signup. No org_id claim means
|
||||||
|
// this account was created (e.g. via Google sign-in's implicit account
|
||||||
|
// creation) but never provisioned — see the no-claim guard below, which
|
||||||
|
// is what stops that from being a live data exposure.
|
||||||
|
const claimOrgId = typeof claims.org_id === "string" ? claims.org_id : null;
|
||||||
|
const claimOrgRole = claims.org_role === "owner" || claims.org_role === "member" ? claims.org_role : null;
|
||||||
|
setOrgId(claimOrgId);
|
||||||
|
setOrgRole(claimOrgRole);
|
||||||
|
|
||||||
|
// drb_session is only a UX redirect signal (middleware.ts), not a
|
||||||
|
// security boundary (see CLAUDE.md) — but it must not be set for an
|
||||||
|
// unprovisioned account, or the middleware will wave them straight into
|
||||||
|
// /dashboard instead of /onboarding.
|
||||||
|
if (claimOrgId) {
|
||||||
|
document.cookie = "drb_session=1; path=/; SameSite=Strict";
|
||||||
|
} else {
|
||||||
|
document.cookie = "drb_session=; path=/; max-age=0";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return onAuthStateChanged(auth, async (u) => {
|
||||||
|
setUser(u);
|
||||||
|
|
||||||
|
if (u) {
|
||||||
|
await applyClaims(u, true);
|
||||||
} else {
|
} else {
|
||||||
document.cookie = "drb_session=; path=/; max-age=0";
|
document.cookie = "drb_session=; path=/; max-age=0";
|
||||||
setRole(null);
|
setRole(null);
|
||||||
setOwnedNodeIds([]);
|
setOwnedNodeIds([]);
|
||||||
|
setOrgId(null);
|
||||||
|
setOrgRole(null);
|
||||||
}
|
}
|
||||||
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -66,11 +104,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
document.cookie = "drb_session=; path=/; max-age=0";
|
document.cookie = "drb_session=; path=/; max-age=0";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshClaims() {
|
||||||
|
if (auth.currentUser) await applyClaims(auth.currentUser, true);
|
||||||
|
}
|
||||||
|
|
||||||
const isAdmin = role === "admin";
|
const isAdmin = role === "admin";
|
||||||
const isOperator = role === "operator";
|
const isOperator = role === "operator";
|
||||||
|
const isOrgOwner = orgRole === "owner";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, role, isAdmin, isOperator, ownedNodeIds, signOut }}>
|
<AuthContext.Provider
|
||||||
|
value={{ user, loading, role, isAdmin, isOperator, ownedNodeIds, orgId, orgRole, isOrgOwner, signOut, refreshClaims }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { CallRecord } from "@/lib/types";
|
import type { CallRecord } from "@/lib/types";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { severityBadge } from "@/lib/severity";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
call: CallRecord;
|
call: CallRecord;
|
||||||
@@ -37,11 +38,31 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
: call.incident_id ? [call.incident_id] : [];
|
: call.incident_id ? [call.incident_id] : [];
|
||||||
|
|
||||||
const isActive = call.status === "active";
|
const isActive = call.status === "active";
|
||||||
const hasDetails = call.transcript || call.transcript_corrected || (call.tags && call.tags.length > 0) || incidentIds.length > 0 || call.audio_url;
|
// Rows come straight from Firestore (lib/useCalls.ts), and the doc only holds
|
||||||
|
// the private gs:// object location — never a playable URL. Presence of audio
|
||||||
|
// is known from the doc; the actual link is minted by the API on expand.
|
||||||
|
// audio_url is the legacy field: older docs stored an (unplayable) gs:// URI
|
||||||
|
// there, so it still signals "this call has a recording".
|
||||||
|
const hasAudio = !!(call.audio_gcs_uri || call.audio_url);
|
||||||
|
const hasDetails = call.transcript || call.transcript_corrected || (call.tags && call.tags.length > 0) || incidentIds.length > 0 || hasAudio;
|
||||||
const displayTranscript = (!showOriginal && call.transcript_corrected) ? call.transcript_corrected : call.transcript;
|
const displayTranscript = (!showOriginal && call.transcript_corrected) ? call.transcript_corrected : call.transcript;
|
||||||
const hasBoth = !!(call.transcript && call.transcript_corrected);
|
const hasBoth = !!(call.transcript && call.transcript_corrected);
|
||||||
const hasSegments = call.segments && call.segments.length > 1;
|
const hasSegments = call.segments && call.segments.length > 1;
|
||||||
|
|
||||||
|
// Fetched lazily on expand: playback links are short-lived, so minting one
|
||||||
|
// for every row up front would waste most of them and expire the rest.
|
||||||
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||||
|
const [audioError, setAudioError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!expanded || !hasAudio || audioUrl || audioError) return;
|
||||||
|
let cancelled = false;
|
||||||
|
c2api.getCall(call.call_id)
|
||||||
|
.then((full) => { if (!cancelled) setAudioUrl(full.audio_url ?? null); })
|
||||||
|
.catch(() => { if (!cancelled) setAudioError(true); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [expanded, hasAudio, audioUrl, audioError, call.call_id]);
|
||||||
|
|
||||||
function startEdit() {
|
function startEdit() {
|
||||||
setEditText(call.transcript_corrected ?? call.transcript ?? "");
|
setEditText(call.transcript_corrected ?? call.transcript ?? "");
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
@@ -78,6 +99,10 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
{call.tags[0]}
|
{call.tags[0]}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{/* Routine/minor are the majority of traffic and stay unbadged; moderate+ is the triage signal worth a badge in a dense list. */}
|
||||||
|
{(call.severity === "moderate" || call.severity === "major") && (
|
||||||
|
<span className="ml-2">{severityBadge(call.severity)}</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{systemName ?? call.system_id ?? "—"}</td>
|
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{systemName ?? call.system_id ?? "—"}</td>
|
||||||
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{call.node_id}</td>
|
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{call.node_id}</td>
|
||||||
@@ -89,7 +114,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-xs">
|
<td className="px-4 py-2 text-xs">
|
||||||
{call.audio_url ? (
|
{hasAudio ? (
|
||||||
<span className="text-blue-400">▶</span>
|
<span className="text-blue-400">▶</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-700">—</span>
|
<span className="text-gray-700">—</span>
|
||||||
@@ -104,13 +129,19 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
<tr className="bg-gray-900/60 border-b border-gray-800">
|
<tr className="bg-gray-900/60 border-b border-gray-800">
|
||||||
<td colSpan={7} className="px-6 py-3 space-y-2">
|
<td colSpan={7} className="px-6 py-3 space-y-2">
|
||||||
{/* Audio player */}
|
{/* Audio player */}
|
||||||
{call.audio_url && (
|
{hasAudio && (
|
||||||
|
audioError ? (
|
||||||
|
<p className="text-xs text-red-400 font-mono">Could not load audio.</p>
|
||||||
|
) : audioUrl ? (
|
||||||
<audio
|
<audio
|
||||||
controls
|
controls
|
||||||
src={call.audio_url}
|
src={audioUrl}
|
||||||
className="w-full max-w-sm h-8"
|
className="w-full max-w-sm h-8"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-600 font-mono">Loading audio…</p>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
import { Nav } from "@/components/Nav";
|
||||||
|
import { MarketingHeader } from "@/components/marketing/MarketingHeader";
|
||||||
|
import { MarketingFooter } from "@/components/marketing/MarketingFooter";
|
||||||
|
|
||||||
|
// Public marketing surface — exact paths, not prefixes, so e.g. /features/x
|
||||||
|
// (if it ever exists) doesn't accidentally get pulled into marketing chrome.
|
||||||
|
// Keep in sync with PUBLIC_PATHS in middleware.ts (that one decides whether
|
||||||
|
// to redirect at all; this one just picks page chrome).
|
||||||
|
const MARKETING_PATHS = new Set(["/", "/features", "/pricing", "/faq", "/terms", "/privacy"]);
|
||||||
|
|
||||||
|
// Pages a signed-in user with no org_id claim must still be able to reach —
|
||||||
|
// otherwise the redirect below would loop against itself, or lock someone
|
||||||
|
// out of the one screen (/onboarding) that fixes their account.
|
||||||
|
const NO_ORG_ALLOWED_PATHS = new Set(["/onboarding", "/login", "/signup", "/profile"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks page chrome by route: the public marketing pages get a full-bleed
|
||||||
|
* layout with their own header/footer, everything else (the authenticated
|
||||||
|
* app, including /login and /settings) keeps the existing app Nav + padded
|
||||||
|
* main container.
|
||||||
|
*
|
||||||
|
* Also carries AuthProvider's no-claim guard (SAAS_PLAN.md B3): a signed-in
|
||||||
|
* Firebase user with no org_id claim is a real session that is nonetheless
|
||||||
|
* provisioned into nothing — AuthProvider already refuses to set the
|
||||||
|
* drb_session cookie for them, so middleware.ts's redirect only covers
|
||||||
|
* "not signed in at all". This effect covers the other case: signed in, no
|
||||||
|
* org, anywhere in the app — send them to /onboarding rather than letting
|
||||||
|
* every page's data hooks fail open or silently return nothing.
|
||||||
|
*/
|
||||||
|
export function ChromeSwitcher({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { user, loading, orgId } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
if (!user) return; // not signed in — middleware.ts already routes this to /login
|
||||||
|
if (orgId) return;
|
||||||
|
if (MARKETING_PATHS.has(pathname) || NO_ORG_ALLOWED_PATHS.has(pathname)) return;
|
||||||
|
router.replace("/onboarding");
|
||||||
|
}, [loading, user, orgId, pathname, router]);
|
||||||
|
|
||||||
|
if (MARKETING_PATHS.has(pathname)) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MarketingHeader />
|
||||||
|
{children}
|
||||||
|
<MarketingFooter />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Nav />
|
||||||
|
<main className="max-w-screen-2xl mx-auto px-4 md:px-6 py-6">{children}</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Shared incident "type" badge — used on the incidents list, incident detail,
|
||||||
|
// and the dashboard's active-incidents panel. `other` covers anything outside
|
||||||
|
// the four radio-traffic archetypes (rail ops, public works, utility
|
||||||
|
// coordination, …) and must always resolve to a styled badge, never fall
|
||||||
|
// through unstyled.
|
||||||
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
|
fire: "bg-red-900 text-red-300",
|
||||||
|
police: "bg-blue-900 text-blue-300",
|
||||||
|
ems: "bg-yellow-900 text-yellow-300",
|
||||||
|
accident: "bg-orange-900 text-orange-300",
|
||||||
|
other: "bg-gray-800 text-gray-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TypeBadge({ type }: { type: string | null }) {
|
||||||
|
const cls = TYPE_COLORS[type ?? "other"] ?? TYPE_COLORS.other;
|
||||||
|
return (
|
||||||
|
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
|
||||||
|
{type ?? "other"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { useUnconfiguredNodes } from "@/lib/useNodes";
|
|||||||
import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
|
import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { useTheme } from "@/components/ThemeProvider";
|
import { useTheme } from "@/components/ThemeProvider";
|
||||||
|
import { FOUNDING_ORG_ID } from "@/lib/tenancy";
|
||||||
|
|
||||||
// Links visible to all authenticated roles (viewer+)
|
// Links visible to all authenticated roles (viewer+)
|
||||||
const viewerLinks = [
|
const viewerLinks = [
|
||||||
@@ -15,9 +16,13 @@ const viewerLinks = [
|
|||||||
{ href: "/incidents", label: "Incidents" },
|
{ href: "/incidents", label: "Incidents" },
|
||||||
{ href: "/map", label: "Map" },
|
{ href: "/map", label: "Map" },
|
||||||
{ href: "/alerts", label: "Alerts" },
|
{ href: "/alerts", label: "Alerts" },
|
||||||
{ href: "/trips", label: "Trips" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Trips is an internal utility feature, not a tenant-scoped product surface
|
||||||
|
// (see [[trips-feature-intentional]] and SAAS_PLAN.md B7) — shown only to
|
||||||
|
// the founding org, matching routers/trips.py's own gating.
|
||||||
|
const tripsLink = { href: "/trips", label: "Trips" };
|
||||||
|
|
||||||
// Additional links for operators and admins
|
// Additional links for operators and admins
|
||||||
const operatorLinks = [
|
const operatorLinks = [
|
||||||
{ href: "/nodes", label: "Nodes" },
|
{ href: "/nodes", label: "Nodes" },
|
||||||
@@ -25,7 +30,8 @@ const operatorLinks = [
|
|||||||
{ href: "/tokens", label: "Tokens" },
|
{ href: "/tokens", label: "Tokens" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Admin-only links
|
// Platform-admin-only link. Settings is handled separately below — it's
|
||||||
|
// customer-facing for org owners too, not admin-only (SAAS_PLAN.md B7).
|
||||||
const adminLinks = [
|
const adminLinks = [
|
||||||
{ href: "/admin", label: "Admin" },
|
{ href: "/admin", label: "Admin" },
|
||||||
];
|
];
|
||||||
@@ -55,7 +61,7 @@ function MoonIcon() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Nav() {
|
export function Nav() {
|
||||||
const { user, isAdmin, isOperator } = useAuth();
|
const { user, isAdmin, isOperator, isOrgOwner, orgId } = useAuth();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { nodes: pending } = useUnconfiguredNodes();
|
const { nodes: pending } = useUnconfiguredNodes();
|
||||||
@@ -67,8 +73,10 @@ export function Nav() {
|
|||||||
|
|
||||||
const allLinks = [
|
const allLinks = [
|
||||||
...viewerLinks,
|
...viewerLinks,
|
||||||
|
...(orgId === FOUNDING_ORG_ID || isAdmin ? [tripsLink] : []),
|
||||||
...(isAdmin || isOperator ? operatorLinks : []),
|
...(isAdmin || isOperator ? operatorLinks : []),
|
||||||
...(isAdmin ? adminLinks : []),
|
...(isAdmin ? adminLinks : []),
|
||||||
|
...(isAdmin || isOrgOwner ? [{ href: "/settings", label: "Settings" }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
function navLinkClass(href: string) {
|
function navLinkClass(href: string) {
|
||||||
|
|||||||
@@ -45,6 +45,18 @@ export function NodeCard({ node, system }: Props) {
|
|||||||
⚠ Needs configuration
|
⚠ Needs configuration
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{node.is_overridden && (
|
||||||
|
<div className="mt-3 text-xs text-yellow-500 font-mono border-t border-gray-800 pt-2 flex justify-between">
|
||||||
|
<span>⚠ Local Override</span>
|
||||||
|
{node.override_timeout_at ? (
|
||||||
|
<span className="text-gray-500">
|
||||||
|
Resets: {new Date(node.override_timeout_at).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500">Permanent</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ const PRESETS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function NodeConfigModal({ node, systems, onClose }: Props) {
|
export function NodeConfigModal({ node, systems, onClose }: Props) {
|
||||||
const [systemId, setSystemId] = useState("");
|
const [systemId, setSystemId] = useState(node.assigned_system_id ?? "");
|
||||||
const [preset, setPreset] = useState("rtl-sdr-v3");
|
const [preset, setPreset] = useState(node.hardware_preset ?? "rtl-sdr-v3");
|
||||||
const [ppm, setPpm] = useState("0");
|
const [ppm, setPpm] = useState(node.ppm_override ? String(node.ppm_override) : "0");
|
||||||
|
const [nodeType, setNodeType] = useState(node.node_type ?? "");
|
||||||
|
const [enforceTimeout, setEnforceTimeout] = useState(node.enforce_override_timeout ?? true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -32,6 +34,10 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
await c2api.updateNode(node.node_id, {
|
||||||
|
node_type: nodeType,
|
||||||
|
enforce_override_timeout: enforceTimeout,
|
||||||
|
});
|
||||||
await c2api.assignSystem(node.node_id, systemId, preset, ppmOverride);
|
await c2api.assignSystem(node.node_id, systemId, preset, ppmOverride);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -100,12 +106,41 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Node Type *</label>
|
||||||
|
<select
|
||||||
|
value={nodeType}
|
||||||
|
onChange={(e) => setNodeType(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select node type...</option>
|
||||||
|
<option value="fixed">Fixed Node (Standard)</option>
|
||||||
|
<option value="portable">Portable Node (Handheld)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nodeType === "fixed" && (
|
||||||
|
<div className="flex items-center gap-2 py-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="enforceTimeout"
|
||||||
|
checked={enforceTimeout}
|
||||||
|
onChange={(e) => setEnforceTimeout(e.target.checked)}
|
||||||
|
className="rounded bg-gray-800 border-gray-700 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
|
||||||
|
/>
|
||||||
|
<label htmlFor="enforceTimeout" className="text-xs text-gray-400 cursor-pointer select-none">
|
||||||
|
Enforce Local Override Timeout (24 hours)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
|
||||||
<div className="flex gap-3 pt-1">
|
<div className="flex gap-3 pt-1">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving || !systemId}
|
disabled={saving || !systemId || !nodeType}
|
||||||
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
|
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
|
||||||
>
|
>
|
||||||
{saving ? "Saving…" : "Assign & Configure"}
|
{saving ? "Saving…" : "Assign & Configure"}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export function MarketingFooter() {
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-gray-800 mt-24">
|
||||||
|
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-10 flex flex-col md:flex-row items-start md:items-center justify-between gap-6">
|
||||||
|
<div className="flex items-center gap-2 font-mono font-bold text-white">
|
||||||
|
<span className="inline-flex items-center justify-center w-6 h-6 rounded-lg bg-indigo-600 text-white text-xs">D</span>
|
||||||
|
DRB
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm font-mono text-gray-500">
|
||||||
|
<Link href="/features" className="hover:text-gray-300 transition-colors">Features</Link>
|
||||||
|
<Link href="/pricing" className="hover:text-gray-300 transition-colors">Pricing</Link>
|
||||||
|
<Link href="/faq" className="hover:text-gray-300 transition-colors">FAQ</Link>
|
||||||
|
<Link href="/waitlist" className="hover:text-gray-300 transition-colors">Request access</Link>
|
||||||
|
<Link href="/terms" className="hover:text-gray-300 transition-colors">Terms</Link>
|
||||||
|
<Link href="/privacy" className="hover:text-gray-300 transition-colors">Privacy</Link>
|
||||||
|
<Link href="/login" className="hover:text-gray-300 transition-colors">Sign in</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<p className="text-xs font-mono text-gray-600">© {new Date().getFullYear()} DRB. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
import { LinkButton } from "@/components/ui/Button";
|
||||||
|
|
||||||
|
const LINKS = [
|
||||||
|
{ href: "/features", label: "Features" },
|
||||||
|
{ href: "/pricing", label: "Pricing" },
|
||||||
|
{ href: "/faq", label: "FAQ" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MarketingHeader() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-40 border-b border-gray-800 bg-gray-950/95 backdrop-blur">
|
||||||
|
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-4 flex items-center gap-6">
|
||||||
|
<Link href="/" className="flex items-center gap-2 shrink-0 font-mono font-bold text-white tracking-tight">
|
||||||
|
<span className="inline-flex items-center justify-center w-7 h-7 rounded-lg bg-indigo-600 text-white text-sm">D</span>
|
||||||
|
DRB
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="hidden md:flex items-center gap-6 ml-4">
|
||||||
|
{LINKS.map(({ href, label }) => (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className={`text-sm font-mono transition-colors ${
|
||||||
|
pathname === href ? "text-white" : "text-gray-400 hover:text-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="ml-auto hidden md:flex items-center gap-3">
|
||||||
|
{!loading && user ? (
|
||||||
|
<LinkButton href="/dashboard" size="md">Go to dashboard</LinkButton>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LinkButton href="/login" variant="ghost" size="md">Sign in</LinkButton>
|
||||||
|
<LinkButton href="/login" size="md">Get started</LinkButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileOpen((v) => !v)}
|
||||||
|
className="md:hidden ml-auto text-gray-400 hover:text-gray-200 transition-colors p-1"
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
{mobileOpen ? (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="18" x2="21" y2="18" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mobileOpen && (
|
||||||
|
<div className="md:hidden border-t border-gray-800 bg-gray-950 px-4 py-3 flex flex-col gap-1">
|
||||||
|
{LINKS.map(({ href, label }) => (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className={`py-2 text-sm font-mono transition-colors ${pathname === href ? "text-white" : "text-gray-400"}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<div className="border-t border-gray-800 pt-3 mt-2 flex flex-col gap-2">
|
||||||
|
{!loading && user ? (
|
||||||
|
<LinkButton href="/dashboard" size="md" fullWidth>Go to dashboard</LinkButton>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LinkButton href="/login" variant="secondary" size="md" fullWidth>Sign in</LinkButton>
|
||||||
|
<LinkButton href="/login" size="md" fullWidth>Get started</LinkButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type Tone = "neutral" | "brand" | "success" | "warning" | "danger" | "info";
|
||||||
|
|
||||||
|
const TONE_CLASSES: Record<Tone, string> = {
|
||||||
|
neutral: "bg-gray-800 text-gray-300",
|
||||||
|
brand: "bg-indigo-900 text-indigo-300",
|
||||||
|
success: "bg-green-900 text-green-300",
|
||||||
|
warning: "bg-yellow-900 text-yellow-300",
|
||||||
|
danger: "bg-red-900 text-red-300",
|
||||||
|
info: "bg-blue-900 text-blue-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Badge({ children, tone = "neutral", className }: { children: ReactNode; tone?: Tone; className?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"inline-flex items-center gap-1 text-xs font-mono px-2 py-0.5 rounded-full whitespace-nowrap",
|
||||||
|
TONE_CLASSES[tone],
|
||||||
|
className ?? "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
|
|
||||||
|
type Variant = "primary" | "secondary" | "ghost" | "danger";
|
||||||
|
type Size = "sm" | "md" | "lg";
|
||||||
|
|
||||||
|
const VARIANT_CLASSES: Record<Variant, string> = {
|
||||||
|
primary:
|
||||||
|
"bg-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 text-white shadow-card disabled:hover:bg-indigo-600",
|
||||||
|
secondary:
|
||||||
|
"bg-gray-800 hover:bg-gray-700 active:bg-gray-700 text-gray-100 border border-gray-700 disabled:hover:bg-gray-800",
|
||||||
|
ghost:
|
||||||
|
"bg-transparent hover:bg-gray-800 active:bg-gray-800 text-gray-300 hover:text-white disabled:hover:bg-transparent",
|
||||||
|
danger:
|
||||||
|
"bg-red-700 hover:bg-red-600 active:bg-red-700 text-white disabled:hover:bg-red-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIZE_CLASSES: Record<Size, string> = {
|
||||||
|
sm: "text-xs px-3 py-1.5 rounded-lg gap-1.5",
|
||||||
|
md: "text-sm px-4 py-2 rounded-lg gap-2",
|
||||||
|
lg: "text-sm px-5 py-2.5 rounded-xl gap-2",
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE =
|
||||||
|
"inline-flex items-center justify-center font-semibold font-mono transition-colors " +
|
||||||
|
"disabled:opacity-50 disabled:cursor-not-allowed " +
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950";
|
||||||
|
|
||||||
|
interface CommonProps {
|
||||||
|
variant?: Variant;
|
||||||
|
size?: Size;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ButtonProps = CommonProps &
|
||||||
|
ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
href?: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LinkButtonProps extends CommonProps {
|
||||||
|
href: string;
|
||||||
|
external?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classes(variant: Variant, size: Size, fullWidth: boolean | undefined, extra?: string) {
|
||||||
|
return [BASE, VARIANT_CLASSES[variant], SIZE_CLASSES[size], fullWidth ? "w-full" : "", extra ?? ""]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Button — use for in-page actions. Pass `href` instead to render a Link (see LinkButton export). */
|
||||||
|
export function Button({ variant = "primary", size = "md", children, className, fullWidth, ...rest }: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button className={classes(variant, size, fullWidth, className)} {...rest}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same visual language as Button, but renders a Next.js Link — for navigation, not actions. */
|
||||||
|
export function LinkButton({ variant = "primary", size = "md", children, className, fullWidth, href, external }: LinkButtonProps) {
|
||||||
|
if (external) {
|
||||||
|
return (
|
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer" className={classes(variant, size, fullWidth, className)}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Link href={href} className={classes(variant, size, fullWidth, className)}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { HTMLAttributes, ReactNode } from "react";
|
||||||
|
|
||||||
|
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
children: ReactNode;
|
||||||
|
hover?: boolean;
|
||||||
|
padding?: "none" | "sm" | "md" | "lg";
|
||||||
|
highlighted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PADDING: Record<NonNullable<CardProps["padding"]>, string> = {
|
||||||
|
none: "",
|
||||||
|
sm: "p-4",
|
||||||
|
md: "p-5",
|
||||||
|
lg: "p-8",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Standard surface card — the base container used across app + settings + marketing. */
|
||||||
|
export function Card({ children, hover, padding = "md", highlighted, className, ...rest }: CardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"bg-gray-900 border rounded-xl",
|
||||||
|
highlighted ? "border-indigo-600/40 shadow-glow" : "border-gray-800",
|
||||||
|
hover ? "transition-colors hover:border-gray-600" : "",
|
||||||
|
PADDING[padding],
|
||||||
|
className ?? "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({ title, subtitle, action }: { title: ReactNode; subtitle?: ReactNode; action?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-4 mb-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-white font-semibold text-sm">{title}</h3>
|
||||||
|
{subtitle && <p className="text-gray-500 text-xs mt-0.5 leading-snug">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{action && <div className="shrink-0">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon?: ReactNode;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consistent "nothing here yet" panel — replaces the ad-hoc `<p className="text-gray-600">` scattered across pages. */
|
||||||
|
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center text-center py-12 px-6 border border-dashed border-gray-800 rounded-xl">
|
||||||
|
{icon && <div className="text-gray-700 mb-3">{icon}</div>}
|
||||||
|
<p className="text-gray-300 text-sm font-semibold font-mono">{title}</p>
|
||||||
|
{description && <p className="text-gray-600 text-xs font-mono mt-1 max-w-sm">{description}</p>}
|
||||||
|
{action && <div className="mt-4">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inline error banner — for API/Firestore errors surfaced within a page section. */
|
||||||
|
export function ErrorBanner({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-950 border border-red-800 rounded-lg p-4">
|
||||||
|
<p className="text-red-400 text-sm font-mono">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
badge?: ReactNode;
|
||||||
|
action?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Standard page title row — title + optional badge on the left, primary action on the right. */
|
||||||
|
export function PageHeader({ title, description, badge, action }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<h1 className="text-xl font-bold text-white font-mono">{title}</h1>
|
||||||
|
{badge}
|
||||||
|
</div>
|
||||||
|
{description && <p className="text-gray-500 text-sm mt-1 max-w-2xl">{description}</p>}
|
||||||
|
</div>
|
||||||
|
{action && <div className="shrink-0">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/** Loading placeholder block. Use instead of a bare "Loading…" string wherever the eventual
|
||||||
|
* content has a predictable shape (cards, table rows, stat tiles). */
|
||||||
|
export function Skeleton({ className }: { className?: string }) {
|
||||||
|
return <div className={`skeleton bg-gray-800 rounded-md ${className ?? "h-4 w-full"}`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonCard() {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3">
|
||||||
|
<Skeleton className="h-4 w-1/3" />
|
||||||
|
<Skeleton className="h-3 w-2/3" />
|
||||||
|
<Skeleton className="h-3 w-1/2" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonRow({ cols = 5 }: { cols?: number }) {
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-gray-800">
|
||||||
|
{Array.from({ length: cols }).map((_, i) => (
|
||||||
|
<td key={i} className="px-4 py-3">
|
||||||
|
<Skeleton className="h-3 w-full max-w-[8rem]" />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Organization API keys — STUB MODULE, no backend endpoint exists yet.
|
||||||
|
*
|
||||||
|
* This is a distinct concept from the two API-key-shaped things that already
|
||||||
|
* exist server-side:
|
||||||
|
* - `node_keys` (Firestore collection) — per-node upload credentials, issued
|
||||||
|
* via /nodes/{id}/reissue-key. Not this.
|
||||||
|
* - The Discord bot token pool (app/tokens) — Discord bot tokens, not this.
|
||||||
|
*
|
||||||
|
* This module models organization-level API keys for third-party
|
||||||
|
* integrations (a standard SaaS feature) that DRB does not yet expose.
|
||||||
|
* Everything below is in-memory demo state so the settings UI has something
|
||||||
|
* real to render; nothing here is persisted or capable of authenticating
|
||||||
|
* against the real API.
|
||||||
|
*
|
||||||
|
* TODO(api-keys): to make this real, add to drb-c2-core:
|
||||||
|
* - `org_api_keys` Firestore collection: {key_id, org_id, name, key_hash,
|
||||||
|
* key_prefix, created_at, last_used_at, created_by_uid, revoked}
|
||||||
|
* - POST /org/api-keys → generate, return the raw key ONCE
|
||||||
|
* - GET /org/api-keys → list (prefix + metadata only, never the raw key)
|
||||||
|
* - DELETE /org/api-keys/{id} → revoke
|
||||||
|
* - A new auth path in internal/auth.py that checks `Authorization: Bearer drb_live_…`
|
||||||
|
* against `key_hash` (constant-time compare), scoped like a viewer/operator token.
|
||||||
|
* Then replace the functions below with c2api calls hitting those routes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ApiKeyRecord {
|
||||||
|
key_id: string;
|
||||||
|
name: string;
|
||||||
|
/** Only the prefix is ever shown after creation — mirrors how real key systems (Stripe, GitHub) do it. */
|
||||||
|
key_prefix: string;
|
||||||
|
created_at: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
revoked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample/demo fixture — obviously not real keys, never sent anywhere.
|
||||||
|
let DEMO_KEYS: ApiKeyRecord[] = [
|
||||||
|
{
|
||||||
|
key_id: "demo_key_1",
|
||||||
|
name: "Ops dashboard integration",
|
||||||
|
key_prefix: "drb_live_sample_4f2a",
|
||||||
|
created_at: "2026-07-02T14:00:00.000Z",
|
||||||
|
last_used_at: "2026-08-15T09:12:00.000Z",
|
||||||
|
revoked: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function listApiKeys(): Promise<ApiKeyRecord[]> {
|
||||||
|
return DEMO_KEYS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the full (fake) key exactly once, same UX contract a real key
|
||||||
|
* issuance flow would have — the raw secret is shown once and never again.
|
||||||
|
*/
|
||||||
|
export async function createApiKey(name: string): Promise<{ record: ApiKeyRecord; rawKey: string }> {
|
||||||
|
const suffix = Math.random().toString(36).slice(2, 10);
|
||||||
|
const record: ApiKeyRecord = {
|
||||||
|
key_id: `demo_key_${DEMO_KEYS.length + 1}`,
|
||||||
|
name,
|
||||||
|
key_prefix: `drb_live_sample_${suffix.slice(0, 4)}`,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
last_used_at: null,
|
||||||
|
revoked: false,
|
||||||
|
};
|
||||||
|
DEMO_KEYS = [...DEMO_KEYS, record];
|
||||||
|
return { record, rawKey: `drb_live_sample_${suffix}_DEMO_NOT_A_REAL_KEY` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeApiKey(keyId: string): Promise<void> {
|
||||||
|
DEMO_KEYS = DEMO_KEYS.map((k) => (k.key_id === keyId ? { ...k, revoked: true } : k));
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* Billing/licensing boundary — STUB MODULE.
|
||||||
|
*
|
||||||
|
* This file defines the typed shape the settings UI (app/settings/billing) talks to.
|
||||||
|
* Nothing here calls a real payment processor. Every exported function returns
|
||||||
|
* hardcoded sample data or throws, and is marked with a TODO describing exactly
|
||||||
|
* what a real integration would do.
|
||||||
|
*
|
||||||
|
* Do NOT wire a real Stripe (or other) publishable/secret key into this file.
|
||||||
|
* When a processor is chosen:
|
||||||
|
* 1. Add a c2-core router (e.g. `routers/billing.py`) that owns all server-side
|
||||||
|
* calls to the processor's API using a secret key from server env — never
|
||||||
|
* exposed to the frontend.
|
||||||
|
* 2. Add a Stripe (or similar) webhook endpoint on c2-core that keeps an
|
||||||
|
* `organizations/{orgId}` Firestore doc in sync with subscription state
|
||||||
|
* (plan, status, current_period_end, seats, node_limit).
|
||||||
|
* 3. Replace the bodies below with `c2api`-style `fetch` calls into that router.
|
||||||
|
* Checkout/portal functions should return a redirect URL from a real
|
||||||
|
* Checkout/Billing Portal session — the frontend's only job is
|
||||||
|
* `window.location.href = url`, it should never touch card data directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PlanId = "free" | "pro" | "enterprise";
|
||||||
|
export type SubscriptionStatus = "trialing" | "active" | "past_due" | "canceled" | "none";
|
||||||
|
export type BillingInterval = "monthly" | "annual";
|
||||||
|
|
||||||
|
export interface PlanLimits {
|
||||||
|
seats: number | "unlimited";
|
||||||
|
nodes: number | "unlimited";
|
||||||
|
retentionDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanDefinition {
|
||||||
|
id: PlanId;
|
||||||
|
name: string;
|
||||||
|
tagline: string;
|
||||||
|
priceMonthlyUsd: number | null; // null = "contact us"
|
||||||
|
priceAnnualUsd: number | null;
|
||||||
|
limits: PlanLimits;
|
||||||
|
features: string[];
|
||||||
|
highlighted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Subscription {
|
||||||
|
planId: PlanId;
|
||||||
|
status: SubscriptionStatus;
|
||||||
|
interval: BillingInterval;
|
||||||
|
currentPeriodEnd: string | null; // ISO date
|
||||||
|
cancelAtPeriodEnd: boolean;
|
||||||
|
trialEndsAt: string | null; // ISO date
|
||||||
|
seatsUsed: number;
|
||||||
|
nodesUsed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Invoice {
|
||||||
|
id: string;
|
||||||
|
date: string; // ISO date
|
||||||
|
amountUsd: number;
|
||||||
|
status: "paid" | "open" | "void" | "uncollectible";
|
||||||
|
description: string;
|
||||||
|
/** In a real integration, a short-lived link to the processor-hosted PDF/receipt. */
|
||||||
|
hostedUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageSummary {
|
||||||
|
seatsUsed: number;
|
||||||
|
seatsLimit: number | "unlimited";
|
||||||
|
nodesUsed: number;
|
||||||
|
nodesLimit: number | "unlimited";
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plan catalog — this is real UI copy (safe to ship), just not wired to a
|
||||||
|
// live pricing table. In a real integration this would likely be fetched
|
||||||
|
// from the processor (Stripe Prices API) instead of hardcoded here so price
|
||||||
|
// changes don't require a frontend deploy.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const PLANS: PlanDefinition[] = [
|
||||||
|
{
|
||||||
|
id: "free",
|
||||||
|
name: "Community",
|
||||||
|
tagline: "For a single node and a small crew keeping an eye on local traffic.",
|
||||||
|
priceMonthlyUsd: 0,
|
||||||
|
priceAnnualUsd: 0,
|
||||||
|
limits: { seats: 3, nodes: 1, retentionDays: 7 },
|
||||||
|
features: [
|
||||||
|
"1 field node",
|
||||||
|
"3 team seats",
|
||||||
|
"Live incident map",
|
||||||
|
"7-day call & incident history",
|
||||||
|
"Discord voice relay",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pro",
|
||||||
|
name: "Pro",
|
||||||
|
tagline: "For agencies and serious hobbyist networks running multiple nodes.",
|
||||||
|
priceMonthlyUsd: 79,
|
||||||
|
priceAnnualUsd: 790,
|
||||||
|
limits: { seats: 15, nodes: 10, retentionDays: 90 },
|
||||||
|
features: [
|
||||||
|
"Up to 10 field nodes",
|
||||||
|
"15 team seats",
|
||||||
|
"AI incident correlation & summaries",
|
||||||
|
"90-day call & incident history",
|
||||||
|
"Alert rules with Discord webhooks",
|
||||||
|
"API key access",
|
||||||
|
],
|
||||||
|
highlighted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "enterprise",
|
||||||
|
name: "Enterprise",
|
||||||
|
tagline: "For regional networks with custom retention, SSO, and support needs.",
|
||||||
|
priceMonthlyUsd: null,
|
||||||
|
priceAnnualUsd: null,
|
||||||
|
limits: { seats: "unlimited", nodes: "unlimited", retentionDays: 365 },
|
||||||
|
features: [
|
||||||
|
"Unlimited field nodes",
|
||||||
|
"Unlimited team seats",
|
||||||
|
"1-year+ retention (custom)",
|
||||||
|
"SSO / SAML",
|
||||||
|
"Dedicated support & uptime SLA",
|
||||||
|
"Custom data residency",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getPlan(id: PlanId): PlanDefinition {
|
||||||
|
return PLANS.find((p) => p.id === id) ?? PLANS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sample account state — clearly a demo fixture, not a real customer record.
|
||||||
|
// TODO(billing): replace with `c2api.getSubscription()` once c2-core exposes
|
||||||
|
// GET /org/subscription backed by the processor + Firestore org doc.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SAMPLE_SUBSCRIPTION: Subscription = {
|
||||||
|
planId: "pro",
|
||||||
|
status: "trialing",
|
||||||
|
interval: "monthly",
|
||||||
|
currentPeriodEnd: new Date(Date.now() + 1000 * 60 * 60 * 24 * 21).toISOString(),
|
||||||
|
cancelAtPeriodEnd: false,
|
||||||
|
trialEndsAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString(),
|
||||||
|
seatsUsed: 4,
|
||||||
|
nodesUsed: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SAMPLE_INVOICES: Invoice[] = [
|
||||||
|
{ id: "sample_inv_1003", date: "2026-07-16", amountUsd: 79, status: "paid", description: "Pro plan — monthly", hostedUrl: null },
|
||||||
|
{ id: "sample_inv_1002", date: "2026-06-16", amountUsd: 79, status: "paid", description: "Pro plan — monthly", hostedUrl: null },
|
||||||
|
{ id: "sample_inv_1001", date: "2026-05-16", amountUsd: 0, status: "paid", description: "Community plan", hostedUrl: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO(billing): replace with `c2api.getSubscription()` → GET /org/subscription.
|
||||||
|
* Returns sample data so the settings UI has something real to render today.
|
||||||
|
*/
|
||||||
|
export async function getCurrentSubscription(): Promise<Subscription> {
|
||||||
|
return SAMPLE_SUBSCRIPTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO(billing): replace with `c2api.getUsageSummary()` → GET /org/usage,
|
||||||
|
* computed server-side from `nodes` count + org member count.
|
||||||
|
*/
|
||||||
|
export async function getUsageSummary(): Promise<UsageSummary> {
|
||||||
|
const sub = await getCurrentSubscription();
|
||||||
|
const plan = getPlan(sub.planId);
|
||||||
|
const now = new Date();
|
||||||
|
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||||
|
return {
|
||||||
|
seatsUsed: sub.seatsUsed,
|
||||||
|
seatsLimit: plan.limits.seats,
|
||||||
|
nodesUsed: sub.nodesUsed,
|
||||||
|
nodesLimit: plan.limits.nodes,
|
||||||
|
periodStart: periodStart.toISOString(),
|
||||||
|
periodEnd: periodEnd.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO(billing): replace with `c2api.getInvoices()` → GET /org/invoices,
|
||||||
|
* which on the backend would list Stripe Invoices for the org's customer id
|
||||||
|
* and map them to this shape (hostedUrl = Stripe's `hosted_invoice_url`).
|
||||||
|
*/
|
||||||
|
export async function getInvoices(): Promise<Invoice[]> {
|
||||||
|
return SAMPLE_INVOICES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO(billing): replace with `c2api.createCheckoutSession(planId, interval)`
|
||||||
|
* → POST /org/billing/checkout-session, which creates a Stripe Checkout
|
||||||
|
* Session server-side (secret key never leaves the server) and returns
|
||||||
|
* `{ url }`. Frontend then does `window.location.href = url`.
|
||||||
|
*
|
||||||
|
* Throws here — there is no live checkout to redirect to.
|
||||||
|
*/
|
||||||
|
export async function createCheckoutSession(_planId: PlanId, _interval: BillingInterval): Promise<{ url: string }> {
|
||||||
|
throw new Error(
|
||||||
|
"Checkout is not wired to a payment processor yet. This is a demo build — " +
|
||||||
|
"no card will be charged. See lib/billing.ts for the integration TODO."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO(billing): replace with `c2api.createBillingPortalSession()` →
|
||||||
|
* POST /org/billing/portal-session, which creates a Stripe Billing Portal
|
||||||
|
* session server-side and returns `{ url }` for redirect. The portal is
|
||||||
|
* where a real integration would let customers update payment methods,
|
||||||
|
* cancel, or download invoices — avoids building that UI ourselves.
|
||||||
|
*/
|
||||||
|
export async function createBillingPortalSession(): Promise<{ url: string }> {
|
||||||
|
throw new Error(
|
||||||
|
"Billing portal is not wired to a payment processor yet. See lib/billing.ts for the integration TODO."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO(billing): replace with `c2api.previewPlanChange(planId, interval)` →
|
||||||
|
* GET /org/billing/preview?plan=…, which on the backend would call the
|
||||||
|
* processor's upcoming-invoice/proration preview endpoint.
|
||||||
|
* Returns a rough client-side estimate so the upgrade/downgrade UI has
|
||||||
|
* something to show; not a real proration calculation.
|
||||||
|
*/
|
||||||
|
export async function previewPlanChange(planId: PlanId, interval: BillingInterval): Promise<{ dueTodayUsd: number; nextAmountUsd: number }> {
|
||||||
|
const plan = getPlan(planId);
|
||||||
|
const price = interval === "annual" ? plan.priceAnnualUsd : plan.priceMonthlyUsd;
|
||||||
|
return { dueTodayUsd: price ?? 0, nextAmountUsd: price ?? 0 };
|
||||||
|
}
|
||||||
@@ -30,6 +30,12 @@ export const c2api = {
|
|||||||
if (ppmOverride !== undefined) params.set("ppm_override", String(ppmOverride));
|
if (ppmOverride !== undefined) params.set("ppm_override", String(ppmOverride));
|
||||||
return request(`/nodes/${nodeId}/config/${systemId}?${params}`, { method: "POST" });
|
return request(`/nodes/${nodeId}/config/${systemId}?${params}`, { method: "POST" });
|
||||||
},
|
},
|
||||||
|
ackOverride: (nodeId: string, timeoutMinutes: number = 1440) =>
|
||||||
|
request(`/nodes/${nodeId}/override/ack`, { method: "POST", body: JSON.stringify({ timeout_minutes: timeoutMinutes }) }),
|
||||||
|
resetOverride: (nodeId: string) =>
|
||||||
|
request(`/nodes/${nodeId}/override/reset`, { method: "POST" }),
|
||||||
|
updateNode: (id: string, body: { node_type?: string; enforce_override_timeout?: boolean }) =>
|
||||||
|
request(`/nodes/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||||
|
|
||||||
// Systems
|
// Systems
|
||||||
getSystems: () => request<unknown[]>("/systems"),
|
getSystems: () => request<unknown[]>("/systems"),
|
||||||
@@ -216,4 +222,35 @@ export const c2api = {
|
|||||||
// Session recording — called on each explicit sign-in
|
// Session recording — called on each explicit sign-in
|
||||||
recordSession: () =>
|
recordSession: () =>
|
||||||
request<{ ok: boolean }>("/auth/session", { method: "POST" }),
|
request<{ ok: boolean }>("/auth/session", { method: "POST" }),
|
||||||
|
|
||||||
|
// Org provisioning (SAAS_PLAN.md B4) — called once from /onboarding right
|
||||||
|
// after a Firebase account exists but before it has an org_id claim.
|
||||||
|
signup: (orgName: string) =>
|
||||||
|
request<{ org_id: string; org_name: string; already_provisioned: boolean }>("/auth/signup", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ org_name: orgName }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Organization profile
|
||||||
|
getOrg: () =>
|
||||||
|
request<{ org_id: string; name: string; created_at: string }>("/org"),
|
||||||
|
updateOrg: (name: string) =>
|
||||||
|
request<{ ok: boolean; name: string }>("/org", { method: "PATCH", body: JSON.stringify({ name }) }),
|
||||||
|
|
||||||
|
// Per-org enrollment tokens (SAAS_PLAN.md B2b)
|
||||||
|
listEnrollmentTokens: () =>
|
||||||
|
request<{ token_id: string; label: string; created_at: string; revoked: boolean; uses: number }[]>(
|
||||||
|
"/org/enrollment-tokens"
|
||||||
|
),
|
||||||
|
mintEnrollmentToken: (label: string) =>
|
||||||
|
request<{ token_id: string; token: string; label: string }>("/org/enrollment-tokens", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ label }),
|
||||||
|
}),
|
||||||
|
revokeEnrollmentToken: (tokenId: string) =>
|
||||||
|
request(`/org/enrollment-tokens/${tokenId}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
// 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) }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Shared severity ladder for calls and incidents: routine < minor < moderate < major.
|
||||||
|
* Every call/incident gets one of these four. `"unknown"` (and any other
|
||||||
|
* unrecognized value) is a legacy value still present on historical docs —
|
||||||
|
* treat it as "no severity", not as a fifth level.
|
||||||
|
*/
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
|
export type Severity = "routine" | "minor" | "moderate" | "major";
|
||||||
|
|
||||||
|
export const SEVERITY_ORDER: Record<Severity, number> = { routine: 0, minor: 1, moderate: 2, major: 3 };
|
||||||
|
export const SEVERITY_LABEL: Record<Severity, string> = { routine: "Routine", minor: "Minor", moderate: "Moderate", major: "Major" };
|
||||||
|
export const SEVERITY_COLORS: Record<Severity, string> = {
|
||||||
|
routine: "bg-gray-800/40 text-gray-600",
|
||||||
|
minor: "bg-gray-800 text-gray-400",
|
||||||
|
moderate: "bg-orange-950 text-orange-400",
|
||||||
|
major: "bg-red-950 text-red-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isKnownSeverity(s: string | null | undefined): s is Severity {
|
||||||
|
return s === "routine" || s === "minor" || s === "moderate" || s === "major";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy/unset severities rank below `routine` so a recency-sorted list never confuses them with a real (low) severity. */
|
||||||
|
export function severityRank(s: string | null | undefined): number {
|
||||||
|
return isKnownSeverity(s) ? SEVERITY_ORDER[s] : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function severityBadge(severity: string | null | undefined): ReactElement | null {
|
||||||
|
if (!isKnownSeverity(severity)) return null;
|
||||||
|
return (
|
||||||
|
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${SEVERITY_COLORS[severity]}`}>
|
||||||
|
{SEVERITY_LABEL[severity]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Mirrors drb-c2-core/app/internal/tenancy.py's FOUNDING_ORG_ID — the org
|
||||||
|
* every pre-tenancy document and every legacy enrollment path resolves
|
||||||
|
* into. Frontend-side, it's used only to gate the /trips feature (an
|
||||||
|
* internal utility riding along on this stack, not a tenant-scoped product
|
||||||
|
* surface — see [[trips-feature-intentional]] and SAAS_PLAN.md B7) to the
|
||||||
|
* founding org, matching the same restriction the backend already enforces
|
||||||
|
* in routers/trips.py.
|
||||||
|
*/
|
||||||
|
export const FOUNDING_ORG_ID = "founding";
|
||||||
@@ -52,6 +52,11 @@ export interface NodeRecord {
|
|||||||
approval_status: ApprovalStatus | null;
|
approval_status: ApprovalStatus | null;
|
||||||
hardware_preset?: string;
|
hardware_preset?: string;
|
||||||
ppm_override?: number | null;
|
ppm_override?: number | null;
|
||||||
|
node_type?: string;
|
||||||
|
enforce_override_timeout?: boolean;
|
||||||
|
is_overridden?: boolean;
|
||||||
|
override_system_id?: string | null;
|
||||||
|
override_timeout_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VocabularyPendingTerm {
|
export interface VocabularyPendingTerm {
|
||||||
@@ -88,6 +93,13 @@ export interface CallRecord {
|
|||||||
freq: number | null;
|
freq: number | null;
|
||||||
started_at: string;
|
started_at: string;
|
||||||
ended_at: string | null;
|
ended_at: string | null;
|
||||||
|
/** Private gs:// object location. Present on the Firestore doc; not playable. */
|
||||||
|
audio_gcs_uri?: string | null;
|
||||||
|
/**
|
||||||
|
* Short-lived playback link. Minted per read by the API — only populated on
|
||||||
|
* calls fetched via c2api, never on docs read straight from Firestore.
|
||||||
|
* On pre-fix docs this holds a legacy (unplayable) gs:// URI instead.
|
||||||
|
*/
|
||||||
audio_url: string | null;
|
audio_url: string | null;
|
||||||
transcript: string | null;
|
transcript: string | null;
|
||||||
transcript_corrected: string | null;
|
transcript_corrected: string | null;
|
||||||
@@ -99,6 +111,8 @@ export interface CallRecord {
|
|||||||
location: string | null;
|
location: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
status: "active" | "ended";
|
status: "active" | "ended";
|
||||||
|
/** Four-level ladder: routine | minor | moderate | major. Legacy docs may still carry "unknown". */
|
||||||
|
severity?: string | null;
|
||||||
// Correlation debug — written by the correlator, present after a call is linked
|
// Correlation debug — written by the correlator, present after a call is linked
|
||||||
corr_path?: string | null;
|
corr_path?: string | null;
|
||||||
corr_score?: number | null;
|
corr_score?: number | null;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { collection, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore";
|
import { collection, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore";
|
||||||
import { onAuthStateChanged } from "firebase/auth";
|
import { onAuthStateChanged } from "firebase/auth";
|
||||||
import { db, auth } from "@/lib/firebase";
|
import { db, auth } from "@/lib/firebase";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import type { AlertEvent } from "@/lib/types";
|
import type { AlertEvent } from "@/lib/types";
|
||||||
|
|
||||||
const toISO = (v: unknown): string =>
|
const toISO = (v: unknown): string =>
|
||||||
@@ -14,6 +15,7 @@ export function useAlerts(limitCount = 50) {
|
|||||||
const [alerts, setAlerts] = useState<AlertEvent[]>([]);
|
const [alerts, setAlerts] = useState<AlertEvent[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { orgId } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubFirestore: (() => void) | undefined;
|
let unsubFirestore: (() => void) | undefined;
|
||||||
@@ -26,9 +28,15 @@ export function useAlerts(limitCount = 50) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!orgId) {
|
||||||
|
setAlerts([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const q = query(
|
const q = query(
|
||||||
collection(db, "alert_events"),
|
collection(db, "alert_events"),
|
||||||
|
where("org_id", "==", orgId),
|
||||||
orderBy("triggered_at", "desc"),
|
orderBy("triggered_at", "desc"),
|
||||||
limit(limitCount)
|
limit(limitCount)
|
||||||
);
|
);
|
||||||
@@ -52,13 +60,14 @@ export function useAlerts(limitCount = 50) {
|
|||||||
unsubAuth();
|
unsubAuth();
|
||||||
if (unsubFirestore) unsubFirestore();
|
if (unsubFirestore) unsubFirestore();
|
||||||
};
|
};
|
||||||
}, [limitCount]);
|
}, [limitCount, orgId]);
|
||||||
|
|
||||||
return { alerts, loading, error };
|
return { alerts, loading, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUnacknowledgedAlerts() {
|
export function useUnacknowledgedAlerts() {
|
||||||
const [alerts, setAlerts] = useState<AlertEvent[]>([]);
|
const [alerts, setAlerts] = useState<AlertEvent[]>([]);
|
||||||
|
const { orgId } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubFirestore: (() => void) | undefined;
|
let unsubFirestore: (() => void) | undefined;
|
||||||
@@ -70,9 +79,14 @@ export function useUnacknowledgedAlerts() {
|
|||||||
setAlerts([]);
|
setAlerts([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!orgId) {
|
||||||
|
setAlerts([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const q = query(
|
const q = query(
|
||||||
collection(db, "alert_events"),
|
collection(db, "alert_events"),
|
||||||
|
where("org_id", "==", orgId),
|
||||||
where("acknowledged", "==", false),
|
where("acknowledged", "==", false),
|
||||||
orderBy("triggered_at", "desc"),
|
orderBy("triggered_at", "desc"),
|
||||||
limit(100)
|
limit(100)
|
||||||
@@ -89,7 +103,7 @@ export function useUnacknowledgedAlerts() {
|
|||||||
unsubAuth();
|
unsubAuth();
|
||||||
if (unsubFirestore) unsubFirestore();
|
if (unsubFirestore) unsubFirestore();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [orgId]);
|
||||||
|
|
||||||
return alerts;
|
return alerts;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { useEffect, useState } from "react";
|
|||||||
import { collection, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore";
|
import { collection, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore";
|
||||||
import { onAuthStateChanged } from "firebase/auth";
|
import { onAuthStateChanged } from "firebase/auth";
|
||||||
import { db, auth } from "@/lib/firebase";
|
import { db, auth } from "@/lib/firebase";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import type { CallRecord } from "@/lib/types";
|
import type { CallRecord } from "@/lib/types";
|
||||||
|
|
||||||
export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
|
export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
|
||||||
const [calls, setCalls] = useState<CallRecord[]>([]);
|
const [calls, setCalls] = useState<CallRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { orgId } = useAuth();
|
||||||
|
|
||||||
// Stable ms values so the effect dependency doesn't fire on every render
|
// Stable ms values so the effect dependency doesn't fire on every render
|
||||||
const dateFromMs = dateFrom?.getTime();
|
const dateFromMs = dateFrom?.getTime();
|
||||||
@@ -26,11 +28,21 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// No org_id claim yet (still resolving, or genuinely unprovisioned —
|
||||||
|
// see ChromeSwitcher's no-claim guard) — an unfiltered query here
|
||||||
|
// would be exactly the cross-tenant read this scoping exists to
|
||||||
|
// close, so wait rather than fall back to "query everything".
|
||||||
|
if (!orgId) {
|
||||||
|
setCalls([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const from = dateFromMs != null ? new Date(dateFromMs) : undefined;
|
const from = dateFromMs != null ? new Date(dateFromMs) : undefined;
|
||||||
const to = dateToMs != null ? new Date(dateToMs) : undefined;
|
const to = dateToMs != null ? new Date(dateToMs) : undefined;
|
||||||
|
|
||||||
const constraints = [
|
const constraints = [
|
||||||
|
where("org_id", "==", orgId),
|
||||||
...(from ? [where("started_at", ">=", from)] : []),
|
...(from ? [where("started_at", ">=", from)] : []),
|
||||||
...(to ? [where("started_at", "<=", to)] : []),
|
...(to ? [where("started_at", "<=", to)] : []),
|
||||||
orderBy("started_at", "desc"),
|
orderBy("started_at", "desc"),
|
||||||
@@ -52,7 +64,7 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
|
|||||||
unsubAuth();
|
unsubAuth();
|
||||||
if (unsubFirestore) unsubFirestore();
|
if (unsubFirestore) unsubFirestore();
|
||||||
};
|
};
|
||||||
}, [limitCount, dateFromMs, dateToMs]);
|
}, [limitCount, dateFromMs, dateToMs, orgId]);
|
||||||
|
|
||||||
return { calls, loading, error };
|
return { calls, loading, error };
|
||||||
}
|
}
|
||||||
@@ -60,6 +72,7 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
|
|||||||
export function useCallsByIncident(incidentId: string | null) {
|
export function useCallsByIncident(incidentId: string | null) {
|
||||||
const [calls, setCalls] = useState<CallRecord[]>([]);
|
const [calls, setCalls] = useState<CallRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const { orgId } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!incidentId) { setLoading(false); return; }
|
if (!incidentId) { setLoading(false); return; }
|
||||||
@@ -68,11 +81,16 @@ export function useCallsByIncident(incidentId: string | null) {
|
|||||||
const unsubAuth = onAuthStateChanged(auth, (user) => {
|
const unsubAuth = onAuthStateChanged(auth, (user) => {
|
||||||
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
|
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
|
||||||
if (!user) { setLoading(false); return; }
|
if (!user) { setLoading(false); return; }
|
||||||
|
if (!orgId) { setCalls([]); setLoading(false); return; }
|
||||||
|
|
||||||
const toISO = (v: any): string | null =>
|
const toISO = (v: any): string | null =>
|
||||||
v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null);
|
v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null);
|
||||||
|
|
||||||
const q = query(collection(db, "calls"), where("incident_ids", "array-contains", incidentId));
|
const q = query(
|
||||||
|
collection(db, "calls"),
|
||||||
|
where("org_id", "==", orgId),
|
||||||
|
where("incident_ids", "array-contains", incidentId)
|
||||||
|
);
|
||||||
unsubFirestore = onSnapshot(q, (snap) => {
|
unsubFirestore = onSnapshot(q, (snap) => {
|
||||||
const docs = snap.docs.map((d) => {
|
const docs = snap.docs.map((d) => {
|
||||||
const data = d.data();
|
const data = d.data();
|
||||||
@@ -85,13 +103,14 @@ export function useCallsByIncident(incidentId: string | null) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => { unsubAuth(); if (unsubFirestore) unsubFirestore(); };
|
return () => { unsubAuth(); if (unsubFirestore) unsubFirestore(); };
|
||||||
}, [incidentId]);
|
}, [incidentId, orgId]);
|
||||||
|
|
||||||
return { calls, loading };
|
return { calls, loading };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useActiveCalls() {
|
export function useActiveCalls() {
|
||||||
const [calls, setCalls] = useState<CallRecord[]>([]);
|
const [calls, setCalls] = useState<CallRecord[]>([]);
|
||||||
|
const { orgId } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubFirestore: (() => void) | undefined;
|
let unsubFirestore: (() => void) | undefined;
|
||||||
@@ -103,8 +122,12 @@ export function useActiveCalls() {
|
|||||||
setCalls([]);
|
setCalls([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!orgId) {
|
||||||
|
setCalls([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const q = query(collection(db, "calls"), where("status", "==", "active"));
|
const q = query(collection(db, "calls"), where("org_id", "==", orgId), where("status", "==", "active"));
|
||||||
const toISO = (v: any): string | null =>
|
const toISO = (v: any): string | null =>
|
||||||
v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null);
|
v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null);
|
||||||
unsubFirestore = onSnapshot(q, (snap) => {
|
unsubFirestore = onSnapshot(q, (snap) => {
|
||||||
@@ -119,7 +142,7 @@ export function useActiveCalls() {
|
|||||||
unsubAuth();
|
unsubAuth();
|
||||||
if (unsubFirestore) unsubFirestore();
|
if (unsubFirestore) unsubFirestore();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [orgId]);
|
||||||
|
|
||||||
return calls;
|
return calls;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { collection, doc, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore";
|
import { collection, doc, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore";
|
||||||
import { onAuthStateChanged } from "firebase/auth";
|
import { onAuthStateChanged } from "firebase/auth";
|
||||||
import { db, auth } from "@/lib/firebase";
|
import { db, auth } from "@/lib/firebase";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import type { IncidentRecord } from "@/lib/types";
|
import type { IncidentRecord } from "@/lib/types";
|
||||||
|
|
||||||
const toISO = (v: unknown): string =>
|
const toISO = (v: unknown): string =>
|
||||||
@@ -14,6 +15,7 @@ export function useIncidents(limitCount = 100) {
|
|||||||
const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
|
const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { orgId } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubFirestore: (() => void) | undefined;
|
let unsubFirestore: (() => void) | undefined;
|
||||||
@@ -26,9 +28,15 @@ export function useIncidents(limitCount = 100) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!orgId) {
|
||||||
|
setIncidents([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const q = query(
|
const q = query(
|
||||||
collection(db, "incidents"),
|
collection(db, "incidents"),
|
||||||
|
where("org_id", "==", orgId),
|
||||||
orderBy("started_at", "desc"),
|
orderBy("started_at", "desc"),
|
||||||
limit(limitCount)
|
limit(limitCount)
|
||||||
);
|
);
|
||||||
@@ -53,7 +61,7 @@ export function useIncidents(limitCount = 100) {
|
|||||||
unsubAuth();
|
unsubAuth();
|
||||||
if (unsubFirestore) unsubFirestore();
|
if (unsubFirestore) unsubFirestore();
|
||||||
};
|
};
|
||||||
}, [limitCount]);
|
}, [limitCount, orgId]);
|
||||||
|
|
||||||
return { incidents, loading, error };
|
return { incidents, loading, error };
|
||||||
}
|
}
|
||||||
@@ -97,6 +105,7 @@ export function useIncident(incidentId: string | null) {
|
|||||||
|
|
||||||
export function useActiveIncidents() {
|
export function useActiveIncidents() {
|
||||||
const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
|
const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
|
||||||
|
const { orgId } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubFirestore: (() => void) | undefined;
|
let unsubFirestore: (() => void) | undefined;
|
||||||
@@ -108,8 +117,12 @@ export function useActiveIncidents() {
|
|||||||
setIncidents([]);
|
setIncidents([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!orgId) {
|
||||||
|
setIncidents([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const q = query(collection(db, "incidents"), where("status", "==", "active"));
|
const q = query(collection(db, "incidents"), where("org_id", "==", orgId), where("status", "==", "active"));
|
||||||
unsubFirestore = onSnapshot(q, (snap) => {
|
unsubFirestore = onSnapshot(q, (snap) => {
|
||||||
setIncidents(snap.docs.map((d) => {
|
setIncidents(snap.docs.map((d) => {
|
||||||
const data = d.data();
|
const data = d.data();
|
||||||
@@ -126,7 +139,7 @@ export function useActiveIncidents() {
|
|||||||
unsubAuth();
|
unsubAuth();
|
||||||
if (unsubFirestore) unsubFirestore();
|
if (unsubFirestore) unsubFirestore();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [orgId]);
|
||||||
|
|
||||||
return incidents;
|
return incidents;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { collection, onSnapshot, query, FirestoreError } from "firebase/firestore";
|
import { collection, onSnapshot, query, where, FirestoreError } from "firebase/firestore";
|
||||||
import { onAuthStateChanged } from "firebase/auth";
|
import { onAuthStateChanged } from "firebase/auth";
|
||||||
import { db, auth } from "@/lib/firebase";
|
import { db, auth } from "@/lib/firebase";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import type { NodeRecord } from "@/lib/types";
|
import type { NodeRecord } from "@/lib/types";
|
||||||
|
|
||||||
export function useNodes() {
|
export function useNodes() {
|
||||||
const [nodes, setNodes] = useState<NodeRecord[]>([]);
|
const [nodes, setNodes] = useState<NodeRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { orgId } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubFirestore: (() => void) | undefined;
|
let unsubFirestore: (() => void) | undefined;
|
||||||
@@ -22,8 +24,13 @@ export function useNodes() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!orgId) {
|
||||||
|
setNodes([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const q = query(collection(db, "nodes"));
|
const q = query(collection(db, "nodes"), where("org_id", "==", orgId));
|
||||||
unsubFirestore = onSnapshot(q, (snap) => {
|
unsubFirestore = onSnapshot(q, (snap) => {
|
||||||
setNodes(snap.docs.map((d) => d.data() as NodeRecord));
|
setNodes(snap.docs.map((d) => d.data() as NodeRecord));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -34,7 +41,7 @@ export function useNodes() {
|
|||||||
unsubAuth();
|
unsubAuth();
|
||||||
if (unsubFirestore) unsubFirestore();
|
if (unsubFirestore) unsubFirestore();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [orgId]);
|
||||||
|
|
||||||
return { nodes, loading, error };
|
return { nodes, loading, error };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { collection, onSnapshot, FirestoreError } from "firebase/firestore";
|
import { collection, onSnapshot, query, where, FirestoreError } from "firebase/firestore";
|
||||||
import { onAuthStateChanged } from "firebase/auth";
|
import { onAuthStateChanged } from "firebase/auth";
|
||||||
import { db, auth } from "@/lib/firebase";
|
import { db, auth } from "@/lib/firebase";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import type { SystemRecord } from "@/lib/types";
|
import type { SystemRecord } from "@/lib/types";
|
||||||
|
|
||||||
export function useSystems() {
|
export function useSystems() {
|
||||||
const [systems, setSystems] = useState<SystemRecord[]>([]);
|
const [systems, setSystems] = useState<SystemRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { orgId } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubFirestore: (() => void) | undefined;
|
let unsubFirestore: (() => void) | undefined;
|
||||||
@@ -22,8 +24,14 @@ export function useSystems() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!orgId) {
|
||||||
|
setSystems([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
unsubFirestore = onSnapshot(collection(db, "systems"), (snap) => {
|
const q = query(collection(db, "systems"), where("org_id", "==", orgId));
|
||||||
|
unsubFirestore = onSnapshot(q, (snap) => {
|
||||||
setSystems(snap.docs.map((d) => d.data() as SystemRecord));
|
setSystems(snap.docs.map((d) => d.data() as SystemRecord));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, (err: FirestoreError) => { console.error("useSystems:", err); setError(err.message); setLoading(false); });
|
}, (err: FirestoreError) => { console.error("useSystems:", err); setError(err.message); setLoading(false); });
|
||||||
@@ -33,7 +41,7 @@ export function useSystems() {
|
|||||||
unsubAuth();
|
unsubAuth();
|
||||||
if (unsubFirestore) unsubFirestore();
|
if (unsubFirestore) unsubFirestore();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [orgId]);
|
||||||
|
|
||||||
return { systems, loading, error };
|
return { systems, loading, error };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,32 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
// Public marketing pages — no session required. Keep this in sync with
|
||||||
|
// MARKETING_PATHS in components/ChromeSwitcher.tsx (that one picks page
|
||||||
|
// chrome; this one decides whether to redirect at all).
|
||||||
|
const PUBLIC_PATHS = new Set(["/", "/features", "/pricing", "/faq", "/terms", "/privacy", "/waitlist"]);
|
||||||
|
|
||||||
|
// /signup and /onboarding are deliberately NOT gated by the drb_session
|
||||||
|
// cookie here, even though they aren't "public" in the sense of not needing
|
||||||
|
// an account — AuthProvider only sets that cookie once a user has an org_id
|
||||||
|
// claim (SAAS_PLAN.md B3's no-claim guard), and /onboarding exists
|
||||||
|
// specifically for a signed-in user who doesn't have one yet. Gating it on
|
||||||
|
// the same cookie would bounce the exact users who need it back to /login
|
||||||
|
// before ChromeSwitcher's client-side redirect ever runs. Both pages do
|
||||||
|
// their own client-side auth check (redirect to /login if genuinely signed
|
||||||
|
// out) instead.
|
||||||
|
const NO_SESSION_COOKIE_GATE = new Set(["/signup", "/onboarding"]);
|
||||||
|
|
||||||
|
// NOTE: this is a UX redirect only, not a security boundary — it just checks
|
||||||
|
// a client-set cookie's presence. Real enforcement is server-side, in
|
||||||
|
// drb-c2-core/app/internal/auth.py. See CLAUDE.md.
|
||||||
export function middleware(request: NextRequest) {
|
export function middleware(request: NextRequest) {
|
||||||
const session = request.cookies.get("drb_session");
|
const session = request.cookies.get("drb_session");
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
if (PUBLIC_PATHS.has(pathname) || NO_SESSION_COOKIE_GATE.has(pathname)) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname === "/login") {
|
if (pathname === "/login") {
|
||||||
if (session) return NextResponse.redirect(new URL("/dashboard", request.url));
|
if (session) return NextResponse.redirect(new URL("/dashboard", request.url));
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
|
|||||||
@@ -10,6 +10,28 @@ const config: Config = {
|
|||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
mono: ["ui-monospace", "Cascadia Code", "Source Code Pro", "monospace"],
|
mono: ["ui-monospace", "Cascadia Code", "Source Code Pro", "monospace"],
|
||||||
|
sans: ["ui-sans-serif", "system-ui", "-apple-system", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "sans-serif"],
|
||||||
|
},
|
||||||
|
// Marketing/product type scale — used by the (marketing) surface and
|
||||||
|
// settings shell so headings read as a deliberate hierarchy rather than
|
||||||
|
// ad-hoc text-xl/text-2xl bumps.
|
||||||
|
fontSize: {
|
||||||
|
"display-lg": ["3.5rem", { lineHeight: "1.05", letterSpacing: "-0.02em", fontWeight: "700" }],
|
||||||
|
"display": ["2.75rem", { lineHeight: "1.1", letterSpacing: "-0.02em", fontWeight: "700" }],
|
||||||
|
"display-sm": ["2.125rem", { lineHeight: "1.15", letterSpacing: "-0.01em", fontWeight: "700" }],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
card: "0 1px 2px 0 rgb(0 0 0 / 0.4), 0 1px 3px 0 rgb(0 0 0 / 0.3)",
|
||||||
|
"card-hover": "0 4px 12px 0 rgb(0 0 0 / 0.45), 0 2px 4px 0 rgb(0 0 0 / 0.3)",
|
||||||
|
glow: "0 0 0 1px rgb(99 102 241 / 0.4), 0 0 24px 0 rgb(99 102 241 / 0.25)",
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"fade-in": "fade-in 0.4s ease-out",
|
||||||
|
"slide-up": "slide-up 0.4s ease-out",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"fade-in": { from: { opacity: "0" }, to: { opacity: "1" } },
|
||||||
|
"slide-up": { from: { opacity: "0", transform: "translateY(8px)" }, to: { opacity: "1", transform: "translateY(0)" } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
# Managed by CI — deployed to /etc/caddy/Caddyfile on the server.
|
|
||||||
# Caddy handles TLS automatically via Let's Encrypt.
|
|
||||||
|
|
||||||
api.{$DRB_DOMAIN} {
|
|
||||||
reverse_proxy localhost:8888 {
|
|
||||||
header_up X-Forwarded-For {remote_host}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.{$DRB_DOMAIN} {
|
|
||||||
reverse_proxy localhost:3000 {
|
|
||||||
header_up X-Forwarded-For {remote_host}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
# The "Deploy Caddyfile" task notifies this. Without this file the play aborts
|
||||||
|
# with "The requested handler 'Reload Caddy' was not found" — notify does not
|
||||||
|
# tolerate a missing handler.
|
||||||
|
#
|
||||||
|
# reloaded, not restarted: caddy reload swaps config with zero downtime and
|
||||||
|
# keeps existing TLS certs/connections; a restart drops every in-flight request.
|
||||||
|
|
||||||
|
- name: Reload Caddy
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: caddy
|
||||||
|
state: reloaded
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# For the mqtt-cert-sync.service/.path unit files — systemd won't pick up a
|
||||||
|
# new/changed unit file until the manager config is reloaded.
|
||||||
|
- name: Reload systemd daemon
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
daemon_reload: true
|
||||||
@@ -2,12 +2,19 @@
|
|||||||
# First-time setup: clone repo, write secrets, pull pre-built images and start stack.
|
# First-time setup: clone repo, write secrets, pull pre-built images and start stack.
|
||||||
# Images are built and pushed by Gitea CI — this role never builds on the VM.
|
# Images are built and pushed by Gitea CI — this role never builds on the VM.
|
||||||
|
|
||||||
- name: Clone repo (skipped if already present)
|
# update: true (was false) — with update disabled, every re-run of this playbook
|
||||||
|
# redeployed the code that happened to be on the VM at first clone, so any fix
|
||||||
|
# pushed to main was invisible here and the only way to ship one was CI or a
|
||||||
|
# manual pull. force: true discards local edits made on the VM; the templated
|
||||||
|
# .env files and Caddyfile live outside git tracking, so nothing generated by
|
||||||
|
# this role is at risk.
|
||||||
|
- name: Clone or update repo
|
||||||
git:
|
git:
|
||||||
repo: "{{ repo_url }}"
|
repo: "{{ repo_url }}"
|
||||||
dest: "{{ app_dir }}"
|
dest: "{{ app_dir }}"
|
||||||
version: main
|
version: main
|
||||||
update: false
|
update: true
|
||||||
|
force: true
|
||||||
become: false
|
become: false
|
||||||
|
|
||||||
- name: Set ownership of app directory
|
- name: Set ownership of app directory
|
||||||
@@ -59,6 +66,93 @@
|
|||||||
mode: "0644"
|
mode: "0644"
|
||||||
notify: Reload Caddy
|
notify: Reload Caddy
|
||||||
|
|
||||||
|
# --- MQTT TLS cert sync (Caddy -> mosquitto) --------------------------------
|
||||||
|
# See MQTT-PUBLIC-AUTH-PLAN.md "Infra". mosquitto reads its cert from this
|
||||||
|
# directory (docker-compose.prod.yml bind-mounts it in); nothing but root can
|
||||||
|
# read Caddy's own cert storage, so a systemd path unit + oneshot service
|
||||||
|
# copies a readable copy out and SIGHUPs the broker on every change.
|
||||||
|
|
||||||
|
# root:1883 0750, not root:root 0700. The stock eclipse-mosquitto entrypoint
|
||||||
|
# drops privileges to the in-image `mosquitto` user (uid/gid 1883), so a
|
||||||
|
# root-only directory makes the broker fail to read its own cert and
|
||||||
|
# crash-loop: "Unable to load server certificate ... Permission denied".
|
||||||
|
# The host has no `mosquitto` user, hence the numeric gid.
|
||||||
|
- name: Create mosquitto certs directory
|
||||||
|
file:
|
||||||
|
path: /opt/drb/mosquitto-certs
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: "1883"
|
||||||
|
mode: "0750"
|
||||||
|
|
||||||
|
# dynamic-security.json (node credentials — see app/internal/dynsec.py)
|
||||||
|
# lives here, and mosquitto WRITES it, so this must be owned by the uid the
|
||||||
|
# broker actually runs as (1883), not root. The earlier assumption that the
|
||||||
|
# container runs as root was wrong — the image's entrypoint drops privileges
|
||||||
|
# to the `mosquitto` user, which a real deploy proved by failing to read a
|
||||||
|
# root-owned cert. Same numeric-gid reasoning as the certs directory above.
|
||||||
|
- name: Create mosquitto data directory
|
||||||
|
file:
|
||||||
|
path: /opt/drb/mosquitto-data
|
||||||
|
state: directory
|
||||||
|
owner: "1883"
|
||||||
|
group: "1883"
|
||||||
|
mode: "0700"
|
||||||
|
# recurse so an existing root-owned dynamic-security.json / mosquitto.db
|
||||||
|
# left behind by the earlier root-owned deploy gets fixed too — chowning
|
||||||
|
# only the directory would leave the broker unable to rewrite them.
|
||||||
|
recurse: true
|
||||||
|
|
||||||
|
- name: Deploy MQTT cert-sync script
|
||||||
|
template:
|
||||||
|
src: sync-mqtt-cert.sh.j2
|
||||||
|
dest: /opt/drb/sync-mqtt-cert.sh
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0700"
|
||||||
|
|
||||||
|
- name: Deploy MQTT cert-sync systemd service unit
|
||||||
|
template:
|
||||||
|
src: mqtt-cert-sync.service.j2
|
||||||
|
dest: /etc/systemd/system/mqtt-cert-sync.service
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
notify: Reload systemd daemon
|
||||||
|
|
||||||
|
- name: Deploy MQTT cert-sync systemd path unit
|
||||||
|
template:
|
||||||
|
src: mqtt-cert-sync.path.j2
|
||||||
|
dest: /etc/systemd/system/mqtt-cert-sync.path
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
notify: Reload systemd daemon
|
||||||
|
|
||||||
|
# Flush the daemon-reload handler now (rather than at end-of-play) so the
|
||||||
|
# path unit is registered and actively watching BEFORE the "Reload Caddy"
|
||||||
|
# handler below fires and Caddy goes to obtain the mqtt.{{ domain }} cert —
|
||||||
|
# otherwise the unit could miss the very first PathChanged event.
|
||||||
|
- name: Apply pending handlers (systemd daemon-reload)
|
||||||
|
meta: flush_handlers
|
||||||
|
|
||||||
|
- name: Enable and start MQTT cert-sync path unit
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: mqtt-cert-sync.path
|
||||||
|
state: started
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Best-effort initial sync in case Caddy already has a cert from a previous
|
||||||
|
# run (e.g. re-running this playbook after the first successful deploy) —
|
||||||
|
# the path unit only fires on a CHANGE, so it won't pick up a cert that was
|
||||||
|
# already sitting there unchanged before it started watching. Non-fatal if
|
||||||
|
# nothing exists yet (first-ever run, before Caddy has issued anything).
|
||||||
|
- name: Best-effort initial MQTT cert sync
|
||||||
|
command: /opt/drb/sync-mqtt-cert.sh
|
||||||
|
register: _initial_sync
|
||||||
|
changed_when: "'copied cert' in _initial_sync.stdout"
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
- name: Log in to container registry
|
- name: Log in to container registry
|
||||||
command: >
|
command: >
|
||||||
docker login {{ vault_registry_host }}
|
docker login {{ vault_registry_host }}
|
||||||
|
|||||||
@@ -1,12 +1,40 @@
|
|||||||
# Managed by Ansible — do not edit manually on the server.
|
# Managed by Ansible — do not edit manually.
|
||||||
|
|
||||||
api.{{ domain }} {
|
api.{{ domain }} {
|
||||||
|
# MQTT auth is no longer an HTTP backend c2-core exposes (it moved to
|
||||||
|
# mosquitto's own built-in dynamic-security plugin, administered over MQTT
|
||||||
|
# control topics — see app/internal/dynsec.py) — there is currently no
|
||||||
|
# /internal/* route in c2-core at all. This block stays anyway as defence
|
||||||
|
# in depth: c2-core's app-wide reverse_proxy below forwards every path by
|
||||||
|
# default, so this guarantees any FUTURE /internal/* route (or a
|
||||||
|
# regression that reintroduces one) is still unreachable from the public
|
||||||
|
# internet unless someone also deliberately deletes this block. `route`
|
||||||
|
# forces top-to-bottom evaluation instead of Caddy's automatic directive
|
||||||
|
# sorting, so this is guaranteed to run before reverse_proxy.
|
||||||
|
route {
|
||||||
|
respond /internal/* 404
|
||||||
reverse_proxy localhost:8888 {
|
reverse_proxy localhost:8888 {
|
||||||
header_up X-Forwarded-For {remote_host}
|
header_up X-Forwarded-For {remote_host}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.{{ domain }} {
|
# mqtt.{{ domain }} has no application behind it — mosquitto's TLS listener
|
||||||
|
# (8883) is a raw MQTT socket, not HTTP, so Caddy can't reverse_proxy to it.
|
||||||
|
# This block's only job is to make Caddy request+manage a Let's Encrypt cert
|
||||||
|
# for the name via ACME HTTP-01, which infra/ansible's cert-sync unit then
|
||||||
|
# copies out to mosquitto. The DNS A record for mqtt.{{ domain }} must exist
|
||||||
|
# before this runs, or ACME issuance fails (see MQTT-PUBLIC-AUTH-PLAN.md).
|
||||||
|
mqtt.{{ domain }} {
|
||||||
|
respond 404
|
||||||
|
}
|
||||||
|
|
||||||
|
# Frontend is served on the bare domain, not app.{{ domain }}: only drb and api
|
||||||
|
# have public DNS records. A vhost for a name with no A record still starts,
|
||||||
|
# but Caddy retries ACME against it forever and logs a failure each time.
|
||||||
|
# To move it to app.{{ domain }}, create the A record first, then change this
|
||||||
|
# line — the reverse_proxy target stays the same either way.
|
||||||
|
{{ domain }} {
|
||||||
reverse_proxy localhost:3000 {
|
reverse_proxy localhost:3000 {
|
||||||
header_up X-Forwarded-For {remote_host}
|
header_up X-Forwarded-For {remote_host}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user