Skip to content

feat(admin): Phase 3b — Admin Triage Dispatch#44

Merged
Exc1D merged 52 commits intomainfrom
phase-3b-impl
Apr 19, 2026
Merged

feat(admin): Phase 3b — Admin Triage Dispatch#44
Exc1D merged 52 commits intomainfrom
phase-3b-impl

Conversation

@Exc1D
Copy link
Copy Markdown
Owner

@Exc1D Exc1D commented Apr 18, 2026

Summary

  • verifyReport callable: admin advances new → awaiting_verify → verified with idempotency, event log, and municipality-gated rules
  • rejectReport callable: admin rejects awaiting_verify → cancelled_false_report with structured moderation incident record
  • dispatchResponder callable: admin dispatches on-shift active responders with severity-based acknowledgement deadlines (high=5min, medium=15min, low=30min), TOCTOU protection, and dual event streams
  • cancelDispatch callable: admin cancels a pending dispatch and reverts report to verified
  • Admin desktop UI (TriageQueuePage, ReportDetailPanel, DispatchModal): full triage flow with verify/reject/dispatch actions
  • Responder eligibility hook (useEligibleResponders): filters responders by municipality + RTDB on-shift flag
  • Rate-limit helpers and corrected error codes (FAILED_PRECONDITION, INVALID_STATUS_TRANSITION)
  • All new code covered by unit tests (127 tests passing)

Test plan

  • pnpm test — 127 tests passing
  • pnpm lint — clean
  • pnpm typecheck — clean
  • pnpm build — 10 apps/packages built
  • Run scripts/phase-3b/acceptance.ts against emulator (manual acceptance gate)

Summary by Sourcery

Implement admin triage and dispatch flow across backend callables, admin desktop UI, responder app, and monitoring, including verification, rejection, dispatch, and cancellation of reports.

New Features:

  • Add verifyReport callable to advance reports through new → awaiting_verify → verified with event logging and idempotency.
  • Add rejectReport callable to mark reports as cancelled_false_report with structured moderation incident records.
  • Add dispatchResponder callable to create dispatches for on-shift active responders with severity-based acknowledgement deadlines and dual event streams.
  • Add cancelDispatch callable to cancel pending dispatches and revert associated reports back to verified.
  • Introduce admin desktop triage UI with municipality-scoped queue, report detail panel, dispatch modal, and typed callable clients.
  • Introduce responder PWA flows for authenticated responders to view their active dispatches via Firestore subscriptions.
  • Add responder eligibility service/hook that combines Firestore responder documents with RTDB shift index to surface on-shift active responders.
  • Add rate-limit service for callables backed by Firestore, with reusable helper and schema.
  • Add Phase 3b verification and dispatch acceptance script and local smoke-test runbook for end-to-end emulator testing.

Bug Fixes:

  • Align dispatch document schema to use nested assignedTo object and update validators/tests accordingly.
  • Extend responder document schema with isActive flag used across eligibility checks.
  • Map new FAILED_PRECONDITION error code into HTTPS error translation and extend error enum/tests.
  • Fix rules test factory usage by splitting admin-SDK seed helpers from RulesTestEnvironment helpers and documenting constraints.

Enhancements:

  • Export new callables from the Cloud Functions entrypoint and wire up Firebase Admin RTDB access.
  • Add monitoring metric for dispatch.created events with Cloud Functions and Cloud Run-compatible filter and label extraction.
  • Tighten Firestore security rules coverage for admin municipality-scoped queue reads via new rules test suite.
  • Refine ESLint/typecheck patterns in frontend apps to satisfy strict rules for async handlers and deprecated React types.

Documentation:

  • Document Phase 3b admin triage and dispatch work in progress/progress log and add a dedicated verify+dispatch smoke-test runbook with troubleshooting and rollback steps.

Tests:

  • Add unit tests for verifyReport, rejectReport, dispatchResponder, cancelDispatch, responder eligibility, and rate-limit services covering happy paths and error conditions.
  • Add Firestore rules tests for admin triage queue onSnapshot access control.
  • Add validator tests for updated dispatch document schema and error enums.

Summary by CodeRabbit

  • New Features

    • Admin & Responder apps: authentication, role-protected routes, router-driven UI, login flows, triage UI with report details/verify/reject/dispatch actions, dispatch modal, responder dispatch list, real-time updates, Firebase/App Check & emulator support; backend callables for verify/reject/dispatch/cancel with idempotency and rate-limiting; responder eligibility service; new Firestore index and monitoring metric.
  • Tests

    • Expanded unit/integration suites, rules tests, and end-to-end acceptance/bootstrap scripts for verify/dispatch flows.
  • Documentation

    • Runbook, progress notes, and learnings for Phase 3b smoke tests and troubleshooting.

claude and others added 30 commits April 18, 2026 18:57
Runtime code writes `assignedTo: { uid, agencyId, municipalityId }` but the
schema had flat top-level `responderId`, `municipalityId`, `agencyId` fields.
All actual dispatch documents would fail validation against the old schema.

Updated both schema and tests to use the nested `assignedTo` object shape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
dispatchResponderCore checks `responder.isActive !== true` and
getEligibleResponders queries `.where('isActive', '==', true)`, but the
schema had no isActive field — only availabilityStatus (different
semantics). Actual responder documents would fail schema validation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
onSnapshot had no error callback — Firestore errors were silently discarded,
leaving the UI with stale data. In a disaster alert system, a responder not
knowing their dispatch list is broken could cost lives.

Now returns { rows, error } matching the pattern in useMuniReports.
DispatchListPage surfaces the error to the user.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
claude and others added 2 commits April 19, 2026 07:27
…ying on cast

Replace unsafe `report.status as 'awaiting_verify'` cast with an explicit
runtime check. The prior code would throw a generic "invalid transition"
error for any non-awaiting_verify source state, including terminal states
like 'new'. Now throws a specific error clearly indicating the precondition.

Also remove now-unused isValidReportTransition import.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…mapping

Previously rejectReport threw BantayogErrorCode.INVALID_STATUS_TRANSITION
when rejecting a non-awaiting_verify report, but the test and spec
expected FAILED_PRECONDITION. Both codes map to HTTP 'failed-precondition'
so the client-side behavior was correct, but the error code was wrong.

Changes:
- Add FAILED_PRECONDITION to BantayogErrorCode enum (shared-validators)
- Map FAILED_PRECONDITION → 'failed-precondition' in BANTAYOG_TO_HTTPS_CODE
- Change rejectReportCore to throw FAILED_PRECONDITION (not
  INVALID_STATUS_TRANSITION) when report is not in awaiting_verify state
- Update test assertion in reject-report.test.ts to match actual code
  (was already correct; FAILED_PRECONDITION is the actual enum value)
- Update errors-and-logging.test.ts count from 18 → 19 (added FAILED_PRECONDITION)

Note: cancel-dispatch.test.ts lines 122 and 162 check 'FAILED_PRECONDITION'
and 'INVALID_STATUS_TRANSITION' respectively — these match the actual
implementation (line 74 throws FAILED_PRECONDITION, line 81 throws
INVALID_STATUS_TRANSITION). No changes needed there.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Apr 18, 2026

Reviewer's Guide

Implements Phase 3b admin triage + dispatch: new callable functions for verifying/rejecting reports and creating/cancelling dispatches with idempotency, rate limiting, muni scoping and logging; shared validators and rate-limit/responder-eligibility services; admin desktop triage UI and responder app dispatch list wired to these callables and Firestore/RTDB; infra metric for dispatch.created; plus seed factories, tests, scripts, and docs to cover the new flows.

Sequence diagram for admin verify and dispatch flow

sequenceDiagram
  actor Admin
  participant AdminDesktop
  participant FirebaseAuth
  participant Firestore
  participant RTDB
  participant verifyReportCF as verifyReport_callable
  participant dispatchResponderCF as dispatchResponder_callable
  participant ResponderApp

  Admin->>AdminDesktop: Open TriageQueuePage
  AdminDesktop->>FirebaseAuth: onAuthStateChanged
  FirebaseAuth-->>AdminDesktop: Admin user with claims (role, municipalityId)
  AdminDesktop->>Firestore: onSnapshot reports where municipalityId and status in queue
  Firestore-->>AdminDesktop: Queue of reports

  Admin->>AdminDesktop: Click report row
  AdminDesktop->>Firestore: onSnapshot reports/{reportId}, report_ops/{reportId}
  Firestore-->>AdminDesktop: ReportDetailPanel data

  Admin->>AdminDesktop: Click Verify (status new)
  AdminDesktop->>verifyReportCF: verifyReport(reportId, idempotencyKey)
  verifyReportCF->>Firestore: rate_limits verifyReport key check
  Firestore-->>verifyReportCF: Rate limit ok
  verifyReportCF->>Firestore: Transaction load reports/{reportId}
  Firestore-->>verifyReportCF: status new
  verifyReportCF->>Firestore: Update status awaiting_verify
  verifyReportCF->>Firestore: Insert report_events new to awaiting_verify
  verifyReportCF-->>AdminDesktop: { status awaiting_verify, reportId }
  Firestore-->>AdminDesktop: onSnapshot status awaiting_verify

  Admin->>AdminDesktop: Click Verify again (status awaiting_verify)
  AdminDesktop->>verifyReportCF: verifyReport(reportId, new idempotencyKey)
  verifyReportCF->>Firestore: Rate limit check
  verifyReportCF->>Firestore: Transaction load report status awaiting_verify
  verifyReportCF->>Firestore: Update status verified, verifiedBy, verifiedAt
  verifyReportCF->>Firestore: Insert report_events awaiting_verify to verified
  verifyReportCF-->>AdminDesktop: { status verified, reportId }
  Firestore-->>AdminDesktop: onSnapshot status verified

  Admin->>AdminDesktop: Click Dispatch
  AdminDesktop->>RTDB: onValue responder_index/{municipalityId}
  AdminDesktop->>Firestore: onSnapshot responders where municipalityId and isActive true
  RTDB-->>AdminDesktop: On shift flags
  Firestore-->>AdminDesktop: Eligible responders
  Admin->>AdminDesktop: Pick responder and confirm
  AdminDesktop->>dispatchResponderCF: dispatchResponder(reportId, responderUid, idempotencyKey)
  dispatchResponderCF->>Firestore: rate_limits dispatchResponder key check
  dispatchResponderCF->>RTDB: Get responder_index municipality shift
  dispatchResponderCF->>Firestore: Transaction load reports/{reportId}, responders/{responderUid}
  Firestore-->>dispatchResponderCF: Report status verified, responder isActive true
  dispatchResponderCF->>RTDB: Recheck responder_index for TOCTOU
  RTDB-->>dispatchResponderCF: isOnShift true
  dispatchResponderCF->>Firestore: Create dispatches/{dispatchId} pending with acknowledgementDeadlineAt
  dispatchResponderCF->>Firestore: Update reports/{reportId} status assigned, currentDispatchId
  dispatchResponderCF->>Firestore: Insert report_events verified to assigned
  dispatchResponderCF->>Firestore: Insert dispatch_events null to pending
  dispatchResponderCF-->>AdminDesktop: { dispatchId, status pending, reportId }
  Firestore-->>AdminDesktop: onSnapshot report status assigned

  ResponderApp->>FirebaseAuth: signInWithEmailAndPassword
  FirebaseAuth-->>ResponderApp: user with role responder
  ResponderApp->>Firestore: onSnapshot dispatches where assignedTo.uid and status in active set
  Firestore-->>ResponderApp: List including new pending dispatch
Loading

ER diagram for reports, dispatches, responders, and moderation data

