Commit Graph
46 Commits
Author SHA1 Message Date
Logan CusanoandClaude Opus 5.5 fa194e0f0a frontend: search, filters and load-more on Incidents; open Archive to viewers
Incidents page: text search (title, location, summary, units, vehicles,
tags, location mentions), status and type filters, and a Load more button
that pages the Firestore query 100 at a time. Filtering runs over the
loaded window, and the page says so when older incidents exist.

Archive (/calls): readable by every org member, not just admins.
GET /calls/search now takes any Firebase token scoped to the caller's org
— the Firestore rules already let members read every call in their org,
so this widens nothing. Attach/detach stays admin-only (UI and routes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 23:53:46 -04:00
Logan CusanoandClaude Sonnet 5 5f85a878fa admin: STT eval harness — record human-verified transcripts, measure real WER (#163)
Build & Deploy / Build & push images (push) Successful in 4m10s
Build & Deploy / Deploy Firestore rules & indexes (push) Failing after 3s
Build & Deploy / Deploy to VM (push) Successful in 1m56s
Build & Deploy / Report a failed deploy (push) Successful in 1s
Backend: three new routes on the calls router, deliberately separate from
PATCH /{call_id}/transcript (a production correction with real side effects
-- re-extraction, incident unlinking, vocabulary learning). This is pure
measurement and must never share that code path.

  GET  /calls/eval-queue        -- calls with a transcript but no
                                    eval_transcript yet, paged (same bounded-
                                    window-plus-cursor shape as /search)
  PUT  /{call_id}/eval-transcript -- records eval_transcript/_by/_at only;
                                      never touches transcript/transcript_corrected
  GET  /calls/eval-stats        -- eval_count + average word error rate of
                                    the raw and corrected machine transcripts
                                    against the human-verified ones

internal/wer.py: standard word-level Levenshtein WER. Returns None (not 0.0)
when the reference is empty -- a call nobody transcribed must not score as a
perfect match.

Frontend: a new "STT Eval" tab on /admin -- one call at a time, audio player,
a textarea pre-filled with the machine transcript to correct into ground
truth, Save & next / Skip, running WER stats at the top. Built for working a
handful of calls at a time over however many sittings it takes, not a
one-shot form: the queue auto-refills from where the last save left off.

Verified: 438 pass, 0 fail (12 new backend tests). Frontend is UNVERIFIED --
this box has no Node.js/npm (confirmed absent), so neither typecheck nor the
dev server could be run. Matches existing code patterns and the CallRecord/
c2api types by manual review only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 00:04:30 -04:00
Logan CusanoandClaude Sonnet 5 3d2b722c64 Wire AIS end to end: telemetry ingestion + live map overlay
node-26#9, same shape as the ADS-B commit. Adds POST /telemetry/ais
(same node-key auth, same org_id-stamped upsert-by-key pattern, this time
by mmsi into a new `vessels` collection) and its docInMyOrg() firestore
rule. Frontend gets useVessels() (mirrors useAircraft(), longer staleness
window since AIS position reports are minutes apart, not seconds) and an
opt-in "Vessels" map overlay.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 16:12:56 -04:00
Logan CusanoandClaude Sonnet 5 5537b095df Wire ADS-B end to end: telemetry ingestion + live map overlay
node-26#9. Adds POST /telemetry/adsb (node-key authed via
require_node_service_or_firebase_token) that upserts one Firestore doc per
icao into a new `aircraft` collection, org_id stamped from the reporting
node the same way upload.py defensively stamps `calls`. firestore.rules
gets a matching docInMyOrg()-gated read rule.

Frontend: useAircraft() mirrors useNodes()'s onSnapshot pattern, filtering
docs older than 2 minutes client-side since nothing prunes a stale aircraft
doc server-side yet. MapView gets an opt-in "Aircraft" overlay (unchecked
by default, like the weather radar layer) rendering a rotated plane glyph
per sighting.

Unverified via typecheck — no Node.js/npm on this authoring box yet (see
CLAUDE.md Testing reality). Server side is pytest-covered (test_telemetry.py).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 16:08:29 -04:00
Logan CusanoandClaude Sonnet 5 d67b2057e6 frontend: safe fixes from the #109 punch-list
- CallSpineEntry.tsx: drop the dead `hasAudio` prop + the early `return null`
  that sat between hooks in InlinePlayer (React #310 risk). Parent already
  gates the mount on audio presence.
- NodeCard.tsx + nodes/page.tsx: pending-node card no longer double-fires.
  NodeCard gains `linkToDetail` (default true); the pending branch passes
  false so the wrapping onClick (open config modal) isn't swallowed by the
  inner <Link> navigation. List view unchanged.
- trips/page.tsx: TripCard badge now buckets on end_date >= today, matching
  the list's own upcoming/past split — an in-progress trip no longer shows a
  "Past" badge under "Upcoming".
- trips/page.tsx, NodeConfigModal.tsx, nodes/[id]/page.tsx: tall modals get
  `p-4` on the overlay + `max-h-[90vh] overflow-y-auto` on the panel so they
  don't clip on short viewports (incidents' CreateModal pattern).
- lib/types.ts: IncidentRecord.units / vehicles are optional now, matching
  Firestore (older docs omit them); incidents/[id] gains a `?? []` guard.

Untypechecked (no node/npm locally). next build in deploy.yml gates it.
Full list of remaining items in server-26 #109.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 00:07:54 -04:00
DRB CEO agentandClaude Opus 5 a1bdccff45 Gate A: take invented prices off every public surface
Build & Deploy / Build & push images (push) Successful in 4m59s
Build & Deploy / Deploy to VM (push) Successful in 1m42s
Build & Deploy / Report a failed deploy (push) Skipped
/pricing and the homepage teaser rendered the $0/$79/Custom catalog from
lib/billing.ts with a below-the-fold disclaimer. Board minutes #42 ratified
Gate A: no price on a public surface until the model is ratified and the
entitlements exist — a false price anchor with a footnote is worse than no
price. The page has been live in breach since ratification (server-26#46).

- /pricing: no numbers, no plan cards, no interval toggle. "Pricing is in
  development", CTA to the existing /waitlist request-access page.
- homepage: pricing teaser replaced with the same message; PLANS import gone.
- homepage CTAs pointed at /login, which has no signup path — a real visitor
  could not create an account. Now /waitlist ("Request access"); the secondary
  CTA is honestly labelled "Sign in".
- lib/billing.ts: plan catalog header now states the prices are invented and
  that retention/SSO/SLA have no backend, so the next person to import PLANS
  is warned at the definition site.

Refs server-26#46, server-26#62. Authenticated /settings/billing is unchanged
and still stubbed — not a public price surface, stays with #46.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 23:34:46 -04:00
Logan CusanoandClaude Opus 5 964343c819 area_context v2 + Maps place verification (server-26#36, #37)
Build & Deploy / Build & push images (push) Successful in 4m9s
Build & Deploy / Deploy to VM (push) Successful in 1m54s
Build & Deploy / Report a failed deploy (push) Skipped
#36 — the correction pass shipped in 58efdbd was right, its reference-data
shape was not. One shape now, at both scopes, every field nullable:

  area_context: { municipality?, county?, state?,
                  center?, radius_km?, resolved_from?, resolved_at?,
                  local_knowledge?: [{term, meaning}] }

`state` closes the ambiguity that made "Ossining" a national guess.
`local_knowledge` replaces roads[]/landmarks[], which could not hold
intersections, schools or nicknames and carried no meanings — `11-X-ray` is
useless alone, `11-X-ray — MTA PD patrol unit` is what a corrector can act on.
Pre-#36 roads[]/landmarks[] are read forward as bare terms so nothing an
operator already entered is lost.

Nullability is the mechanism: which scope gets filled is the operator's
declaration of how homogeneous the system is. One town — fill it once at system
level. Statewide — leave it blank and fill each talkgroup.

The backend owns the derived anchor. PUT /systems/{id} merges config.talkgroups[]
against what is stored instead of writing the client's blob verbatim, which
would have erased the anchor and the pending queue — the same defect as the
ten_codes wipe.

#37 — Maps as a verifier, not as prompt stuffing. The corrector emits its
location nouns; each is geocoded against the talkgroup's anchor, and on a miss
we look for a sound-alike that does resolve there, correct to it, and propose
{term, meaning} to that talkgroup. Cost scales with location nouns, not calls.

No anchor means SKIP. An area too wide to discriminate stores no anchor at all,
because a statewide radius would confirm anything inside it — verification that
passes everything is worse than none, since it reads as a check in the data.

Also re-anchors _geocode_location, which rejected results >40km from the NODE
(server-26#6). An antenna is not a jurisdiction; distance-from-node was always
a stand-in for the anchor and is now only the fallback.

The induction loop proposes at talkgroup level and never promotes. Blast
radius: a wrong term on a channel misleads that channel, the same term
system-wide misleads one 400km away on a statewide system.

38 new tests; 240 pass. Frontend typechecks clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 16:43:59 -04:00
Logan CusanoandClaude Opus 5 58efdbd6eb Correct the transcript before anything reads it
Build & Deploy / Build & push images (push) Successful in 4m0s
Build & Deploy / Deploy to VM (push) Successful in 2m28s
Build & Deploy / Report a failed deploy (push) Skipped
Correction existed, but as a line in intelligence.py's EXTRACTION_PROMPT --
which put it in the wrong place twice over. The same model call that extracted
units, location and severity emitted the correction afterwards, so extraction
reasoned over text already known to be wrong; and it sat behind
correlation_enabled, so during a cost-controlled STT-only window nothing was
ever corrected at all. That is the normal state during development.

internal/transcript_correction.py is now its own pass, between the degenerate
filter and the Firestore write. It receives an already-produced transcript plus
a reference list, so unlike a Whisper prompt it has no series to extend -- the
distinction that keeps vocabulary out of the recogniser's prompt, where an
enumerated ten-code list once made it hallucinate ten-code runs.

Reference data is merged from the talkgroup and the system, TALKGROUP FIRST. A
system spanning several counties can have a talkgroup covering one
municipality, and that municipality's streets must not be buried under a
county-wide list. A single-municipality system is the degenerate case: populate
the system level and every talkgroup inherits it. Area context is now SET --
municipality, county, roads, landmarks, on both scopes -- rather than guessed
from talkgroup names, which is what vocabulary_learner did and which is close
to useless across multiple counties.

Segments are corrected too, not just the joined text. extract_scenes builds its
prompt from numbered segments whenever there is more than one, so a correction
that only fixed the transcript would have been discarded on exactly the
multi-transmission calls carrying the most content. Alignment is enforced: an
array of the wrong length or type is dropped whole, because scenes map back to
transmissions by index and a shifted array would misattribute audio silently.

Whisper is also retried once on degenerate output. Call e49ea32c produced a
56-word ten-code counting run on one attempt and ordinary speech on the next --
same clip, same temperature=0 -- so a hallucination is a coin-flip, and
discarding on the first bad roll threw away a recoverable transcript.

Two things found on the way:

PUT /systems/{id} wiped ten_codes on every save. The systems form sends only
{name, type, config}, and model_dump() wrote every omitted field as its default
over the top. Now exclude_unset. area_context would have been the next victim,
which is why it gets its own route alongside ten-codes rather than a field on
that payload.

Closes server-26#36.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:23:41 -04:00
Logan CusanoandClaude Opus 5 457e6d7e0f Make Archive a real page instead of a redirect
Build & Deploy / Build & push images (push) Successful in 4m7s
Build & Deploy / Deploy to VM (push) Successful in 3m44s
Build & Deploy / Report a failed deploy (push) Skipped
/calls was a ten-line stub that redirected to /incidents, so there was nowhere
in the app to look at a call. The nav's "Archive" link led to the incident
list, and a call that never correlated was invisible entirely -- which is
backwards when correlation quality is the thing under development, because the
orphans are the evidence. Its stated blocker (Gitea #17/#18) closed weeks ago.

The page browses the org's calls newest-first over the new /calls/search route,
filtered by link state (all / orphans / linked), transcript presence, and
system, with a transcript substring search and cursor paging. A row expands to
the full transcript, a playback link minted on demand, and the correlation path
that decided it. The counts line -- how many of the loaded calls are orphaned,
how many have no transcript at all -- is the number worth watching during an
AI window.

Attribution is the point of it: attach an orphan to the incident it belongs to,
or detach one the correlator got wrong. Both go through the routes fixed in the
previous commit, so a manual attachment now actually shows up on the incident.

Admin-only. It exposes every call in the org regardless of node ownership and
carries controls that rewrite incident membership.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 12:36:38 -04:00
Logan Cusano 8fdedee25b Frontend redesign chunk 3: type layer honesty and the duplicate fix
Build & Deploy / Build & push images (push) Successful in 4m18s
Build & Deploy / Deploy to VM (push) Failing after 2m7s
Declare the fields the backend already writes and the UI was discarding:
CallRecord gains location_coords, units, vehicles, cleared_units,
duplicate_of, srcaddr (intelligence.py ~315-327); IncidentRecord gains
units_active, units_cleared, location_mentions, last_thin_at
(incident_correlator.py _attach, ~1270-1300).

Filter duplicate_of client-side in useCalls.ts's three hooks (useCalls,
useCallsByIncident, useActiveCalls) so a call flagged as a second node's
recording of the same transmission no longer renders twice. Client-side
rather than a where() clause to avoid a new composite index.

Removes the two now-resolved DEFERRED.md entries (dedup/useCalls,
lib/types.ts field gaps).

Per UI_REDESIGN.md chunk 3.
2026-08-19 22:57:21 -04:00
Logan Cusano 3a786bc227 Frontend redesign chunk 2: primitives and marks
Build & Deploy / Build & push images (push) Successful in 5m11s
Build & Deploy / Deploy to VM (push) Failing after 3m33s
Rewrite components/ui/* (Button, Card, Badge, PageHeader, EmptyState,
Skeleton) against the chunk-1 tokens instead of hardcoded gray-9xx classes,
and drop the remaining font-mono from label/heading text.

Add the three colour-blindness-validated encoding components from
UI_REDESIGN.md §2.3:
- components/marks/SeverityMark.tsx — glyph (filled triangle / outline
  triangle / outline circle) + optional spine + optional label, from the
  four-level severity ladder. Colour is never the only channel.
- components/marks/TypeGlyph.tsx — five stroked SVG glyphs (fire, police,
  ems, collision, other) in currentColor. Incident type is now shape, not
  hue, since five hues can't clear an all-pairs CVD gate.
- components/marks/NodeMark.tsx — diamond at four weights (filled+ring /
  filled / hollow / hollow-dashed). Green is gone from node state entirely.

lib/severity.tsx now renders through SeverityMark; SEVERITY_COLORS reads
the validated sev-moderate/sev-major tokens with routine/minor neutral.
IncidentBadges.tsx's TypeBadge is reimplemented on TypeGlyph instead of a
coloured pill.

Per UI_REDESIGN.md chunk 2.
2026-08-19 22:56:02 -04:00
Logan Cusano 4dc3f27ac4 Fix the no-org redirect loop and swallowed Google sign-in errors
Redirect chain traced across middleware.ts, ChromeSwitcher.tsx and
AuthProvider.tsx before touching anything, per the ask. Those three were
already correct as of c7f985d/2a1d52b/83416fe (middleware exempts
/onboarding and /signup from the drb_session cookie gate, ChromeSwitcher
sends any signed-in no-org user to /onboarding, AuthProvider only sets the
cookie once an org_id claim exists). The actual loop was one file upstream
of all three: app/login/page.tsx hardcoded `router.push("/dashboard")`
after both the email/password and Google handlers resolved. That push
races AuthProvider's async onAuthStateChanged -> getIdTokenResult ->
cookie decision. For a no-org account the cookie never gets set, so
middleware bounces the very next request back to /login with no
explanation — the ping-pong the coordinator saw live.

Fix: login page no longer navigates from the handlers. It waits on
AuthProvider's own `loading`/`orgId` and redirects once claims are
settled (/dashboard with org_id, /onboarding without). This also fixes a
second case: a user who lands on /login already signed in (e.g. bounced
there by middleware while their Firebase session was still valid) now
gets routed the same way instead of sitting inert on a login form with no
feedback. /onboarding itself (org-name form, single action) was already
adequate as the "explain the state" screen once the loop stopped
recreating it.

Also, live tonight: Google sign-in was failing outright in prod with no
console/network trace. app/login/page.tsx's Google handler did
`catch { setError("Google sign-in failed. Try again.") }` — no binding,
error discarded. Added lib/authErrors.ts: logs the raw error, and maps
Firebase codes to messages that distinguish two categories — the user's
own situation (popup blocked/closed, bad password, network) says "try
again"; deployment misconfiguration (auth/unauthorized-domain,
auth/operation-not-allowed) says so explicitly and does not suggest
retrying, since retrying can't fix a missing authorized-domain entry or a
disabled provider. Applied to both handlers in login/page.tsx and both
in signup/page.tsx (same swallowing pattern, same fix). Per the
coordinator's steer: this is diagnosis only — no popup-to-redirect
fallback, no auth method change. If production is hitting
auth/unauthorized-domain, that's a Firebase Console fix
(drb.cusano.net -> Authorized domains), not a code fix.

Nav.tsx: sign-out was only reachable from /profile. Added a profile
dropdown (desktop) and drawer entries (mobile) with Profile / Refresh
access / Sign out, so sign-out is reachable from anywhere in the app.

"Refresh access" calls AuthProvider.refreshClaims() (already existed,
already used by /onboarding after signup) so a user whose role or org
was just changed server-side can pick it up without a full logout.

Decision on unknown Google accounts (point 4): kept self-serve org
creation via /onboarding rather than a "request access" pending state.
BUSINESS_MODEL.md #2.1 already answers this for the owner: "a limited
free public tier *and* full paid access without contributing... cash is
the primary revenue line from day one." A pending-approval gate would
contradict that — it would make org creation itself the thing being
gated, when the model explicitly does not want contribution (or approval)
to be the only door. Self-serve org provisioning via POST /auth/signup
was already built for this (2a1d52b) and needed no further gating
decision, just for the loop in front of it to stop.

Reversible: no schema change, no new gating, no billing/Stripe touched.
Bench: rsync'd to the WSL-native ~/drb-frontend workspace and ran
`npx tsc --noEmit` there (per CLAUDE.md — the H: drive install path is
not viable) — exit 0, no errors. No Python touched this pass.
2026-08-18 21:57:39 -04:00
Logan CusanoandClaude Opus 5 83416fe169 Split platform-admin from org-owner, hide Trips from non-founding orgs
SAAS_PLAN.md B7. "admin" meant two different things before this: platform
operator (SAAS_PLAN.md's own framing) and, by accident of how
app/settings/layout.tsx was gated, the only role that could ever reach an
org's own billing/members/node-ownership settings. A paying customer who is
their own org's owner couldn't reach their own Settings page - the gate
checked isAdmin, which only platform admins ever have.

settings/layout.tsx now admits org_role === "owner" as well as platform
admins (isAdmin stays valid too, for support access to any org's
settings). Nav.tsx shows the Settings link on the same condition, and moves
Admin (the platform-operator screens: feature flags, users, audit,
correlation debug) out of the customer-facing link group entirely - it was
already gated server-side, this is just the nav no longer implying it's
part of the product.

Trips - an internal utility feature riding along on this stack, not a
tenant-scoped product surface (see [[trips-feature-intentional]]) - drops
out of the customer-facing viewer link group and only shows for the
founding org (new lib/tenancy.ts mirrors app/internal/tenancy.py's
FOUNDING_ORG_ID) or a platform admin, matching the mutation-route gating
routers/trips.py already got in the backend tenancy commit. Reads stay
open to any signed-in user, same as before - trips' own visibility model
(public/private per trip) predates and is unrelated to org tenancy, and
restricting it further wasn't asked for.

Also closes two DEFERRED.md items now that they have somewhere to write to:
app/settings/organization's "Save changes" button now actually calls
c2api.getOrg()/updateOrg() (routers/org.py, shipped in the backend tenancy
commit) instead of being permanently disabled. app/settings/nodes gained an
EnrollmentTokensPanel (mint/list/revoke against the same commit's
/org/enrollment-tokens routes) - without this, B2b's whole point (a
customer enrolls their own node with their own token instead of an
admin-issued key) had no way to actually be used outside a raw API call.

Left alone, and written up as new DEFERRED.md entries instead of guessed
at: node/system *write* routes (approve, create, delete) stay
platform-admin-only rather than being loosened to org owner/operator - a
real gap per SAAS_PLAN.md 2.4, but a separate authorization design that the
plan's 12-item build order doesn't enumerate. And settings/members +
settings/nodes' ownership table both still call GET /admin/users
(platform-admin-only) - a pure org owner who reaches the page via this
commit's gate will get 403s from it. Today's only real user is also a
platform admin, so this is invisible until a second, non-admin org owner
exists.

Typecheck: clean (tsc --noEmit via the WSL-native ~/drb-frontend copy).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:39:16 -04:00
Logan CusanoandClaude Opus 5 2a1d52b7af Add a real signup path instead of the accidental one
SAAS_PLAN.md 2.2: there was no /signup page. The only self-serve path was
Google sign-in on /login, which auto-provisions a Firebase account with no
role or org claim at all - previously that meant "viewer role, full read
access" the moment the AuthProvider cookie logic (previous commit) let it
through. That's closed now regardless; this commit is the other side of it
- giving people an actual way in.

app/signup/page.tsx: email/password (createUserWithEmailAndPassword) or
Google, same visual language as /login. It only creates the Firebase
account - org naming is deliberately not on this page, so every path that
produces an account with no org (this one, and Google-via-/login) converges
on the same next screen.

app/onboarding/page.tsx: that screen. Shown to any signed-in user with no
orgId (ChromeSwitcher's redirect, previous commit), collects an org name,
calls the new c2api.signup() -> POST /auth/signup (routers/links.py,
already shipped), then refreshClaims() to force-refetch the ID token so
orgId picks up immediately and the same redirect effect sends them on to
/dashboard - no manual reload needed.

lib/c2api.ts also gained getOrg/updateOrg and the enrollment-token
mint/list/revoke calls (routers/org.py, already shipped on the backend)
and joinWaitlist (routers/waitlist.py) - none consumed yet, wired in ahead
of the settings/legal commits that use them so this stays one add per
concept rather than scattering client additions across later commits.

/login gained a "Don't have an account? Sign up" link to /signup. This is
signup plumbing, not marketing copy - pricing/plan copy (app/pricing,
lib/billing.ts) is untouched in this pass, that's a separate, still-open
decision (SAAS_PLAN.md section 6).

Typecheck: clean (tsc --noEmit via the WSL-native ~/drb-frontend copy).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:34:04 -04:00
Logan CusanoandClaude Opus 5 c7f985df42 Scope every Firestore hook to org_id and stop granting sessions to nobody's org
Frontend half of SAAS_PLAN.md B2/B3. The backend commits so far (org_id
stamping, Firestore rules) don't protect anything by themselves - every
hook in lib/use*.ts reads Firestore directly from the browser
(onSnapshot(collection(db, ...))), which is why B1's rules commit called
this out as the actual read path in the first place. Until these hooks
filter by org_id, the rules just turn "any signed-in user sees everything"
into "any signed-in user sees nothing" the moment they're deployed, because
nothing supplies the org_id the rules now require.

useCalls (all three exports), useIncidents (useIncidents +
useActiveIncidents), useNodes, useSystems, and useAlerts (both exports) now
pull orgId from AuthProvider and add where("org_id","==",orgId) to their
query. If orgId is falsy - not yet resolved, or the account genuinely has
no org - each hook returns empty rather than falling back to an unfiltered
query, which would silently reopen the exact leak this closes for anyone
whose claim hasn't loaded yet. useIncident/useNodes single-doc-by-id reads
and useTrips are intentionally untouched: single-doc reads are already
covered by the rules directly, and trips has no org_id at all (see the
previous commit's trips.py gating - it's staying founding-org-only via B7,
not becoming tenant-scoped).

AuthProvider grew orgId/orgRole state (read from the org_id/org_role custom
claims POST /auth/signup sets) and a refreshClaims() escape hatch for the
signup flow to force a claims refetch after provisioning. The load-bearing
change is in when it sets the drb_session cookie: only when a claim carries
org_id. A signed-in user with no org - the accidental-signup hole
SAAS_PLAN.md 2.2/2.3 flagged, where Google sign-in on /login auto-creates a
Firebase account with no role or org claim at all - now gets no cookie,
which starts them at "no data, by construction" rather than "viewer role,
full read access" once combined with the rules deployed earlier.

ChromeSwitcher carries the other half of that guard: a signed-in user with
no orgId, anywhere outside the marketing pages, gets redirected to
/onboarding (added to the frontend in the next commit) instead of letting
every page's data hooks just quietly return empty forever. middleware.ts
adds /signup and /onboarding to a new no-cookie-gate list, since
AuthProvider's cookie logic means an unprovisioned user by definition has
no drb_session cookie - gating those two routes on it would bounce exactly
the users who need them back to /login before the client-side redirect
above ever runs. /terms and /privacy (next-next commit) are pre-added to
both PUBLIC_PATHS and ChromeSwitcher's MARKETING_PATHS here so that commit
doesn't need to touch routing files.

Typecheck: clean (tsc --noEmit via the WSL-native ~/drb-frontend copy).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:33:46 -04:00
Logan Cusano 53965e1a19 Rebuild the frontend as a product rather than an internal tool
Build & Deploy / Build & push images (push) Successful in 4m3s
Build & Deploy / Deploy to VM (push) Successful in 2m35s
The UI worked but read as an operator console: no public face, no way to
describe or sell the thing, and no account surface beyond the node list. This
adds the missing halves and reorganises what was already there around the
incident, which is the unit of value the rest of the pipeline is built to
produce.

A shared design system replaces per-page styling: components/ui (Button, Card,
Badge, EmptyState, Skeleton, PageHeader), a type scale and shadow set in the
Tailwind config, and light-mode tokens in globals.css. The existing
html:not(.dark) remap mechanism is extended rather than replaced -- a parallel
theming system would have been two sources of truth for the same colours.

Public marketing pages (/, /features, /pricing, /faq) load without a session.
middleware.ts gained a PUBLIC_PATHS allowlist to permit that; it remains a UX
redirect and is still NOT an authorisation boundary, which the comment there
says explicitly. Real enforcement is unchanged and still lives server-side in
c2-core's auth.py. Chrome switching is done by pathname in ChromeSwitcher
instead of by route group, because a route group would have collided on / and
forced most of app/ to move for no behavioural gain.

Billing and API keys ship as typed stubs, not integrations. lib/billing.ts and
lib/apiKeys.ts define the data model and the screens consume it, but every
mutating call throws with a message naming the backend route that has to exist
first, and the sample data is labelled as sample. Nothing here can charge
anyone or mint a real credential -- picking a payment processor and holding its
keys is a decision for a human, and a half-wired checkout is worse than an
obviously absent one.

The severity work from the c2-core change lands here too. severity is now a
filter and sort dimension on the incident list rather than decoration, since
a busy dispatch channel is only readable if you can collapse it to moderate and
above. routine gets a muted treatment because it is the majority of traffic,
legacy "unknown" still renders nothing, and TypeBadge handles the new "other"
incident type. Severity rendering moved into lib/severity.tsx so the incident
list, incident detail and call rows cannot drift apart.

Deliberately not touched: calls, map, alerts, nodes, systems, tokens, trips and
admin. They already share the palette and stay coherent, and rewriting them
would have buried the parts that actually needed to change. No colour tokens
were renamed, so nothing regressed there.

Verified with tsc --noEmit (npm run typecheck), clean. No runtime verification
was possible and none was done. No new environment variables.
2026-08-16 19:34:47 -04:00
Logan Cusano a2cd2c57ca Serve call audio through c2-core instead of GCS signed URLs
Build & Deploy / Build & push images (push) Successful in 4m0s
Build & Deploy / Deploy to VM (push) Failing after 2m34s
upload_audio() could only sign a URL when GCP_CREDENTIALS_PATH pointed at a
service-account key file. The deployed VM runs on Application Default
Credentials with no key file, so every upload silently took the fallback
branch and returned a bare gs:// URI. That broke two things at once:

  * Browsers cannot fetch a gs:// URI, so no recording was ever playable.
  * _public_url_to_gcs_uri() only matched https://storage.googleapis.com/ and
    returned None for it, so `if gcs_uri:` in the upload path was always false
    and transcription never ran. Nothing was logged, which is why this looked
    like an OpenAI credits problem rather than a storage one.

The fallback also interpolated the client-supplied filename instead of the
call_id-derived safe name, so the URI did not even name the object written.

Calls now store only the canonical gs:// location. A short-lived playback link
is minted per read as an HMAC over (call_id, expiry) keyed by SERVICE_KEY, and
audio is served from the private bucket by the new /media route. An <audio src>
cannot carry an Authorization header, so the link has to be the credential;
that router is therefore public with the check done inline, as enrollment.py
already does. Signing GCS URLs from the VM would have needed a
serviceAccountTokenCreator grant on its own service account — this avoids the
IAM change entirely and keeps the bucket private.

gcs_uri_for_call() reconstructs the object name from call_id, so recordings
made before this fix are reachable again without a data migration.

Frontend rows come straight from Firestore via onSnapshot and never see a
server-minted field, so CallRow fetches the link lazily on expand.

Also removes the last long-lived (1 year) signed URL and the log line that
printed it.
2026-08-16 16:26:41 -04:00
Logan Cusano c42bd1902c feat: Add local system override with 24h timeout support 2026-07-12 23:05:53 -04:00
Logan 1f17b6c0d2 feat: add role-based user management, audit log, and session tracking
Introduces a full user management system with three roles (admin, operator,
viewer), an audit log, and per-session login history.

Backend:
- app/internal/audit.py: write_audit() helper → audit_log Firestore collection
- app/internal/auth.py: get_role() helper; require_admin_token accepts both
  legacy admin:true claim and new role:"admin" claim for backward compat
- app/routers/users.py: CRUD under /admin/users — list, create (returns
  one-time invite link), get (with sessions), patch role/nodes/name,
  disable, enable, delete; operator role requires ≥1 owned node
- app/routers/links.py: POST /auth/session records sign-in events to
  user_sessions Firestore collection
- app/routers/admin.py: GET /admin/audit paginated endpoint
- app/main.py: register users router

Frontend:
- AuthProvider: exposes role, isAdmin, isOperator, ownedNodeIds from claims
- Nav: role-gated links — viewers get dashboard/calls/incidents/map/alerts/
  trips; operators add nodes/systems/tokens; admins add admin
- admin/page.tsx: new Users tab (list table, create modal, inline edit panel
  with role/nodes editor, disable/enable/delete, login history) and Audit
  Log tab (paginated, color-coded actions)
- login/page.tsx: calls recordSession() on email and Google sign-in
- nodes, systems, tokens pages: role guards redirect viewers to dashboard
- profile/page.tsx: shows accurate role badge and label
- lib/types.ts: UserRole, UserRecord, UserSession, AuditEntry types
- lib/c2api.ts: user management methods + recordSession

Firestore collections added: user_profiles, audit_log, user_sessions
Firebase custom claims schema: { role, owned_node_ids, admin (legacy) }
2026-06-22 00:02:09 -04:00
Logan 961cc6f36e add button to clear stale 'active' calls 2026-06-21 23:45:28 -04:00
Logan 6ae4d398f8 add trips permissions 2026-06-21 20:00:48 -04:00
Logan 981f03ac06 allow overlap (note) tags 2026-06-21 15:52:15 -04:00
Logan 4dd3343026 add event editing 2026-06-21 15:35:57 -04:00
Logan 3fb3bca034 add tags
Trip-level tags: admins configure available tags in the trip header (inline add/remove pills). The AI can also create new tags via the add_tag tool.
Event tags: selectable in the Add Event modal, shown as colored pills on event cards in the timeline, and on AI suggestion cards.
AI integration: sees available tags in its system prompt, applies them when proposing events, can create new ones with add_tag.
Discord: tags shown as inline code blocks under each event in /trip view.
Colors: auto-assigned from an 8-color palette by tag index, consistent everywhere.
2026-06-21 15:00:37 -04:00
Logan 7b9aefbcc5 Add UI to trips 2026-06-21 10:12:33 -04:00
Logan 8edb717dd2 Add trips to UI
lib/types.ts — TripRecord and TripEvent types

lib/c2api.ts — getTrips, getTrip, createTrip, deleteTrip, createTripEvent, deleteTripEvent

lib/useTrips.ts — Firestore realtime hook on the trips collection, ordered by start date

app/trips/page.tsx — List page split into Upcoming / Past sections, card click navigates to detail, "+ New Trip" modal for admins with all fields including date range and maps link

app/trips/[id]/page.tsx — Detail page fetched via C2 API (gets trip + events in one call), day-by-day itinerary with time, location, maps link, notes, and Discord attendees. Add Event modal (date constrained to trip range). Admin-only delete trip + remove event.

components/Nav.tsx — Trips link added to the nav
2026-06-20 23:34:45 -04:00
Logan e55412d8c7 UI Updates
app/map/page.tsx

Removed IncidentCard component and the incidents grid below the map — the on-map sidebar inside MapView is the single display
Moved kiosk exit button from top-3 left-3 (overlapping zoom controls) to bottom-[5.5rem] left-3
components/MapView.tsx

Fixed popup "View incident →" link — adds stopPropagation() + window.location.href to prevent Leaflet intercepting the click
Added "View details →" link on each sidebar incident card so you can navigate from the map panel without opening a popup
Added "News Alerts" overlay layer (placeholder, ready for RSS/feed integration)
lib/types.ts

Added preferred_token_id?: string | null to SystemRecord
lib/c2api.ts

Added setPreferredToken(tokenId, systemId) calling PUT /tokens/{tokenId}/prefer/{systemId} (backend already existed)
app/systems/page.tsx

Added PreferredTokenPanel component — loads the token pool lazily on expand, shows radio buttons to set/clear the preferred token, displayed on each system card above the AI flags panel
2026-06-03 01:08:21 -04:00
Logan 913fe0cbee Add source call audio playback to vocabulary suggestions
When the induction loop proposes a new vocabulary term, it now records
which sampled call(s) most likely produced the suggestion. Admins see
a collapsible "▶ source" player under each pending term showing the
audio clip and transcript, so they can hear what was actually said
before approving or dismissing.

- vocabulary_learner: track sampled call docs, attach source_call_ids
  to each pending term via word-overlap search with fallback
- types: VocabularyPendingTerm.source_call_ids?: string[]
- c2api: add getCall(id) using existing GET /calls/{call_id} endpoint
- VocabularyPanel: SourceCallPlayer component — lazy-loads call on
  first expand, shows audio controls + transcript snippet
2026-06-01 01:45:03 -04:00
Logan 5eed4e08ce Implement delete node function 2026-05-25 20:20:50 -04:00
Logan 35ce8e911e audio fixes attempt 2026-05-23 14:59:51 -04:00
Logan 9cf8fd4221 fix date filter 2026-05-23 13:28:23 -04:00
Logan bcc3d3406d add debug in admin 2026-05-17 18:42:42 -04:00
Logan 97f4286810 Add debugging 2026-05-04 01:46:56 -04:00
Logan 640667c9f9 Implement per-system AI flags 2026-04-27 00:50:01 -04:00
Logan c959437059 Implement Admin UI to disable AI components 2026-04-27 00:37:51 -04:00
Logan 64232279ca fix calls 2026-04-26 00:04:32 -04:00
Logan 317f9d2a9d Updates to intel and correlation 2026-04-23 01:26:41 -04:00
Logan 88103c8011 UI Fix 2026-04-21 22:26:33 -04:00
Logan 338b946ba3 Start to learn vocab from talkgroups to improve accuracy of STT 2026-04-21 22:17:30 -04:00
Logan ba43796c51 Updates, big updates
incident_correlator.py — full rewrite: always runs on every call, fetches all active incidents cross-type, fast path collects all talkgroup matches and disambiguates by unit/vehicle overlap → location proximity → embedding, new location proximity path, slow path requires location corroboration, "Auto:" stripped from titles, "auto-generated" tag added, units/vehicles now accumulated on update
intelligence.py — resolved field in GPT schema, returned as 5th value
upload.py — both pipelines unpack 5-tuple, always call correlate, auto-resolve on resolved=True
summarizer.py — stale sweep runs each tick, resolves incidents idle for 90+ minutes
config.py — correlation_window_hours=2, embedding_similarity_threshold=0.93, location_proximity_km=0.5, incident_auto_resolve_minutes=90
2026-04-19 22:53:53 -04:00
Logan 303c5b13cf big ui and intel updates 2026-04-19 16:48:55 -04:00
Logan 0df53df92e UI Updates 2026-04-19 15:22:29 -04:00
Logan 616c06f09c stt updates and intelligence updates 2026-04-13 00:01:19 -04:00
Logan 3b3a136d04 Massive update 2026-04-11 13:44:08 -04:00
Logan 636a847ee1 changes 2026-04-06 00:22:03 -04:00
Logan 2f0597c81b Initial commit — DRB server stack
Includes c2-core (FastAPI/MQTT/Firestore), discord-bot (slash commands),
frontend (Next.js admin UI), and mosquitto config.
2026-04-05 19:01:39 -04:00