Report a sidewalk, tree, road, drain or lighting hazard in under a minute, see exactly why it ranks where it does, and get warned before tonight's rain turns an open report into an incident. A resident app and the API behind it, for New Brunswick, NJ. Congressional App Challenge 2026 · NJ.
One claim. Someone trips on a sidewalk slab lifted by a tree root. That hazard was visible for months, was probably already reported, and was never ranked against anything. The failure is not a shortage of complaints; it is that neighbourhood knowledge never becomes a prioritised, weather-aware, auditable signal. RootCause turns a photo into a work order with a five-term priority score that every resident and every inspector can read, keeps the intake working with no signal, and joins the open backlog to the NWS forecast so the city can act before the storm — with rules it can show a council, not a model it has to trust.
Status (October 2026). The resident app is complete for the v1 demo: offline-first capture, Claude vision proposals, the five-term score, urgency votes, comments, follows, the status lifecycle with resident verification (confirmations; the photo that reopens a report cannot be attached from the app yet), NWS-driven storm alerts with an inbox and a briefing, phone verification for SMS, data export and deletion. The public-works console (spec O1–O12) is specified in docs/plan.md but not built in this phase; staff status changes go through PATCH /api/v1/reports/:id (or scripts/set-status.ts). docs/gap-analysis.md lists every spec row as done, partial, missing, deferred or excluded; docs/QA.md holds the demo script and device procedures; docs/runbook.md the operating notes.
- What it does
- Technical breakdown
- Stack
- Run it
- Verify
- Build, deploy, release
- Demo scenarios
- Data and licences
- Project layout
- Contributing
| Tab / screen | What it shows | Offline? |
|---|---|---|
S-00 Onboarding /onboarding |
Home area, kept on this phone ("Use my location", else the saved one, else the pilot centre), the categories you care about, quiet hours. No sign-in here; notification permission is asked later, on your first follow, vote or report. | Yes — everything stays on the phone |
Home S-01 / |
The predictive-alert card for the newest unread weather alert or the storm demo ("See the spots", "Open alerts"), the open-hazard index with its named drivers, "Map view" and "How is this scored?", nearby reports ranked by urgency / distance / newest with a category filter and an inline vote, and the "your votes shape the repair queue" barometer. | Yes — cached feed ("No signal · showing saved reports"; its age shows online and on Offline data) |
Map S-02 /map |
MapLibre map with pins coloured by severity band and sized by votes, clustered at low zoom; category chips; tap → peek card → detail; the list under the map mirrors every pin, nearest first; "Report what I see here". | Yes — cached pins; an optional offline tile pack from Offline data |
Report S-04 Capture /new |
The Report tab opens capture as a modal: camera or library, guidance on framing; the photo is resized to 1280 px, EXIF dropped and saved as a draft on disk; a photo without a GPS fix (any library photo) asks where it was taken; then sign-in (if needed), upload and analysis. | Yes — the draft waits on disk |
Report tab placeholder /report |
Reached only by a deep link (the tab bar opens /new instead): the three intake steps and a button to /new. |
Yes |
S-05 Analysis /new/analysis |
The uploaded photo; the proposed category and sub-type with a confidence, or an honest "unclear" / offline / timed-out line; "Where this is" (approximate address, pedestrians per day, school / senior / transit flags, ADA); duplicates nearby with "Add to that report". | Partly — skipped offline; you can still file |
S-06 Form /new/form |
Category and sub-type (prefilled from a confident proposal, always correctable), "How dangerous is it right now?", "Has anyone already been hurt here?", location text (the approximate address as a hint), note, identity (Named / Initials only / Anonymous). Emergency shows the 911 line. | Yes — Submit queues the draft |
S-07 Submitted /new/submitted |
The score with its five terms, where it ranks among open orders in its category (computed on the phone), what happens next by SLA band, share with neighbours (at 25 votes a report enters supervisor review), and "Open the report". | Partly — "Saved on this phone · sends when online" |
S-08 Report detail /report/[id] |
A photo count with the note that photos appear after inspector review (no release path exists yet), badges, the vote meter (n of 25), the score card → "Why this score", the severity marked "not confirmed" until an inspector confirms it, "Is it fixed?" on completed reports, the status timeline in resident wording, comments (public record), nearby related orders, follow, flag. | Yes for cached reports — votes, comments and follows queue |
Alerts S-03 /alerts |
The inbox: predictive alerts from the server (plus their push mirrors), status changes mirrored from pushes, and the labelled storm-demo advisory while that demo runs; chips All / Weather / Status; a link to your alert rules. | Yes — inbox stored on the phone |
S-09 Alert briefing /alert/[id] |
Why you got this alert, the forecast numbers, the specific spots, what the city is doing. | Yes — from the inbox cache |
Me S-10 /me |
Reports filed, resolved, urgency votes cast, median time to resolve; my reports, including drafts and failed sends; settings, offline data and privacy links; "Export my data". Signed out: why to sign in. | Yes — stats cached; the local list comes from the phone |
S-11 Settings /settings, /watch-areas |
Watch areas (home / work / route / custom, radius, categories), quiet hours, phone number and SMS opt-in verified by a 6-digit code, sign out, "Delete my data" (de-identifies), staff sign-in. | Mostly — preferences save locally; codes need a signal |
S-12 Offline data /data |
How many reports are saved and when they were last checked; drafts waiting to send; "Check for new reports"; the offline map pack with its size; "Simulate no signal"; the demo scenario picker; the low-storage notice; reset. | Yes |
S-13 Privacy / Terms /privacy, /terms |
Photo handling, what is shared, identity choices, retention and deletion, SMS terms; for contact it points to the app store listing. | Yes — bundled text |
S-14 Sign-in /sign-in, /auth/callback |
Apple (iOS only), Google or a 6-digit e-mail code; says why ("one vote per person; your reports stay yours"); returns to where it was opened. | No — needs a signal; the draft waits |
Why /why, /why/score/[id] |
The open-hazard index definition with its live numbers; the score formula, the weights and this report's five terms. | Yes — constants from the bundle |
Public page /r/[id] |
The public page for one report, meant as the share-link target (the app's Share buttons send text without a link yet): the public projection, read-only, with "Open in RootCause". | No — fetched on each open; also renders in the app |
| Not found | Any unknown URL: says the link points to nothing in RootCause, with "Back to Home". | Yes |
Not in v1 (on purpose), each with its reason (plan §20 unless noted):
- Cityworks / Cartegraph sync (spec §14 Phase 1) — "no vendor sandbox or credentials for the pilot". RootCause owns the work order; the vendor status-mapping table for a later sync lives in
src/domain/status.ts. - Submitting a report without any account (spec §11 Anonymous role) — owner decision D3: accounts are required for every write. The Anonymous identity on a report remains, and the server stores no link between such a report and the account.
- Languages beyond English (spec §12 EN/ES/ZH/VI) — owner decision D6;
src/i18nis the typed catalog a second language would plug into. - Voice alert channel (spec §5) — "SMS covers the degraded path; voice needs an IVR vendor".
- Face / plate blur (spec §13) — "needs an on-device ML module or a vision service"; instead every photo is stored staff-only; releasing one at triage is a console action that is not built yet, so no route or script makes a photo public.
- Address verification for voting (spec §4.3) — "participation vs integrity trade-off left to the city".
- The DPW console and public equity pages (spec O1–O12) — planned for v1 (plan D5, §10) but left out of this phase by the owner's brief; the API checks staff capabilities for status moves, internal notes and after-photos, while merging, moderation, photo release, alert sending, users and settings have no routes yet.
- Spec Phase 2/3 (§14): bundling suggestions, crews & dispatch (O5), urban forestry (O7), allergen / pollen (O8, R10–R12), budget & CIP (O9), the on-device live detector, species ID at quality, millimetre severity, the incident/recurrence model and risk heat layer, trust score, device-fingerprint anomaly detection beyond the simple flags, multi-tenant deployment, SOC 2. The spec's own sequencing: "Ship trip hazards first; that is where the injury, the claim and the budget are."
One TypeScript codebase holds both halves. The phone app is an Expo Router app; the server is the set of Expo Router API routes under src/app/api, which run on the Metro dev server during development and on EAS Hosting (workerd) in production. Apart from the operator scripts in scripts/, the routes are the only code that talks to the database, the model and the messaging providers; the phone talks to Supabase directly only for sign-in.
flowchart LR
subgraph phone["Phone app (Expo Router, React Native)"]
screens["Screens<br/>src/app"] --> services["Services<br/>src/services"]
screens --> store["App store<br/>src/store"]
services --> store
store <--> data["Local storage<br/>src/data"]
screens -.-> domain["Domain rules<br/>src/domain"]
services -.-> domain
end
services -- "HTTPS JSON, bearer JWT" --> routes
services -- "sign-in only" --> sbauth["Supabase Auth"]
subgraph server["API routes (Metro in dev, EAS Hosting in prod)"]
routes["Routes<br/>src/app/api"] --> core["Server modules<br/>src/server"]
core --> repos["Repositories<br/>src/server/repos"]
tick["/api/jobs/tick"] --> jobs["Jobs<br/>src/server/jobs"]
jobs --> repos
core -.-> domain2["src/domain"]
end
repos --> pg["Supabase Postgres + PostGIS<br/>and the private photo bucket"]
core --> claude["Anthropic API<br/>Claude Haiku 4.5"]
core --> push["Expo push service"]
core --> twilio["Twilio Verify and Messaging"]
jobs --> nws["NWS api.weather.gov"]
cron["pg_cron + pg_net, every minute"] --> tick
src/domain is shared by both halves. The server uses it for the stored truth (score, status machine, taxonomy, alert rules); the phone runs the same code for the demo data and the storm-demo briefing, and reads its constants and wording for the "Why this score" screen, the taxonomy and the status labels. The phone never recomputes a live score or runs the status machine: an optimistic vote changes only the vote count until the server answers.
| Layer | Folder | What lives there | May import |
|---|---|---|---|
| Screens | src/app (minus api) |
Expo Router screens and layouts, one file per route | ui, services, store, domain, i18n (the Me tab also writes the export file through src/data/files) |
| UI kit | src/ui |
Theme tokens, primitives, widgets, the map component | domain, i18n, store hooks; MapPackCell also uses the offline-map service |
| Services | src/services |
Everything with a side effect on the phone: API client, auth, photos, sync queue, refresh, notifications, location, offline map | data, store, domain, i18n |
| Store | src/store |
One useSyncExternalStore app store and derived hooks |
data, domain |
| Local data | src/data |
expo-sqlite/kv-store and expo-file-system wrappers (with .web.ts fallbacks) and the repositories over them |
domain |
| Domain | src/domain |
Pure rules and types: taxonomy, score, status machine, roles, votes, SLA, alerts, geo, time, demo data | nothing app-specific; no React Native, no Expo |
| Strings | src/i18n |
The English string catalog (en.ts) and the t(), tn() and tlist() helpers |
nothing app-specific |
| API routes | src/app/api |
+api.ts handlers: parse, authorise, call a server module, answer JSON |
server, domain |
| Server | src/server |
Server-only modules: auth, env, HTTP helpers, rate limiting, lifecycle, notifications, SMS, vision, jobs, repositories | domain |
Rules the code enforces or the tests pin (AGENTS.md):
src/domainhas no React Native or Expo imports;src/serverand the routes have none either (__tests__/boundary.test.ts).- Within
src,src/serveris imported only fromsrc/app/api(ESLintno-restricted-importsineslint.config.js, plus the boundary test); the Node scriptsdemo-storm.ts,set-status.tsandvision-check.tsimport it directly. Secret env names (service key, Anthropic key, Twilio token, job secret) appear only insrc/serverandsrc/app/api; CI also greps the exported web bundle for them. - Routes keep no in-memory state, because workerd isolates are short-lived: rate-limit counters and job cursors live in Postgres. The development-only memory repos are the one exception and are opt-in.
- Every public response goes through
toPublicReport()(src/server/public.ts), an allow-list that drops precise locations of account-linked reports, staff-only photos and internal notes. - Every resident or staff write route requires a signed-in session, and most also check the role's capability in
src/domain/roles.ts, resident actions included (auditors get 403 on civic writes; a staff move needs the target status's capability). The machine endpoints authenticate differently: the jobs tick withJOB_SECRET, the Twilio webhook with Twilio's signature. - Every number on screen can be explained:
/whyand/why/score/[id]print the formula, weights and terms. - Demo data is labelled wherever it appears and never mixes with live reports. There are no spinners, and offline is a state, not an error.
- Map-provider details live only in
src/ui/HazardMap*.tsx,src/services/mapOffline*.tsandsrc/services/mapPackState.ts.
sequenceDiagram
actor R as Resident
participant App as Phone app
participant API as API routes
participant AI as Claude Haiku 4.5
participant DB as Supabase
R->>App: Report tab, take or pick a photo (/new)
App->>App: resize to 1280 px JPEG, drop EXIF, save the draft on disk
App->>App: requireSession() opens sign-in if needed
App->>API: POST /api/v1/photos (multipart)
API->>DB: strip EXIF again, store a pending photo in the private bucket
App->>API: POST /api/v1/vision/analyze
API->>DB: duplicates within the sub-type radius
API->>AI: photo + taxonomy, structured output
API-->>App: proposals or "unclear", duplicates, exposure, ADA, address
R->>App: confirm category, danger, injury, identity (/new/analysis, /new/form)
App->>API: POST /api/v1/reports (clientDraftId, photoIds)
API->>DB: insert report, attach photo, score, event, correction label
API-->>App: report with its score (/new/submitted ranks it on the phone)
- The draft is written to disk before anything touches the network (
src/services/photos.ts,src/data/repos.ts), so a crash, a lost signal or a sign-in detour never loses the photo. - Sign-in is asked at the first server write, the photo upload (plan §23). A signed-out resident can still capture and fill in the form.
- Offline, the analysis step is skipped and the form opens without proposals; Submit queues the draft and
src/services/syncQueue.tssends it on a later refresh, retrying with back-off and parking drafts that need the resident's attention.clientDraftIdmakes a resend idempotent (the server answers the original report with 200). - If the server no longer knows an uploaded photo (for example after a dev-server restart), the queue re-uploads it once and retries.
- The analysis screen offers "Add to that report" for a duplicate: the photo is attached to the existing report and counts as an urgency vote.
src/domain/score.ts computes score = round(100 × Σ(weight × term) × stormMultiplier):
| Term | Weight | What it measures |
|---|---|---|
| Severity | 0.32 | Severity band 1–4: the resident's "How dangerous is it right now?" answer (Emergency scores as 3) until an inspector confirms a band; the AI's proposed band is shown on S-05 but not scored yet |
| Exposure | 0.24 | Pedestrians per day from the bundled OSM extract, min(1, log1p(peds)/log1p(5000)), then a multiplier for school routes, senior facilities and transit stops |
| Community | 0.22 | Weighted urgency votes, log-saturating so an organised block cannot dominate: min(1, log1p(Σ weight)/log1p(0.25 × max(active users, 20))). The server has no active-user counts yet, so the floor of 20 applies and the term is full at a weight sum of 5 |
| Liability / ADA | 0.14 | The largest of: an ADA-relevant sub-type 0.9, an injury 1.0 (near miss 0.5), prior notice over 90 days 0.7; the server does not track prior notice yet |
| Decay | 0.08 | Age since the first report, so nothing rots forever |
The storm multiplier is 1.0 unless an active storm scenario matches the sub-type's sensitivity (rain, wind, freeze), and is clamped to 1.0–1.6. Today only the storm demo applies it: the server stores 1.0 at insert and nothing changes it yet (activeStormMultiplier() in src/server/jobs/scenarioEval.ts has no caller). Scores are computed at insert, recomputed when a vote changes the community term, and refreshed nightly by the recompute job as reports age. The phone shows every term on the report and on /why/score/[id]. Each account gets one vote per report, weighted 1.0, or 0.6 for an account younger than 7 days or without a home watch area (src/domain/votes.ts, applied by the votes route; the reporter's own vote and a vote cast through "Add to that report" are always 1.0 for now); a voter more than 1.5 km from the report is flagged unverified_geo but still counts. 25 votes flag a report for supervisor review and 100 make it a council item.
stateDiagram-v2
[*] --> new
new --> triaged: inspector triages
triaged --> assessed: inspector assesses (may confirm the band)
assessed --> mitigated: made safe
assessed --> scheduled
mitigated --> scheduled
assessed --> completed: after-photo required
mitigated --> completed: after-photo required
scheduled --> completed: after-photo required
completed --> verified: 2 resident confirmations or 14 days of silence
completed --> assessed: a rejection with a new photo reopens, one band higher
verified --> [*]
Staff move a report with PATCH /api/v1/reports/:id; src/domain/status.ts decides whether the move is legal for the actor's role and src/server/lifecycle.ts applies it, writes the timeline event and notifies the reporter (unless anonymous) and followers: a push to every registered device, or a text instead for verified, opted-in residents with no device; quiet hours hold back everything but emergencies. Any open report can be rejected by triage staff with a reason the resident sees. On a completed report residents answer "Is it fixed?" through POST /api/v1/reports/:id/verify: one verdict per account per completion, two confirmations verify it, and a rejection that carries the resident's own new photo reopens it to assessed one severity band higher, with an audit row. The app's "Is it fixed?" card sends no photo yet, so a rejection made in the app is recorded but does not reopen the report. The nightly autoVerify job verifies completed reports after 14 days without a verdict for the current completion.
weatherPoll(every 15 minutes) reads the NWS gridpoint forecast for the pilot centre and stores oneweather_forecastrow: rain per hour, the worst six-hour rain total, chance of rain, gusts and the temperature range.scenarioEval(after each poll) checks every enabled row of thescenariotable, which it seeds from the defaults insrc/domain/alerts.tswhen the table is empty — heavy rain (≥ 35 mm in 6 h at ≥ 60 % confidence), high wind (gusts ≥ 60 km/h), freeze–thaw (−2 °C / +2 °C) — against the open reports whose sub-type is sensitive to that weather. A forecast polled more than 3 hours ago is not evaluated.- When a scenario fires (and has not fired in the last 12 hours), the audience is every account with a watch area within 400 m of one of those hazards (measured from the area's edge) whose category list is empty or includes that hazard's category, minus anyone who already had 2 non-emergency alerts by push or SMS in 7 days and anyone in quiet hours (an emergency alert would bypass both, but the rules only produce advisory or warning alerts, so nothing creates an emergency one yet). It writes one
scenario_run(the work list), onealertwith the briefing copy and forecast snapshot, an inboxalert_deliveryfor every recipient and a push delivery for those with a registered device. With no console yet, a rules-based run is dispatched without a supervisor approval step. alertDispatch(on every one-minute tick; its 30-second interval keeps it due each time) sends up to 20 queued pushes per tick. A tap opens/alert/[id], the briefing: why you got it, the forecast, the specific spots and what the city is doing. The inbox (/alerts) merges server alerts fromGET /api/v1/me/alertswith the push mirror.
The storm demo scenario drives the same screens on the phone without a server; scripts/demo-storm.ts fires a real one against a live project.
- At boot
hydrate()reads everything synchronously fromexpo-sqlite/kv-store, so the first frame already shows saved reports, drafts and settings (src/app/_layout.tsx,src/store/appStore.ts). - Persisted keys (
src/data/repos.ts): schema version, onboarding flag, preferences, settings, drafts, queued mutations, the cached feed, alerts, the links to my reports, votes, follows, the session mirror, cache timestamps and the last low-storage notice. Draft photos live underPaths.document/drafts/<draftId>/. - Votes and follows are optimistic: the store changes at once, then the route is called, or a mutation is queued when offline or after a retryable failure (
src/services/engagement.ts). Comments and verdicts are queued the same way offline; online they wait for the server's answer. A newer vote or follow toggle for the same report replaces the queued one, so two taps leave a single request that changes nothing on the server. src/services/network.tswatches connectivity. Coming back online, or returning to the foreground, callsrefreshIfStale()(src/services/refresh.ts): when the last refresh is more than 5 minutes old it flushes the draft and mutation queues, then refreshes the feed and the alerts. Pull-to-refresh and sign-in run the same refresh at once.- "Simulate no signal" on the Offline data screen makes the app behave as offline without touching the device's radio.
- The phone uses supabase-js for Auth only, with PKCE and a storage adapter over kv-store (
src/services/supabaseClient.ts). Methods: a 6-digit e-mail code (or the e-mail link), Sign in with Apple on iOS and Google on iOS and Android, neither on the web yet (src/services/auth.ts). An e-mail link is honoured only when this phone asked for it and only for the address it asked for. - The access token is attached as a bearer token by
src/services/apiClient.ts(10 s default timeout — 30 s for photo uploads and the data export, 20 s for the analyze call — zod-validated responses, and anx-install-idheader the server stores with votes but does not use for rate limiting yet). - The server verifies the JWT against the project's JWKS (RS256/ES256,
exp,iss,aud; keys cached 10 minutes) and loads the account's role (src/server/auth.ts). Signing out clears the persisted Auth storage even when the network is down. - Development only: in
__DEV__builds the sign-in sheet offers a developer button that mints a throwaway@e2e.rootcause.appaccount throughPOST /api/dev/session; the route answers only on a dev server started withROOTCAUSE_DEV_MEMORY=1andROOTCAUSE_DEV_SESSION=1(404 otherwise).
- Push permission is asked on the first follow, vote or report, not at install; the token is registered with
POST /api/v1/me/devices(src/services/notifications.ts). - The server sends through the Expo push service (
src/server/push.ts). Status changes, verification results and predictive alerts use the same path; quiet hours drop (never delay) everything except emergencies: a status push or text reaches someone inside their quiet hours only for a report filed with the "Emergency" answer, and an account in quiet hours is left out of a predictive alert altogether, inbox included. ThepushReceiptsjob is still a placeholder: tokens Expo reports as unregistered are counted in the logs but not yet removed. - SMS is the fallback channel:
POST /api/v1/me/phoneand/me/phone/checkverify a number with Twilio Verify; status-change texts each write ansms_messageclaim row before the Twilio call (src/server/sms.ts); its unique (alert_id, user_id) index only de-duplicates alert texts, so a repeated status text is prevented by the status machine refusing the same move twice. Texts needSMS_ENABLED=true, the tenant'ssms_enabledswitch, and (while it is set) the number onSMS_ALLOWLIST. Twilio's delivery callbacks arrive at/api/webhooks/twiliowith a signature check.
- One place:
src/server/vision.ts, called byPOST /api/v1/vision/analyze. It sends one resized photo to Claude Haiku 4.5 (claude-haiku-4-5through the official Anthropic TypeScript SDK, structured output) and gets back whether a hazard is visible, a proposed category and sub-type, a severity band 1–3 and a confidence. The category and sub-type are constrained to the taxonomy enums insrc/domain/taxonomy.tsby the output grammar and re-validated with zod; anything else is treated as "unclear". - Flagged and bypassable.
VISION_ENABLEDon the server is the switch (vision is also off whenANTHROPIC_API_KEYis unset orVISION_MODELnames any model other thanclaude-haiku-4-5); with it off the route answers "disabled" and the form opens without proposals. (The phone also stores avisionAssistsetting seeded fromEXPO_PUBLIC_VISION, but no screen reads it yet.) The server gives the model 12 s; the phone waits up to 20 s for the whole analyze call, after which the form opens without proposals. Offline, the step is skipped. The report flow is identical with vision off. - Floors. Below a confidence of 0.70 the app says "Unclear from the photo — pick a category on the next step." The answer has no measurement field and the prompt forbids millimetre figures (spec §8 degrade rule; the free-text notes are not filtered), and a band above 3 is clamped to 3: emergency severity exists only when a human confirms it. A hazardous plant is never auto-confirmed. When a new report is filed with an analysed photo and its sub-type differs from the model's answer (shown or not), a
vision_feedbackrow keeps both. - Budgets. 20 analyses per account per day (a database-backed limiter, 429 with
Retry-After) andVISION_DAILY_MAXper tenant per day (default 500), after which the route answers "disabled" and the app continues. At Haiku 4.5 rates one analysis costs well under a cent. - Nowhere else. The priority score is the five weighted terms above, status changes are a state machine, storm scenarios are threshold rules over the NWS forecast and the open backlog, alert and SMS copy is templated, and addresses are the nearest bundled OSM address point, labelled approximate. Nothing here trains a model.
npm run vision:check -- <photo.jpg>runs the real call from the terminal.
- Expo API routes in front of Supabase.
src/app/api/**/+api.tsare the only API. Behind them Supabase provides Postgres + PostGIS (duplicates, bbox feeds, audiences), Auth (e-mail code, Apple, Google), a private Storage bucket for photos (served through 1-hour signed URLs) and pg_cron → pg_net calling/api/jobs/tickevery minute. Only the routes hold the service key; without it they answer 503 instead of running against nothing. - What the phone sends. The resized, EXIF-stripped photo; the report fields and your two answers; votes (with the phone's current location when it has a fix), comments, follows, verdicts and flags; your quiet hours, and after Sign in with Apple your name and Apple's authorization code; watch areas you choose to save; a push token after your first follow, vote or report; your phone number only if you opt in to SMS; the session token; an install id. It sends your home area only as the centre of the feed's bounding box (
GET /api/v1/reports?bbox=, the home point ± max(4 × radius, 1.5 km)); the server keeps it only when you save it as a watch area. - What the phone holds. Drafts and their photos, the cached feed and alerts with their timestamps, preferences and settings, the session, the local link that lets "My reports" list your anonymous reports, your vote and follow lists, the votes, comments, follows and verdicts waiting to send, the install id, any data export you saved (
rootcause-export-<date>.json, which Reset does not delete), and the optional offline map pack. - Every public response goes through
toPublicReport(): account-linked reports get a location snapped to a 50 m grid and jittered deterministically, and the projection carries no home areas, phone numbers, internal notes, staff-only photos or install ids.
External services: Supabase, Anthropic, NWS, Twilio, Expo push and Apple (Sign in with Apple token exchange and revocation); the phone also loads map tiles from OpenFreeMap and signs in through Apple's and Google's SDKs · Keys on the phone: none (one publishable Supabase key and OAuth client ids).
What each file does, folder by folder. Every named function, component, class and method in these files carries a doc comment with the details; this table is the map.
One file per route (Expo Router). Route files under src/app/api are listed under API routes.
| Route | File | What it does |
|---|---|---|
| — | (tabs)/_layout.tsx |
Defines the five-tab bar (Home, Map, Report, Alerts, Me) with fixed-size labels and every tab mounted up front. Pressing Report pushes the /new capture flow as a full-screen modal instead of opening a tab; Alerts shows an unread dot. |
| — | _layout.tsx |
Root stack: loads the store from disk before the first frame, uses Stack.Protected to show either onboarding or the app screens, keeps public pages reachable, and at boot starts demo data, auth, push handlers and a refresh on reconnect or foreground. |
| — | new/_layout.tsx |
Headerless nested stack for the intake flow (capture, analysis, form, submitted). The root stack presents it full-screen over the tabs. |
| +not-found (any unmatched URL) | +not-found.tsx |
Catch-all screen for unknown routes and bad deep links. It says the link points nowhere and offers a button that replaces the screen with Home. |
| / | (tabs)/index.tsx |
Home feed built from local data: the active predictive-alert hero, the open-hazard index with its drivers, the sortable, filterable report queue with inline urgency votes, and the vote barometer. Pull to refresh; handles the ?demo= deep link. |
| /alert/[id] | alert/[id].tsx |
Alert briefing (S-09) rendered from the local inbox item: why it was sent, forecast facts, the at-risk spots (each opens its report, with its distance when that report is saved on the phone), and the city's action. Marks the alert read; an unknown id shows a not-found callout. |
| /alerts | (tabs)/alerts.tsx |
Alerts inbox: server predictive alerts and mirrored status pushes from the store, plus the labelled storm-demo advisory, filtered by All / Weather / Status chips. Tapping marks an item read and opens its briefing or report. Links to the alert rules in Settings. |
| /auth/callback | auth/callback.tsx |
Deep-link target for the email sign-in link. It reads a PKCE code or token hash from the URL, redeems it through services/auth, refreshes and returns Home, showing progress or the reason it failed. It never blocks. |
| /data | data.tsx |
Offline data (S-12): saved-report count and last check, pending drafts, manual refresh, offline map pack, simulate-offline switch, demo scenario picker, low-storage notice, and a confirmed reset that signs out and wipes local storage. |
| /map | (tabs)/map.tsx |
Map tab: MapLibre pins for the visible reports plus the same reports as a nearest-first list (the screen-reader mirror), one category filter for both, a peek card for the selected pin, and a report-here button. Supports the ?cat= deep link. |
| /me | (tabs)/me.tsx |
Profile and impact: reports filed and resolved, urgency votes (the account's count when online), median days to a fix, drafts and own reports, and links. Export my data saves GET /api/v1/me/export as JSON and opens the share sheet. |
| /new | new/index.tsx |
Intake step 1: a camera or library photo, a draft written to disk first, the image re-encoded on the phone, and the spot picked from known places when no GPS fix is in memory (always for library photos). Then sign-in, upload and analysis lead to step 2; offline or signed out it goes straight to the form. |
| /new/analysis | new/analysis.tsx |
Intake step 2: the uploaded photo, the server's category and severity proposal with confidence (or why there is none), address and exposure, and nearby duplicates. "Add to that report" attaches the photo there and counts as a vote; otherwise it continues to the form. |
| /new/form | new/form.tsx |
Intake step 3: danger level, injuries, category and sub-type (prefilled from a confident proposal), location text, note and identity, each saved to the draft on disk. Submit asks for sign-in, queues offline or sends through syncQueue; Save draft keeps it on the phone. |
| /new/submitted | new/submitted.tsx |
Intake result. A sent report shows its score breakdown, its rank in the category queue (computed on the phone), SLA deadlines and share; an unsent draft shows whether it is queued, waiting for sign-in or failed. No spinner. |
| /onboarding | onboarding.tsx |
First-run setup: home area (from GPS, else the pilot centre), alert categories and quiet hours, saved on the phone. Finishing switches the root guard to the app; no sign-in or network is needed. |
| /privacy | privacy.tsx |
Static privacy notice (draft, pending legal review): what leaves the phone, identity options, how long data is kept and what deleting it does. Public, so it is reachable before onboarding and on the web. |
| /r/[id] | r/[id].tsx |
Public web share page for one report: fetches GET /api/v1/reports/:id and shows severity, status, score breakdown, facts and timeline, with an "Open in RootCause" deep link. Read-only; it also renders natively. |
| /report | (tabs)/report.tsx |
Report tab placeholder, reached only by deep link because the tab bar intercepts the tap. It explains the three intake steps and offers a button to /new. |
| /report/[id] | report/[id].tsx |
Report detail (S-08) from local data: a photo count (the images themselves are not shown yet), vote and threshold, score terms, timeline, comments, nearby reports, share, follow, flag and the "Is it fixed?" check. Online it fetches comments or an uncached report; writes go through services/engagement. |
| /settings | settings.tsx |
Alerts & preferences (S-11): quiet hours (mirrored to the account), the watch-areas link, phone verification through Twilio Verify with SMS opt-in, and the account section (identity, sign out, delete my data, resident and staff sign-in). |
| /sign-in | sign-in.tsx |
Sign-in sheet opened by requireSession() or from Me, Settings and Watch areas: Apple (iOS), Google or a 6-digit email code, plus a dev-only shortcut. Says why sign-in is needed and hands waiting callers the session, or null if dismissed. |
| /terms | terms.tsx |
Static terms of use (draft), including the SMS program wording the Twilio 10DLC campaign requires. Public, so it is reachable before onboarding and on the web. |
| /watch-areas | watch-areas.tsx |
Watch areas (S-11): lists, adds, edits and removes the account's alert areas (kind, label, radius in 100 m steps, centre, categories) through /api/v1/me/watch-areas, copying a home area into local prefs. Shows signed-out and offline states. |
| /why | why/index.tsx |
Explains the home open-hazard index: its value, report count, radius and vantage point, the three driver reports, which statuses count as open, and the published score weights. It uses the same hook as the home card. |
| /why/score/[id] | why/score/[id].tsx |
Explains one report's priority score: its five-term breakdown next to the real constants from domain/score (weights, severity band values, community saturation constant). |
Server-only code. Inside src only the API routes import it; scripts/set-status.ts, scripts/demo-storm.ts and scripts/vision-check.ts also run it from Node.
| File | What it does |
|---|---|
apple.ts |
Sign in with Apple on the server: mints the ES256 client secret per call, swaps the app's authorization code for a refresh token, revokes it when the account is deleted, and seals/opens the stored token with AES-256-GCM. The exchange and revoke calls never throw: they return a result object or a boolean. |
auth.ts |
Verifies Supabase access tokens without a JWT library (cached JWKS, RS256/ES256 via WebCrypto, exp/iss/aud checks). requireUser() returns the signed-in user with the role from app_user, or a 401; requireCapability() returns a 403 or null. |
db.ts |
Creates the Supabase service-role client the routes and repos use, with session handling off. Caches one client per isolate (best effort only) and throws ConfigError, which becomes a 503, when the Supabase env is missing. |
devSession.ts |
Developer sign-in for simulators and rehearsals. On the dev-memory server only, it upserts a throwaway account under e2e.rootcause.app through the Auth admin API with a fresh password and returns real Supabase session tokens. |
env.ts |
Parses the server-only environment variables into a typed ServerEnv with defaults. getServerEnv() throws ConfigError naming any missing Supabase variable, so routes fail closed with 503; isConfigured() lets optional features check first. |
exif.ts |
Pure-JS JPEG walker used on upload: strips APP1–APP15 and COM segments (EXIF, GPS, XMP, ICC) while copying the image data byte for byte, detects JPEGs and EXIF, and reads width/height from the frame header. |
exposure.ts |
Loads and validates the bundled OSM extract once. lookupExposure() gives a pedestrians-per-day proxy and vulnerable-site flags, reverseGeocode() an approximate address, for report intake and photo analysis. Bad data degrades to safe defaults. |
flags.ts |
Shared handler behind POST /api/v1/reports/[id]/flag and POST /api/v1/comments/[id]/flag. Checks session, capability, body and a 10-per-day account limit, confirms the target exists, then writes one content_flag row for moderation. |
http.ts |
Request/response helpers for every API route: JSON and error envelopes, zod body parsing into 400s, request ids, bearer and client-IP extraction, and withTiming(), the wrapper that logs each request and turns thrown errors into 503/500. |
lifecycle.ts |
Every report status change outside the jobs goes through here: changeStatus() for staff (PATCH route, scripts/set-status.ts) and recordVerdict() for residents' verdicts on a fix. Both validate with domain/status and write timeline events and severity audits; changeStatus() always writes the status and notifies, recordVerdict() only when the verdict verifies or reopens the report. |
log.ts |
Structured JSON logger for the server: one line per event, with fields whose names look like secrets or personal data redacted and errors reduced to name and message. Tests capture lines through setLogSink(). |
notify.ts |
Status-change notifications. Writes the push and SMS copy, addresses the reporter (unless anonymous) and followers, skips anyone in quiet hours unless the report is an emergency, pushes to devices, and texts verified, opted-in recipients with no device. |
public.ts |
The public projection. toPublicReport() turns a report row into the allow-listed PublicReport that every non-staff response uses: the public point, the reporter name per identity choice, public photos only, and the timeline without actors. |
push.ts |
Expo push sender over plain fetch (workerd cannot run expo-server-sdk). Validates tokens, sends in chunks of 100, and returns one ticket per message plus the invalid tokens; failures become error tickets instead of throwing. |
ratelimit.ts |
Rate limiting with hashed keys: a Postgres-backed limiter for production (the rate_limit_hit RPC; refuses on error) and an in-memory sliding-window limiter for tests and the dev server. |
sms.ts |
Twilio over REST: phone verification through Twilio Verify, one-segment status texts through the Messaging Service with a claim row written first, GSM-7 copy fitting, and status-callback signature checks. Sends no status text while SMS is disabled (SMS_ENABLED off, the tenant switch off, or a number outside SMS_ALLOWLIST); Verify codes need only the Twilio credentials and the Verify service. |
vision.ts |
Photo analysis with Claude Haiku 4.5 through the Anthropic SDK: one structured-output call per photo, turned into a sub-type and severity proposal only at 0.70 confidence or above. Any failure means no proposal. Also stores residents' corrections as training labels. |
Cadences are in Background jobs.
| File | What it does |
|---|---|
alertDispatch.ts |
Job run on every one-minute tick: sends up to 20 queued push alert deliveries through Expo, one message per device, and marks each alert_delivery row sent with its ticket id, or failed. scripts/demo-storm.ts also calls it. |
autoVerify.ts |
Nightly job: reports completed more than 14 days ago with no resident verdict become verified and get a system timeline event. Chunked with an id cursor. |
coarsenGps.ts |
Nightly job: moves anonymous reports older than 30 days to their 50 m grid node (geom and geom_public), writing only rows that still move. Chunked with a (created_at, id) cursor. |
purgePhotos.ts |
Nightly job: deletes photos that were uploaded but never attached to a report after 24 hours (storage objects, then report_photo rows), and drops rate_limit_counter windows older than 48 hours. |
pushReceipts.ts |
Placeholder job on a 15-minute schedule that does nothing yet. Its header describes the planned Expo receipt check, which will mark push deliveries delivered or failed and drop dead device tokens. |
recompute.ts |
Nightly job: recomputes each open report's score and terms from stored inputs (severity bands, exposure, liability flags, age decay) using the tenant's weights, writing only rows that change. The community term stays as stored. |
scenarioEval.ts |
Job run after each weather poll: checks enabled storm scenarios against the latest forecast and open hazards. On a match it writes the scenario run, the alert briefing, and inbox/push deliveries for the audience left after the fatigue budget and quiet hours. |
tick.ts |
Job dispatcher behind POST /api/jobs/tick. Holds the JOBS schedule, decides from job_run which jobs are due (nightly 02:00, intervals, leases, retry waits), runs one chunk of each within an 18-second deadline, and records cursors and errors. |
types.ts |
The contract shared by the dispatcher and the jobs: JobContext and JobResult, chunk and write-batch sizes, and helpers for the deadline check, ISO timestamps and decoding stored keyset cursors. |
weatherPoll.ts |
Job every 15 minutes: fetches the NWS hourly and gridpoint forecasts for the pilot centre, summarises the next 24 h (peak 6-hour rain, chance of rain, gust, temperatures) and stores one weather_forecast row. An NWS failure is logged and ends the run without a row; a failed insert throws, so the tick records it and retries. |
Every data family has a contract (repos/<family>.ts or types.ts), an in-memory implementation (memory/, used by the tests and the dev-memory server) and a Supabase implementation (supabase/); the jobs store is the exception and keeps both implementations in jobs.ts. Routes get them through getRepos() and the per-family getters, so a test can swap in the memory bundle.
| File | What it does |
|---|---|
types.ts |
Core repository contracts (users, reports, health, jobs) and their server-side row shapes. getRepos() returns the test override, the ROOTCAUSE_DEV_MEMORY bundle kept on globalThis, or a Supabase bundle, and throws ConfigError (route answers 503) without the env. |
derive.ts |
Derives the insert-time columns of a new report for both reports repos: reporter link, public point (snapped and jittered for reporter-linked reports, precise for anonymous ones), address, taxonomy flags and the intake score. rescoreForVotes() recomputes only the community term after the vote weight changes. |
photoKeys.ts |
Builds a photo's storage keys in the private bucket (//full.jpg and thumb.jpg). It has no imports, so the memory and Supabase repos can share it without a require cycle. |
memory/index.ts |
createMemoryRepos() builds the in-memory bundle for route tests and the ROOTCAUSE_DEV_MEMORY dev server. The repos that need users or reports share one MemoryUsersRepo and one MemoryReportsRepo, and the bundle also carries photos, lifecycle, alerts, me, engagement, SMS and the jobs store. |
supabase/index.ts |
createSupabaseRepos() builds the production bundle (users, reports, health, jobs, photos) over one service-role client. One SupabaseUsersRepo is shared for the tenant lookup; the other repos are built by their own getters. |
alerts.ts |
Defines the AlertsRepo contract and row shapes for forecasts, scenarios, scenario runs, alerts and per-channel deliveries used by the weatherPoll, scenarioEval and alertDispatch jobs and the /me/alerts inbox. getAlertsRepo() returns the test override, the dev-memory bundle's repo, or the Supabase one. |
memory/alerts.ts |
In-memory AlertsRepo for tests and the dev server. Forecasts, scenarios, runs, alerts and deliveries live in arrays, hazards come from MemoryReportsRepo rows, and the audience is computed in code over watch areas, devices and quiet hours that tests seed. |
supabase/alerts.ts |
Production AlertsRepo over weather_forecast, scenario, scenario_run, alert, alert_delivery, report (open hazards), device, app_user and tenant, plus the audience_for_hazards RPC. Each call is a plain query with batch follow-up reads, and writes throw on error so the job retries. |
engagement.ts |
Defines the EngagementRepo contract for votes, follows, comments, content flags, report counters and timeline events, plus the account context and push devices a vote or status push needs. getEngagementRepo() resolves the test override, the active bundle's repo, then Supabase. |
memory/engagement.ts |
In-memory EngagementRepo. Vote, follow, comment and flag rows live in arrays; counters, timeline events and comment_count are written straight into MemoryReportsRepo rows. Account context and devices are seeded by tests. |
supabase/engagement.ts |
Production EngagementRepo over report_vote, report_follow, report_comment, content_flag, report_event, the report counters, app_user and the home watch_area (account context), and device. It detects duplicate votes and follows by unique violation, and pointFromDb() decodes PostGIS points (GeoJSON, WKT or EWKB hex). |
memory/health.ts |
In-memory HealthRepo and JobsRepo for the /api/health tests and the dev server: they return whatever reachability, latency, storage usage and per-job last-success times a test sets on their public fields. |
supabase/health.ts |
Supabase HealthRepo and JobsRepo for GET /api/health: a timed head-only query on tenant (doubling as the Free-plan keep-alive), quota usage from the usage_bytes() RPC, and job_run.last_ok_at per job. Never throws; failures come back as ok:false, null or {}. |
jobs.ts |
Defines the JobsStore contract behind POST /api/jobs/tick: job_run bookkeeping plus the chunked, cursor-paged reads and batched writes of recompute, autoVerify, coarsenGps and purgePhotos. Holds both implementations (MemoryJobsStore, SupabaseJobsStore) and getJobsStore()/setJobsStore(). |
lifecycle.ts |
Defines the LifecycleRepo contract for status moves, report_event and severity_audit rows and resident verdicts. Verdict reads take a since bound so only the current completion cycle counts. getLifecycleRepo() resolves the override, then the bundle, then Supabase. |
memory/lifecycle.ts |
In-memory LifecycleRepo. Status, severity and timeline writes go into MemoryReportsRepo rows; verification and severity_audit rows live in arrays, and verdict reads apply the since bound the way the SQL does. |
supabase/lifecycle.ts |
Production LifecycleRepo: status updates that also set the status's SLA timestamp column (first_ack_at through closed_at), report_event and severity_audit inserts, and verification rows read with the since bound. |
me.ts |
Contract and factory for the /api/v1/me routes: MeRepo covers profile, impact stats, watch areas, push devices, own reports, data export, phone verification and DELETE /me de-identification. getMeRepo() returns the test override, the dev-memory repo or the Supabase one. |
memory/me.ts |
In-memory MeRepo for the /me route tests and the dev server, and the reference behaviour for the deidentify_user() SQL. Profile extras, watch areas, devices, votes, follows and comments live in its own arrays beside the bundle's users and reports repos. |
supabase/me.ts |
Supabase MeRepo over app_user, report (impact stats and own-report ids), watch_area, device, the vote/follow/comment tables and the auth admin API. Account deletion runs the deidentify_user() RPC (one transaction), then deletes the Auth user; own reports are read back through the reports repo. |
photos.ts |
Photo storage contract: PhotosRepo (upload, read, attach pending photos, vision analysis, correction labels), the PHOTO_LIMITS upload caps, getPhotosRepo(), and the attachPhoto hook that POST /api/v1/reports/[id]/photos looks up with photoAttacher(). |
memory/photos.ts |
In-memory PhotosRepo: photo bytes in a Map keyed by the bucket's //full|thumb.jpg layout, rows with ph_000001… ids, plus stored vision analyses and vision_feedback rows, so tests can assert exactly what was stored. |
supabase/photos.ts |
Supabase PhotosRepo: uploads full image and thumbnail to the private photos bucket without overwriting, inserts the pending report_photo row and removes uploaded objects if a step fails; attaches pending rows with one conditional UPDATE and stores vision output and feedback. |
memory/reports.ts |
In-memory ReportsRepo and photo attacher (rc_000001… ids) for route tests and the dev server: idempotent create with derived fields, duplicate search by distance, bbox/category/status feed paging, and photo attach that counts as a vote. Exports OPEN_STATUSES. |
supabase/reports.ts |
Supabase ReportsRepo and photo attacher: idempotent report insert with event, vote, follow and photo writes, the find_duplicates and reports_in_bbox RPCs, and full-row reads that add reporter names, events, comment counts and signed URLs for public photos. Exports PHOTO_BUCKET. |
sms.ts |
Defines the SmsRepo contract for sms_message claim rows, the Twilio status-webhook updates and the tenant.sms_enabled switch, plus advances(), the forward-only delivery-status rule. getSmsRepo() resolves the override, the dev-memory bundle, then Supabase. |
memory/smsStatus.ts |
In-memory SmsRepo that tests use as the reference for the Supabase one: claim rows unique per (alert_id, user_id), the claim id rewritten to Twilio's SID, forward-only status updates over test-seeded alert deliveries, and a tenantEnabled switch. |
supabase/smsStatus.ts |
Production SmsRepo over sms_message, alert_delivery and tenant.sms_enabled. Claim inserts detect duplicates by unique violation, confirm() rewrites the claim id to Twilio's SID, and webhook updates apply only forward status moves. |
memory/users.ts |
In-memory UsersRepo keyed by user id, with a fixed tenant and clock. devStaffRole() signs in ROOTCAUSE_DEV_STAFF e-mails as supervisors on the dev-memory server, so a demo can move reports through statuses. |
supabase/users.ts |
Production UsersRepo over app_user. It reads the columns the routes need, creates a missing resident row when the auth trigger has not run yet, and caches the pilot tenant id (TENANT_SLUG) for the reports and photos repos. |
Pure TypeScript shared by the phone and the server: no React Native, no Expo, no I/O.
| File | What it does |
|---|---|
alerts.ts |
Evaluates spec §9 alert trigger expressions against forecast and report facts, defines the default rain, wind and freeze scenarios (scenarioEval seeds them into an empty scenario table), picks advisory or warning, and builds the alert body (place, behaviour change, why, spots, city action) shared by the scenario job and the storm demo. |
demo.ts |
Defines the calm, storm and verify demo scenarios (app-clock offset, storm multiplier, advisory copy) and generates 12 deterministic, labelled demo reports around the pilot centre, scored by score.ts and SLA-checked by sla.ts rather than taken from the prototype's placeholder figures. |
exposure.ts |
Defines the zod schema of the bundled OpenStreetMap extract (src/server/exposure.ts loads the file) and, over that data, estimates pedestrians/day from the nearest road class (placeholder figures labelled 'estimated from map data'), sets school/senior/transit flags within 200 m, and builds an approximate address: nearest address point, ' near ', or coordinates. |
geo.ts |
Geometry helpers shared by the phone, API and scripts: haversine distance, metres per degree, bounding boxes and bbox parsing, the FNV-1a seed hash, 50 m grid snapping, and the deterministically jittered public point for reporter-linked reports. |
ids.ts |
Dependency-free ids: newId() makes time-ordered, URL-safe ids used as draft idempotency keys and for queued mutations, local comments and timeline events; workOrderLabel() formats 'WO-2026-0418' display ids but currently has no callers. |
intake.ts |
Pure helpers for the report flow: the 0.7 vision confidence floor, a report's rank among open reports of its category (computed on the phone for S-07), and the known places a resident picks from when a photo has no GPS. |
moderation.ts |
Comment filter shared by the phone and the comments and verify routes: refuses empty text, more than 1000 characters, any link, or a blocked word (after leetspeak normalisation), each with a resident-facing message the routes return in a 400. |
pilot.ts |
Pilot-area constants for New Brunswick, NJ: the centre, a 4 km radius whose box (PILOT_BBOX) is the default feed area and the offline map pack bounds, and the 400 m default home radius. |
roles.ts |
Capability matrix for the six roles, resident to auditor: can(role, action) is the check routes run after sign-in (403 when false) and status.ts uses for transitions; isStaff() marks inspector, supervisor and director comments as staff. |
score.ts |
The published priority score: five terms (severity, exposure, community, liability, decay) weighted .32/.24/.22/.14/.08, scaled by a storm multiplier clamped to 1.0–1.6 and rounded to whole points, plus the severity-band rules (only a person sets band 4). Server, recompute job and demo data share it. |
sla.ts |
Service-level clocks per severity band (acknowledge, assess, mitigate, permanent fix), computed at read time into per-stage deadlines and states and one on_track / at_risk (last 20 % of an interval) / breached verdict; a later stage satisfies earlier ones and rejected reports owe nothing. |
status.ts |
The eight-status report machine: transition() decides whether a role, or the system, may move a report and what evidence it needs (after-photo, rejection reason, resident confirmations or a rejection with photo); also maps each status to Open311, Cityworks and resident wording. |
taxonomy.ts |
The fixed hazard taxonomy: five categories and 17 sub-types, each with a resident-facing label, storm sensitivity (rain/wind/freeze), ADA relevance and a linear flag, plus duplicate-search radii (25 m, or 60 m for linear features) and short chip labels. |
time.ts |
Time helpers: the app clock (device time plus the demo offset) and its setter, epoch parsing, whole-day differences, 'x min ago' text, America/New_York date and time formatting, and quiet-hours checks that wrap midnight. |
types.ts |
Shared enums, TypeScript types and zod schemas: the public report, most API request and response bodies, alert bodies, phone-verification inputs, and phone-only state (drafts, prefs, settings, cache metadata). Routes parse requests and the phone checks responses against these same schemas; a few small bodies (the feed query, photo attach, Apple link, dev session and some response envelopes) keep a local schema in their route or service. |
votes.ts |
Urgency-vote rules: weight 0.6 for accounts under 7 days old or without a home area (else 1.0), a 1.5 km geo check that flags but keeps distant votes, 25- and 100-vote review thresholds, and anomaly flags no route uses yet. |
Everything on the phone that has a side effect: network, Auth, files, notifications, location.
| File | What it does |
|---|---|
alerts.ts |
Keeps the predictive-alert inbox in sync: refreshAlerts() pulls GET /api/v1/me/alerts and merges it with push mirrors (server copy wins, local read marks kept); markAlertRead() marks read locally, then posts the receipt. Skips offline, in a demo, or signed out. |
apiClient.ts |
The phone's only client for the app's API routes (sign-in calls go through supabase-js): api() wraps fetch with zod validation, a 10 s default timeout, bearer-token and install-id headers, and never throws (returns ApiResult). Also holds typed route helpers: reportsApi, lifecycleApi (verify), meApi (export, phone, profile), alertsApi. |
auth.ts |
Sign-in over supabase-js: Apple, Google, 6-digit email code, email links and a dev-only sign-in. requireSession() opens the /sign-in sheet and waits for it; initAuth() mirrors the Supabase session into the store and learns the role from GET /api/v1/me. |
camera.ts |
Never-throwing wrappers for S-04 capture: camera permission check and request, returned as a PermissionState the screen renders, and a single-image library picker that never requests EXIF. |
demo.ts |
Switches the labelled demo scenarios (calm, storm, verify): writes the setting and clock offset, regenerates the in-memory demo reports, restores them at boot, and supplies the demo label shown under titles. |
engagement.ts |
Votes, follows, comments, fix verdicts and flags, each after a session check. All but flags update the store optimistically and, offline or on a transient failure, queue a mutation (mutations:v1) that flushMutations() replays in order; refusals roll back. Flags are online-only and never queued. Also fetches comments and single reports. |
location.ts |
acquireLocation() asks for foreground location permission, publishes the last known fix at once and then a high-accuracy fix to the store (memory only), and returns the fix or null. Used by onboarding and Watch areas. |
mapOffline.ts |
Native offline map pack: downloads one MapLibre region (pilot bbox, zoom 11-14, about 105 MB, Wi-Fi only by default), removes it, and reconciles the persisted state with MapLibre. Loads MapLibre guardedly so Expo Go without the module does not crash. |
mapOffline.web.ts |
Web stand-in for mapOffline.ts with the same API minus NO_MAP_MODULE_DETAIL: downloading records 'unsupported', removing resets the state, and the status check returns the persisted state. Never imports the native module. |
mapPackState.ts |
The offline map pack's definition (bounds, zooms, style URL, size estimate) and its state persisted in kv 'mapPack:v1', with a small subscription and the useMapPack() hook that S-12's MapPackCell renders from. No MapLibre or network calls. |
network.ts |
Watches connectivity with expo-network and writes NetworkInfo (online true/false/null plus connection type) into the store. The root layout starts startNetworkWatch() and calls refreshIfStale() whenever the watch reports the phone online, including the first check at launch. |
notifications.ts |
Push notifications: asks permission at the first vote, follow or report, creates the Android channels and registers the Expo token via POST /api/v1/me/devices. Mirrors every push into the alerts inbox and opens /report/[id] or /alert/[id] on tap. |
photos.ts |
Re-encodes a captured picture to a 1280 px JPEG plus a 320 px thumbnail in the draft's document folder, and calls the photo routes: hand-built multipart upload (POST /api/v1/photos), vision analysis, and attaching a photo to an existing report. |
refresh.ts |
Cache-first refresh: refreshAll() flushes queued drafts and mutations, then downloads the home-area feed and the alert inbox, reporting progress to the store; refreshIfStale() runs it at an online launch, on reconnect and on return to the foreground, unless the last run finished less than 5 minutes ago; pull-to-refresh, the Offline data button and sign-in call refreshAll() directly. |
supabaseClient.ts |
Creates the auth-only supabase-js client from public env config (null when unconfigured, so the app runs signed out), storing its session in kv under 'auth:' with PKCE email links. Also clears persisted auth data and builds the email-link callback URL. |
syncQueue.ts |
Offline-first draft sync: submitDraft() uploads the photo, then creates the report with the draft id as idempotency key; flush() sends due drafts oldest first. Failures become queued (retry), needs_sign_in (401) or failed (other 4xx), never dropped silently. |
token.ts |
Leaf module holding the API's bearer-token provider: initAuth() installs the Supabase session getter, and apiClient reads it through getAccessToken(), which never throws and answers null when signed out. Exists to break the auth/apiClient import cycle. |
| File | What it does |
|---|---|
appStore.ts |
The app's zero-dependency store on useSyncExternalStore: state hydrated synchronously from kv at boot, the useAppState() selector hook, and actions that write to the repos first, then update state. Also isOfflineNow(), which honours the simulate-offline switch. |
derived.ts |
Derived-state hooks over the store: the visible reports (demo set or cached feed), sorted and filtered feeds, the vantage point, the hazard index with its top drivers, own reports, pending drafts, and clocks that tick for time-based labels. |
Synchronous key-value storage and files, with web fallbacks; the repositories own the app-data keys (KEYS in repos.ts); a few services keep their own: the Auth tokens under auth:, auth:pending:v1, mapPack:v1, install:v1 and push:v1.
| File | What it does |
|---|---|
files.ts |
Native file store for JSON under Paths.document/offline (never the purgeable cache): sync read, write (returns bytes, 0 on failure), size, file:// URI for the share sheet, delete, and free disk space for the storage guard. Used by the Me tab's data export. |
files.web.ts |
Web fallback for files.ts. Keeps each 'file' as a JSON blob in kv under file:. Sizes are JSON character counts, writeJson never returns 0, uriOf is always null, and free space is reported as Infinity. |
kv.ts |
Synchronous JSON key-value store on SQLite (expo-sqlite/kv-store, database 'rootcause'), so the first frame renders from disk. get/set/remove/keys/clear never throw. update() is a read-modify-write in one transaction. lastWriteOk tells the storage guard when a write failed. |
kv.web.ts |
Web fallback for kv.ts: localStorage under a 'rootcause:' prefix plus an in-memory copy, so a page without localStorage still works for the session; when localStorage refuses a write (quota, private mode) the value is kept in memory, but get() still returns an older localStorage entry for that key if there is one. Same API; update() is not transactional. |
repos.ts |
Local-first repositories over kv with versioned keys and sanitised reads: prefs, settings, drafts, the offline mutation queue, cached feed and alerts, own-report links, votes/follows, session profile and cache metadata. Includes the storage guard, which drops re-downloadable data on write failure and reports a StorageNotice, plus resetAll. |
Theme tokens and the components every screen is built from.
| File | What it does |
|---|---|
HazardMap.shared.tsx |
Map pieces both renderers share: HazardPin/HazardMapProps, toPins and pinsToGeoJSON, source and layer ids, clustering settings, paint/layout expressions (colour by severity band, radius by votes, selected-pin ring), and MapChrome, which draws the attribution and offline line over the map. |
HazardMap.tsx |
Native report map on @maplibre/maplibre-react-native v11 with the bundled liberty style. Pins are clustered; a pin press selects it, a cluster press zooms in, a background press clears. The module loads guardedly, so Expo Go shows a 'needs a development build' stand-in instead of crashing. |
HazardMap.web.tsx |
Web report map on maplibre-gl 4.7.1: same bundled style, clustered source, layers and colours as native. The library loads by dynamic import inside an effect, so expo export never touches window. Later pin, selection and centre changes are applied to the live map. |
MapPackCell.tsx |
Offline map section of the Offline data screen: pack status, size (estimate before, real bytes after), download date and area, a progress bar while downloading, error callouts, and Wi-Fi-only download / download-again / remove buttons wired to services/mapOffline. |
MapPeek.tsx |
Card shown under the map for the selected pin: category glyph, title with demo label, distance and address, severity badge, score chip, votes, days open and resident status wording, plus Open report and close. It gives in words what the pin colour only hints at. |
Screen.tsx |
Page scaffold every route uses: safe-area padding, the OFFLINE banner (shown whenever the phone is offline, real or simulated), a back nav bar (goBackOr with a fallback route), a large title with subtitle, note and status line ('Reports checked 5 min ago'), then a scrolling body with optional pull-to-refresh. |
icons.tsx |
Icon component: SF Symbols on iOS (expo-symbols), Ionicons elsewhere and as the iOS fallback. Decorative and hidden from screen readers by default. Also maps each hazard category and report status to its glyph. |
intake-widgets.tsx |
Shared pieces of the /new intake flow: useDraftParam (the draft named by ?draft=), exitFlow (replace back to Home), PhotoPreview (the exact image to be uploaded, or a placeholder) and FlowStatus (a one-line progress/outcome message instead of a spinner). |
primitives.tsx |
iOS grouped-list building blocks: text styles, section header/footer/title, Group, Card, Cell, Callout, progress ring and bar, Button, Checkbox, Stepper, Toggle, Segmented, Field and KeyValue. The controls (Button, Checkbox, Stepper, Toggle, Segmented, pressable Cell, Field) and the progress ring and bar set accessibility roles, labels, states or values; Cell and Field rows are at least 44 pt tall. Many exports have no callers: the back-compat aliases (H1–H3, Small, Xs, SectionLabel, Notice), Card, LinkRow, SectionTitle, Divider, Row, pressStyle and every text component except Body. |
report-widgets.tsx |
Report list widgets: ReportRow (one tappable report with severity badge, days open, status wording and an inline vote), VoteControl (the urgency vote button with haptic and screen-reader label) and StatusTimeline (the status ladder with reached steps filled; rejected shown separately). |
score-widgets.tsx |
Priority score UI: ScoreBreakdown shows the score with its five weighted terms (term × weight with bars, plus any storm multiplier) on the report, submitted, /why and /r/[id] screens; ScoreChip is the tone-tinted bare number on the map peek card. |
severity-widgets.tsx |
Severity never by colour alone: SeverityBars draws n of 4 rising bars plus the band word (read as 'High, 3 of 4'); SeverityBadge is the compact uppercase pill for rows, read with 'Not confirmed' when unconfirmed; severityLabel gives the band word. |
theme.ts |
Design tokens for the light-only iOS look: raw palette with contrast notes, semantic colour aliases, tone and severity-to-tone maps, scoreTone thresholds, fonts, tabular numerals, corner radii, layout constants (16 pt gutter, 44 pt MIN_TAP) and the iOS text styles. |
| File | What it does |
|---|---|
en.ts |
The English string catalog for most user-facing copy (some components and services still carry inline English, e.g. MapPackCell, MapPeek, the terms page and the sign-in and engagement messages), keyed by surface, with {name} placeholders and .one/.other plural pairs. Its Key type turns an unknown key into a compile error. |
index.ts |
Small translation helpers over the English catalog: t() fills placeholders, tn() picks one/other plurals and tlist() splits comma lists. Locale stubs are kept for parity with the sibling apps. |
Run with npx tsx scripts/<name>.ts (or the npm script named in the row). fetch-map-style.ts is the exception: run it with npm run map:style (plain node), because tsx compiles it as CommonJS and rejects its top-level await.
| File | What it does |
|---|---|
build-osm.ts |
Queries the public Overpass API twice and writes the pilot area's bundled OpenStreetMap extract (named roads, schools, senior facilities, transit stops, address points) to assets/data/osm/.json for exposure scoring. Run npx tsx scripts/build-osm.ts [--out file]; needs no env vars. |
db-push.ts |
Applies supabase/migrations and supabase/seed.sql to the live Supabase project through the Supabase CLI via npx, using SUPABASE_DB_URL from .env without printing it. Run npm run db:push (add -- --dry for a dry run). |
demo-storm.ts |
Demo helper: inserts a labelled synthetic 52 mm storm forecast, then runs the scenario evaluation and one push dispatch exactly as the job tick would, printing the alert and audience. Needs SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY (EXPO_ACCESS_TOKEN optional); npx tsx scripts/demo-storm.ts. |
e2e-check.ts |
End-to-end smoke test against a running dev server: creates two Supabase test accounts, then walks upload, vision, report, feed, vote/follow/comment, profile, staff status moves, verification and alerts, printing PASS/FAIL/SKIP per step. npx tsx scripts/e2e-check.ts --photo <jpeg>. |
fetch-map-style.ts |
Snapshots the remote MapLibre style (EXPO_PUBLIC_MAP_STYLE_URL from the shell, default OpenFreeMap Liberty) into assets/map/style.json with absolute URLs; when offline it keeps the old snapshot or writes a minimal fallback. Run npm run map:style. |
grant-role.ts |
Grants a staff role to an existing account by e-mail: sets app_user.role and appends an audit_log row. Needs SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in the shell; npx tsx scripts/grant-role.ts <email> <role>. |
set-status.ts |
DPW console stand-in: moves one report through the status machine via lifecycle.changeStatus() as a supervisor, with optional note, after-photo and confirmed severity, notifying the reporter and followers. npx tsx scripts/set-status.ts <reportId> <status> [--note …] [--after-photo id] [--severity n]. |
vision-check.ts |
Live check of the Claude vision call: sends local JPEGs through analyzePhoto() and prints proposal, reason, latency and token usage per photo. Reads ANTHROPIC_API_KEY, VISION_* and the Supabase keys from .env; costs money. npm run vision:check -- photo.jpg [--hint subtype]. |
Jest picks these up automatically for the native modules jest cannot load.
| File | What it does |
|---|---|
@maplibre/maplibre-react-native.tsx |
Jest manual mock for MapLibre v11. Map, Camera, GeoJSONSource and Layer render plain Views with stable testIDs (one pressable mlrn-feature-<id> per feature, press events included); OfflineManager is an in-memory pack store that tests drive with __emitProgress, __emitError and __reset. |
expo-sqlite/kv-store.ts |
Jest manual mock for expo-sqlite/kv-store: an in-memory SQLiteStorage with the sync, async and AsyncStorage-style methods. Instances opened with the same database name share data, so tests exercise real hydrate → write → hydrate persistence without native SQLite. |
npm test runs everything here except int/, with no network and no Docker.
| Folder or file | What it covers |
|---|---|
domain/ |
The pure rules: score oracles derived from the spec formula, the full status × role matrix, vote weights and thresholds, the SLA table, 50 m snap and jitter, quiet hours and the demo clock, the exposure table and address rule, the comment filter, and the demo scenarios |
routes/ |
Every API route except the Twilio webhook (covered in server/) through new Request() against the memory repos: auth and capability checks, validation, rate limits, intake with photos, vision analysis, lifecycle and verification, votes, comments, follows and flags, /me and its sub-routes, phone verification, alerts, health, the jobs tick, the dev session, JWT verification, Sign in with Apple revocation, and the toPublicReport() allow-list |
jobs/ |
The predictive-alert loop: NWS fixture parsing, the rain window, scenario evaluation, audience selection with the fatigue budget and quiet hours, dispatch |
server/ |
EXIF stripping, the SMS leg and the Twilio webhook, the vision interpretation rules (no network) |
services/ |
Phone services without a device: the sign-in flow and link gate, the engagement queue, the sync-queue retry matrix, push routing, the offline map pack |
ui/ |
HazardMap through the MapLibre mock |
screens*.test.tsx |
Every screen except +not-found rendered with expo-router's testing library from local state with zero network, signed out and offline, across the calm, storm and verify scenarios; deeper suites for intake, report detail, map, Me and Settings, alerts and sign-in |
boundary, bundled-data, bundled-osm, migration-enums, i18n, storage-guard |
The layer boundaries, the taxonomy and OSM data that ship in the bundle, SQL enums in step with TypeScript, the string catalog, and the low-storage fallback |
int/ |
Integration placeholder (npm run test:int): skipped unless SUPABASE_DB_URL is set, and then it only checks that the value is a postgres:// URL (it does not connect yet) |
All routes answer JSON, except the empty 204s of DELETE /api/v1/me, DELETE /api/v1/me/watch-areas/:id and the Twilio webhook. Errors share one shape ({ error: { code, message } }) from src/server/http.ts (GET /api/health answers its own body with ok: false instead): 400 invalid input, 401 no session, 403 missing capability, 404, 409 conflict (for example a status move that is not allowed from the current status), 422 missing evidence (completing without an after-photo, rejecting without a reason) or a before-photo on a closed report, 429 rate limit with Retry-After, 503 misconfigured, 500 anything unexpected. The write routes most open to abuse (reports, photos, vision, votes, comments, flags, phone codes, export) are rate-limited per account in Postgres.
| Method | Path | Auth | What it does |
|---|---|---|---|
| POST | /api/dev/session |
dev-only flag (ROOTCAUSE_DEV_MEMORY=1 + ROOTCAUSE_DEV_SESSION=1 + the Supabase env, not production; 404 otherwise) | Mint access/refresh tokens for a throwaway @e2e.rootcause.app developer account |
| GET | /api/health |
none | Version, DB ping (keep-alive), last successful job runs, Free-plan quota usage; 503 when misconfigured or DB down |
| POST | /api/jobs/tick |
job secret (x-job-secret header, constant-time compare) | Run the due background jobs once (pg_cron every minute) |
| POST | /api/v1/comments/:id/flag |
signed-in session + flag capability | Flag a comment for moderation (10/day per account, shared with report flags) |
| GET | /api/v1/me |
signed-in session | Read the account profile (role, phone last4, quiet hours, stats, watch areas) |
| PATCH | /api/v1/me |
signed-in session | Update display name, quiet hours or SMS opt-in (opt-in needs a verified phone) |
| DELETE | /api/v1/me |
signed-in session | Delete the account: revoke Apple token, de-identify data, delete the Auth user |
| GET | /api/v1/me/alerts |
signed-in session + follow capability | Alerts inbox (latest 50); marks inbox deliveries delivered |
| POST | /api/v1/me/alerts/:id/read |
signed-in session + follow capability | Idempotent read receipt for one alert |
| POST | /api/v1/me/apple-link |
signed-in session | Exchange the Apple authorization code and store the sealed refresh token (5/hour) |
| POST | /api/v1/me/devices |
signed-in session + follow capability | Register or move the phone's Expo push token |
| GET | /api/v1/me/export |
signed-in session | Download the account's data as JSON (2/day) |
| POST | /api/v1/me/phone |
signed-in session + follow capability | Start Twilio Verify for a phone number; store it as pending (5/hour) |
| POST | /api/v1/me/phone/check |
signed-in session + follow capability | Check the 6-digit code and mark the phone verified |
| GET | /api/v1/me/reports |
signed-in session | List the account's own Named/Initials reports (public projection) |
| GET | /api/v1/me/watch-areas |
signed-in session | List the account's watch areas |
| POST | /api/v1/me/watch-areas |
signed-in session + follow capability | Create a watch area (max 10 per account) |
| PATCH | /api/v1/me/watch-areas/:id |
signed-in session + follow capability | Edit one of the account's watch areas |
| DELETE | /api/v1/me/watch-areas/:id |
signed-in session + follow capability | Delete one of the account's watch areas |
| POST | /api/v1/photos |
signed-in session + report capability | Upload a JPEG (+ thumbnail) as a pending, EXIF-stripped photo (20/hour) |
| GET | /api/v1/reports |
none | Public report feed / map source with bbox, category, status, sort and cursor |
| POST | /api/v1/reports |
signed-in session + report capability | File a report (idempotent by clientDraftId, 10/hour) |
| GET | /api/v1/reports/:id |
none | One report's public projection |
| PATCH | /api/v1/reports/:id |
staff role (session + STATUS_ACTION capability: inspector and up, scheduled supervisor and up) |
Staff status move with evidence checks and notifications |
| GET | /api/v1/reports/:id/comments |
none | Visible, non-internal comments, oldest first |
| POST | /api/v1/reports/:id/comments |
signed-in session + comment capability (isInternal needs triage: inspector and up) | Post a moderated comment or staff internal note (20/hour) |
| POST | /api/v1/reports/:id/flag |
signed-in session + flag capability | Flag a report for moderation (10/day per account, shared with comment flags) |
| POST | /api/v1/reports/:id/follow |
signed-in session + follow capability | Follow a report's status updates (idempotent) |
| DELETE | /api/v1/reports/:id/follow |
signed-in session + follow capability | Unfollow a report (idempotent) |
| POST | /api/v1/reports/:id/photos |
signed-in session + vote (phase before) or complete (phase after, inspector and up) capability | Attach a pending upload to an existing report; a before photo counts as the account's vote (20/hour) |
| POST | /api/v1/reports/:id/verify |
signed-in session + verify capability | Resident confirm/reject verdict on a completed fix; may verify or reopen the report |
| POST | /api/v1/reports/:id/votes |
signed-in session + vote capability | Cast a weighted urgency vote, rescore, set threshold flags, auto-follow (60/hour shared with DELETE) |
| DELETE | /api/v1/reports/:id/votes |
signed-in session + vote capability | Withdraw the vote and rescore (idempotent) |
| POST | /api/v1/vision/analyze |
signed-in session + report capability (own photo only) | Claude vision proposals, duplicate candidates, exposure, address and ADA flag for the report form (20/day) |
| POST | /api/webhooks/twilio |
Twilio signature (X-Twilio-Signature over TWILIO_WEBHOOK_URL with TWILIO_AUTH_TOKEN) | SMS delivery status callback; mirrors STOP (error 21610) to sms_opt_in = false |
/api/jobs/tick is called every minute by pg_cron through pg_net once app_settings.job_url and job_secret are set (the seed leaves both unset), and checks the x-job-secret header, which carries app_settings.job_secret, against JOB_SECRET. src/server/jobs/tick.ts runs whichever jobs are due within a 20-second budget, frequent jobs first; nightly jobs become due at 02:00 America/New_York. Each job keeps a cursor in job_run, so a long job resumes on the next tick, and a failed job waits before retrying. Overlapping callers are kept apart by a short lease.
| Job | Cadence | What it does |
|---|---|---|
pushReceipts |
every 15 min (PUSH_RECEIPTS_INTERVAL_MS); first in JOBS order | Placeholder that does nothing: reads and writes nothing and reports done. It will later fetch Expo receipts for alert_delivery.provider_id and delete dead device rows. |
alertDispatch |
every 30 s interval (ALERT_DISPATCH_INTERVAL_MS), so due on each one-minute pg_cron tick | Reads up to 20 queued push alert_delivery rows (oldest first) and the recipients' device rows, sends one Expo push per device, and writes each delivery's status: sent with provider_id and sent_at, or failed with an error ('no device' or the ticket error). |
weatherPoll |
every 15 min (WEATHER_POLL_INTERVAL_MS) | Calls the NWS /points, hourly forecast and gridpoint endpoints for the pilot centre. Writes one weather_forecast row covering the next 24 h: peak 6-hour rain, max chance of rain, max gust, min/max temperature, plus a trimmed payload with hourly rain. Logs and stores nothing when /points or both products fail; when only one product fails, it still writes the row with that product's columns null. |
scenarioEval |
every 15 min (same interval as weatherPoll), run right after weatherPoll in JOBS order | Reads the latest weather_forecast (skipped if polled more than 3 h ago), the scenario table (seeding defaults when empty), recent scenario_run rows (12 h cooldown), open reports sensitive to the scenario's kind, the audience RPC, fatigue counts from alert_delivery, and quiet hours and devices. On a match it writes scenario_run, alert and alert_delivery rows (inbox, plus push for accounts with a device). |
recompute |
nightly, once the 02:00 America/New_York boundary has passed since the last success; chunked, resumes on later ticks | Reads tenant.score_weights and the score inputs of open reports (500 per call, (created_at, id) keyset). Writes report.score and score_terms for rows whose recomputed score, or severity, exposure, liability or decay term, differs from the stored one, so a changed tenant weight reaches every open report; the community term is kept as stored. On a run's first call it logs that the M2 block-group load is still needed. |
autoVerify |
nightly after 02:00 America/New_York; chunked by an id keyset | Reads reports completed more than 14 days ago and whether a verdict exists for the current completion. For each one without a verdict it writes status verified (and verified_at) plus a system report_event noting 'auto-verified after 14 days'. |
coarsenGps |
nightly after 02:00 America/New_York; chunked by a (created_at, id) keyset | Reads anonymous reports (reporter_id null) created more than 30 days ago and writes geom and geom_public snapped to the 50 m grid, only for rows not already on a grid node. |
purgePhotos |
nightly after 02:00 America/New_York; repeats calls until a chunk comes back short | Reads report_photo rows with no report that were uploaded more than 24 h ago, deletes their storage objects and then the rows, and at the end of the run's first call deletes rate_limit_counter windows older than 48 h. |
supabase/migrations/0001_init.sql and 0002_me.sql are the only schema source (supabase/seed.sql adds the pilot tenant, the SLA matrix, the remediation ladder, operator settings and the pg_cron tick). Row-level security is on for every table; the routes use the service key, and the RPCs are not executable by anon or authenticated.
| Area | Tables |
|---|---|
| Tenant and settings | tenant, app_settings, sla_config, remediation_option |
| People | app_user (role, phone, SMS opt-in), device (push tokens), watch_area, on_call |
| Reports | report (status, category, sub-type, severity, score and its terms, precise and public geometry), report_photo (before / after, staff-only / public), report_vote, report_comment, report_event (the timeline), report_follow, verification, content_flag, severity_audit, vision_feedback |
| Weather and alerts | weather_forecast, scenario, scenario_run, alert, alert_delivery, sms_message |
| Census | block_group, block_group_stats |
| Operations | rate_limit_counter, job_run, audit_log |
Database functions: rate_limit_hit (atomic counter for the limiter), find_duplicates (PostGIS radius search), reports_in_bbox (the public feed), audience_for_hazards (watch areas near hazards), usage_bytes (quota numbers for /api/health), deidentify_user (account deletion keeps the reports and drops the identity), handle_new_auth_user (creates the app_user row on sign-up), touch_updated_at, and raise_append_only, which keeps report_event, severity_audit and audit_log append-only.
Copy .env.example to .env. Names only below; never commit values.
| Variable | Side | Purpose |
|---|---|---|
SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY |
server | Required by every route; without them routes answer 503 |
ANTHROPIC_API_KEY, VISION_MODEL, VISION_ENABLED, VISION_DAILY_MAX |
server | Vision assist: key, model check (must be claude-haiku-4-5, the default; any other value turns vision off), kill switch, tenant budget per UTC day (default 500) |
TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_MESSAGING_SERVICE_SID, TWILIO_VERIFY_SERVICE_SID, TWILIO_WEBHOOK_URL |
server | Phone verification, texts and the signed delivery webhook |
SMS_ENABLED, SMS_ALLOWLIST |
server | Kill switch for the texts RootCause sends; while the allow-list is set only those numbers receive them. Twilio Verify codes ignore both |
APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY, APPLE_CLIENT_ID |
server | Sign in with Apple: the code exchange in POST /api/v1/me/apple-link (503 without them) and token revocation when an account is deleted; APPLE_CLIENT_ID defaults to the iOS bundle id |
JOB_SECRET |
server | Shared secret the jobs tick expects in its x-job-secret header |
NWS_USER_AGENT |
server | The User-Agent api.weather.gov requires |
EXPO_ACCESS_TOKEN |
server | Authenticates pushes to the Expo push service |
RESEND_API_KEY, STAFF_EMAIL_FROM, OPEN311_API_KEYS, SENTRY_DSN_SERVER |
server | Read by src/server/env.ts, not used by v1 code yet |
SUPABASE_DB_URL |
local | Database URL for npm run db:push; keep it out of any .env you deploy from |
ROOTCAUSE_DEV_MEMORY, ROOTCAUSE_DEV_STAFF, ROOTCAUSE_DEV_SESSION |
local | Development only: in-memory repos, supervisor e-mails, developer sign-in |
EXPO_PUBLIC_API_URL |
app | Where the routes run; empty means the Metro host in development and the same origin on web |
EXPO_PUBLIC_SUPABASE_URL, EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY |
app | Supabase Auth |
EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID, EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID, EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID |
app | Google sign-in client ids (the Android id is not read by the code yet) |
EXPO_PUBLIC_MAP_STYLE_URL |
app | Remote map style for the offline pack and npm run map:style; the app renders the bundled snapshot |
EXPO_PUBLIC_VISION |
app | Seeds the stored visionAssist setting (off → false); no screen reads that setting yet |
EXPO_PUBLIC_PILOT |
app | Pilot area slug (default new-brunswick-nj) |
EXPO_PUBLIC_SENTRY_DSN |
app | Not used by v1 code yet |
EXPO_PUBLIC_* values are inlined into the app at build time: they are configuration, never secrets.
Found while writing this breakdown (October 2026). None of them blocks the dev-memory demo; each is a small, separate fix.
- Live Supabase mode cannot find its tenant.
supabase/seed.sqlcreates the tenant with slugnew-brunswick-nj, butSupabaseUsersRepo.pilotTenantId()looks upTENANT_SLUG = 'pilot', so against a real project the feed, photo upload, analysis and report creation fail with "tenant pilot not found". The dev-memory server never does this lookup. - Residents never see report photos: uploads are stored staff-only and no route or script releases one, so the report screens show a photo count.
- The Share buttons send text without a link, so nothing opens the public
/r/[id]page yet. - A rejection sent from the app cannot carry a photo, so it records the answer but cannot reopen the report (the "Is it fixed?" card says so).
- The categories chosen at onboarding are stored but not read anywhere yet.
- Push tokens that Expo reports as unregistered are logged but never removed: the
pushReceiptsjob is still a placeholder. - Predictive alerts go out by push and in the inbox only; no job sends SMS alert deliveries yet.
- Predictive pushes are sent on the Android channel
rootcause-alerts, which the phone never creates (it createsrootcause-statusandrootcause-pagingonly), so on Android 8 and later they may not display. - In a tick
alertDispatchruns beforeweatherPollandscenarioEval, so the pushes for a new alert leave on the next minute's tick. autoVerifyre-checks only the status before verifying, so a single confirmation or a bare rejection that lands during the nightly run does not stop it.- Deleting, resetting or evicting a draft leaves its photos on disk:
files.remove()resolves names underPaths.document/offline/, while draft photos live underPaths.document/drafts/<draftId>/. - Coming back online within 5 minutes of the last refresh does not flush queued drafts and votes; they wait for a sign-in, a manual refresh, or the next reconnect or return to the foreground at least 5 minutes after the last refresh.
- The votes route also follows the report for the voter, but the phone never learns about that follow, so the report detail can offer "Follow this order" for a report the account already follows.
- The stored
visionAssistsetting (seeded fromEXPO_PUBLIC_VISION) is never read, and the low-storage notice on Offline data cannot be dismissed. - Comments typed on a demo report last only while its screen is open, although the empty state says they stay on the phone.
- With the Supabase repositories, the votes route counts a Named or Initials reporter's own vote twice when it rescores a report (
voteWeightSumaddsreporter_vote_weighton top of the reporter'sreport_voterow); the photo-attach path and the memory repositories count it once. - The vote anomaly flags and their 0.5 weight (
src/domain/votes.ts) are defined and tested but not applied by the votes route. - Live scores do not use the AI's severity proposal (
severity_aiis never written), active-user counts (the community term uses its floor of 20), prior notice, or the storm multiplier (activeStormMultiplier()has no caller). The demo data exercises all of them. - A resident's "Emergency" answer only lets status pushes and texts through quiet hours. The form's hint says it pages the on-call supervisor and
/why/scoresays the community term is normalised by active users in your block group; neither is implemented yet. - Any signed-in resident can submit a verdict on a completed report; the plan limits that to the reporter and followers.
POST /api/v1/reportscounts a replayed draft (sameclientDraftId) against its 10-an-hour limit, so a phone flushing many queued drafts can get 429 for reports that already exist.npm run perf:queuepoints atscripts/perf-queue.ts, which does not exist, andnpm run osm:buildruns the script with plainnode, which cannot resolve its imports (usenpx tsx scripts/build-osm.ts).- Accessibility: segmented controls (32 pt) and small buttons (36 pt) are below the 44 pt target, live-region announcements (offline banner, status lines, map peek) are Android-only in React Native so VoiceOver does not read them, and the radius stepper announces itself as adjustable without providing adjust actions.
- On the dev-memory server the memory repositories keep separate rows: no account is ever in a predictive-alert audience, and the
/mevote count and the data export (votes, follows, comments) leave out what the engagement routes wrote.
Expo SDK 57 · expo-router (typed routes, React Compiler) · React Native 0.86 · TypeScript (strict) · zod 4. Local storage is expo-sqlite/kv-store (synchronous reads, so the first frame renders from disk) plus expo-file-system for draft photos; web falls back to localStorage. Map: @maplibre/maplibre-react-native on the phone and maplibre-gl in HazardMap.web.tsx, both rendering the bundled OpenFreeMap style snapshot (assets/map/style.json) with MapLibre clustering — no API key. Auth: supabase-js for Auth only, expo-apple-authentication, @react-native-google-signin/google-signin. Camera expo-camera, picker expo-image-picker, resize expo-image-manipulator, push expo-notifications, export expo-sharing. Clock: America/New_York via Intl. Server: Expo Router API routes, @supabase/supabase-js with the service key, the Anthropic TypeScript SDK, Twilio and the Expo push service over REST, NWS with a User-Agent.
Same design system as the sibling projects nmi-typhoon-watch and studyspace: native iOS grouped-list idiom, one accent — RootCause green #1F6B4F, which also means "good / verified" — and three semantic colours (red = critical / emergency / SLA breached · amber = high severity, at risk, stale data · blue = storm and forecast context, moderate severity). Severity is never colour alone (SeverityBars, n of 4, with a label), no shadows, 44 pt cells and full-size buttons (small buttons are 36 pt, segmented controls and stepper buttons 32 pt; see Known gaps), tabular numerals, no spinners, every cached thing time-stamped, every score shows its terms, demo data labelled wherever it appears.
npm install
cp .env.example .env # fill in what you need; names are listed under ConfigurationWithout applying the schema (dev-memory). The whole API runs in the dev server's memory with real Supabase Auth sessions, so it still needs a Supabase project: the server needs SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY (without them signed-in routes answer 503 and POST /api/dev/session answers 404), and sign-in needs EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY. Data lasts until the server restarts. Expo CLI rebundles a route on the next request after a file change, but the in-memory repositories are built once and kept on globalThis, so restart the server after changing them or code they call.
ROOTCAUSE_DEV_MEMORY=1 ROOTCAUSE_DEV_SESSION=1 ROOTCAUSE_DEV_STAFF=<your e-mail> npx expo start --port 8090ROOTCAUSE_DEV_STAFF (comma-separated) signs those e-mails in as supervisors; the sign-in sheet's developer button uses sim.tester@e2e.rootcause.app. A supervisor account can move a report through the status machine with PATCH /api/v1/reports/:id; the app has no staff screen, so send those calls with the account's token (scripts/e2e-check.ts shows them). Do not set CI=1 on a server you keep editing against: Expo CLI then runs Metro without file watching. For a headless start, redirect stdin (< /dev/null) instead.
Against Supabase. Apply the schema once (npm run db:push with SUPABASE_DB_URL set, or npx supabase link and npx supabase db push --include-seed; a plain db push skips supabase/seed.sql, which creates the tenant), then npx expo start. Not working end to end yet: the seed names the tenant new-brunswick-nj, but the Supabase repositories look it up as TENANT_SLUG = 'pilot' (src/server/repos/supabase/users.ts); until the two agree, the feed, photo upload, analysis and report creation answer 500 ("tenant pilot not found"). Supabase's built-in e-mail sender allows only a couple of messages an hour; configure SMTP in the Supabase dashboard before inviting testers.
On a phone or simulator. Expo Go runs the app, but MapLibre is a native module Expo Go does not include, so the map shows a placeholder above the list, Google sign-in (also a native module) answers "not available on this build", and remote push needs a development build. For the full app build a development client:
npx expo run:ios # or: npm run ios / npx expo run:android- Builds with Xcode 27 (iOS 27 SDK) must use the UIKit scene life cycle.
app.jsonenables it throughexpo-build-properties(ios.enableSceneSupport: true, Expo 57.0.23 or newer); after changing native config runnpx expo prebuild --clean --platform ios. - Sign in with Apple adds an entitlement that makes Expo CLI ask for a code-signing certificate even for the simulator. Sign in to Xcode with an Apple Developer team, or, for a simulator-only build, delete the
com.apple.developer.applesigninkey from the generatedios/RootCause/RootCause.entitlements(theios/folder is generated and not committed).
npm run typecheck # tsc --noEmit
npm run lint # expo lint, including the src/server import boundary
npm test # jest: domain, routes (new Request() against memory repos), screens (renderRouter), services, jobs, boundary
npm run test:int # integration placeholder: skipped without SUPABASE_DB_URL; it only checks the URL's scheme so far
npx tsx scripts/e2e-check.ts --api http://localhost:8090 --photo hazard.jpg # drives a running dev server end to end, including a live vision call (JPEG ≤ 600 KB); the server's ROOTCAUSE_DEV_STAFF must list rootcause.e2e.staff@example.com
npm run vision:check -- <photo.jpg> # one real Claude call from the terminalCI (.github/workflows/ci.yml) runs typecheck, lint with zero warnings and the jest suite, the integration suite against a local Supabase, and a web export that fails if a server secret name reaches the client bundle.
Device procedures, the nine-state matrix, the demo script and the deep links are in docs/QA.md.
Identifiers: iOS com.27363.rootcause, Android com.tstst.rootcause (app.json). Profiles in eas.json: development, development-simulator, preview, production; EAS CLI 16 or newer (npm i -g eas-cli). preview and production map one-to-one to two Supabase projects, two Twilio Messaging Services and two EAS environments.
1. Link the project (once)
eas login
eas init # writes extra.eas.projectId into app.json2. Database. supabase/migrations is the only schema source: npx supabase link --project-ref <preview-ref> && npx supabase db push, then the same for production after review. db push applies migrations only, so on a new project run supabase/seed.sql once (or use npm run db:push, which includes it). After the first deploy, set app_settings.job_url to https://<name>.expo.app/api/jobs/tick and app_settings.job_secret to the JOB_SECRET value; until then the pg_cron tick posts nothing and no job runs (docs/runbook.md §2). Every migration must keep the previous native build working (expand → migrate → contract).
3. Server variables. eas deploy --environment production ships the EAS production variables with the deployment, laid over whatever the local .env* files hold (.env, .env.local, .env.production; on a name clash the EAS value wins), and npx expo export inlines EXPO_PUBLIC_* from the same files. Export and deploy from a checkout whose .env files hold production values only, never your development .env. Secrets are stored as sensitive.
for name in SUPABASE_URL SUPABASE_SERVICE_ROLE_KEY ANTHROPIC_API_KEY TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_MESSAGING_SERVICE_SID TWILIO_VERIFY_SERVICE_SID APPLE_PRIVATE_KEY JOB_SECRET EXPO_ACCESS_TOKEN; do
eas env:set --name $name --value <value> --environment production --visibility sensitive
done
eas env:set --name VISION_MODEL --value claude-haiku-4-5 --environment production --visibility plaintext
# plaintext as well: VISION_ENABLED, VISION_DAILY_MAX, SMS_ENABLED, SMS_ALLOWLIST, TWILIO_WEBHOOK_URL, NWS_USER_AGENT, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_CLIENT_ID
npx expo export --platform web
eas deploy --prod --environment production # prints https://<name>.expo.appCheck it: curl https://<name>.expo.app/api/health answers with the version, database reachability, each job's last successful run and database / Storage usage against the Free-plan quota. Point an uptime monitor at it every 5 minutes; the query doubles as the Supabase Free keep-alive.
4. Point the app at it. EXPO_PUBLIC_* values are inlined at build time.
eas env:set --name EXPO_PUBLIC_API_URL --value https://<name>.expo.app --environment production --visibility plaintext
# likewise: EXPO_PUBLIC_SUPABASE_URL, EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY, EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID,
# EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID, EXPO_PUBLIC_MAP_STYLE_URL, EXPO_PUBLIC_VISION, EXPO_PUBLIC_PILOTRepeat step 4 with --environment preview and --environment development for those profiles.
5. Builds
eas build --profile development --platform ios # dev build for a real iPhone (camera, MapLibre, Apple sign-in, push)
eas build --profile development-simulator --platform ios # same, for the iOS Simulator
eas build --profile preview --platform android # installable APK
eas build --profile production --platform all # store builds, version auto-incremented
eas submit --profile production --platform ios # TestFlight / App Store
eas submit --profile production --platform android # Play ConsoleRelease gate (plan §15, M5): device procedures recorded in docs/QA.md; App Store privacy labels and Play Data Safety filled in (phone number, photos, location); docs/privacy.md and docs/terms.md reviewed by the city attorney (D12); one restore from the nightly backup performed and recorded in docs/runbook.md; 10DLC registration status noted.
Offline data → Demo scenario → Off / Calm / Storm tonight / Fix to verify. The scenarios are generated on the phone (src/domain/demo.ts, deterministic, 12 reports around the pilot centre), labelled demo wherever they appear and never uploaded; they work signed in or out. storm adds the predictive-alert card on Home, the storm briefing and the storm multiplier; verify puts one report in completed awaiting your confirmation; calm shows the open-hazard index with no alert. "Simulate no signal" on the same screen makes the app behave as offline; the real test is airplane mode.
Deep links do the same: rootcause://?demo=storm (also calm, verify, off), rootcause://report/<id>, rootcause://map?cat=vegetation (also roadway, sidewalk, drainage, lighting). In Expo Go on the iOS Simulator: xcrun simctl openurl booted "exp://127.0.0.1:<port>/--/?demo=storm" (8081 by default, 8090 with the dev-memory command above). A development build takes the rootcause:// links directly.
- Map: the OpenFreeMap
libertystyle, snapshotted intoassets/map/style.jsonbynpm run map:style(scripts/fetch-map-style.ts); tiles come from OpenFreeMap directly, no key, no SLA. The attribution "© OpenFreeMap © OpenMapTiles © OpenStreetMap contributors" is visible in every map state, including offline. - Exposure and reverse geocoding: OpenStreetMap via the Overpass API (
npx tsx scripts/build-osm.ts→assets/data/osm/new-brunswick-nj.json), © OpenStreetMap contributors, ODbL 1.0 — https://www.openstreetmap.org/copyright. The extract is a derivative database and stays under ODbL. Pedestrians per day per road class are placeholder estimates until the city supplies counts: the server-side lookup tags themestimated from map data, and the analysis screen shows "≈ n pedestrians/day" with a note that exposure is a proxy, not a count. - Weather: NWS
api.weather.govgridpoint forecast (public domain), fetched server-side every 15 minutes with the requiredUser-Agent; the chance of precipitation is the confidence for rain scenarios. - Census: the schema has block-group tables for per-capita normalisation; no loader ships in this phase.
- Taxonomy, SLA table and remediation options come from the spec; unit costs, recurrence months and every prototype figure are "plausible placeholders" (spec, closing note) to be replaced with the pilot city's own numbers.
- Demo data is fabricated and labelled. The spec's "City of Elmwood" is fictional; the pilot area is New Brunswick, NJ (owner decision D1). No partnership with or endorsement by the city is implied.
- Evidence trail, read dates and what the app does not claim: docs/data-sources.md.
src/app/ expo-router screens: (tabs)/, new/ (report intake), report/[id], alert/[id], why/, r/[id] (public web page), settings, sign-in ...
src/app/api/ server routes: health, jobs/tick, dev/session, v1/** (photos, reports, vision, me, comments), webhooks/twilio
src/domain/ pure logic: types + zod, taxonomy, score, status machine, roles, votes, SLA, alerts, intake, moderation, exposure, geo, time, ids, pilot, demo
src/data/ local storage (kv + files, with .web.ts fallbacks) and the repositories over it
src/services/ API client (+ token), auth (+ supabaseClient), photos, sync queue, engagement, refresh, alerts, network, location, camera, notifications, offline map (+ pack state, .web), demo
src/server/ server-only: env, db, auth, devSession, http, log, rate limit, exif, exposure, flags, public projection, lifecycle, notify, push, sms, vision, apple, jobs/, repos/ (memory + supabase)
src/store/ useSyncExternalStore app store + derived hooks
src/ui/ theme tokens, Screen scaffold, primitives, icons, severity / score / report / intake widgets, HazardMap (+ .web, .shared), map peek, map pack cell
src/i18n/ English string catalog and t()
assets/ app icon, images, map/style.json (bundled map style), data/osm/<pilot>.json
supabase/ config, migrations (the only schema source), seed, e-mail template
scripts/ db-push, e2e-check, vision-check, set-status, demo-storm, grant-role, build-osm, fetch-map-style
__mocks__/ jest fakes for MapLibre and expo-sqlite/kv-store
__fixtures__/ synthetic JPEG bytes and recorded NWS responses (points, hourly, gridpoint) for the tests
.github/workflows/ ci.yml (typecheck, lint, tests, integration, web export + secret grep) and backup.yml (nightly backup)
__tests__/ domain, routes, jobs, server, services, screens, ui, boundary, bundled data, migration enums, i18n, storage guard, int (Docker)
docs/ plan · QA · runbook · gap-analysis · data-sources · privacy · terms
- Keep the layer rules above.
npm run lintandnpm testenforce three of them:src/serveris imported only fromsrc/app/api, secret env names stay in those two folders, andsrc/domain,src/serverand the routes import no React Native or Expo. The rest of the "May import" column is kept by review. - Every named function, component, class and method carries a doc comment: what it does in the app's terms, then only what the signature does not say (units, null cases, side effects, errors, the rule it enforces). Keep comments true when you change the code.
- A new screen, route, job or module gets a row in the module reference above.
- Domain logic is pure and tested in
__tests__/domain; a route gets anew Request()test against the memory repos in__tests__/routes; a screen gets arenderRoutertest covering offline and signed-out states. - Never put a secret in an
EXPO_PUBLIC_*variable, a client file or a commit.