erDiagram
  REPORTS {
    string reportId PK
    string municipalityId
    string municipalityLabel
    string status
    string severityDerived
    string source
    string currentDispatchId
    string verifiedBy
    number createdAt
    number lastStatusAt
    number verifiedAt
  }

  REPORT_OPS {
    string reportId PK
    number verifyQueuePriority
    string assignedMunicipalityAdmins
  }

  REPORT_PRIVATE {
    string reportId PK
    string reporterUid
    string rawDescription
  }

  REPORT_EVENTS {
    string eventId PK
    string reportId FK
    string from
    string to
    string actor
    string actorRole
    string correlationId
    number at
  }

  MODERATION_INCIDENTS {
    string incidentId PK
    string reportId FK
    string reason
    string notes
    string actor
    string actorRole
    string correlationId
    number at
  }

  RESPONDERS {
    string uid PK
    string municipalityId
    string agencyId
    string displayName
    boolean isActive
  }

  DISPATCHES {
    string dispatchId PK
    string reportId FK
    string status
    string assignedTo_uid
    string assignedTo_agencyId
    string assignedTo_municipalityId
    string dispatchedBy
    string dispatchedByRole
    number dispatchedAt
    number lastStatusAt
    number acknowledgementDeadlineAt
  }

  DISPATCH_EVENTS {
    string eventId PK
    string dispatchId FK
    string reportId FK
    string from
    string to
    string actor
    string actorRole
    string reason
    string correlationId
    number at
  }

  RATE_LIMITS {
    string key PK
    number timestamps
    number updatedAt
  }

  RTDB_RESPONDER_INDEX {
    string municipalityId PK
    string responderUid PK
    boolean isOnShift
  }

  REPORTS ||--|| REPORT_OPS : has_ops
  REPORTS ||--|| REPORT_PRIVATE : has_private
  REPORTS ||--o{ REPORT_EVENTS : has_events
  REPORTS ||--o{ MODERATION_INCIDENTS : has_moderation
  REPORTS ||--o{ DISPATCHES : has_dispatches
  DISPATCHES ||--o{ DISPATCH_EVENTS : has_events
  RESPONDERS ||--o{ DISPATCHES : may_be_assigned
  RTDB_RESPONDER_INDEX ||--|| RESPONDERS : mirrors_shift
  RATE_LIMITS ||..|| REPORTS : controls_verify
  RATE_LIMITS ||..|| DISPATCHES : controls_dispatch
Loading

Class diagram for callables, core dependencies, and shared services

classDiagram
  class VerifyReportCoreDeps {
    +string reportId
    +string idempotencyKey
    +VerifyReportActor actor
    +Timestamp now
  }

  class VerifyReportActor {
    +string uid
    +string role
    +string municipalityId
    +boolean active
  }

  class VerifyReportResult {
    +string status
    +string reportId
  }

  class RejectReportCoreDeps {
    +string reportId
    +string reason
    +string notes
    +string idempotencyKey
    +SimpleActor actor
    +Timestamp now
  }

  class DispatchResponderCoreDeps {
    +string reportId
    +string responderUid
    +string idempotencyKey
    +SimpleActor actor
    +Timestamp now
  }

  class CancelDispatchCoreDeps {
    +string dispatchId
    +string reason
    +string idempotencyKey
    +SimpleActor actor
    +Timestamp now
  }

  class SimpleActor {
    +string uid
    +string role
    +string municipalityId
  }

  class RateLimitCheck {
    +string key
    +number limit
    +number windowSeconds
    +Timestamp now
    +Timestamp updatedAt
  }

  class RateLimitResult {
    +boolean allowed
    +number remaining
    +number retryAfterSeconds
  }

  class EligibleResponderServiceModel {
    +string uid
    +string displayName
    +string agencyId
    +string municipalityId
  }

  class VerifyReportFunction {
    +verifyReportCore(db Firestore, deps VerifyReportCoreDeps) VerifyReportResult
  }

  class RejectReportFunction {
    +rejectReportCore(db Firestore, deps RejectReportCoreDeps) VerifyReportResult
  }

  class DispatchResponderFunction {
    +dispatchResponderCore(db Firestore, rtdb Database, deps DispatchResponderCoreDeps) DispatchResult
  }

  class CancelDispatchFunction {
    +cancelDispatchCore(db Firestore, deps CancelDispatchCoreDeps) DispatchCancelResult
  }

  class RateLimitService {
    +checkRateLimit(db Firestore, check RateLimitCheck) RateLimitResult
  }

  class ResponderEligibilityService {
    +getEligibleResponders(db Firestore, rtdb Database, municipalityId string, agencyId string) EligibleResponderServiceModel[]
  }

  class DispatchResult {
    +string dispatchId
    +string status
    +string reportId
  }

  class DispatchCancelResult {
    +string dispatchId
    +string status
  }

  VerifyReportCoreDeps --> VerifyReportActor
  RejectReportCoreDeps --> SimpleActor
  DispatchResponderCoreDeps --> SimpleActor
  CancelDispatchCoreDeps --> SimpleActor

  VerifyReportFunction --> VerifyReportCoreDeps
  VerifyReportFunction --> RateLimitService
  RejectReportFunction --> RejectReportCoreDeps
  RejectReportFunction --> RateLimitService
  DispatchResponderFunction --> DispatchResponderCoreDeps
  DispatchResponderFunction --> RateLimitService
  DispatchResponderFunction --> ResponderEligibilityService
  CancelDispatchFunction --> CancelDispatchCoreDeps
  CancelDispatchFunction --> RateLimitService

  RateLimitService --> RateLimitCheck
  RateLimitService --> RateLimitResult
  ResponderEligibilityService --> EligibleResponderServiceModel
Loading

File-Level Changes

Change Details Files
Add dispatchResponder callable and core logic for creating dispatches with severity-based acknowledgement deadlines and dual event streams.
  • Introduce dispatchResponderCore that validates actor claims, checks RTDB on-shift index, enforces municipality + responder activity constraints, and ensures valid verified→assigned status transition with TOCTOU recheck
  • Persist dispatch documents with assignedTo subobject, correlationId, acknowledgementDeadlineAt derived from severity, and write corresponding report_events and dispatch_events entries
  • Wrap core in onCall handler with App Check, auth/role checks, rate limiting via checkRateLimit, idempotency via withIdempotency, and BantayogError→HttpsError mapping
functions/src/callables/dispatch-responder.ts
functions/src/index.ts
functions/src/__tests__/callables/dispatch-responder.test.ts
infra/terraform/modules/monitoring/phase-3/main.tf
packages/shared-validators/src/dispatches.ts
packages/shared-validators/src/dispatches.test.ts
Add verifyReport and rejectReport callables to advance or cancel reports with event / moderation logging and idempotency.
  • Implement verifyReportCore to handle new→awaiting_verify→verified transitions with municipality scoping, isValidReportTransition guard, idempotent event logging, and verifiedBy/verifiedAt stamping
  • Implement rejectReportCore to transition awaiting_verify→cancelled_false_report only, writing moderation_incidents and report_events with structured reasons and notes, guarded by municipality and FAILED_PRECONDITION semantics
  • Expose both as HTTPS callable functions with auth/role/active checks, rate limiting via checkRateLimit, and BantayogError mapping
functions/src/callables/verify-report.ts
functions/src/callables/reject-report.ts
functions/src/index.ts
functions/src/__tests__/callables/verify-report.test.ts
functions/src/__tests__/callables/reject-report.test.ts
Add cancelDispatch callable to cancel pending dispatches and revert associated reports to verified with event logging.
  • Implement cancelDispatchCore that loads dispatch, enforces municipality ownership, restricts from-state to pending in 3b, validates via isValidDispatchTransition, and updates dispatch status/cancellation metadata
  • Revert associated report.status from assigned→verified when currentDispatchId matches, logging both dispatch_events and report_events with correlationId and reason
  • Provide onCall wrapper with standard auth, role, active, rate-limit and idempotency handling and BantayogError translation
functions/src/callables/cancel-dispatch.ts
functions/src/index.ts
functions/src/__tests__/callables/cancel-dispatch.test.ts
Introduce shared services for rate limiting and responder eligibility plus corresponding tests.
  • Add Firestore-based sliding-window rate limit helper checkRateLimit storing per-key timestamp buckets and returning remaining quota/retryAfterSeconds
  • Add getEligibleResponders service that queries active responders in a municipality (optionally by agency) and intersects with RTDB /responder_index on-shift flags
  • Cover services with unit tests using RulesTestEnvironment for Firestore and RTDB emulators
functions/src/services/rate-limit.ts
functions/src/services/responder-eligibility.ts
functions/src/__tests__/services/rate-limit.test.ts
functions/src/__tests__/services/responder-eligibility.test.ts
Expand seed factories to support admin-SDK-based seeding of reports, responders, shifts, and dispatches for callable and service tests.
  • Document existing RulesTestEnvironment seed helpers with JSDoc and rename dispatch seeder used in rules tests to seedDispatchRT
  • Add admin-SDK seedReportAtStatus, seedResponderDoc, seedResponderShift, and seedDispatch utilities for mid-lifecycle report and dispatch setup in callable tests
  • Note incompatibility of admin Timestamp in RulesTestEnvironment in docs/learnings and update rules tests accordingly
functions/src/__tests__/helpers/seed-factories.ts
functions/src/__tests__/rules/dispatches.rules.test.ts
docs/learnings.md
Wire up admin desktop triage UI to new callables with muni-scoped queue, report detail panel, dispatch modal, and auth/router scaffolding.
  • Initialize Firebase (auth, Firestore, functions, App Check) with emulator support and build AuthProvider that exposes admin claims and guards routes via ProtectedRoute
  • Implement hooks useMuniReports and useReportDetail to subscribe to municipality-scoped queue and per-report details/ops, plus useEligibleResponders that combines Firestore responders with RTDB shift index
  • Create TriageQueuePage, ReportDetailPanel, and DispatchModal components that drive verify/reject/dispatch flows via typed callables wrappers and idempotency keys; update App and routes to use React Router v7 and protected root route
  • Add docs/runbooks and progress entries describing Phase 3b admin triage dispatch and corrections
apps/admin-desktop/src/app/firebase.ts
apps/admin-desktop/src/app/auth-provider.tsx
apps/admin-desktop/src/app/protected-route.tsx
apps/admin-desktop/src/pages/TriageQueuePage.tsx
apps/admin-desktop/src/pages/ReportDetailPanel.tsx
apps/admin-desktop/src/pages/DispatchModal.tsx
apps/admin-desktop/src/hooks/useMuniReports.ts
apps/admin-desktop/src/hooks/useReportDetail.ts
apps/admin-desktop/src/hooks/useEligibleResponders.ts
apps/admin-desktop/src/services/callables.ts
apps/admin-desktop/src/routes.tsx
apps/admin-desktop/src/App.tsx
apps/admin-desktop/src/main.tsx
apps/admin-desktop/src/pages/LoginPage.tsx
apps/admin-desktop/package.json
docs/progress.md
docs/runbooks/phase-3b-verify-and-dispatch.md
Build responder app auth + dispatch list experience wired to Firestore and RTDB plus router scaffolding.
  • Add Firebase initialization for responder app with emulator wiring for Firestore/Auth/Functions/RTDB and an AuthProvider + ProtectedRoute enforcing responder role
  • Implement useOwnDispatches hook that subscribes to dispatches assignedTo.uid for active statuses ordered by dispatchedAt desc
  • Replace placeholder App with router-based LoginPage and read-only DispatchListPage showing pending/active dispatches and acknowledgement deadlines
apps/responder-app/src/app/firebase.ts
apps/responder-app/src/app/auth-provider.tsx
apps/responder-app/src/app/protected-route.tsx
apps/responder-app/src/hooks/useOwnDispatches.ts
apps/responder-app/src/pages/LoginPage.tsx
apps/responder-app/src/pages/DispatchListPage.tsx
apps/responder-app/src/routes.tsx
apps/responder-app/src/App.tsx
apps/responder-app/src/main.tsx
apps/responder-app/package.json
Enhance shared validators and error mapping to support new dispatch and moderation flows.
  • Adjust dispatchDocSchema to use assignedTo { uid, agencyId, municipalityId } congruent with new dispatch documents and update tests accordingly
  • Extend responderDocSchema with isActive boolean used across eligibility and rules
  • Add FAILED_PRECONDITION BantayogErrorCode and map it to HttpsError code failed-precondition; update error-and-logging tests for new enum length
packages/shared-validators/src/dispatches.ts
packages/shared-validators/src/dispatches.test.ts
packages/shared-validators/src/responders.ts
packages/shared-validators/src/errors.ts
packages/shared-validators/src/errors-and-logging.test.ts
functions/src/callables/https-error.ts
Add scripts and infra for end-to-end Phase 3b verification and monitoring.
  • Add bootstrap-test-responder.ts to idempotently provision a test responder user, responder doc, and RTDB shift index (with emulator/staging awareness)
  • Implement acceptance.ts script that seeds reports, signs in as admin via web SDK, exercises verifyReport and dispatchResponder, and emits a JSON assertion report for CI gating
  • Create google_logging_metric dispatch_created metric capturing dispatch.created events from both Cloud Functions and Cloud Run with municipality label extraction
scripts/phase-3b/bootstrap-test-responder.ts
scripts/phase-3b/acceptance.ts
infra/terraform/modules/monitoring/phase-3/main.tf
docs/progress.md
Add Firestore rules tests for admin muni-scoped queue access and adjust existing tests for new schemas and types.
  • Introduce admin-onsnapshot.rules.test.ts to verify muni-admin can query reports by own municipality + queue statuses and that cross-muni, unauthenticated, and citizen-role reads are denied
  • Update dispatches.rules.test.ts to use renamed seedDispatchRT and new dispatch schema shape; fix type-only imports and helper signatures to satisfy strict TS/ESLint rules
functions/src/__tests__/rules/admin-onsnapshot.rules.test.ts
functions/src/__tests__/rules/dispatches.rules.test.ts
functions/src/__tests__/helpers/seed-factories.ts
docs/progress.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 18, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds Phase 3b admin triage and dispatch: Firebase client/init + App Check and emulator wiring, auth providers and route protection, admin/responder React pages and realtime hooks, new callable functions (verify/reject/dispatch/cancel) with idempotency and rate-limiting, RTDB support, tests, validator updates, infra index, docs and scripts.

Changes

Cohort / File(s) Summary
Admin Desktop: bootstrap & deps
apps/admin-desktop/package.json, apps/admin-desktop/src/App.tsx, apps/admin-desktop/src/main.tsx
Added firebase and react-router-dom; App switched to default export and now composes RouterProvider + AuthProvider; removed StrictMode.
Admin Desktop: platform & auth
apps/admin-desktop/src/app/firebase.ts, apps/admin-desktop/src/app/auth-provider.tsx, apps/admin-desktop/src/app/protected-route.tsx, apps/admin-desktop/src/routes.tsx
New Firebase init (env-driven, emulator, App Check optional), AuthProvider exposing claims/signOut/refreshClaims, ProtectedRoute enforces roles/active/municipality, router with /login and protected /.
Admin Desktop: UI, pages & hooks
apps/admin-desktop/src/pages/*, apps/admin-desktop/src/hooks/*, apps/admin-desktop/src/services/callables.ts
Added TriageQueuePage, ReportDetailPanel, DispatchModal, LoginPage; realtime hooks (useMuniReports, useReportDetail, useEligibleResponders); typed callables wrapper for cloud functions.
Responder App: bootstrap & deps
apps/responder-app/package.json, apps/responder-app/src/App.tsx, apps/responder-app/src/main.tsx
Added firebase and react-router-dom; App switched to default export delegating to router; CSS import simplified.
Responder App: platform & auth
apps/responder-app/src/app/firebase.ts, apps/responder-app/src/app/auth-provider.tsx, apps/responder-app/src/app/protected-route.tsx, apps/responder-app/src/routes.tsx
New Firebase init including RTDB and emulator wiring, AuthProvider and useAuth, ProtectedRoute enforcing responder role, AppRouter composing RouterProvider under AuthProvider.
Responder App: pages & hooks
apps/responder-app/src/pages/*, apps/responder-app/src/hooks/useOwnDispatches.ts
Added DispatchListPage and LoginPage; useOwnDispatches subscribes to dispatches for a UID with filtering, ordering and error handling.
Cloud Functions: callables & exports
functions/src/callables/*.ts, functions/src/index.ts
Added verifyReport, rejectReport, dispatchResponder, cancelDispatch plus core implementations using idempotency wrappers, Firestore transactions, role/municipality checks, rate-limiting; re-exported from index.
Functions: services & admin SDK
functions/src/services/rate-limit.ts, functions/src/services/responder-eligibility.ts, functions/src/firebase-admin.ts
Added sliding-window Firestore rate-limiter, responder eligibility service (Firestore + RTDB), and exported admin RTDB client.
Tests & test helpers
functions/src/__tests__/*, functions/src/__tests__/helpers/seed-factories.ts
Extensive callable and rules tests added; seed helpers expanded and renamed (seedDispatchRT, seedReportAtStatus, seedResponderDoc, seedResponderShift, admin seedDispatch).
Shared validators & types
packages/shared-validators/src/dispatches.ts, packages/shared-validators/src/responders.ts, packages/shared-validators/src/errors.ts, tests
Dispatch schema now nests assignedTo; responder schema adds isActive; added FAILED_PRECONDITION error code; tests updated.
Infra & monitoring
infra/firebase/firestore.indexes.json, infra/terraform/modules/monitoring/phase-3/main.tf
Added composite Firestore index for dispatches and a logging metric dispatch.created with municipality label.
Docs & scripts
docs/*, scripts/phase-3b/*
Added runbook, progress/learnings, acceptance and bootstrap scripts for Phase 3b.
Rules & state machines
infra/firebase/firestore.rules, packages/shared-validators/src/state-machines/report-states.ts
Allowed dispatch transition pending → cancelled in rules and dispatch state machine.

Sequence Diagram(s)

sequenceDiagram
    participant Admin as Admin User
    participant AdminApp as Admin Desktop App
    participant Auth as AuthProvider
    participant Functions as Cloud Functions
    participant Firestore as Firestore
    participant RTDB as Realtime DB

    Admin->>AdminApp: sign in (email/password)
    AdminApp->>Auth: signInWithEmailAndPassword
    Auth->>Firestore: getIdToken / custom claims
    Firestore-->>Auth: claims
    Auth-->>AdminApp: authenticated

    AdminApp->>Firestore: query reports (municipality + status)
    Firestore-->>AdminApp: report list

    Admin->>AdminApp: click Verify
    AdminApp->>Functions: callables.verifyReport(reportId, idempotencyKey)
    Functions->>Firestore: read/validate/update report & write report_events
    Firestore-->>Functions: commit
    Functions-->>AdminApp: { status, reportId }

    Admin->>AdminApp: open Dispatch modal
    AdminApp->>RTDB: read /responder_index/{municipality}
    RTDB-->>AdminApp: shift states
    AdminApp->>Functions: callables.dispatchResponder(reportId, responderUid, idempotencyKey)
    Functions->>RTDB: verify on-shift
    RTDB-->>Functions: confirmed
    Functions->>Firestore: create dispatch, update report, write events
    Firestore-->>Functions: commit
    Functions-->>AdminApp: { dispatchId, status, reportId }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hopped into code at dawn's light,

Verified queues and set dispatches right,
Idempotent keys kept repeats at bay,
RTDB and Firestore danced in play,
Now tests hum and dashboards brighten the day. 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 24.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(admin): Phase 3b — Admin Triage Dispatch' clearly and concisely describes the main change—implementing Phase 3b admin triage and dispatch functionality. It is directly related to the primary scope of the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch phase-3b-impl

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread scripts/phase-3b/acceptance.ts Fixed
Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 6 issues, and left some high level feedback:

  • dispatchResponderCore assumes severityDerived is one of low|medium|high, but your data model and seed helpers mention critical as well; consider extending DEADLINE_BY_SEVERITY or adding a safe fallback so a critical severity doesn’t produce an undefined/NaN acknowledgement deadline.
  • In cancelDispatchCore you treat non-pending states as a 3b-only restriction but surface them as INVALID_STATUS_TRANSITION; if you intend this to behave as a business precondition (per your docs/tests), consider using FAILED_PRECONDITION there so callers can distinguish "wrong lifecycle" from "invalid state machine edge."
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- dispatchResponderCore assumes `severityDerived` is one of `low|medium|high`, but your data model and seed helpers mention `critical` as well; consider extending `DEADLINE_BY_SEVERITY` or adding a safe fallback so a `critical` severity doesn’t produce an undefined/NaN acknowledgement deadline.
- In cancelDispatchCore you treat non-pending states as a 3b-only restriction but surface them as INVALID_STATUS_TRANSITION; if you intend this to behave as a business precondition (per your docs/tests), consider using FAILED_PRECONDITION there so callers can distinguish "wrong lifecycle" from "invalid state machine edge."

## Individual Comments

### Comment 1
<location path="infra/terraform/modules/monitoring/phase-3/main.tf" line_range="74-83" />
<code_context>
+resource "google_logging_metric" "dispatch_created" {
+  name        = "${var.env}-bantayog-dispatch-created"
+  description = "Count of dispatches created via dispatchResponder"
+  filter      = "resource.type=\"cloud_function\" AND jsonPayload.event=\"dispatch.created\" OR resource.type=\"cloud_run_revision\" AND jsonPayload.event=\"dispatch.created\""
+  metric_descriptor {
+    metric_kind = "DELTA"
+    value_type  = "INT64"
+    unit        = "1"
+    labels {
+      key         = "municipality_id"
+      value_type  = "STRING"
+      description = "Municipality the dispatch was created in"
+    }
+  }
+  label_extractors = {
+    "municipality_id" = "EXTRACT(jsonPayload.municipalityId)"
+  }
+}
</code_context>
<issue_to_address>
**issue (bug_risk):** Clarify log filter precedence and align label extractor with emitted payload shape

Two points:

1) The filter currently relies on implicit `AND`/`OR` precedence. To avoid misclassification if you ever change the combinations, add parentheses to make the intended grouping explicit.

2) `municipality_id` is extracted from `jsonPayload.municipalityId`, but the `dispatch.created` log payload doesn’t appear to expose `municipalityId` at that path. This will yield empty labels. Either include `municipalityId` in the `logEvent` data for `dispatch.created`, or update the extractor to match the actual field location in the log (e.g., under `jsonPayload.data.*`).
</issue_to_address>

### Comment 2
<location path="apps/responder-app/src/app/firebase.ts" line_range="12-13" />
<code_context>
+
+let _app: FirebaseApp | undefined
+
+export function getFirebaseApp(): FirebaseApp {
+  _app ??= initializeApp({ projectId: PROJECT_ID })
+  return _app
+}
</code_context>
<issue_to_address>
**issue (bug_risk):** Using `initializeApp({ projectId })` will break production web usage

For the web client SDK, `initializeApp({ projectId })` only works with emulators; in production it requires the full config (apiKey, authDomain, etc.) and will otherwise fail to initialize. Here `USE_EMULATOR` only switches emulator connections, not the app config.

Please use a full config object (e.g., from Vite env vars as in the admin-desktop app) and use `USE_EMULATOR` solely to toggle emulator connections so the same build works for both local and real Firebase.
</issue_to_address>

### Comment 3
<location path="apps/responder-app/src/hooks/useOwnDispatches.ts" line_range="17-20" />
<code_context>
+  const [rows, setRows] = useState<OwnDispatchRow[]>([])
+  const [error, setError] = useState<string | null>(null)
+  useEffect(() => {
+    if (!uid) {
+      return
+    }
+    const q = query(
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Clear stale dispatch data when the responder logs out or UID disappears

When `uid` becomes falsy (e.g. on sign-out), this effect exits without clearing `rows` or `error`, so the previous user’s dispatches stay visible until remount.

Consider resetting local state when `uid` is falsy:
```ts
if (!uid) {
  setRows([])
  setError(null)
  return
}
```
so the UI immediately reflects the unauthenticated state.

```suggestion
  useEffect(() => {
    if (!uid) {
      setRows([])
      setError(null)
      return
    }
```
</issue_to_address>

### Comment 4
<location path="functions/src/__tests__/callables/cancel-dispatch.test.ts" line_range="27-36" />
<code_context>
+describe('cancelDispatchCore (3b branches)', () => {
</code_context>
<issue_to_address>
**suggestion (testing):** Extend cancelDispatchCore tests to cover NOT_FOUND and non-current dispatch scenarios

These tests exercise the main 3b flows, but two key branches in `cancelDispatchCore` are still untested:

- When the dispatch document is missing, `cancelDispatchCore` should throw with `code: 'NOT_FOUND'`. Please add a test calling it with a random `dispatchId` and asserting this error.
- When `report.currentDispatchId !== dispatchId`, the report status and `currentDispatchId` should not change and no `report_events` entry should be written. A test that seeds such a report + pending dispatch and verifies those invariants would protect against reverting unrelated dispatches.

Optionally, you could also add a `NOT_FOUND` case for a missing report (dispatch references a non‑existent `reportId`).

Suggested implementation:

```typescript
  await testEnv.clearFirestore()
})

describe('cancelDispatchCore edge cases', () => {
  it('throws NOT_FOUND when dispatch does not exist', async () => {
    const db = testEnv.unauthenticatedContext().firestore() as any
    const randomDispatchId = 'non-existent-dispatch'

    await expect(
      cancelDispatchCore(db, {
        dispatchId: randomDispatchId,
        // add any other required fields for cancelDispatchCore here (e.g. auth context)
      }),
    ).rejects.toMatchObject({ code: 'NOT_FOUND' })
  })

  it('does not modify report or write events when canceling non-current dispatch', async () => {
    const db = testEnv.unauthenticatedContext().firestore() as any

    // Seed a report and two pending dispatches so that currentDispatchId !== dispatchId being cancelled
    const { reportId } = await seedReportAtStatus(db, 'assigned', { municipalityId: 'daet' })
    const { dispatchId: currentDispatchId } = await seedDispatch(db, {
      reportId,
      responderUid: 'r-current',
      municipalityId: 'daet',
      status: 'pending',
    })
    const { dispatchId: otherDispatchId } = await seedDispatch(db, {
      reportId,
      responderUid: 'r-other',
      municipalityId: 'daet',
      status: 'pending',
    })

    // Make report point at the "current" dispatch
    await db.collection('reports').doc(reportId).update({
      currentDispatchId,
      status: 'assigned',
    })

    // Capture initial state
    const beforeReportSnap = await db.collection('reports').doc(reportId).get()
    const beforeReportData = beforeReportSnap.data()
    const beforeEventsSnap = await db
      .collection('report_events')
      .where('reportId', '==', reportId)
      .get()

    // Attempt to cancel the non-current dispatch
    await cancelDispatchCore(db, {
      dispatchId: otherDispatchId,
      // add any other required fields for cancelDispatchCore here (e.g. auth context)
    })

    const afterReportSnap = await db.collection('reports').doc(reportId).get()
    const afterReportData = afterReportSnap.data()
    const afterEventsSnap = await db
      .collection('report_events')
      .where('reportId', '==', reportId)
      .get()

    // Report status and currentDispatchId should not change
    expect(afterReportData).toMatchObject(beforeReportData!)

    // No new report_events entries should be written
    expect(afterEventsSnap.size).toBe(beforeEventsSnap.size)
  })

  // Optionally, add a NOT_FOUND-for-report case once cancelDispatchCore's behavior is confirmed
})

describe('cancelDispatchCore (3b branches)', () => {

```

1. Ensure `cancelDispatchCore` is imported into this test file (e.g. from `../../src/callables/cancel-dispatch` or the appropriate module).
2. Fill in any additional parameters required by `cancelDispatchCore` in the two new test calls (e.g. auth/user context, municipalityId, etc.) so the function can execute without throwing unrelated errors.
3. If your schema uses different collection names or field names (e.g. `reportsV2`, `reportEvents`), update the `db.collection(...)` calls and field keys (`currentDispatchId`, `status`, `reportId`) accordingly.
4. If there are existing helpers for reading report events (e.g. `getReportEventsForReport(db, reportId)`), you may prefer to use them instead of raw `collection('report_events').where(...)` queries to stay consistent with the rest of the test suite.
5. If `cancelDispatchCore` returns a structured error instead of throwing, adapt the first test assertion from `rejects.toMatchObject` to the pattern used elsewhere in this test file (for example, checking a returned `error` object).
</issue_to_address>

### Comment 5
<location path="functions/src/__tests__/services/responder-eligibility.test.ts" line_range="20-29" />
<code_context>
+describe('getEligibleResponders', () => {
</code_context>
<issue_to_address>
**suggestion (testing):** Add tests for responders with no shift entry and the empty-result case

To fully cover `getEligibleResponders`, consider adding:

- **No shift entry in RTDB**: Seed an active responder in Firestore but omit the RTDB `/responder_index/...` node, and assert they are not returned, matching the current “RTDB is source of truth for on‑shift” behavior.

- **Empty result**: Add a case where no responders match (e.g., municipality or agency with none) and assert `result.length === 0`, to lock in behavior when filtering and sorting yield no results.

Suggested implementation:

```typescript
describe('getEligibleResponders', () => {
  it('excludes active responders that have no on-shift entry in RTDB', async () => {
    await testEnv.withSecurityRulesDisabled(async (ctx) => {
      const db = ctx.firestore() as unknown as Firestore
      const rtdb = ctx.database() as unknown as Database

      // Active responder in municipality/agency but NO RTDB shift entry
      await seedResponderDoc(db, {
        uid: 'r-no-shift',
        municipalityId: 'daet',
        agencyId: 'bfp-daet',
        isActive: true,
      })

      // Another responder that is actually on shift to prove the filter works
      await seedResponderDoc(db, {
        uid: 'r-on-shift',
        municipalityId: 'daet',
        agencyId: 'bfp-daet',
        isActive: true,
      })
      await rtdb.ref('/responder_index/daet/bfp-daet/r-on-shift').set({
        uid: 'r-on-shift',
        status: 'on_shift',
      })

      const result = await getEligibleResponders({
        firestore: db,
        database: rtdb,
        municipalityId: 'daet',
        agencyId: 'bfp-daet',
      })

      const uids = result.map((r) => r.uid)
      expect(uids).toContain('r-on-shift')
      expect(uids).not.toContain('r-no-shift')
    })
  })

  it('returns an empty array when no responders match the filters', async () => {
    await testEnv.withSecurityRulesDisabled(async (ctx) => {
      const db = ctx.firestore() as unknown as Firestore
      const rtdb = ctx.database() as unknown as Database

      // Seed responders in a different municipality/agency
      await seedResponderDoc(db, {
        uid: 'r-other',
        municipalityId: 'vinzons',
        agencyId: 'bfp-vinzons',
        isActive: true,
      })
      await rtdb.ref('/responder_index/vinzons/bfp-vinzons/r-other').set({
        uid: 'r-other',
        status: 'on_shift',
      })

      const result = await getEligibleResponders({
        firestore: db,
        database: rtdb,
        municipalityId: 'daet',
        agencyId: 'bfp-daet',
      })

      expect(result).toHaveLength(0)
    })
  })

  it('returns only active responders in the target municipality who are on shift', async () => {

```

These edits assume:
1. `getEligibleResponders` is imported into this test file and accepts an options object with `{ firestore, database, municipalityId, agencyId }` (or a compatible signature). Adjust the call shape if your actual function signature differs.
2. `seedResponderDoc` writes responder metadata to Firestore but does not implicitly create RTDB entries; RTDB shift entries are explicitly created via `rtdb.ref('/responder_index/...').set(...)`. If the RTDB path or payload shape differs in your codebase, update the `/responder_index/...` paths and data objects accordingly.
3. If your existing tests use different municipality/agency IDs or a factory/helper for RTDB seeding, mirror those conventions (e.g., reuse a `seedShiftEntry` helper instead of direct `rtdb.ref(...).set(...)`).
4. Place these new `it` blocks alongside the existing ones within the same `describe('getEligibleResponders', ...)` block, preserving any surrounding setup/teardown logic or shared helpers that are present in parts of the file not shown here.
</issue_to_address>

### Comment 6
<location path="docs/learnings.md" line_range="366" />
<code_context>

 Commit `6546a0e` ("fix validators: cap pendingMediaIds at 20") was made by a subagent and appeared in `git log` but wasn't in the worktree's HEAD. It was on `main`. After `git rebase main`, it appeared at the correct position in history. Always rebase worktrees onto main before starting implementation to avoid this confusion.
+
+### `seedReportAtStatus` uses Firebase Admin Timestamp, incompatible with RulesTestContext
+
+`seedReportAtStatus` (seed-factories.ts) uses `firebase-admin/firestore` `Timestamp.now()` which is incompatible with RulesTestEnvironment's `withSecurityRulesDisabled` context (uses JS SDK). Error: `FirebaseError: Function DocumentReference.set() called with invalid data. Unsupported field value: a custom Timestamp object`. Fix: write inline seeding with numeric `ts` timestamps (like other rules tests) instead of calling `seedReportAtStatus`.
</code_context>
<issue_to_address>
**suggestion (typo):** Heading uses `RulesTestContext` while the body refers to `RulesTestEnvironment`

Please update the heading to use `RulesTestEnvironment` so it matches the term used in the explanation below and avoids confusion.

```suggestion
### `seedReportAtStatus` uses Firebase Admin Timestamp, incompatible with RulesTestEnvironment
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +74 to +83
filter = "resource.type=\"cloud_function\" AND jsonPayload.event=\"dispatch.created\" OR resource.type=\"cloud_run_revision\" AND jsonPayload.event=\"dispatch.created\""
metric_descriptor {
metric_kind = "DELTA"
value_type = "INT64"
unit = "1"
labels {
key = "municipality_id"
value_type = "STRING"
description = "Municipality the dispatch was created in"
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Clarify log filter precedence and align label extractor with emitted payload shape

Two points:

  1. The filter currently relies on implicit AND/OR precedence. To avoid misclassification if you ever change the combinations, add parentheses to make the intended grouping explicit.

  2. municipality_id is extracted from jsonPayload.municipalityId, but the dispatch.created log payload doesn’t appear to expose municipalityId at that path. This will yield empty labels. Either include municipalityId in the logEvent data for dispatch.created, or update the extractor to match the actual field location in the log (e.g., under jsonPayload.data.*).

Comment on lines +12 to +13
export function getFirebaseApp(): FirebaseApp {
_app ??= initializeApp({ projectId: PROJECT_ID })
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Using initializeApp({ projectId }) will break production web usage

For the web client SDK, initializeApp({ projectId }) only works with emulators; in production it requires the full config (apiKey, authDomain, etc.) and will otherwise fail to initialize. Here USE_EMULATOR only switches emulator connections, not the app config.

Please use a full config object (e.g., from Vite env vars as in the admin-desktop app) and use USE_EMULATOR solely to toggle emulator connections so the same build works for both local and real Firebase.

Comment on lines +17 to +20
useEffect(() => {
if (!uid) {
return
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Clear stale dispatch data when the responder logs out or UID disappears

When uid becomes falsy (e.g. on sign-out), this effect exits without clearing rows or error, so the previous user’s dispatches stay visible until remount.

Consider resetting local state when uid is falsy:

if (!uid) {
  setRows([])
  setError(null)
  return
}

so the UI immediately reflects the unauthenticated state.

Suggested change
useEffect(() => {
if (!uid) {
return
}
useEffect(() => {
if (!uid) {
setRows([])
setError(null)
return
}

Comment on lines +27 to +36
describe('cancelDispatchCore (3b branches)', () => {
it('cancels a pending dispatch and reverts report to verified', async () => {
const db = testEnv.unauthenticatedContext().firestore() as any
const { reportId } = await seedReportAtStatus(db, 'assigned', { municipalityId: 'daet' })
const { dispatchId } = await seedDispatch(db, {
reportId,
responderUid: 'r1',
municipalityId: 'daet',
status: 'pending',
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Extend cancelDispatchCore tests to cover NOT_FOUND and non-current dispatch scenarios

These tests exercise the main 3b flows, but two key branches in cancelDispatchCore are still untested:

  • When the dispatch document is missing, cancelDispatchCore should throw with code: 'NOT_FOUND'. Please add a test calling it with a random dispatchId and asserting this error.
  • When report.currentDispatchId !== dispatchId, the report status and currentDispatchId should not change and no report_events entry should be written. A test that seeds such a report + pending dispatch and verifies those invariants would protect against reverting unrelated dispatches.

Optionally, you could also add a NOT_FOUND case for a missing report (dispatch references a non‑existent reportId).

Suggested implementation:

  await testEnv.clearFirestore()
})

describe('cancelDispatchCore edge cases', () => {
  it('throws NOT_FOUND when dispatch does not exist', async () => {
    const db = testEnv.unauthenticatedContext().firestore() as any
    const randomDispatchId = 'non-existent-dispatch'

    await expect(
      cancelDispatchCore(db, {
        dispatchId: randomDispatchId,
        // add any other required fields for cancelDispatchCore here (e.g. auth context)
      }),
    ).rejects.toMatchObject({ code: 'NOT_FOUND' })
  })

  it('does not modify report or write events when canceling non-current dispatch', async () => {
    const db = testEnv.unauthenticatedContext().firestore() as any

    // Seed a report and two pending dispatches so that currentDispatchId !== dispatchId being cancelled
    const { reportId } = await seedReportAtStatus(db, 'assigned', { municipalityId: 'daet' })
    const { dispatchId: currentDispatchId } = await seedDispatch(db, {
      reportId,
      responderUid: 'r-current',
      municipalityId: 'daet',
      status: 'pending',
    })
    const { dispatchId: otherDispatchId } = await seedDispatch(db, {
      reportId,
      responderUid: 'r-other',
      municipalityId: 'daet',
      status: 'pending',
    })

    // Make report point at the "current" dispatch
    await db.collection('reports').doc(reportId).update({
      currentDispatchId,
      status: 'assigned',
    })

    // Capture initial state
    const beforeReportSnap = await db.collection('reports').doc(reportId).get()
    const beforeReportData = beforeReportSnap.data()
    const beforeEventsSnap = await db
      .collection('report_events')
      .where('reportId', '==', reportId)
      .get()

    // Attempt to cancel the non-current dispatch
    await cancelDispatchCore(db, {
      dispatchId: otherDispatchId,
      // add any other required fields for cancelDispatchCore here (e.g. auth context)
    })

    const afterReportSnap = await db.collection('reports').doc(reportId).get()
    const afterReportData = afterReportSnap.data()
    const afterEventsSnap = await db
      .collection('report_events')
      .where('reportId', '==', reportId)
      .get()

    // Report status and currentDispatchId should not change
    expect(afterReportData).toMatchObject(beforeReportData!)

    // No new report_events entries should be written
    expect(afterEventsSnap.size).toBe(beforeEventsSnap.size)
  })

  // Optionally, add a NOT_FOUND-for-report case once cancelDispatchCore's behavior is confirmed
})

describe('cancelDispatchCore (3b branches)', () => {
  1. Ensure cancelDispatchCore is imported into this test file (e.g. from ../../src/callables/cancel-dispatch or the appropriate module).
  2. Fill in any additional parameters required by cancelDispatchCore in the two new test calls (e.g. auth/user context, municipalityId, etc.) so the function can execute without throwing unrelated errors.
  3. If your schema uses different collection names or field names (e.g. reportsV2, reportEvents), update the db.collection(...) calls and field keys (currentDispatchId, status, reportId) accordingly.
  4. If there are existing helpers for reading report events (e.g. getReportEventsForReport(db, reportId)), you may prefer to use them instead of raw collection('report_events').where(...) queries to stay consistent with the rest of the test suite.
  5. If cancelDispatchCore returns a structured error instead of throwing, adapt the first test assertion from rejects.toMatchObject to the pattern used elsewhere in this test file (for example, checking a returned error object).

Comment on lines +20 to +29
describe('getEligibleResponders', () => {
it('returns only active responders in the target municipality who are on shift', async () => {
await testEnv.withSecurityRulesDisabled(async (ctx) => {
const db = ctx.firestore() as unknown as Firestore
const rtdb = ctx.database() as unknown as Database
await seedResponderDoc(db, {
uid: 'r1',
municipalityId: 'daet',
agencyId: 'bfp-daet',
isActive: true,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add tests for responders with no shift entry and the empty-result case

To fully cover getEligibleResponders, consider adding:

  • No shift entry in RTDB: Seed an active responder in Firestore but omit the RTDB /responder_index/... node, and assert they are not returned, matching the current “RTDB is source of truth for on‑shift” behavior.

  • Empty result: Add a case where no responders match (e.g., municipality or agency with none) and assert result.length === 0, to lock in behavior when filtering and sorting yield no results.

Suggested implementation:

describe('getEligibleResponders', () => {
  it('excludes active responders that have no on-shift entry in RTDB', async () => {
    await testEnv.withSecurityRulesDisabled(async (ctx) => {
      const db = ctx.firestore() as unknown as Firestore
      const rtdb = ctx.database() as unknown as Database

      // Active responder in municipality/agency but NO RTDB shift entry
      await seedResponderDoc(db, {
        uid: 'r-no-shift',
        municipalityId: 'daet',
        agencyId: 'bfp-daet',
        isActive: true,
      })

      // Another responder that is actually on shift to prove the filter works
      await seedResponderDoc(db, {
        uid: 'r-on-shift',
        municipalityId: 'daet',
        agencyId: 'bfp-daet',
        isActive: true,
      })
      await rtdb.ref('/responder_index/daet/bfp-daet/r-on-shift').set({
        uid: 'r-on-shift',
        status: 'on_shift',
      })

      const result = await getEligibleResponders({
        firestore: db,
        database: rtdb,
        municipalityId: 'daet',
        agencyId: 'bfp-daet',
      })

      const uids = result.map((r) => r.uid)
      expect(uids).toContain('r-on-shift')
      expect(uids).not.toContain('r-no-shift')
    })
  })

  it('returns an empty array when no responders match the filters', async () => {
    await testEnv.withSecurityRulesDisabled(async (ctx) => {
      const db = ctx.firestore() as unknown as Firestore
      const rtdb = ctx.database() as unknown as Database

      // Seed responders in a different municipality/agency
      await seedResponderDoc(db, {
        uid: 'r-other',
        municipalityId: 'vinzons',
        agencyId: 'bfp-vinzons',
        isActive: true,
      })
      await rtdb.ref('/responder_index/vinzons/bfp-vinzons/r-other').set({
        uid: 'r-other',
        status: 'on_shift',
      })

      const result = await getEligibleResponders({
        firestore: db,
        database: rtdb,
        municipalityId: 'daet',
        agencyId: 'bfp-daet',
      })

      expect(result).toHaveLength(0)
    })
  })

  it('returns only active responders in the target municipality who are on shift', async () => {

These edits assume:

  1. getEligibleResponders is imported into this test file and accepts an options object with { firestore, database, municipalityId, agencyId } (or a compatible signature). Adjust the call shape if your actual function signature differs.
  2. seedResponderDoc writes responder metadata to Firestore but does not implicitly create RTDB entries; RTDB shift entries are explicitly created via rtdb.ref('/responder_index/...').set(...). If the RTDB path or payload shape differs in your codebase, update the /responder_index/... paths and data objects accordingly.
  3. If your existing tests use different municipality/agency IDs or a factory/helper for RTDB seeding, mirror those conventions (e.g., reuse a seedShiftEntry helper instead of direct rtdb.ref(...).set(...)).
  4. Place these new it blocks alongside the existing ones within the same describe('getEligibleResponders', ...) block, preserving any surrounding setup/teardown logic or shared helpers that are present in parts of the file not shown here.

Comment thread docs/learnings.md

Commit `6546a0e` ("fix validators: cap pendingMediaIds at 20") was made by a subagent and appeared in `git log` but wasn't in the worktree's HEAD. It was on `main`. After `git rebase main`, it appeared at the correct position in history. Always rebase worktrees onto main before starting implementation to avoid this confusion.

### `seedReportAtStatus` uses Firebase Admin Timestamp, incompatible with RulesTestContext
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (typo): Heading uses RulesTestContext while the body refers to RulesTestEnvironment

Please update the heading to use RulesTestEnvironment so it matches the term used in the explanation below and avoids confusion.

Suggested change
### `seedReportAtStatus` uses Firebase Admin Timestamp, incompatible with RulesTestContext
### `seedReportAtStatus` uses Firebase Admin Timestamp, incompatible with RulesTestEnvironment

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 41

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/responder-app/package.json (1)

1-29: ⚠️ Potential issue | 🟠 Major

Unresolved responder-app build failure is a merge blocker.

CI is currently red for @bantayog/responder-app (tsc --noEmit && vite build), so this should not merge until that job is green.

As per coding guidelines: "VERIFY OR IT DIDN'T HAPPEN. Every edit must be followed by tests, linting, and type-checking. Never ignore warnings."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/responder-app/package.json` around lines 1 - 29, CI shows the build for
`@bantayog/responder-app` failing (the "build" script running "tsc --noEmit &&
vite build"); run the same locally (npm run typecheck and npm run build) to
capture TypeScript errors and Vite build failures, fix all reported type errors
in the src code or adjust types/exports in the shared packages referenced
(symbols: package.json "build", "typecheck", and dependencies
"@bantayog/shared-types"/"@bantayog/shared-ui"), add any missing `@types` or
devDependencies needed, and re-run the scripts until "tsc --noEmit && vite
build" completes cleanly before merging.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/admin-desktop/src/app/auth-provider.tsx`:
- Around line 39-50: The onAuthStateChanged listener in useEffect must handle
getIdTokenResult rejections and avoid applying stale async results: when auth
state changes, ensure you setClaims(undefined) and setLoading(false) immediately
for null users; for non-null users, capture an identifier (e.g., currentUid =
u.uid) or a cancelled flag scoped to this effect before calling
u.getIdTokenResult(), and inside the then/catch/finally only call
setClaims/setLoading if the identifier/flag still matches (user hasn't changed);
also add a .catch to handle rejection and a .finally to guarantee
setLoading(false) runs even on error; keep unsubscribe cleanup as-is to cancel
the listener.

In `@apps/admin-desktop/src/app/firebase.ts`:
- Around line 28-31: The current console.warn on missing VITE_RECAPTCHA_SITE_KEY
allows startups in non-emulator environments; change the branch that handles
missing VITE_RECAPTCHA_SITE_KEY (the block that currently calls
console.warn('[firebase] VITE_RECAPTCHA_SITE_KEY not set — App Check disabled.
DO NOT USE IN PRODUCTION.')) to fail fast in production by throwing a
descriptive Error (or calling process.exit(1)) when not running under the
emulator, e.g., check the existing emulator flag/condition and if emulator mode
is false and VITE_RECAPTCHA_SITE_KEY is absent, throw new Error with a clear
message mentioning VITE_RECAPTCHA_SITE_KEY and App Check so deployments cannot
start without the key.

In `@apps/admin-desktop/src/hooks/useEligibleResponders.ts`:
- Around line 18-19: The hook useEligibleResponders leaves stale state when
municipalityId is falsy; update both effects that early-return on
(!municipalityId) to explicitly clear responders and shift by calling
setResponders([]) and setShift(null) (or the existing reset values) before
returning so previous municipality data is not preserved; target the useEffect
blocks that reference municipalityId, responders, shift, setResponders and
setShift and add the clear calls in the early-exit paths.

In `@apps/admin-desktop/src/hooks/useMuniReports.ts`:
- Around line 19-23: The hook useMuniReports currently sets error on failures
but never clears it; update the logic so setError(null) is called both when
municipalityId is falsy (near the branch that calls setRows([]) and
setLoading(false)) and after successful snapshot/fetch handling (where you call
setRows and setLoading on success). Locate uses of municipalityId, setRows,
setLoading and the error state setter (setError) and add calls to clear the
error on boundary change and immediately after successful data processing to
avoid stale errors remaining visible.

In `@apps/admin-desktop/src/hooks/useReportDetail.ts`:
- Around line 27-30: When clearing state because reportId is falsy and when a
successful listener/snapshot updates state, also clear the error state; update
the block in useReportDetail where you do setReport(null) and setOps(null) to
also call setError(null), and likewise add setError(null) inside the
listener/snapshot success handler (the function that sets report and ops around
the code referenced at 41-53) so recovered snapshots remove prior errors.
- Line 37: The current object construction in useReportDetail uses ({ reportId:
s.id, ...(s.data() as Partial<ReportDetail>) } as ReportDetail) which lets the
Firestore document fields overwrite reportId; change the spread order so the
canonical s.id wins by spreading s.data() first and then setting reportId (e.g.,
build the object from ...(s.data() as Partial<ReportDetail>) and then reportId:
s.id), or explicitly remove reportId from the spread before composing the
object—update the expression in useReportDetail that references s.data() and
s.id accordingly so reportId cannot be overridden.

In `@apps/admin-desktop/src/pages/TriageQueuePage.tsx`:
- Around line 27-40: Validate the prompt input instead of force-casting: check
the `reason` string returned by prompt() against the allowed set
('obviously_false','duplicate','test_submission','insufficient_detail') before
calling `callables.rejectReport`; if invalid, show an error/abort. Pass the
validated reason to `callables.rejectReport` (still generate `idempotencyKey`
with `crypto.randomUUID()`), and on success ensure you clear or update the
success banner/UI state consistently. Also replace the empty catch with proper
error logging/notification so exceptions from the async block are surfaced.

In `@apps/responder-app/src/app/auth-provider.tsx`:
- Around line 20-31: The onAuthStateChanged callback currently calls
u.getIdTokenResult() without error handling which can leave the app stuck in
loading; update the handler around u.getIdTokenResult() (the promise returned in
the onAuthStateChanged callback) to add a .catch() that sets claims to null (via
setClaims(null)) on failure and a .finally() that always calls
setLoading(false), ensuring both success and error paths clear the loading state
and reset claims when appropriate.
- Line 2: The package.json for the responder app is missing the firebase runtime
dependency but the code imports onAuthStateChanged and the User type from
'firebase/auth' (see auth-provider.tsx), causing TypeScript to resolve User as
any; add "firebase": "^12.12.0" to the responder-app dependencies in
package.json (matching admin-desktop and citizen-pwa), run npm/yarn install, and
re-run the build so the import of onAuthStateChanged and the User type are
correctly resolved.

In `@apps/responder-app/src/app/firebase.ts`:
- Around line 23-35: The emulator connections are started with non-blocking
dynamic imports causing a race where db/auth/functions/rtdb may be used before
connectFirestoreEmulator, connectAuthEmulator, connectFunctionsEmulator, and
connectDatabaseEmulator finish; fix this by performing the imports and connector
calls synchronously at module initialization (e.g., await the dynamic imports or
use static imports) inside the USE_EMULATOR branch so that
connectFirestoreEmulator(db,...), connectAuthEmulator(auth,...),
connectFunctionsEmulator(functions,...), and connectDatabaseEmulator(rtdb,...)
complete before any exported services are used.
- Line 20: The exported Cloud Functions client is created with getFunctions(app)
which defaults to us-central1; update the exported const functions to call
getFunctions(app, "asia-southeast1") so the client targets the deployed region.
Locate the exported symbol functions and the getFunctions call in the file
(using the app variable) and replace the no-arg call with the region-aware call
matching the pattern used in other modules.

In `@apps/responder-app/src/app/protected-route.tsx`:
- Around line 1-4: The file is using React.ReactNode in the ProtectedRoute
component but never imports React, causing type errors under
verbatimModuleSyntax; update the imports to import ReactNode directly from
'react' (e.g., add "import { ReactNode } from 'react'") and change the component
signature to use that type if necessary; locate the ProtectedRoute function and
the top import block (where Navigate and useLocation are imported) and add the
ReactNode import to match the pattern used in auth-provider.tsx.

In `@apps/responder-app/src/hooks/useOwnDispatches.ts`:
- Around line 18-20: When uid becomes falsy in useOwnDispatches, reset the
hook's local state instead of returning early with stale values; inside the
existing early-return branch where you check `if (!uid) return`, call the state
setters to clear previous data (e.g., `setRows([])`, `setError(null or
undefined)`, and `setLoading(false)` as appropriate) so `rows` and `error` don't
keep showing prior-session dispatches; refer to the hook's state setters
(`setRows`, `setError`, `setLoading`) and `uid` in useOwnDispatches to implement
this change.

In `@docs/learnings.md`:
- Line 370: Update the heading text "ESLint `no-explicit-any` requires combined
disable comment for multiple rules on same line" to improve grammar by inserting
an article: change it to something like "ESLint `no-explicit-any` requires a
combined disable comment for multiple rules on the same line" (or "must be
combined into a single disable comment for multiple rules on the same line");
edit the heading string in docs/learnings.md to reflect this corrected wording.

In `@docs/progress.md`:
- Around line 78-92: The doc shows conflicting status: the heading "## Phase 3b
Admin Triage + Dispatch (Complete)" contradicts the Verification table where
Steps 3–5 are marked PENDING and another section states "In Progress"; change
the canonical status to reflect that the phase is still in progress (e.g.,
update the heading to "(In Progress)" or a neutral "Pending verification") and
ensure the Verification table entries for Steps 3–5 remain PENDING until those
emulator/staging/manual acceptance gates pass; also make the same consistent
update where the duplicate status appears (the other section referenced around
the same Phase 3b content) so both the heading and the verification block use
the same single canonical status and avoid misleading release gating.

In `@docs/runbooks/phase-3b-verify-and-dispatch.md`:
- Around line 163-171: The rollback snippet uses CommonJS require() which breaks
in ESM projects; update the inline Node invocation in the firebase
emulators:exec example to an ESM-compatible form (use dynamic import or run node
with --input-type=module and call initializeApp() then getFirestore()) or add a
short note that the snippet requires CommonJS; specifically replace
require('firebase-admin/firestore')/getFirestore usage with an ESM
import/initializeApp/getFirestore flow (or use dynamic
import('firebase-admin/firestore')) and ensure async/await is handled for the
db.collection(...).update calls.

In `@functions/src/__tests__/callables/dispatch-responder.test.ts`:
- Around line 16-24: The test suite sets up a TestEnvironment with beforeEach
using initializeTestEnvironment and assigns it to testEnv but never tears it
down; add an afterAll that calls testEnv.cleanup() to release resources. Locate
the beforeEach block that calls initializeTestEnvironment and add an
afterAll(async () => { await testEnv.cleanup() }) so the TestEnvironment created
in testEnv is cleaned up after all tests.

In `@functions/src/__tests__/callables/reject-report.test.ts`:
- Around line 8-15: Tests create a RulesTestEnvironment per test but never call
cleanup, causing leaked handles; add an afterEach that awaits testEnv.cleanup()
to properly tear down the environment created by initializeTestEnvironment and
avoid flaky CI—use the existing testEnv variable inside the new afterEach(async
() => { if (testEnv) await testEnv.cleanup(); }) so cleanup runs even if tests
fail.

In `@functions/src/__tests__/callables/verify-report.test.ts`:
- Around line 15-25: Add an afterAll cleanup that calls testEnv.cleanup() to
tear down the Firebase test environment initialized by initializeTestEnvironment
in this test file; specifically, after the existing beforeEach block that sets
testEnv (the variable assigned from initializeTestEnvironment in the
beforeEach), add an afterAll(async () => { await testEnv.cleanup() }) to prevent
resource leaks.

In `@functions/src/__tests__/helpers/seed-factories.ts`:
- Around line 296-301: seedResponderDoc currently sets createdAt and updatedAt
with new Date(), which is inconsistent with other seed helpers; change the
values for createdAt and updatedAt in seedResponderDoc to use Timestamp.now() so
they match seedReportAtStatus and seedDispatch (update the assignments for
createdAt and updatedAt within the seedResponderDoc object).
- Around line 210-211: The two type imports "Firestore" and "Database" are
located mid-file; move "import type { Firestore } from
'firebase-admin/firestore'" and "import type { Database } from
'firebase-admin/database'" to the top of the file with the other imports so they
appear before any function definitions (keep them as type-only imports),
ensuring they remain after the existing initial import block and preserve
ESLint/TS ordering conventions in seed-factories.ts.

In `@functions/src/__tests__/rules/admin-onsnapshot.rules.test.ts`:
- Around line 36-44: The test suite creates a RulesTestEnvironment in the
beforeEach via initializeTestEnvironment assigned to testEnv but never cleans it
up; add an afterAll hook that calls await testEnv.cleanup() to release resources
and avoid port conflicts (i.e., add afterAll(async () => { await
testEnv.cleanup() }) corresponding to the existing
beforeEach/testEnv/initializeTestEnvironment usage).

In `@functions/src/__tests__/services/responder-eligibility.test.ts`:
- Around line 10-18: The test suite creates a TestEnvironment via
initializeTestEnvironment() in beforeEach but never calls cleanup, which can
leak handles; add a teardown that calls testEnv.cleanup() after tests (e.g., in
afterEach or afterAll) to ensure the TestEnvironment is disposed — reference the
testEnv variable and initializeTestEnvironment() used in the beforeEach block
and invoke testEnv.cleanup() in the appropriate teardown hook.

In `@functions/src/callables/cancel-dispatch.ts`:
- Around line 70-82: The code first verifies
CANCELLABLE_FROM_STATES.includes(from) and then redundantly calls
isValidDispatchTransition(from, to); remove the second validation block that
calls isValidDispatchTransition and throws BantayogError (the if
(!isValidDispatchTransition(...)) branch) so that only the explicit
CANCELLABLE_FROM_STATES check remains; keep the existing variables (from, to)
and the BantayogError throw for the cancellable-states check to preserve
behavior.

In `@functions/src/callables/dispatch-responder.ts`:
- Around line 112-119: The code incorrectly asserts const from = report.status
as 'verified' which misrepresents the runtime value; update the
dispatch-responder logic to stop the misleading type cast and either remove the
"as 'verified'" annotation so from uses the actual type of report.status, or add
an explicit runtime check (e.g., if (report.status !== 'verified') throw new
BantayogError(...)) before calling isValidReportTransition; make sure to
reference and keep using isValidReportTransition(from, to) and the existing
BantayogError/BantayogErrorCode when rejecting invalid states.
- Around line 78-88: The RTDB read (shiftSnap via rtdb.ref) inside the Firestore
transaction is not atomic with the Firestore commit; add a compensating
post-commit validation: after the Firestore transaction that creates the
dispatch completes, re-read `/responder_index/.../${deps.responderUid}` and if
isOnShift !== true then immediately call the existing cancelDispatch flow (or
update the newly-created dispatch to a cancelled state) and log the discrepancy;
keep the original RTDB pre-check for TOCTOU mitigation but ensure the
post-commit check (using the same rtdb.ref path and the cancelDispatch routine)
guarantees we don't leave a dispatch assigned to an off-shift responder.

In `@functions/src/callables/reject-report.ts`:
- Line 8: The import for bantayogErrorToHttps uses a .js extension while other
relative imports in this module do not; update the import statement for
bantayogErrorToHttps (the import line referencing './https-error.js') to match
the project's convention by removing the .js extension (i.e., import from
'./https-error'), or alternately add .js to all other relative imports in this
file so they are consistent—pick one approach and apply it consistently across
the module.

In `@functions/src/callables/verify-report.ts`:
- Around line 82-103: The code computes the next state with an explicit state
machine (variables from/to) and then redundantly calls
isValidReportTransition(from, to); remove the redundant validation by either (A)
keeping the explicit mapping (the if/else that sets to) and deleting the
subsequent isValidReportTransition check and its BantayogError throw, or (B)
replace the explicit mapping with a single call to isValidReportTransition to
determine/validate the transition and throw BantayogError with
BantayogErrorCode.INVALID_STATUS_TRANSITION when it returns false; update the
error context to include reportId and from/to as appropriate (refer to symbols
from, to, isValidReportTransition, BantayogError,
BantayogErrorCode.INVALID_STATUS_TRANSITION).
- Around line 4-14: Remove the duplicate import of logDimension and normalize
the relative import extension: keep a single import of logDimension from
'@bantayog/shared-validators' (with isValidReportTransition, BantayogError,
BantayogErrorCode, type ReportStatus) and delete the redundant import at the
bottom; change the import of bantayogErrorToHttps from './https-error.js' to use
the same extension style as the other relative imports (e.g., './https-error')
so imports are consistent and non-duplicated, and then run a quick build to
ensure no path/type errors.

In `@functions/src/firebase-admin.ts`:
- Around line 4-10: The RTDB admin client export (rtdb) is added directly; wrap
its initialization behind a controlled rollout gate so it only initializes in
non-production contexts unless explicitly enabled — check
getApps()/initializeApp usage and the rtdb export and add a guard that reads a
config/ENV flag (e.g. ENABLE_RTDB_ADMIN or NODE_ENV === 'test' /
FIREBASE_EMULATOR) before calling getDatabase(app); also add a clear comment and
require running the emulator + full test suite and an explicit staging approval
before enabling the flag in staging/production.

In `@functions/src/services/rate-limit.ts`:
- Around line 20-33: Validate the incoming RateLimitCheck params before entering
the transaction: ensure limit and windowSeconds are positive integers (>0) (use
Number.isFinite and >0, optionally Math.floor/coerce), and if they are invalid
throw a descriptive Error (e.g., "Invalid rate limit config: limit/windowSeconds
must be > 0") so the caller can handle it; perform this check using the function
parameters (key, limit, windowSeconds, now, updatedAt) before calling
db.runTransaction in checkRateLimit so the transaction math never runs with bad
values.

In `@functions/src/services/responder-eligibility.ts`:
- Around line 33-41: The mapping callback that builds responder objects
inconsistently handles missing fields: displayName and agencyId are null-coerced
but municipalityId is cast directly; change the municipalityId extraction in the
.map callback (the code that uses doc.data() and returns
uid/displayName/agencyId/municipalityId) to defensively coerce like the others
(e.g., use String(data.municipalityId ?? '') or equivalent) so malformed docs
don’t yield undefined or throw; update only the municipalityId line to mirror
displayName/agencyId handling.

In `@infra/terraform/modules/monitoring/phase-3/main.tf`:
- Line 74: The filter in the monitoring module is using jsonPayload.event but
the dispatch responder emits the field as code; update the filter string in
main.tf (the filter variable that currently contains
resource.type="cloud_function" AND jsonPayload.event="dispatch.created" ...) to
use jsonPayload.code="dispatch.created" instead so it matches the logEvent
emission in functions/src/callables/dispatch-responder.ts (the logEvent call
around line 181) and stays consistent with other metrics in the module.
- Around line 71-73: Update the google_logging_metric resource
google_logging_metric.dispatch_created to use var.environment (not var.env) for
the name, add project = var.project_id to match other metrics, and change the
log filter discriminator from jsonPayload.event to jsonPayload.code; set the
code value to the canonical string used for dispatch creation logs (verify the
exact code value for dispatch.created in the logs and use that string, e.g.,
"DISPATCH_CREATED" if that is the canonical code).

In `@packages/shared-validators/src/dispatches.ts`:
- Around line 36-40: The nested assignedTo schema (defined as assignedTo:
z.object({...})) is not strict by default; update the assignedTo definition to
use z.object({...}).strict() so unknown keys in the nested object are rejected,
ensuring per-level boundary validation; locate the assignedTo z.object and
append .strict() to it (keeping surrounding top-level .strict() intact).

In `@packages/shared-validators/src/errors-and-logging.test.ts`:
- Around line 14-16: The test "has 19 named error codes" currently only asserts
the enum length; update it to also assert the presence of the newly introduced
member on BantayogErrorCode by adding an explicit membership assertion (e.g.,
check that Object.values(BantayogErrorCode) contains the new enum member or that
BantayogErrorCode has the specific property name) — reference the
BantayogErrorCode symbol and replace <NEW_MEMBER_NAME> with the actual enum key
added.

In `@packages/shared-validators/src/responders.ts`:
- Line 11: You added a new required field isActive in the responder schema which
requires a formal migration: create a runbook at
docs/runbooks/migrations/<YYYY-MM-DD>-responder.md describing old/new schema,
trigger compat matrix, backfill strategy, rollback and monitoring; increment the
responder schemaVersion constant where the schema is defined; implement branched
read/write handling so code that reads responders accepts both documents without
isActive and with isActive (e.g., add compatibility logic around the schema
validator or in the functions that load responders), add a scheduled backfill
job to set isActive on existing documents, run the migration in the dev emulator
and test suite, then get staging approval before deploying to production.

In `@scripts/phase-3b/acceptance.ts`:
- Line 4: Remove the unused import getFunctions from the top of the file (the
import statement "import { getFunctions } from 'firebase-admin/functions'")
since it's never referenced; simply delete that named import or the entire
import line if nothing else is imported from that module to satisfy the linter
and avoid unused-import warnings.
- Around line 86-88: When EMU is true the script only calls
connectAuthEmulator(webAuth, ...) but never connects the Functions emulator, so
callable functions still hit production; add a call to the Functions emulator
inside the same EMU block (e.g., invoke connectFunctionsEmulator with your
Functions instance—referencing the symbol used in this file such as webFunctions
or functions—and the local host/port for the emulator like localhost and 5001),
and ensure the connectFunctionsEmulator import is present so local callable
invocations target the emulator.
- Around line 82-84: webInitApp is called with only appId causing incomplete
Firebase initialization; update the webInitApp call to include at least
projectId and apiKey (keep appId:'demo'), then call webGetAuth(webApp) as before
and initialize functions with webGetFunctions(webApp, { region:
'asia-southeast1' }) so the client targets the correct region; finally, when EMU
is true, call connectFunctionsEmulator(webFunctions, 'localhost', 5001) (or
equivalent) to connect to the local emulator so function invocations work
locally.

In `@scripts/phase-3b/bootstrap-test-responder.ts`:
- Around line 48-54: Replace the brittle substring check in the catch block that
tests err.message with a check against the Firebase error code: inspect err.code
(e.g., compare to 'auth/uid-already-exists') instead of
err.message.includes('already'), so in the catch for the user creation flow (the
catch handling the variable err in scripts/phase-3b/bootstrap-test-responder.ts)
log '[bootstrap] auth user already exists' when err.code ===
'auth/uid-already-exists' and rethrow otherwise; use a safe cast or type guard
(e.g., treat err as any or check typeof err === 'object' && 'code' in err) so
you don’t rely on message parsing.

---

Outside diff comments:
In `@apps/responder-app/package.json`:
- Around line 1-29: CI shows the build for `@bantayog/responder-app` failing (the
"build" script running "tsc --noEmit && vite build"); run the same locally (npm
run typecheck and npm run build) to capture TypeScript errors and Vite build
failures, fix all reported type errors in the src code or adjust types/exports
in the shared packages referenced (symbols: package.json "build", "typecheck",
and dependencies "@bantayog/shared-types"/"@bantayog/shared-ui"), add any
missing `@types` or devDependencies needed, and re-run the scripts until "tsc
--noEmit && vite build" completes cleanly before merging.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5eba9209-9259-4239-98c7-c995d7b0f799

📥 Commits

Reviewing files that changed from the base of the PR and between 869bcd7 and 53ec5a4.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (55)
  • apps/admin-desktop/package.json
  • apps/admin-desktop/src/App.tsx
  • apps/admin-desktop/src/app/auth-provider.tsx
  • apps/admin-desktop/src/app/firebase.ts
  • apps/admin-desktop/src/app/protected-route.tsx
  • apps/admin-desktop/src/hooks/useEligibleResponders.ts
  • apps/admin-desktop/src/hooks/useMuniReports.ts
  • apps/admin-desktop/src/hooks/useReportDetail.ts
  • apps/admin-desktop/src/main.tsx
  • apps/admin-desktop/src/pages/DispatchModal.tsx
  • apps/admin-desktop/src/pages/LoginPage.tsx
  • apps/admin-desktop/src/pages/ReportDetailPanel.tsx
  • apps/admin-desktop/src/pages/TriageQueuePage.tsx
  • apps/admin-desktop/src/routes.tsx
  • apps/admin-desktop/src/services/callables.ts
  • apps/responder-app/package.json
  • apps/responder-app/src/App.tsx
  • apps/responder-app/src/app/auth-provider.tsx
  • apps/responder-app/src/app/firebase.ts
  • apps/responder-app/src/app/protected-route.tsx
  • apps/responder-app/src/hooks/useOwnDispatches.ts
  • apps/responder-app/src/main.tsx
  • apps/responder-app/src/pages/DispatchListPage.tsx
  • apps/responder-app/src/pages/LoginPage.tsx
  • apps/responder-app/src/routes.tsx
  • docs/learnings.md
  • docs/progress.md
  • docs/runbooks/phase-3b-verify-and-dispatch.md
  • functions/src/__tests__/callables/cancel-dispatch.test.ts
  • functions/src/__tests__/callables/dispatch-responder.test.ts
  • functions/src/__tests__/callables/reject-report.test.ts
  • functions/src/__tests__/callables/verify-report.test.ts
  • functions/src/__tests__/helpers/seed-factories.ts
  • functions/src/__tests__/rules/admin-onsnapshot.rules.test.ts
  • functions/src/__tests__/rules/dispatches.rules.test.ts
  • functions/src/__tests__/services/rate-limit.test.ts
  • functions/src/__tests__/services/responder-eligibility.test.ts
  • functions/src/callables/cancel-dispatch.ts
  • functions/src/callables/dispatch-responder.ts
  • functions/src/callables/https-error.ts
  • functions/src/callables/reject-report.ts
  • functions/src/callables/verify-report.ts
  • functions/src/firebase-admin.ts
  • functions/src/index.ts
  • functions/src/services/rate-limit.ts
  • functions/src/services/responder-eligibility.ts
  • infra/firebase/firestore.indexes.json
  • infra/terraform/modules/monitoring/phase-3/main.tf
  • packages/shared-validators/src/dispatches.test.ts
  • packages/shared-validators/src/dispatches.ts
  • packages/shared-validators/src/errors-and-logging.test.ts
  • packages/shared-validators/src/errors.ts
  • packages/shared-validators/src/responders.ts
  • scripts/phase-3b/acceptance.ts
  • scripts/phase-3b/bootstrap-test-responder.ts

Comment on lines +39 to +50
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (u) => {
setUser(u)
if (u) {
void u.getIdTokenResult().then((tok) => {
setClaims({
role: tok.claims.role as string | undefined,
municipalityId: tok.claims.municipalityId as string | undefined,
active: tok.claims.active === true,
} as AdminClaims)
setLoading(false)
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle token-fetch failures and stale async resolution in auth state listener.

If getIdTokenResult() rejects, loading may never flip to false. Also, async completion can race after auth state changes and set stale claims.

Proposed fix
   useEffect(() => {
     const unsubscribe = onAuthStateChanged(auth, (u) => {
       setUser(u)
       if (u) {
-        void u.getIdTokenResult().then((tok) => {
-          setClaims({
-            role: tok.claims.role as string | undefined,
-            municipalityId: tok.claims.municipalityId as string | undefined,
-            active: tok.claims.active === true,
-          } as AdminClaims)
-          setLoading(false)
-        })
+        void u
+          .getIdTokenResult()
+          .then((tok) => {
+            if (auth.currentUser?.uid !== u.uid) return
+            setClaims({
+              role: tok.claims.role as string | undefined,
+              municipalityId: tok.claims.municipalityId as string | undefined,
+              active: tok.claims.active === true,
+            } as AdminClaims)
+          })
+          .catch(() => {
+            setClaims(null)
+          })
+          .finally(() => {
+            if (auth.currentUser?.uid === u.uid) setLoading(false)
+          })
       } else {
         setClaims(null)
         setLoading(false)
       }
     })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (u) => {
setUser(u)
if (u) {
void u.getIdTokenResult().then((tok) => {
setClaims({
role: tok.claims.role as string | undefined,
municipalityId: tok.claims.municipalityId as string | undefined,
active: tok.claims.active === true,
} as AdminClaims)
setLoading(false)
})
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (u) => {
setUser(u)
if (u) {
void u
.getIdTokenResult()
.then((tok) => {
if (auth.currentUser?.uid !== u.uid) return
setClaims({
role: tok.claims.role as string | undefined,
municipalityId: tok.claims.municipalityId as string | undefined,
active: tok.claims.active === true,
} as AdminClaims)
})
.catch(() => {
setClaims(null)
})
.finally(() => {
if (auth.currentUser?.uid === u.uid) setLoading(false)
})
} else {
setClaims(null)
setLoading(false)
}
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-desktop/src/app/auth-provider.tsx` around lines 39 - 50, The
onAuthStateChanged listener in useEffect must handle getIdTokenResult rejections
and avoid applying stale async results: when auth state changes, ensure you
setClaims(undefined) and setLoading(false) immediately for null users; for
non-null users, capture an identifier (e.g., currentUid = u.uid) or a cancelled
flag scoped to this effect before calling u.getIdTokenResult(), and inside the
then/catch/finally only call setClaims/setLoading if the identifier/flag still
matches (user hasn't changed); also add a .catch to handle rejection and a
.finally to guarantee setLoading(false) runs even on error; keep unsubscribe
cleanup as-is to cancel the listener.

Comment on lines +28 to +31
} else {
console.warn(
'[firebase] VITE_RECAPTCHA_SITE_KEY not set — App Check disabled. DO NOT USE IN PRODUCTION.',
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fail fast in production when App Check key is missing.

In non-emulator mode, missing VITE_RECAPTCHA_SITE_KEY only logs a warning and continues without App Check. That weakens abuse protection if misconfigured deployment reaches prod.

🔧 Proposed guard
   } else {
-    console.warn(
-      '[firebase] VITE_RECAPTCHA_SITE_KEY not set — App Check disabled. DO NOT USE IN PRODUCTION.',
-    )
+    if (import.meta.env.PROD) {
+      throw new Error(
+        '[firebase] Missing VITE_RECAPTCHA_SITE_KEY in production; refusing to start without App Check.',
+      )
+    }
+    console.warn('[firebase] VITE_RECAPTCHA_SITE_KEY not set — App Check disabled in non-prod.')
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-desktop/src/app/firebase.ts` around lines 28 - 31, The current
console.warn on missing VITE_RECAPTCHA_SITE_KEY allows startups in non-emulator
environments; change the branch that handles missing VITE_RECAPTCHA_SITE_KEY
(the block that currently calls console.warn('[firebase] VITE_RECAPTCHA_SITE_KEY
not set — App Check disabled. DO NOT USE IN PRODUCTION.')) to fail fast in
production by throwing a descriptive Error (or calling process.exit(1)) when not
running under the emulator, e.g., check the existing emulator flag/condition and
if emulator mode is false and VITE_RECAPTCHA_SITE_KEY is absent, throw new Error
with a clear message mentioning VITE_RECAPTCHA_SITE_KEY and App Check so
deployments cannot start without the key.

Comment thread apps/admin-desktop/src/hooks/useEligibleResponders.ts Outdated
Comment on lines +19 to +23
if (!municipalityId) {
setRows([])
setLoading(false)
return
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reset error on boundary change and successful snapshots.

The hook sets error on failures but never clears it on success (or when municipalityId becomes undefined). This can leave the page stuck showing stale errors.

Proposed fix
   useEffect(() => {
     if (!municipalityId) {
       setRows([])
+      setError(null)
       setLoading(false)
       return
     }
+    setError(null)
     setLoading(true)
@@
       (snap) => {
         setRows(
@@
         )
+        setError(null)
         setLoading(false)
       },

Also applies to: 34-50

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-desktop/src/hooks/useMuniReports.ts` around lines 19 - 23, The
hook useMuniReports currently sets error on failures but never clears it; update
the logic so setError(null) is called both when municipalityId is falsy (near
the branch that calls setRows([]) and setLoading(false)) and after successful
snapshot/fetch handling (where you call setRows and setLoading on success).
Locate uses of municipalityId, setRows, setLoading and the error state setter
(setError) and add calls to clear the error on boundary change and immediately
after successful data processing to avoid stale errors remaining visible.

Comment on lines +27 to +30
if (!reportId) {
setReport(null)
setOps(null)
return
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reset error on report switch and successful snapshots.

error is only ever appended/set on failures. It should be cleared when reportId changes to empty and when listeners recover successfully.

Proposed fix
   useEffect(() => {
     if (!reportId) {
       setReport(null)
       setOps(null)
+      setError(null)
       return
     }
+    setError(null)
     const u1 = onSnapshot(
       doc(db, 'reports', reportId),
       (s) => {
@@
         )
+        setError(null)
       },
@@
       (s) => {
         setOps(s.exists() ? (s.data() as ReportOps) : null)
+        setError(null)
       },

Also applies to: 41-53

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-desktop/src/hooks/useReportDetail.ts` around lines 27 - 30, When
clearing state because reportId is falsy and when a successful listener/snapshot
updates state, also clear the error state; update the block in useReportDetail
where you do setReport(null) and setOps(null) to also call setError(null), and
likewise add setError(null) inside the listener/snapshot success handler (the
function that sets report and ops around the code referenced at 41-53) so
recovered snapshots remove prior errors.

Comment on lines +14 to +16
it('has 19 named error codes', () => {
const codes = Object.values(BantayogErrorCode)
expect(codes).toHaveLength(18)
expect(codes).toHaveLength(19)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider hardening this assertion with an explicit member check.

The count change is correct, but adding a direct assertion for the newly introduced code makes this test more robust.

♻️ Suggested test hardening
   it('has 19 named error codes', () => {
     const codes = Object.values(BantayogErrorCode)
     expect(codes).toHaveLength(19)
+    expect(codes).toContain(BantayogErrorCode.FAILED_PRECONDITION)
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/shared-validators/src/errors-and-logging.test.ts` around lines 14 -
16, The test "has 19 named error codes" currently only asserts the enum length;
update it to also assert the presence of the newly introduced member on
BantayogErrorCode by adding an explicit membership assertion (e.g., check that
Object.values(BantayogErrorCode) contains the new enum member or that
BantayogErrorCode has the specific property name) — reference the
BantayogErrorCode symbol and replace <NEW_MEMBER_NAME> with the actual enum key
added.

displayCode: z.string().min(1),
specialisations: z.array(z.string()).default([]),
availabilityStatus: z.enum(['on_duty', 'off_duty', 'on_break', 'unavailable']),
isActive: z.boolean(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n packages/shared-validators/src/responders.ts

Repository: Exc1D/bantayog-alert

Length of output: 814


🏁 Script executed:

# Search for migration files related to responders
fd -t f -i '*migrat*' --exec grep -l 'isActive\|responder' {} \;

Repository: Exc1D/bantayog-alert

Length of output: 351


🏁 Script executed:

# Search for any backfill or data migration scripts
rg -t ts -t js 'isActive.*responder|responder.*isActive|backfill' --

Repository: Exc1D/bantayog-alert

Length of output: 152


🏁 Script executed:

# Check if there's a Cloud Functions or server-side code handling this
fd -t f -i '*responder*' | head -20

Repository: Exc1D/bantayog-alert

Length of output: 354


🏁 Script executed:

# Retry with proper glob syntax for migration files
fd --glob '**/*migrat*' -t f

Repository: Exc1D/bantayog-alert

Length of output: 97


🏁 Script executed:

# Search for responder factory, seed, or initialization functions
rg -t ts -t js 'export.*responder|createResponder|responderFactory' -A 3

Repository: Exc1D/bantayog-alert

Length of output: 2030


🏁 Script executed:

# Check Firestore initialization or seed scripts
fd --glob '**/seed*' -o --glob '**/init*' -t f

Repository: Exc1D/bantayog-alert

Length of output: 180


🏁 Script executed:

# Look for test fixtures that might show expected document structure
rg -t ts 'responderDocSchema|ResponderDoc' -B 2 -A 5

Repository: Exc1D/bantayog-alert

Length of output: 10870


🏁 Script executed:

cat -n functions/src/__tests__/helpers/seed-factories.ts | head -100

Repository: Exc1D/bantayog-alert

Length of output: 4084


🏁 Script executed:

cat -n docs/runbooks/schema-migration.md

Repository: Exc1D/bantayog-alert

Length of output: 3366


🏁 Script executed:

# Check if there's any default/fallback handling for isActive
rg 'isActive.*\?' -t ts -A 2 -B 2

Repository: Exc1D/bantayog-alert

Length of output: 46


🏁 Script executed:

# Check for any migration or backfill Cloud Functions
fd --glob '**/migration*' --glob '**/backfill*' -o --glob '**/backfill*' -t f

Repository: Exc1D/bantayog-alert

Length of output: 180


🏁 Script executed:

# Get full seedResponderDoc implementation
rg -A 50 'export async function seedResponderDoc' functions/src/__tests__/helpers/seed-factories.ts

Repository: Exc1D/bantayog-alert

Length of output: 1352


🏁 Script executed:

# Check if schemaVersion has been incremented for responders
rg 'schemaVersion' packages/shared-validators/src/responders.ts

Repository: Exc1D/bantayog-alert

Length of output: 111


🏁 Script executed:

# Look for migration plan documents
fd --glob 'docs/runbooks/migrations/*' -t f

Repository: Exc1D/bantayog-alert

Length of output: 46


🏁 Script executed:

# Check for any existing migration progress tracking
fd --glob '**/migrations/*' -t f | head -20

Repository: Exc1D/bantayog-alert

Length of output: 46


Schema migration protocol required: isActive is a new required field without backfill plan.

Per docs/runbooks/schema-migration.md Stage 1, adding a required field without a default to an already-populated collection triggers the full migration protocol. Existing responder documents lack isActive, so:

  1. The strict schema will reject legacy reads/writes
  2. Dispatch eligibility filtering (isActive !== true) silently drops untagged responders

Before deployment:

  • Write a migration plan in docs/runbooks/migrations/<YYYY-MM-DD>-responder.md covering: old/new schema, trigger compat matrix, backfill strategy, rollback, monitoring
  • Increment schemaVersion in the responder schema
  • Implement branched read paths accepting both old and new versions during the 30-day migration window
  • Schedule a backfill function to update existing documents
  • Deploy to dev emulator, test full suite, then request explicit staging approval
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/shared-validators/src/responders.ts` at line 11, You added a new
required field isActive in the responder schema which requires a formal
migration: create a runbook at
docs/runbooks/migrations/<YYYY-MM-DD>-responder.md describing old/new schema,
trigger compat matrix, backfill strategy, rollback and monitoring; increment the
responder schemaVersion constant where the schema is defined; implement branched
read/write handling so code that reads responders accepts both documents without
isActive and with isActive (e.g., add compatibility logic around the schema
validator or in the functions that load responders), add a scheduled backfill
job to set isActive on existing documents, run the migration in the dev emulator
and test suite, then get staging approval before deploying to production.

Comment thread scripts/phase-3b/acceptance.ts Outdated
Comment on lines +86 to +88
if (EMU) {
connectAuthEmulator(webAuth, 'http://localhost:9099', { disableWarnings: true })
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Connect Functions emulator when in EMU mode.

The script connects the Auth emulator but doesn't connect the Functions emulator. Without this, callable invocations will attempt to reach production/deployed functions instead of the local emulator.

🐛 Proposed fix
+import { connectFunctionsEmulator } from 'firebase/functions'
...
   if (EMU) {
     connectAuthEmulator(webAuth, 'http://localhost:9099', { disableWarnings: true })
+    connectFunctionsEmulator(webFunctions, 'localhost', 5001)
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/phase-3b/acceptance.ts` around lines 86 - 88, When EMU is true the
script only calls connectAuthEmulator(webAuth, ...) but never connects the
Functions emulator, so callable functions still hit production; add a call to
the Functions emulator inside the same EMU block (e.g., invoke
connectFunctionsEmulator with your Functions instance—referencing the symbol
used in this file such as webFunctions or functions—and the local host/port for
the emulator like localhost and 5001), and ensure the connectFunctionsEmulator
import is present so local callable invocations target the emulator.

Comment on lines +48 to +54
} catch (err: unknown) {
if (err instanceof Error && err.message.includes('already')) {
console.log('[bootstrap] auth user already exists')
} else {
throw err
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fragile error detection: check error code instead of message substring.

Checking err.message.includes('already') is brittle—the Firebase Admin SDK provides structured error codes. The auth/uid-already-exists code is stable across SDK versions.

🛡️ Proposed fix using error code
   } catch (err: unknown) {
-    if (err instanceof Error && err.message.includes('already')) {
+    if (
+      err instanceof Error &&
+      'code' in err &&
+      (err as { code: string }).code === 'auth/uid-already-exists'
+    ) {
       console.log('[bootstrap] auth user already exists')
     } else {
       throw err
     }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (err: unknown) {
if (err instanceof Error && err.message.includes('already')) {
console.log('[bootstrap] auth user already exists')
} else {
throw err
}
}
} catch (err: unknown) {
if (
err instanceof Error &&
'code' in err &&
(err as { code: string }).code === 'auth/uid-already-exists'
) {
console.log('[bootstrap] auth user already exists')
} else {
throw err
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/phase-3b/bootstrap-test-responder.ts` around lines 48 - 54, Replace
the brittle substring check in the catch block that tests err.message with a
check against the Firebase error code: inspect err.code (e.g., compare to
'auth/uid-already-exists') instead of err.message.includes('already'), so in the
catch for the user creation flow (the catch handling the variable err in
scripts/phase-3b/bootstrap-test-responder.ts) log '[bootstrap] auth user already
exists' when err.code === 'auth/uid-already-exists' and rethrow otherwise; use a
safe cast or type guard (e.g., treat err as any or check typeof err === 'object'
&& 'code' in err) so you don’t rely on message parsing.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review continued from previous batch...

Comment on lines +82 to +84
const webApp = webInitApp({ appId: 'demo' })
const webAuth = webGetAuth(webApp)
const webFunctions = webGetFunctions(webApp)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Firebase web SDK initializeApp minimum required config fields

💡 Result:

The Firebase web SDK's initializeApp function accepts a FirebaseOptions config object with no TypeScript-required fields—all properties are optional. However, in practice, a minimal valid configuration typically requires at least the following fields obtained from the Firebase console when registering a web app: - apiKey: string (required for most API calls) - authDomain: string (for Authentication) - projectId: string (identifies the project) - storageBucket: string (for Cloud Storage) - messagingSenderId: string (for Cloud Messaging) - appId: string (unique app identifier, essential for app association) The complete standard config object includes these plus optional fields like databaseURL and measurementId. Example minimal config: const firebaseConfig = { apiKey: "AIza...", authDomain: "project.firebaseapp.com", projectId: "project", storageBucket: "project.appspot.com", messagingSenderId: "123456789", appId: "1:123456789:web:abcdef123456" }; const app = initializeApp(firebaseConfig); Note: In specific environments like Firebase App Hosting, initializeApp can be called with no arguments if FIREBASE_WEBAPP_CONFIG is set. For general web use, provide the full config from the console.

Citations:


🏁 Script executed:

# First, locate and read the acceptance.ts file
find . -name "acceptance.ts" -type f | head -20

Repository: Exc1D/bantayog-alert

Length of output: 129


🏁 Script executed:

# Search for webInitApp and webGetFunctions definitions
rg -B2 -A8 "function webInitApp|export.*webInitApp|const webInitApp" --type ts

Repository: Exc1D/bantayog-alert

Length of output: 46


🏁 Script executed:

# Search for webGetFunctions definitions
rg -B2 -A8 "function webGetFunctions|export.*webGetFunctions|const webGetFunctions" --type ts

Repository: Exc1D/bantayog-alert

Length of output: 46


🏁 Script executed:

# Read the acceptance.ts file
cat -n ./scripts/phase-3b/acceptance.ts | head -100

Repository: Exc1D/bantayog-alert

Length of output: 4254


🏁 Script executed:

# Search for imports of webInitApp, webGetAuth, webGetFunctions
rg "webInitApp|webGetAuth|webGetFunctions" --type ts -B2 -A2

Repository: Exc1D/bantayog-alert

Length of output: 1153


🏁 Script executed:

# Search for web* functions more broadly - might be from SDK
rg "export.*web(Init|Get)" --type ts

Repository: Exc1D/bantayog-alert

Length of output: 46


🏁 Script executed:

# Search for other usages of webGetFunctions or getFunctions in web SDK context
rg "getFunctions\(|getAuth\(|initializeApp\(" --type ts apps/citizen-pwa -B2 -A3

Repository: Exc1D/bantayog-alert

Length of output: 450


🏁 Script executed:

# Check if there's any Firebase config or setup related to emulator in web SDK
rg "connectAuthEmulator|connectFunctionsEmulator|EMULATOR" --type ts

Repository: Exc1D/bantayog-alert

Length of output: 2673


🏁 Script executed:

# Check if acceptance.ts continues and calls connectFunctionsEmulator anywhere
cat -n ./scripts/phase-3b/acceptance.ts | sed -n '80,150p'

Repository: Exc1D/bantayog-alert

Length of output: 3222


🏁 Script executed:

# Search for connectFunctionsEmulator in entire acceptance.ts
rg "connectFunctionsEmulator" ./scripts/phase-3b/acceptance.ts

Repository: Exc1D/bantayog-alert

Length of output: 46


🏁 Script executed:

# Check how admin-desktop and responder-app set up initializeApp for comparison
cat -n ./apps/admin-desktop/src/app/firebase.ts

Repository: Exc1D/bantayog-alert

Length of output: 2275


Incomplete Firebase web SDK initialization and missing emulator setup.

Three issues here:

  1. webInitApp({ appId: 'demo' }) is missing required configuration fields. Provide at least projectId and apiKey to initialize the app properly.
  2. webGetFunctions(webApp) doesn't specify the asia-southeast1 region. The backend Cloud Functions are deployed to that region, so the client must target it.
  3. Missing connectFunctionsEmulator() call in emulator mode. When EMU is true, you must connect the Functions client to the local emulator (on port 5001) or Cloud Function invocations will fail.
🐛 Proposed fix
   const webApp = webInitApp({
+    apiKey: 'demo-key',
     appId: 'demo',
+    projectId: PROJECT_ID,
   })
   const webAuth = webGetAuth(webApp)
-  const webFunctions = webGetFunctions(webApp)
+  const webFunctions = webGetFunctions(webApp, 'asia-southeast1')

   if (EMU) {
     connectAuthEmulator(webAuth, 'http://localhost:9099', { disableWarnings: true })
+    // Import connectFunctionsEmulator at the top
+    connectFunctionsEmulator(webFunctions, 'localhost', 5001)
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/phase-3b/acceptance.ts` around lines 82 - 84, webInitApp is called
with only appId causing incomplete Firebase initialization; update the
webInitApp call to include at least projectId and apiKey (keep appId:'demo'),
then call webGetAuth(webApp) as before and initialize functions with
webGetFunctions(webApp, { region: 'asia-southeast1' }) so the client targets the
correct region; finally, when EMU is true, call
connectFunctionsEmulator(webFunctions, 'localhost', 5001) (or equivalent) to
connect to the local emulator so function invocations work locally.

…ort, function or class'

remove import { getFunctions } from 'firebase-admin/functions'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
scripts/phase-3b/acceptance.ts (1)

81-87: ⚠️ Potential issue | 🟠 Major

Wire the Functions client for both region and emulator mode.

Line 83 currently uses default region, and Line 85-87 only connects Auth emulator. In EMU mode this can still call deployed functions; in non-EMU mode it can target the wrong region.

🐛 Proposed fix
-import { httpsCallable, getFunctions as webGetFunctions } from 'firebase/functions'
+import { httpsCallable, getFunctions as webGetFunctions, connectFunctionsEmulator } from 'firebase/functions'
@@
-  const webApp = webInitApp({ appId: 'demo' })
+  const webApp = webInitApp({
+    appId: 'demo',
+    projectId: PROJECT_ID,
+    apiKey: process.env.FIREBASE_WEB_API_KEY ?? 'demo-key',
+  })
   const webAuth = webGetAuth(webApp)
-  const webFunctions = webGetFunctions(webApp)
+  const webFunctions = webGetFunctions(webApp, 'asia-southeast1')
@@
   if (EMU) {
     connectAuthEmulator(webAuth, 'http://localhost:9099', { disableWarnings: true })
+    connectFunctionsEmulator(webFunctions, 'localhost', 5001)
   }
Firebase JS SDK v9+: For callable functions deployed outside us-central1, is getFunctions(app, "<region>") required? In emulator mode, is connectFunctionsEmulator(functions, host, port) required to avoid hitting deployed functions?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/phase-3b/acceptance.ts` around lines 81 - 87, The Functions client is
currently created with the default region and never wired to the emulator;
update the initialization to explicitly request the deployed region via
getFunctions(webApp, <region>) (use your deployed region constant or same region
used elsewhere) instead of the default, and when EMU is true call
connectFunctionsEmulator(webFunctions, 'localhost', 5001) (or the emulator
host/port you run) after creating webFunctions so callable functions hit the
emulator rather than deployed instances; update references around
webGetFunctions and webFunctions accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@scripts/phase-3b/acceptance.ts`:
- Around line 71-77: The script calls adminAuth.setCustomUserClaims(adminUid,
...) and then adminAuth.createCustomToken(adminUid) but will fail if the user
with uid adminUid doesn't exist; modify the flow to first check for or fetch the
user (using adminAuth.getUser or getUserByEmail) and if not found create the
user with adminUid (and reasonable defaults like displayName/email if needed),
then call adminAuth.setCustomUserClaims(adminUid, {...}) and
adminAuth.createCustomToken(adminUid); ensure errors from getUser/createUser are
handled or surfaced so the acceptance run is deterministic.

---

Duplicate comments:
In `@scripts/phase-3b/acceptance.ts`:
- Around line 81-87: The Functions client is currently created with the default
region and never wired to the emulator; update the initialization to explicitly
request the deployed region via getFunctions(webApp, <region>) (use your
deployed region constant or same region used elsewhere) instead of the default,
and when EMU is true call connectFunctionsEmulator(webFunctions, 'localhost',
5001) (or the emulator host/port you run) after creating webFunctions so
callable functions hit the emulator rather than deployed instances; update
references around webGetFunctions and webFunctions accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d92fa6dc-8f16-4842-b541-c20dcf35b6f2

📥 Commits

Reviewing files that changed from the base of the PR and between 53ec5a4 and 4879853.

📒 Files selected for processing (1)
  • scripts/phase-3b/acceptance.ts

Comment on lines +71 to +77
const adminUid = 'daet-admin-test-01'
await adminAuth.setCustomUserClaims(adminUid, {
role: 'municipal_admin',
municipalityId: 'daet',
active: true,
})
const adminCustomToken = await adminAuth.createCustomToken(adminUid)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does firebase-admin Auth setCustomUserClaims(uid, claims) require that the user already exists? What error is returned when uid does not exist?

💡 Result:

Yes, firebase-admin Auth.setCustomUserClaims(uid, claims) requires that the user already exists in Firebase Authentication. It operates on an existing user record. When the uid does not exist, it returns an error with code auth/user-not-found and message "There is no user record corresponding to the provided identifier." (or similar phrasing like "There is no existing user record corresponding to the provided identifier."). This is confirmed by the official Firebase Admin Authentication API Errors documentation listing auth/user-not-found for operations requiring an existing user, and real-world examples where setCustomUserClaims fails with this exact error and message when the user does not exist.

Citations:


🏁 Script executed:

cat -n scripts/phase-3b/acceptance.ts | sed -n '60,90p'

Repository: Exc1D/bantayog-alert

Length of output: 1259


🏁 Script executed:

rg -n "daet-admin-test-01|createUser|getUser" scripts/phase-3b/acceptance.ts

Repository: Exc1D/bantayog-alert

Length of output: 106


🏁 Script executed:

head -n 70 scripts/phase-3b/acceptance.ts | tail -n 30

Repository: Exc1D/bantayog-alert

Length of output: 878


🏁 Script executed:

head -n 40 scripts/phase-3b/acceptance.ts

Repository: Exc1D/bantayog-alert

Length of output: 1591


🏁 Script executed:

wc -l scripts/phase-3b/acceptance.ts

Repository: Exc1D/bantayog-alert

Length of output: 98


🏁 Script executed:

cat -n scripts/phase-3b/acceptance.ts | sed -n '1,120p'

Repository: Exc1D/bantayog-alert

Length of output: 4979


🏁 Script executed:

cat -n scripts/phase-3b/acceptance.ts | sed -n '120,209p'

Repository: Exc1D/bantayog-alert

Length of output: 3762


Ensure the admin user exists before setting custom claims.

setCustomUserClaims() will fail with auth/user-not-found if the user account doesn't already exist, making this acceptance script non-deterministic and dependent on external state.

🐛 Proposed fix
   const adminUid = 'daet-admin-test-01'
+  try {
+    await adminAuth.getUser(adminUid)
+  } catch (err: unknown) {
+    const code = (err as { code?: string }).code
+    if (code === 'auth/user-not-found') {
+      await adminAuth.createUser({ uid: adminUid })
+    } else {
+      throw err
+    }
+  }
   await adminAuth.setCustomUserClaims(adminUid, {
     role: 'municipal_admin',
     municipalityId: 'daet',
     active: true,
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/phase-3b/acceptance.ts` around lines 71 - 77, The script calls
adminAuth.setCustomUserClaims(adminUid, ...) and then
adminAuth.createCustomToken(adminUid) but will fail if the user with uid
adminUid doesn't exist; modify the flow to first check for or fetch the user
(using adminAuth.getUser or getUserByEmail) and if not found create the user
with adminUid (and reasonable defaults like displayName/email if needed), then
call adminAuth.setCustomUserClaims(adminUid, {...}) and
adminAuth.createCustomToken(adminUid); ensure errors from getUser/createUser are
handled or surfaced so the acceptance run is deterministic.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

♻️ Duplicate comments (1)
functions/src/callables/cancel-dispatch.ts (1)

80-82: ⚠️ Potential issue | 🔴 Critical

pending -> cancelled is currently impossible here.

CANCELLABLE_FROM_STATES admits pending, but the shared dispatch state machine does not define pending -> cancelled, so this branch throws on every valid cancellation attempt. That makes the success path unreachable until the shared transition table is updated or this second check is removed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@functions/src/callables/cancel-dispatch.ts` around lines 80 - 82, The branch
throws because isValidDispatchTransition forbids pending -> cancelled even
though CANCELLABLE_FROM_STATES includes pending; either add the
pending->cancelled transition to the shared dispatch state machine or bypass
this second validation here: in cancel-dispatch.ts, in the block that calls
isValidDispatchTransition(from as DispatchStatus, to) (and currently throws
BantayogError with BantayogErrorCode.INVALID_STATUS_TRANSITION), remove the
redundant check or special-case allowing to === DispatchStatus.CANCELLED when
from is in CANCELLABLE_FROM_STATES so legitimate cancellations succeed while
preserving other transition validations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/responder-app/src/hooks/useOwnDispatches.ts`:
- Around line 33-43: In useOwnDispatches, the code unsafely casts Firestore
fields to Timestamp and coerces strings; instead perform runtime boundary
validation matching the schema: validate that data.dispatchedAt and optional
data.acknowledgementDeadlineAt are integers (epoch ms) using
typeof/Number.isInteger and only assign those numeric values to
OwnDispatchRow.dispatchedAt/acknowledgementDeadlineAt (do not cast to firebase
Timestamp), and remove the unnecessary String(...) around data.reportId and
data.status (assign as strings after asserting typeof === 'string'); update the
mapping in the snap.docs.map block and return/skip rows when validation fails.

In `@functions/src/__tests__/callables/cancel-dispatch.test.ts`:
- Around line 83-95: The test assertions for cancelDispatchCore use the wrong
domain error codes; update the expectations so that when calling
cancelDispatchCore(...) with an actor from another municipality the test asserts
rejects.toMatchObject({ code: 'FORBIDDEN' }) (use the domain-level FORBIDDEN
from cancelDispatchCore), and when attempting to cancel a dispatch that is no
longer pending assert rejects.toMatchObject({ code: 'FAILED_PRECONDITION' })
instead of INVALID_STATUS_TRANSITION; adjust the other similar assertions in the
same file (the second block around the later lines) to match these domain error
codes as well.

In `@functions/src/callables/cancel-dispatch.ts`:
- Around line 45-50: The idempotency payload currently includes the transient
deps.now, so withIdempotency(key:
`cancelDispatch:${deps.actor.uid}:${deps.idempotencyKey}`, payload: deps, ...)
will change on every call and break retries; update the call sites that pass
payload: deps (including the call in cancelDispatch and the other occurrences
around the other handlers) to pass a stable payload object that excludes
deps.now (or only includes deterministic fields used to compute idempotency),
e.g., construct a payload containing actor.uid, idempotencyKey, dispatch id and
other invariant request data but not the Timestamp.now value, so retries with
the same idempotencyKey will hit the cache and not be rate-limited.

In `@functions/src/callables/dispatch-responder.ts`:
- Around line 122-125: The code currently casts report.severityDerived to keyof
typeof DEADLINE_BY_SEVERITY which can falsely assert an invalid string is a
valid key; instead, read the raw value (report.severityDerived ?? 'medium'),
validate it against the keys of DEADLINE_BY_SEVERITY (e.g., with a
hasOwnProperty or "in" check) and only then narrow or cast to keyof typeof
DEADLINE_BY_SEVERITY; finally use that validated severity to compute deadlineMs
from DEADLINE_BY_SEVERITY (falling back to DEADLINE_BY_SEVERITY.high or 'medium'
if validation fails). Use the identifiers report.severityDerived, severity,
DEADLINE_BY_SEVERITY, and deadlineMs to locate and update the logic.
- Around line 46-52: The idempotency payload currently includes deps (which
contains deps.now, a Timestamp), causing the payload hash to change on retry;
update the withIdempotency call in dispatch-responder (the payload passed to
withIdempotency) to exclude the transient deps.now value — e.g., construct a
payload object that omits deps.now (or copy deps and delete the now field) and
pass that as payload so the idempotency key/hash remains stable across retries
while keeping the same key
(`dispatchResponder:${deps.actor.uid}:${deps.idempotencyKey}`) and other fields.

---

Duplicate comments:
In `@functions/src/callables/cancel-dispatch.ts`:
- Around line 80-82: The branch throws because isValidDispatchTransition forbids
pending -> cancelled even though CANCELLABLE_FROM_STATES includes pending;
either add the pending->cancelled transition to the shared dispatch state
machine or bypass this second validation here: in cancel-dispatch.ts, in the
block that calls isValidDispatchTransition(from as DispatchStatus, to) (and
currently throws BantayogError with
BantayogErrorCode.INVALID_STATUS_TRANSITION), remove the redundant check or
special-case allowing to === DispatchStatus.CANCELLED when from is in
CANCELLABLE_FROM_STATES so legitimate cancellations succeed while preserving
other transition validations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ee08316e-5191-40fe-8a0d-19694d6f1bda

📥 Commits

Reviewing files that changed from the base of the PR and between 4879853 and 971607e.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (5)
  • apps/responder-app/package.json
  • apps/responder-app/src/hooks/useOwnDispatches.ts
  • functions/src/__tests__/callables/cancel-dispatch.test.ts
  • functions/src/callables/cancel-dispatch.ts
  • functions/src/callables/dispatch-responder.ts

Comment on lines +33 to +43
snap.docs.map((d) => {
const data = d.data()
const row: OwnDispatchRow = {
dispatchId: d.id,
reportId: String(data.reportId),
status: String(data.status),
dispatchedAt: data.dispatchedAt as Timestamp,
}
if (data.acknowledgementDeadlineAt) {
row.acknowledgementDeadlineAt = data.acknowledgementDeadlineAt as Timestamp
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "1) Source of truth for dispatch document field types"
rg -n "dispatchedAt: z\\.number\\(\\)\\.int\\(\\)|acknowledgementDeadlineAt: z\\.number\\(\\)\\.int\\(\\)|assignedTo: z\\.object" packages/shared-validators/src/dispatches.ts

echo
echo "2) Current hook assumptions/coercions"
rg -n "String\\(data\\.reportId\\)|String\\(data\\.status\\)|as Timestamp|acknowledgementDeadlineAt" apps/responder-app/src/hooks/useOwnDispatches.ts

Repository: Exc1D/bantayog-alert

Length of output: 624


🏁 Script executed:

# Read the useOwnDispatches.ts file to see full context
head -60 apps/responder-app/src/hooks/useOwnDispatches.ts

Repository: Exc1D/bantayog-alert

Length of output: 1739


🏁 Script executed:

# Read the dispatch schema to confirm field types
head -60 packages/shared-validators/src/dispatches.ts

Repository: Exc1D/bantayog-alert

Length of output: 2157


🏁 Script executed:

# Check if there's any validation applied to the Firestore query result
rg -A 5 -B 5 "setRows" apps/responder-app/src/hooks/useOwnDispatches.ts

Repository: Exc1D/bantayog-alert

Length of output: 1000


Fix boundary validation and correct timestamp field types.

The hook receives dispatch data from Firestore where the schema defines dispatchedAt and acknowledgementDeadlineAt as z.number().int() (epoch milliseconds), yet the code casts them to Firebase Timestamp type without validation. Additionally, String(data.reportId) and String(data.status) are unnecessary coercions since the schema already validates these as strings. Apply runtime validation at the boundary to match the schema and eliminate unsafe type assertions.

Proposed hardening diff
 export interface OwnDispatchRow {
   dispatchId: string
   reportId: string
   status: string
-  dispatchedAt: Timestamp
-  acknowledgementDeadlineAt?: Timestamp
+  dispatchedAt: number
+  acknowledgementDeadlineAt?: number
 }
@@
         setRows(
           snap.docs.map((d) => {
             const data = d.data()
+            if (
+              typeof data.reportId !== 'string' ||
+              typeof data.status !== 'string' ||
+              typeof data.dispatchedAt !== 'number'
+            ) {
+              throw new Error(`Invalid dispatch payload for document ${d.id}`)
+            }
             const row: OwnDispatchRow = {
               dispatchId: d.id,
-              reportId: String(data.reportId),
-              status: String(data.status),
-              dispatchedAt: data.dispatchedAt as Timestamp,
+              reportId: data.reportId,
+              status: data.status,
+              dispatchedAt: data.dispatchedAt,
             }
-            if (data.acknowledgementDeadlineAt) {
-              row.acknowledgementDeadlineAt = data.acknowledgementDeadlineAt as Timestamp
+            if (typeof data.acknowledgementDeadlineAt === 'number') {
+              row.acknowledgementDeadlineAt = data.acknowledgementDeadlineAt
             }
             return row
           }),
         )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/responder-app/src/hooks/useOwnDispatches.ts` around lines 33 - 43, In
useOwnDispatches, the code unsafely casts Firestore fields to Timestamp and
coerces strings; instead perform runtime boundary validation matching the
schema: validate that data.dispatchedAt and optional
data.acknowledgementDeadlineAt are integers (epoch ms) using
typeof/Number.isInteger and only assign those numeric values to
OwnDispatchRow.dispatchedAt/acknowledgementDeadlineAt (do not cast to firebase
Timestamp), and remove the unnecessary String(...) around data.reportId and
data.status (assign as strings after asserting typeof === 'string'); update the
mapping in the snap.docs.map block and return/skip rows when validation fails.

Comment on lines +83 to +95
await expect(
cancelDispatchCore(db, {
dispatchId,
reason: 'responder_unavailable',
idempotencyKey: crypto.randomUUID(),
actor: {
uid: 'admin-1',
claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }),
},
now: Timestamp.now(),
}),
).rejects.toMatchObject({ code: 'PERMISSION_DENIED' })
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

These expected error codes don't match cancelDispatchCore.

This suite calls the core function directly, so the cross-municipality case should assert the domain code FORBIDDEN, not the HTTPS-layer PERMISSION_DENIED. Also, once a dispatch is no longer pending, the core exits through the pending-only guard first, so the second cancel should expect FAILED_PRECONDITION, not INVALID_STATUS_TRANSITION.

Suggested assertion fix
-    ).rejects.toMatchObject({ code: 'PERMISSION_DENIED' })
+    ).rejects.toMatchObject({ code: 'FORBIDDEN' })
...
-    ).rejects.toMatchObject({ code: 'INVALID_STATUS_TRANSITION' })
+    ).rejects.toMatchObject({ code: 'FAILED_PRECONDITION' })

Also applies to: 211-223

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@functions/src/__tests__/callables/cancel-dispatch.test.ts` around lines 83 -
95, The test assertions for cancelDispatchCore use the wrong domain error codes;
update the expectations so that when calling cancelDispatchCore(...) with an
actor from another municipality the test asserts rejects.toMatchObject({ code:
'FORBIDDEN' }) (use the domain-level FORBIDDEN from cancelDispatchCore), and
when attempting to cancel a dispatch that is no longer pending assert
rejects.toMatchObject({ code: 'FAILED_PRECONDITION' }) instead of
INVALID_STATUS_TRANSITION; adjust the other similar assertions in the same file
(the second block around the later lines) to match these domain error codes as
well.

Comment on lines +45 to +50
const { result } = await withIdempotency(
db,
{
key: `cancelDispatch:${deps.actor.uid}:${deps.idempotencyKey}`,
payload: deps,
now: () => deps.now.toMillis(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Retries with the same idempotency key are not actually safe.

withIdempotency() hashes the full payload, and payload: deps includes now, which changes on every call because the wrapper passes a fresh Timestamp.now(). A client retrying the same request after a timeout will therefore miss the cached result or hit an idempotency mismatch, and the pre-idempotency rate-limit check can throttle that retry before the cache is consulted.

Also applies to: 165-175, 191-191

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@functions/src/callables/cancel-dispatch.ts` around lines 45 - 50, The
idempotency payload currently includes the transient deps.now, so
withIdempotency(key: `cancelDispatch:${deps.actor.uid}:${deps.idempotencyKey}`,
payload: deps, ...) will change on every call and break retries; update the call
sites that pass payload: deps (including the call in cancelDispatch and the
other occurrences around the other handlers) to pass a stable payload object
that excludes deps.now (or only includes deterministic fields used to compute
idempotency), e.g., construct a payload containing actor.uid, idempotencyKey,
dispatch id and other invariant request data but not the Timestamp.now value, so
retries with the same idempotencyKey will hit the cache and not be rate-limited.

Comment on lines +46 to +52
const { result } = await withIdempotency(
db,
{
key: `dispatchResponder:${deps.actor.uid}:${deps.idempotencyKey}`,
payload: deps,
now: () => deps.now.toMillis(),
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Idempotency payload includes now timestamp, breaking retry semantics.

The payload: deps includes deps.now (a Timestamp). On retry, line 234 generates a fresh Timestamp.now(), so the payload hash will differ from the original request. Per the withIdempotency contract (context snippet 1), this throws IdempotencyMismatchError instead of returning the cached result.

This defeats the purpose of idempotency—clients cannot safely retry the same dispatch request.

🐛 Proposed fix: exclude timestamp from idempotency payload
   const { result } = await withIdempotency(
     db,
     {
       key: `dispatchResponder:${deps.actor.uid}:${deps.idempotencyKey}`,
-      payload: deps,
+      payload: {
+        reportId: deps.reportId,
+        responderUid: deps.responderUid,
+        idempotencyKey: deps.idempotencyKey,
+        actorUid: deps.actor.uid,
+        municipalityId: deps.actor.claims.municipalityId,
+      },
       now: () => deps.now.toMillis(),
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@functions/src/callables/dispatch-responder.ts` around lines 46 - 52, The
idempotency payload currently includes deps (which contains deps.now, a
Timestamp), causing the payload hash to change on retry; update the
withIdempotency call in dispatch-responder (the payload passed to
withIdempotency) to exclude the transient deps.now value — e.g., construct a
payload object that omits deps.now (or copy deps and delete the now field) and
pass that as payload so the idempotency key/hash remains stable across retries
while keeping the same key
(`dispatchResponder:${deps.actor.uid}:${deps.idempotencyKey}`) and other fields.

Comment on lines +122 to +125
const severity = ((report.severityDerived as string | null | undefined) ??
'medium') as keyof typeof DEADLINE_BY_SEVERITY
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const deadlineMs = DEADLINE_BY_SEVERITY[severity] ?? DEADLINE_BY_SEVERITY.high
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Type assertion on severity may mislead maintainers.

If severityDerived is an unexpected string (e.g., "unknown"), the cast as keyof typeof DEADLINE_BY_SEVERITY is incorrect. The fallback on line 125 handles this gracefully, but the type assertion creates a false sense of type safety.

♻️ Suggested improvement: explicit validation
-        const severity = ((report.severityDerived as string | null | undefined) ??
-          'medium') as keyof typeof DEADLINE_BY_SEVERITY
-        // eslint-disable-next-line `@typescript-eslint/no-unnecessary-condition`
-        const deadlineMs = DEADLINE_BY_SEVERITY[severity] ?? DEADLINE_BY_SEVERITY.high
+        const rawSeverity = (report.severityDerived as string | null | undefined) ?? 'medium'
+        const severity = rawSeverity in DEADLINE_BY_SEVERITY
+          ? (rawSeverity as keyof typeof DEADLINE_BY_SEVERITY)
+          : 'high'
+        const deadlineMs = DEADLINE_BY_SEVERITY[severity]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const severity = ((report.severityDerived as string | null | undefined) ??
'medium') as keyof typeof DEADLINE_BY_SEVERITY
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const deadlineMs = DEADLINE_BY_SEVERITY[severity] ?? DEADLINE_BY_SEVERITY.high
const rawSeverity = (report.severityDerived as string | null | undefined) ?? 'medium'
const severity = rawSeverity in DEADLINE_BY_SEVERITY
? (rawSeverity as keyof typeof DEADLINE_BY_SEVERITY)
: 'high'
const deadlineMs = DEADLINE_BY_SEVERITY[severity]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@functions/src/callables/dispatch-responder.ts` around lines 122 - 125, The
code currently casts report.severityDerived to keyof typeof DEADLINE_BY_SEVERITY
which can falsely assert an invalid string is a valid key; instead, read the raw
value (report.severityDerived ?? 'medium'), validate it against the keys of
DEADLINE_BY_SEVERITY (e.g., with a hasOwnProperty or "in" check) and only then
narrow or cast to keyof typeof DEADLINE_BY_SEVERITY; finally use that validated
severity to compute deadlineMs from DEADLINE_BY_SEVERITY (falling back to
DEADLINE_BY_SEVERITY.high or 'medium' if validation fails). Use the identifiers
report.severityDerived, severity, DEADLINE_BY_SEVERITY, and deadlineMs to locate
and update the logic.

claude and others added 2 commits April 19, 2026 08:46
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/shared-validators/src/state-machines/report-states.ts (1)

77-83: ⚠️ Potential issue | 🔴 Critical

pending → cancelled here exposes a server-only transition to client-writable rules.

Line 81 propagates into Firestore rules and allows responder-side status mutation to cancelled, which can bypass cancelDispatch callable side effects (report reversion + event consistency).

Safer direction (split transition sets)
-export const DISPATCH_TRANSITIONS: readonly [DispatchStatus, DispatchStatus][] = [
+// Used by Firestore rules (responder-direct only)
+export const DISPATCH_RESPONDER_TRANSITIONS: readonly [DispatchStatus, DispatchStatus][] = [
   ['accepted', 'acknowledged'],
   ['acknowledged', 'in_progress'],
   ['in_progress', 'resolved'],
-  ['pending', 'cancelled'],
   ['pending', 'declined'],
 ] as const
+
+// Used by server callables
+export const DISPATCH_SERVER_TRANSITIONS: readonly [DispatchStatus, DispatchStatus][] = [
+  ...DISPATCH_RESPONDER_TRANSITIONS,
+  ['pending', 'cancelled'],
+] as const
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/shared-validators/src/state-machines/report-states.ts` around lines
77 - 83, DISPATCH_TRANSITIONS currently includes the server-only transition
['pending', 'cancelled'], which leaks a server-side-only state change into
client-writable rules; split the transitions into two clearly named sets (e.g.,
DISPATCH_CLIENT_TRANSITIONS and DISPATCH_SERVER_TRANSITIONS or
DISPATCH_TRANSITIONS and DISPATCH_SERVER_ONLY_TRANSITIONS), remove
['pending','cancelled'] from the client-writable set while keeping it in the
server-only set, and update any consumers (security rule generator or validators
that reference DISPATCH_TRANSITIONS) to use the appropriate set so clients
cannot mutate status to 'cancelled' directly via the client-writable transition
list.
infra/firebase/firestore.rules (1)

1-356: ⚠️ Potential issue | 🟠 Major

Codegen drift is blocking CI.

firestore.rules is currently not synchronized with the generator output; CI is already failing on this. Regenerate and commit the updated rules artifact before merge.

Suggested verification/fix commands
pnpm exec tsx scripts/build-rules.ts
git diff -- infra/firebase/firestore.rules
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@infra/firebase/firestore.rules` around lines 1 - 356, The CI failure is due
to infra/firebase/firestore.rules being out of sync with the codegen; regenerate
the rules with the generator script (scripts/build-rules.ts) so the helper
functions (e.g. isAuthed, isSuperadmin, adminOf) and all match blocks reflect
the current spec, then commit the updated infra/firebase/firestore.rules
artifact; verify by running the generator and checking git diff before pushing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@infra/firebase/firestore.rules`:
- Around line 1-356: The CI failure is due to infra/firebase/firestore.rules
being out of sync with the codegen; regenerate the rules with the generator
script (scripts/build-rules.ts) so the helper functions (e.g. isAuthed,
isSuperadmin, adminOf) and all match blocks reflect the current spec, then
commit the updated infra/firebase/firestore.rules artifact; verify by running
the generator and checking git diff before pushing.

In `@packages/shared-validators/src/state-machines/report-states.ts`:
- Around line 77-83: DISPATCH_TRANSITIONS currently includes the server-only
transition ['pending', 'cancelled'], which leaks a server-side-only state change
into client-writable rules; split the transitions into two clearly named sets
(e.g., DISPATCH_CLIENT_TRANSITIONS and DISPATCH_SERVER_TRANSITIONS or
DISPATCH_TRANSITIONS and DISPATCH_SERVER_ONLY_TRANSITIONS), remove
['pending','cancelled'] from the client-writable set while keeping it in the
server-only set, and update any consumers (security rule generator or validators
that reference DISPATCH_TRANSITIONS) to use the appropriate set so clients
cannot mutate status to 'cancelled' directly via the client-writable transition
list.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b83e12f9-52d6-482a-b01f-af6c16e295a2

📥 Commits

Reviewing files that changed from the base of the PR and between 971607e and 31bfe92.

📒 Files selected for processing (3)
  • apps/admin-desktop/src/routes.tsx
  • infra/firebase/firestore.rules
  • packages/shared-validators/src/state-machines/report-states.ts

… to 5

Added ['pending','cancelled'] transition in prior commit raised the count
from 4 to 5. Update the spec-count test to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/admin-desktop/src/hooks/useEligibleResponders.ts`:
- Around line 31-35: The client fallback defaults in useEligibleResponders.ts
currently set displayName to String(data.displayName ?? d.id) and agencyId to
String(data.agencyId ?? 'unknown'); change these to match the backend
eligibility contract by defaulting both to empty strings (i.e., use
data.displayName ?? '' and data.agencyId ?? '') so sorting and rendered labels
align with functions/src/services/responder-eligibility.ts; update the object
assigned to out[d.id] (uid, displayName, agencyId) accordingly.
- Around line 48-51: The RTDB snapshot is being cast unchecked and stored via
setShift in the useEligibleResponders hook; instead validate snapVal's shape
before committing: check that snapVal is an object (not null/array), iterate its
keys and ensure each value is an object with a boolean isOnShift (filter or
coerce invalid entries), and only then call setShift with the cleaned/typed
record; also wrap parsing/validation in try/catch and log any errors rather than
swallowing them so malformed payloads don’t corrupt state (refer to the onValue
callback, snapVal, and setShift).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c85a664e-a53d-46f4-a083-59001702c5f9

📥 Commits

Reviewing files that changed from the base of the PR and between 31bfe92 and 5d991c4.

📒 Files selected for processing (1)
  • apps/admin-desktop/src/hooks/useEligibleResponders.ts

Comment on lines +31 to +35
out[d.id] = {
uid: d.id,
displayName: String(data.displayName ?? d.id),
agencyId: String(data.agencyId ?? 'unknown'),
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Align fallback normalization with backend eligibility contract.

Line [33]-Line [34] default to uid / 'unknown', but functions/src/services/responder-eligibility.ts defaults to empty strings. This can cause client/server divergence in sorting and rendered labels for missing profile fields.

Proposed fix
         out[d.id] = {
           uid: d.id,
-          displayName: String(data.displayName ?? d.id),
-          agencyId: String(data.agencyId ?? 'unknown'),
+          displayName: String(data.displayName ?? ''),
+          agencyId: String(data.agencyId ?? ''),
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
out[d.id] = {
uid: d.id,
displayName: String(data.displayName ?? d.id),
agencyId: String(data.agencyId ?? 'unknown'),
}
out[d.id] = {
uid: d.id,
displayName: String(data.displayName ?? ''),
agencyId: String(data.agencyId ?? ''),
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-desktop/src/hooks/useEligibleResponders.ts` around lines 31 - 35,
The client fallback defaults in useEligibleResponders.ts currently set
displayName to String(data.displayName ?? d.id) and agencyId to
String(data.agencyId ?? 'unknown'); change these to match the backend
eligibility contract by defaulting both to empty strings (i.e., use
data.displayName ?? '' and data.agencyId ?? '') so sorting and rendered labels
align with functions/src/services/responder-eligibility.ts; update the object
assigned to out[d.id] (uid, displayName, agencyId) accordingly.

Comment on lines +48 to +51
const unsub = onValue(node, (s) => {
const snapVal = s.val()
setShift(snapVal !== null ? (snapVal as Record<string, { isOnShift: boolean }>) : {})
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate RTDB snapshot shape before committing to state.

Line [50] trusts untyped RTDB payload via direct cast. This skips boundary validation and can store malformed entries.

Proposed fix
   const unsub = onValue(node, (s) => {
     const snapVal = s.val()
-    setShift(snapVal !== null ? (snapVal as Record<string, { isOnShift: boolean }>) : {})
+    if (!snapVal || typeof snapVal !== 'object') {
+      setShift({})
+      return
+    }
+    const normalized: Record<string, { isOnShift: boolean }> = {}
+    for (const [uid, value] of Object.entries(snapVal as Record<string, unknown>)) {
+      if (value && typeof value === 'object' && (value as { isOnShift?: unknown }).isOnShift === true) {
+        normalized[uid] = { isOnShift: true }
+      }
+    }
+    setShift(normalized)
   })

As per coding guidelines: "Use defensive programming: validate external input at boundaries and never swallow errors with empty catch blocks."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const unsub = onValue(node, (s) => {
const snapVal = s.val()
setShift(snapVal !== null ? (snapVal as Record<string, { isOnShift: boolean }>) : {})
})
const unsub = onValue(node, (s) => {
const snapVal = s.val()
if (!snapVal || typeof snapVal !== 'object') {
setShift({})
return
}
const normalized: Record<string, { isOnShift: boolean }> = {}
for (const [uid, value] of Object.entries(snapVal as Record<string, unknown>)) {
if (value && typeof value === 'object' && (value as { isOnShift?: unknown }).isOnShift === true) {
normalized[uid] = { isOnShift: true }
}
}
setShift(normalized)
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-desktop/src/hooks/useEligibleResponders.ts` around lines 48 - 51,
The RTDB snapshot is being cast unchecked and stored via setShift in the
useEligibleResponders hook; instead validate snapVal's shape before committing:
check that snapVal is an object (not null/array), iterate its keys and ensure
each value is an object with a boolean isOnShift (filter or coerce invalid
entries), and only then call setShift with the cleaned/typed record; also wrap
parsing/validation in try/catch and log any errors rather than swallowing them
so malformed payloads don’t corrupt state (refer to the onValue callback,
snapVal, and setShift).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants