Skip to content

feat(phase5): Cluster C — Broadcast + Intelligence#66

Merged
Exc1D merged 24 commits intomainfrom
phase5-cluster-c
Apr 26, 2026
Merged

feat(phase5): Cluster C — Broadcast + Intelligence#66
Exc1D merged 24 commits intomainfrom
phase5-cluster-c

Conversation

@Exc1D
Copy link
Copy Markdown
Owner

@Exc1D Exc1D commented Apr 25, 2026

Summary

Implements Phase 5 PRE-C and Cluster C: mass alert broadcast system, SMS template infrastructure, and daily analytics snapshots.

PRE-C (Schema + Maintenance)

  • PRE-C.1: hasFcmToken flag maintenance — set true on token registration, false when all tokens invalid
  • PRE-C.2: Extended reportSmsConsentDocSchema with municipalityId + followUpConsent; updated processInboxItem materialization
  • PRE-C.3: Expanded massAlertRequestDocSchema status enum (8 states: queued → ndrrmc flow); Firestore rules for NDRRMC escalation path

C.1 (Mass Alert Callables + UI)

  • mass_alert SMS template + renderBroadcastTemplate + enqueueBroadcastSms (no publicRef requirement)
  • sendMassAlertFcm — batched FCM multicast (500-token batches, 5000 cap)
  • 4 callables: massAlertReachPlanPreview, sendMassAlert, requestMassAlertEscalation, forwardMassAlertToNDRRMC
  • MassAlertModal — reach preview, direct send, NDRRMC escalation CTA

C.2 (Analytics Snapshot + Dashboard)

  • CAMARINES_NORTE_MUNICIPALITY_IDS in shared-data
  • analyticsSnapshotWriter scheduled CF — daily count() aggregate per municipality + province-wide
  • AnalyticsDashboardPage with React Query + inline SVG 7-day trend chart + /analytics route

Test plan

  • firebase emulators:exec — 31/31 function tests pass (PRE-C.2, rules, callables, analytics)
  • Admin-desktop tests: 13/13 new tests pass (mass-alert modal + analytics dashboard)
  • npx turbo run lint typecheck — 26/26 clean
  • Fixed critical bug: province aggregate was inside municipality for-loop

Files changed

36 files, +2226 / -24 lines

Summary by Sourcery

Add mass alert broadcast capabilities, daily analytics snapshots, and related UI and schema updates for Phase 5 Cluster C.

New Features:

  • Introduce callable mass alert workflows for direct broadcast and NDRRMC escalation, including reach planning and forwarding flows.
  • Add mass alert SMS template and broadcast enqueue support, plus FCM-based bulk notification sending to responders.
  • Expose a Mass Alert modal in the triage UI and a new analytics dashboard page with live counts and 7-day trend charts.

Bug Fixes:

  • Ensure responder hasFcmToken is cleared only when all FCM tokens are invalid and preserved when explicitly false.
  • Fix analytics snapshot aggregation so province-wide totals are computed outside the municipality loop without overwriting.

Enhancements:

  • Extend responder, report inbox, and SMS consent schemas with hasFcmToken defaults, follow-up consent, municipality metadata, and evidence/forwarding fields for mass alerts.
  • Implement a scheduled analytics snapshot writer that records per-municipality and province aggregates for report status and severity.
  • Tighten Firestore security rules and tests around mass alert request status transitions and roles.

Documentation:

  • Update progress and learnings documentation to describe Phase 5 Cluster C work, testing patterns, and analytics behaviors.

Tests:

  • Add comprehensive unit, integration, and rules tests for mass alert callables, analytics snapshot writing, inbox processing changes, and new admin-desktop UIs.

Summary by CodeRabbit

  • New Features

    • Analytics dashboard (/analytics): live active-incident count, recent 7-day snapshots and bar chart.
    • Mass Alert UI and workflow: compose message, preview reach (FCM/SMS) with route badges, send, request escalation, and forwarding.
  • Improvements

    • Persist explicit FCM token flag and transactional token cleanup.
    • Follow-up SMS consent support and new broadcast SMS rendering/templates.
    • Refined mass-alert access and routing guards.
  • Tests

    • Expanded coverage for mass alerts, analytics snapshots, FCM/SMS sending, inbox processing, and security rules.
  • Documentation

    • Added testing/mocking guidance and progress notes.

claude and others added 15 commits April 25, 2026 19:35
…id token cleanup

- Client: set hasFcmToken: true when registering a new FCM token
- Server: clear hasFcmToken: false when all tokens are invalidated during cleanup
- Schema: remove transform-derived hasFcmToken; make it an explicit stored field with default(false)
- Tests: replace derived-behavior test with explicit-field preservation test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n on partial-invalid

- Update existing partial-invalid test to assert hasFcmToken is NOT present
- Add new test verifying hasFcmToken: false when all tokens are invalid

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Extend inboxPayloadSchema with optional followUpConsent boolean
- Update processInboxItemCore to write followUpConsent onto report_sms_consent
- Add 3 integration tests for municipalityId and followUpConsent fields
- Add shared-validators tests for followUpConsent acceptance/rejection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rmc flow

- mass_alert_requests rules: allow muni admin/superadmin create with
  status in ['queued', 'pending_ndrrmc_review', 'sent']; restrict
  update to superadmin only; preserve read scope for muni admin + superadmin.
- Add 4 rules tests for mass_alert_requests creation boundaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ueueBroadcastSms

- Extend SmsPurpose with 'mass_alert' and add TEMPLATES entry
- Add renderBroadcastTemplate for broadcast messages (no publicRef)
- Add enqueueBroadcastSms helper in send-sms.ts
- Update shared-validators exports and rebuild lib/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rwardToNDRRMC

- Add four callable functions for mass alert system:
  - massAlertReachPlanPreview: counts FCM/SMS recipients, determines route
  - sendMassAlert: sends direct mass alert (creates doc + fires FCM)
  - requestMassAlertEscalation: creates pending NDRRMC review request
  - forwardMassAlertToNDRRMC: superadmin forwards pending request to NDRRMC
- Add mass-alert.test.ts with 17 integration tests
- Add evidencePack and forwardedBy optional fields to massAlertRequestDocSchema
- Export callables from functions/src/index.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ion CTA

- Create MassAlertModal with message input, live GSM-7/UCS-2 encoding display,
  reach plan preview, direct send, and NDRRMC escalation CTA
- Add 4 mass alert callable wrappers to callables.ts
- Wire Mass Alert button into TriageQueuePage header with Escape dismissal
- Fix usePendingHandoffs mocks in triage-queue and shift-handoff tests
- Add @bantayog/shared-validators dependency to admin-desktop

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

sourcery-ai Bot commented Apr 25, 2026

Reviewer's Guide

Implements Phase 5 Cluster C by adding a mass alert broadcast pipeline (FCM + SMS) with NDRRMC escalation, daily Firestore analytics snapshots with a 7‑day admin dashboard, and tightening FCM token + SMS consent schemas and flows.

Sequence diagram for direct mass alert send flow

sequenceDiagram
  actor MunicipalAdmin
  participant TriageQueuePage
  participant MassAlertModal
  participant AdminCallables as AdminCallablesService
  participant CF_MassAlert as CF_massAlertReachPlanPreview
  participant CF_SendMass as CF_sendMassAlert
  participant Firestore as Firestore
  participant FcmMassSend as sendMassAlertFcm
  participant Responders as RespondersDevices

  MunicipalAdmin->>TriageQueuePage: Click "Mass Alert" button
  TriageQueuePage->>MassAlertModal: Render MassAlertModal(municipalityId)

  MunicipalAdmin->>MassAlertModal: Type alert message
  MunicipalAdmin->>MassAlertModal: Click "Preview Reach"
  MassAlertModal->>AdminCallables: massAlertReachPlanPreview(targetScope,message)
  AdminCallables->>CF_MassAlert: HTTPS callable massAlertReachPlanPreview

  CF_MassAlert->>Firestore: Query responders where hasFcmToken==true and municipalityId in scope count()
  CF_MassAlert->>Firestore: Query report_sms_consent where followUpConsent==true and municipalityId in scope count()
  CF_MassAlert-->>AdminCallables: reachPlan(route,fcmCount,smsCount,segmentCount,unicodeWarning)
  AdminCallables-->>MassAlertModal: reachPlan
  MassAlertModal-->>MunicipalAdmin: Show reach preview and route

  MunicipalAdmin->>MassAlertModal: Click "Send Alert" (route == direct)
  MassAlertModal->>AdminCallables: sendMassAlert(reachPlan,message,targetScope,idempotencyKey)
  AdminCallables->>CF_SendMass: HTTPS callable sendMassAlert

  CF_SendMass->>CF_MassAlert: massAlertReachPlanPreviewCore(targetScope,message,actor)
  CF_MassAlert-->>CF_SendMass: serverPreview(reachPlan,route=direct)
  CF_SendMass->>Firestore: Create mass_alert_requests doc(status="sent",estimatedReach,...)
  CF_SendMass->>FcmMassSend: sendMassAlertFcm(municipalityIds,title,body,data)

  FcmMassSend->>Firestore: Query responders where hasFcmToken==true and municipalityId in scope
  FcmMassSend->>Responders: Batched FCM sendEachForMulticast(<=5000 tokens)
  FcmMassSend-->>CF_SendMass: MassSendResult
  CF_SendMass-->>AdminCallables: {requestId}
  AdminCallables-->>MassAlertModal: {requestId}
  MassAlertModal-->>MunicipalAdmin: Close modal
Loading

Sequence diagram for NDRRMC escalation and forwarding

sequenceDiagram
  actor MunicipalAdmin
  actor ProvincialSuperadmin
  participant MassAlertModal
  participant AdminCallables as AdminCallablesService
  participant CF_Escalate as CF_requestMassAlertEscalation
  participant CF_Forward as CF_forwardMassAlertToNDRRMC
  participant Firestore as Firestore
  participant FcmMassSend as sendMassAlertFcm

  MunicipalAdmin->>MassAlertModal: Compose message and preview reach
  MassAlertModal-->>MunicipalAdmin: Route == ndrrmc_escalation
  MunicipalAdmin->>MassAlertModal: Click "Request NDRRMC Escalation"
  MassAlertModal->>AdminCallables: requestMassAlertEscalation(message,targetScope,evidencePack,idempotencyKey)
  AdminCallables->>CF_Escalate: HTTPS callable requestMassAlertEscalation

  CF_Escalate->>Firestore: Create mass_alert_requests doc(status="pending_ndrrmc_review",evidencePack,...)
  CF_Escalate->>FcmMassSend: sendMassAlertFcm(municipalityIds,title="NDRRMC Escalation Request",body,data)
  FcmMassSend->>Firestore: Query responders where hasFcmToken==true and municipalityId in scope
  FcmMassSend-->>CF_Escalate: MassSendResult
  CF_Escalate-->>AdminCallables: {requestId}
  AdminCallables-->>MassAlertModal: {requestId}
  MassAlertModal-->>MunicipalAdmin: Show escalation submitted

  ProvincialSuperadmin->>AdminCallables: forwardMassAlertToNDRRMC(requestId,forwardMethod,ndrrrcRecipient)
  AdminCallables->>CF_Forward: HTTPS callable forwardMassAlertToNDRRMC
  CF_Forward->>Firestore: Get mass_alert_requests doc(requestId)
  CF_Forward-->>CF_Forward: Validate status == pending_ndrrmc_review
  CF_Forward->>Firestore: Update doc(status="forwarded_to_ndrrmc",forwardedAt,forwardedBy,forwardMethod,ndrrrcRecipient)
  CF_Forward-->>AdminCallables: {success:true}
  AdminCallables-->>ProvincialSuperadmin: Confirm forwarded
Loading

Sequence diagram for analytics snapshot writer and dashboard

sequenceDiagram
  participant Scheduler as CloudScheduler
  participant CF_Analytics as analyticsSnapshotWriter
  participant Firestore as Firestore
  participant AdminDashboard as AnalyticsDashboardPage
  participant FirestoreClient as ClientFirestore

  Scheduler->>CF_Analytics: Trigger daily at 00:05 UTC
  CF_Analytics->>CF_Analytics: Compute date and now
  CF_Analytics->>Firestore: For each municipality in CAMARINES_NORTE_MUNICIPALITY_IDS
  loop For each municipality
    CF_Analytics->>Firestore: Count report_ops by status for municipality
    CF_Analytics->>Firestore: Count report_ops by severity for municipality
    CF_Analytics->>Firestore: Write analytics_snapshots/{date}/{municipalityId}/summary
  end
  CF_Analytics->>Firestore: Write analytics_snapshots/{date}/province/summary with aggregates

  AdminDashboard->>FirestoreClient: useQuery activeCount
  FirestoreClient->>Firestore: getCountFromServer(report_ops where municipalityId,status in ACTIVE_STATUSES)
  Firestore-->>FirestoreClient: Count
  FirestoreClient-->>AdminDashboard: activeCount

  AdminDashboard->>FirestoreClient: useQuery snapshots
  FirestoreClient->>Firestore: getDocs(analytics_snapshots ordered by __name__ desc limit 7)
  Firestore-->>FirestoreClient: Snapshot docs
  FirestoreClient-->>AdminDashboard: Last 7 day summaries
  AdminDashboard-->>AdminDashboard: Render live count and SVG 7 day trend
Loading

Entity relationship diagram for mass alert and analytics data

erDiagram
  RESPONDERS {
    string uid
    string agencyId
    string municipalityId
    string availabilityStatus
    boolean isActive
    string[] fcmTokens
    boolean hasFcmToken
    number createdAt
    number updatedAt
    number schemaVersion
  }

  REPORT_SMS_CONSENT {
    string id
    string municipalityId
    boolean smsConsent
    boolean followUpConsent
    string locale
    number createdAt
    number schemaVersion
  }

  MASS_ALERT_REQUESTS {
    string id
    string requestedByMunicipality
    string requestedByUid
    string body
    string targetType
    string targetGeometryRef
    string severity
    number estimatedReach
    string status
    number createdAt
    string evidencePack
    string forwardedBy
    number forwardedAt
    string forwardMethod
    string ndrrrcRecipient
    number schemaVersion
  }

  ANALYTICS_SNAPSHOTS_SUMMARY {
    string date
    string municipalityId
    json reportsByStatus
    json reportsBySeverity
    number generatedAt
    number schemaVersion
  }

  RESPONDERS ||--o{ MASS_ALERT_REQUESTS : "requestedByUid"
  REPORT_SMS_CONSENT }o--|| RESPONDERS : "same municipalityId"
  MASS_ALERT_REQUESTS ||--o{ ANALYTICS_SNAPSHOTS_SUMMARY : "contributes_to_counts"
  RESPONDERS ||--o{ ANALYTICS_SNAPSHOTS_SUMMARY : "counted_by_municipality"
Loading

Class diagram for core mass alert and analytics modules

classDiagram
  class MassAlertModal {
    -string municipalityId
    -string message
    -ReachPlan reachPlan
    -boolean loading
    -string error
    +MassAlertModal(municipalityId,onClose)
    +handlePreview()
    +handleSend()
    +handleEscalate()
  }

  class CallablesService {
    +massAlertReachPlanPreview(targetScope,message)
    +sendMassAlert(reachPlan,message,targetScope,idempotencyKey)
    +requestMassAlertEscalation(message,targetScope,evidencePack,idempotencyKey)
    +forwardMassAlertToNDRRMC(requestId,forwardMethod,ndrrrcRecipient)
  }

  class MassAlertActor {
    +string uid
    +object claims
  }

  class MassAlertReachPlanPreviewCore {
    +massAlertReachPlanPreviewCore(db,input,actor)
  }

  class SendMassAlertCore {
    +sendMassAlertCore(db,input,actor)
  }

  class RequestMassAlertEscalationCore {
    +requestMassAlertEscalationCore(db,input,actor)
  }

  class ForwardMassAlertToNDRRMCCore {
    +forwardMassAlertToNDRRMCCore(db,input,actor)
  }

  class SendMassAlertFcmService {
    +sendMassAlertFcm(db,opts) MassSendResult
  }

  class EnqueueBroadcastSmsService {
    +enqueueBroadcastSms(db,tx,args) outboxId
  }

  class AnalyticsSnapshotWriterCore {
    +analyticsSnapshotWriterCore(db,deps)
  }

  class AnalyticsDashboardPage {
    +AnalyticsDashboardPage()
    -useQueryActiveCount()
    -useQuerySnapshots()
  }

  class ResponderDocSchema {
    +boolean hasFcmToken
    +string[] fcmTokens
  }

  class ReportSmsConsentDocSchema {
    +string municipalityId
    +boolean followUpConsent
  }

  class AnalyticsSnapshotDeps {
    +string date
    +number or Timestamp now
  }

  MassAlertModal --> CallablesService
  MassAlertReachPlanPreviewCore --> MassAlertActor
  SendMassAlertCore --> MassAlertActor
  RequestMassAlertEscalationCore --> MassAlertActor
  ForwardMassAlertToNDRRMCCore --> MassAlertActor

  SendMassAlertCore --> SendMassAlertFcmService
  RequestMassAlertEscalationCore --> SendMassAlertFcmService

  EnqueueBroadcastSmsService --> ReportSmsConsentDocSchema

  AnalyticsSnapshotWriterCore --> AnalyticsSnapshotDeps
  AnalyticsDashboardPage --> AnalyticsSnapshotWriterCore

  ResponderDocSchema --> SendMassAlertFcmService
  ResponderDocSchema --> MassAlertReachPlanPreviewCore
  ReportSmsConsentDocSchema --> MassAlertReachPlanPreviewCore
Loading

File-Level Changes

Change Details Files
Introduce end-to-end mass alert pipeline (preview, direct send, NDRRMC escalation, forwarding) backed by new callables, FCM batching, and Firestore schemas/rules.
  • Add mass-alert callable cores and onCall wrappers (reach-plan preview, direct send with idempotency, escalation request with evidence pack, and NDRRMC forwarding) enforcing role/scope checks and route constraints
  • Implement sendMassAlertFcm service to batch FCM sends to responders with hasFcmToken=true across municipalities, enforcing 500-token/5000-recipient caps and logging outcomes
  • Extend massAlertRequestDocSchema with evidencePack and forwardedBy fields and add Firestore rules tests for the NDRRMC flow, including allowed/forbidden status transitions and role-based write access
functions/src/callables/mass-alert.ts
functions/src/services/fcm-mass-send.ts
packages/shared-validators/src/coordination.ts
functions/src/__tests__/callables/mass-alert.test.ts
functions/src/__tests__/rules/mass-alert-requests.rules.test.ts
infra/firebase/firestore.rules
infra/firebase/firestore.rules.template
functions/src/index.ts
apps/admin-desktop/src/services/callables.ts
Add SMS broadcast template and enqueue path for mass alerts, and wire SMS consent materialization to municipality + follow-up flags for downstream targeting.
  • Introduce mass_alert SMS template and renderBroadcastTemplate helper, exporting it from shared validators
  • Add enqueueBroadcastSms to write mass-alert SMS outbox entries with broadcast body rendering, encoding/segment detection, idempotency keying, and provenance fields
  • Extend inboxPayloadSchema and reportSmsConsentDoc materialization to carry municipalityId and boolean followUpConsent, defaulting to false when absent, plus tests for the new consent field
packages/shared-validators/src/sms-templates.ts
packages/shared-validators/src/index.ts
functions/src/services/send-sms.ts
functions/src/triggers/process-inbox-item.ts
packages/shared-validators/src/reports.ts
packages/shared-validators/src/reports.test.ts
functions/src/__tests__/triggers/process-inbox-item-prc2.test.ts
Tighten responder FCM token denormalization and registration paths so hasFcmToken accurately reflects token presence and is used in mass-alert targeting.
  • Change responderDocSchema to default hasFcmToken to false instead of deriving it from fcmTokens, updating tests to preserve explicit false values
  • Set hasFcmToken=true in the responder app FCM registration hook when adding a token
  • Update sendFcmToResponder to clear hasFcmToken when every token in a send attempt is invalid and add tests to cover partial vs full invalidation
packages/shared-validators/src/responders.ts
packages/shared-validators/src/responders.test.ts
apps/responder-app/src/hooks/useRegisterFcmToken.ts
functions/src/services/fcm-send.ts
functions/src/__tests__/services/fcm-send.test.ts
Add admin-desktop mass alert UI with reach preview, direct send, and NDRRMC escalation flows, plus tests and keybinding integration.
  • Expose new mass-alert callables on the admin desktop client service layer
  • Add MassAlertModal with local message state, encoding/segment display, reach preview, direct send (idempotent) and escalation CTA, including error handling
  • Wire MassAlertModal into TriageQueuePage with a Mass Alert header button, ESC handling, and modal-open state; fix usePendingHandoffs mocks in existing tests and add dedicated modal tests
apps/admin-desktop/src/services/callables.ts
apps/admin-desktop/src/pages/MassAlertModal.tsx
apps/admin-desktop/src/pages/TriageQueuePage.tsx
apps/admin-desktop/src/__tests__/mass-alert-modal.test.tsx
apps/admin-desktop/src/__tests__/triage-queue.test.tsx
apps/admin-desktop/src/__tests__/shift-handoff-modal.test.tsx
Introduce daily analytics snapshot writer for report_ops and a simple React Query–backed analytics dashboard route for admins.
  • Add CAMARINES_NORTE_MUNICIPALITY_IDS shared constant/type used to iterate municipalities in analytics generation and for validation elsewhere
  • Implement analyticsSnapshotWriterCore + scheduled function to aggregate per-municipality and province-wide counts by status/severity into analytics_snapshots with idempotent overwrites and logging
  • Add AnalyticsDashboardPage that shows live active incident counts via getCountFromServer and a 7-day trend SVG based on analytics_snapshots, plus router wiring, package deps, and tests with Firestore/react-query mocking; fix previous bug where province aggregate was computed inside the per-municipality loop
packages/shared-data/src/index.ts
functions/src/scheduled/analytics-snapshot-writer.ts
functions/src/__tests__/triggers/analytics-snapshot-writer.test.ts
apps/admin-desktop/src/pages/AnalyticsDashboardPage.tsx
apps/admin-desktop/src/routes.tsx
apps/admin-desktop/src/__tests__/analytics-dashboard.test.tsx
apps/admin-desktop/package.json
pnpm-lock.yaml
functions/src/index.ts
docs/progress.md
docs/learnings.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 25, 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 a mass-alert broadcast feature (UI + callables + backend services), a scheduled analytics snapshot writer and dashboard route, FCM/SMS broadcast sending and transactional token cleanup, schema and rule updates to support mass alerts and followUpConsent, and many new/updated tests across functions, packages, and apps.

Changes

Cohort / File(s) Summary
Mass Alert Callables & Core
functions/src/callables/mass-alert.ts, functions/src/__tests__/callables/mass-alert.test.ts, functions/src/index.ts
New mass-alert callables (preview, send, escalation, forward), core helpers, actor/scope checks, idempotency, and extensive callable tests; re-exported from functions index.
FCM Mass Send & Cleanup
functions/src/services/fcm-mass-send.ts, functions/src/services/fcm-send.ts, functions/src/__tests__/services/fcm-mass-send.test.ts, functions/src/__tests__/services/fcm-send.test.ts
New chunked/deduped multicast sender sendMassAlertFcm, batched sends with hard cap, transactional invalid-token cleanup (updates hasFcmToken), and related tests.
SMS Enqueue & Templates
functions/src/services/send-sms.ts, packages/shared-validators/src/sms-templates.ts, packages/shared-validators/src/index.ts, packages/shared-validators/lib/*
Adds enqueueBroadcastSms for mass_alert outbox entries, introduces mass_alert templates and renderBroadcastTemplate, restricts renderTemplate to direct purposes, and updates validator exports/typings.
Admin UI & Client Callables
apps/admin-desktop/src/pages/MassAlertModal.tsx, apps/admin-desktop/src/services/callables.ts, apps/admin-desktop/src/pages/TriageQueuePage.tsx, apps/admin-desktop/src/__tests__/mass-alert-modal.test.tsx
New MassAlertModal component, client callable wrappers for mass-alert flows, integration into TriageQueuePage (modal state/keyboard guards), and UI tests for preview/send/escalation flows.
Analytics Snapshot Writer & Dashboard
functions/src/scheduled/analytics-snapshot-writer.ts, functions/src/__tests__/triggers/analytics-snapshot-writer.test.ts, apps/admin-desktop/src/pages/AnalyticsDashboardPage.tsx, apps/admin-desktop/src/__tests__/analytics-dashboard.test.tsx, apps/admin-desktop/src/routes.tsx
Scheduled writer that aggregates report_ops into analytics_snapshots (municipality + province), a 7-day dashboard page with periodic queries, /analytics route with role scoping, and tests.
Inbox Processing & Consent
functions/src/triggers/process-inbox-item.ts, functions/src/__tests__/triggers/process-inbox-item-prc2.test.ts, packages/shared-validators/src/reports.ts, packages/shared-validators/src/reports.test.ts
Persist normalized followUpConsent boolean into report_sms_consent and add validator/tests for optional followUpConsent.
Shared Validators & Responder Schema
packages/shared-validators/src/coordination.ts, packages/shared-validators/src/responders.ts, packages/shared-validators/src/reports.ts, packages/shared-validators/*
Extend massAlertRequestDocSchema with optional evidencePack and forwardedBy; change hasFcmToken to explicit boolean default false; update lib outputs and tests.
Firestore Rules
infra/firebase/firestore.rules, infra/firebase/firestore.rules.template
Refactors /mass_alert_requests/{requestId} rules: extracted isMassAlertMuniAdmin() helper, preserve active/superadmin guards, and make write clauses explicit (client writes denied).
Admin Desktop config & tests
apps/admin-desktop/package.json, apps/admin-desktop/src/__tests__/shift-handoff-modal.test.tsx, apps/admin-desktop/src/__tests__/triage-queue.test.tsx
Adds workspace @bantayog/shared-validators and @tanstack/react-query in package.json; updates tests to new usePendingHandoffs return shape.
Responder app & Shared Data
apps/responder-app/src/hooks/useRegisterFcmToken.ts, packages/shared-data/src/index.ts
Persist hasFcmToken: true when registering tokens; export Camarines Norte municipality IDs and derived type.
Docs & Progress
docs/learnings.md, docs/progress.md
Adds Vitest/Firebase mocking guidance and documents Phase 5 completion notes (broadcast + analytics) and a noted bug fix.
Tests (misc)
functions/src/__tests__/*, apps/admin-desktop/src/__tests__/*, packages/shared-validators/*
Many new and updated tests covering callables, rules, snapshot writer, inbox processing, UI, and validator behavior.

Sequence Diagram(s)

sequenceDiagram
    rect rgba(220,240,255,0.5)
    participant Admin as Admin UI
    participant Modal as MassAlertModal
    participant Functions as Cloud Functions
    participant Firestore as Firestore
    participant FCM as FCM Service
    end

    Admin->>Modal: Compose message & click Preview
    Modal->>Functions: massAlertReachPlanPreview(payload)
    Functions->>Firestore: query responders & sms consent counts
    Firestore-->>Functions: fcmCount, smsCount
    Functions-->>Modal: { route, fcmCount, smsCount, unicodeWarning }
    Modal->>Admin: Display preview

    alt route == direct
        Admin->>Modal: Click Send
        Modal->>Functions: sendMassAlert(payload, idempotencyKey)
        Functions->>Firestore: create mass_alert_request (sent)
        Functions->>FCM: sendMassAlertFcm(municipalityIds, message)
        FCM-->>Functions: { successCount, failureCount }
        Functions-->>Modal: { requestId }
    else route == ndrrmc_escalation
        Admin->>Modal: Click Escalate
        Modal->>Functions: requestMassAlertEscalation(payload, idempotencyKey)
        Functions->>Firestore: create mass_alert_request (pending_ndrrmc_review)
        Functions-->>Modal: { requestId }
    end
Loading
sequenceDiagram
    rect rgba(240,255,220,0.5)
    participant Scheduler as Cloud Scheduler
    participant Writer as analyticsSnapshotWriter
    participant Firestore as Firestore
    end

    Scheduler->>Writer: daily trigger
    Writer->>Firestore: query report_ops by status & municipality
    Firestore-->>Writer: grouped counts
    Writer->>Firestore: write analytics_snapshots/{date}/... summaries
    Writer-->>Scheduler: log analytics.done
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hopped through lines of code at dawn,
Built previews, counts, and a message lawn,
Snapshots tally each day’s small art,
Tokens pruned so alerts can start,
A carrot-ready broadcast leaps — hip hop! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.90% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(phase5): Cluster C — Broadcast + Intelligence' directly summarizes the main changes in this PR, which implements Phase 5 Cluster C features including mass alert broadcast system and analytics dashboard.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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 phase5-cluster-c

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

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 3 issues, and left some high level feedback:

  • The mass-alert Firestore queries use where('municipalityId', 'in', municipalityIds) with municipalityIds allowed up to 12 items, but Firestore's in operator only supports up to 10 values; consider enforcing a max of 10 in targetScopeSchema (and/or splitting queries) to avoid runtime query errors.
  • The analytics snapshot writer stores per-municipality/province summaries in subcollections under analytics_snapshots/{date}/{municipalityId}/summary, but AnalyticsDashboardPage is querying the root analytics_snapshots collection and reading reportsByStatus off the date docs, which are never written—consider querying the appropriate municipality/province subcollection instead.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The mass-alert Firestore queries use `where('municipalityId', 'in', municipalityIds)` with `municipalityIds` allowed up to 12 items, but Firestore's `in` operator only supports up to 10 values; consider enforcing a max of 10 in `targetScopeSchema` (and/or splitting queries) to avoid runtime query errors.
- The analytics snapshot writer stores per-municipality/province summaries in subcollections under `analytics_snapshots/{date}/{municipalityId}/summary`, but `AnalyticsDashboardPage` is querying the root `analytics_snapshots` collection and reading `reportsByStatus` off the date docs, which are never written—consider querying the appropriate municipality/province subcollection instead.

## Individual Comments

### Comment 1
<location path="functions/src/callables/mass-alert.ts" line_range="15" />
<code_context>
+const MAX_DIRECT_ROUTE = 5000
+
+const targetScopeSchema = z.object({
+  municipalityIds: z.array(z.string().min(1)).min(1).max(12),
+})
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Target scope size can exceed Firestore `in` query limits, causing runtime errors.

This schema allows up to 12 `municipalityIds`, but Firestore `where('municipalityId', 'in', municipalityIds)` only supports up to 10 values. Passing 11–12 IDs will cause the preview query to fail at runtime. Please either cap the schema at `.max(10)` or split `municipalityIds` into chunks of ≤10 for multiple `in` queries and then aggregate the results.
</issue_to_address>

### Comment 2
<location path="functions/src/services/fcm-mass-send.ts" line_range="33-36" />
<code_context>
+  },
+): Promise<MassSendResult> {
+  // Query all responders with FCM tokens in scope
+  const snaps = await db
+    .collection('responders')
+    .where('hasFcmToken', '==', true)
+    .where('municipalityId', 'in', opts.municipalityIds)
+    .get()
+
</code_context>
<issue_to_address>
**issue:** Mass FCM sender also risks hitting Firestore `in` query 10-value limit.

This `where('municipalityId', 'in', opts.municipalityIds)` will fail if more than 10 IDs are passed. Either enforce `opts.municipalityIds.length <= 10` at the call site / schema, or handle it here by chunking into groups of ≤10, running multiple queries, and merging the token lists before sending FCM messages.
</issue_to_address>

### Comment 3
<location path="apps/admin-desktop/src/pages/AnalyticsDashboardPage.tsx" line_range="46" />
<code_context>
+  const { data: snapshots } = useQuery({
+    queryKey: ['analytics', 'snapshots', municipalityId],
+    queryFn: async () => {
+      const q = query(collection(db, `analytics_snapshots`), orderBy('__name__', 'desc'), limit(7))
+      const snap = await getDocs(q)
+      return snap.docs.map((d) => ({ date: d.id, ...d.data() }))
</code_context>
<issue_to_address>
**issue (bug_risk):** Analytics dashboard reads from root `analytics_snapshots` docs, which don’t align with how snapshots are written.

Snapshots are written under `analytics_snapshots/{date}/{municipalityId}/summary` and `analytics_snapshots/{date}/province/summary`, but this query reads only the top-level `analytics_snapshots` docs and expects fields like `reportsByStatus` there. That data won’t exist at this level, so the returned `snapshots` will be empty or incorrectly shaped. You’ll need to either query the `{date}/{municipalityId}/summary` / `province/summary` docs (e.g. find the latest dates, then fetch those summaries) or change the write path to denormalize the summary onto the top-level `analytics_snapshots/{date}` doc to match this query.
</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 thread functions/src/callables/mass-alert.ts Outdated
Comment thread functions/src/services/fcm-mass-send.ts Outdated
Comment thread apps/admin-desktop/src/pages/AnalyticsDashboardPage.tsx Outdated
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: 18

Caution

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

⚠️ Outside diff range comments (1)
functions/src/services/send-sms.ts (1)

50-57: ⚠️ Potential issue | 🟠 Major

Keep mass_alert out of the standard enqueue purpose gate.

Line 56 allows buildEnqueueSmsPayload to accept 'mass_alert', but that path renders through renderTemplate (publicRef template contract), not the broadcast renderer. This can produce malformed mass-alert bodies if called.

💡 Proposed fix
 const VALID_PURPOSES = new Set([
   'receipt_ack',
   'verification',
   'status_update',
   'resolution',
   'pending_review',
-  'mass_alert',
 ])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@functions/src/services/send-sms.ts` around lines 50 - 57, VALID_PURPOSES
currently includes 'mass_alert', which lets buildEnqueueSmsPayload accept mass
alerts and route them through renderTemplate (publicRef template contract)
instead of the broadcast renderer; remove 'mass_alert' from the VALID_PURPOSES
set (or adjust the validation in buildEnqueueSmsPayload) so mass_alert is not
permitted by that gate, and ensure mass_alert flows only through the broadcast
renderer path (the broadcast-specific renderer function) or its dedicated
enqueue flow.
🤖 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/__tests__/analytics-dashboard.test.tsx`:
- Around line 58-61: The test "scopes data to the caller's municipalityId for
muni admins" only checks rendered text; update it to assert the Firestore query
was scoped to municipalityId 'daet' by inspecting the mocks (e.g. verify
mockGetCountFromServer was called with a query built from collection/query/where
that includes where('municipalityId','==','daet')), or alternatively assert the
collection/query/where mock functions received the expected arguments; make this
check in the same test after rendering <AnalyticsDashboardPage /> with the
existing wrapper and before/after the screen.findByText assertion.

In `@apps/admin-desktop/src/__tests__/mass-alert-modal.test.tsx`:
- Around line 112-130: Update the two tests to assert on the actual arguments
passed to the mocks (not just call count): after triggering the send path in
MassAlertModal, assert mockSend was called with the expected payload (message
text, municipalityId and any metadata/state your UI sends); similarly, after
triggering the escalation CTA ensure mockEscalate (or
requestMassAlertEscalation) was invoked with the expected escalation payload
(including NDRRMC_PLAN-derived fields if mockPreview influences it). Use the
same mock names mockSend, mockEscalate and mockPreview to locate the tests and
add argument equality checks matching the component's request objects.

In `@apps/admin-desktop/src/pages/AnalyticsDashboardPage.tsx`:
- Around line 43-49: The query in useQuery (queryKey ['analytics','snapshots',
municipalityId]) is reading only analytics_snapshots/{date} docs but the writer
(analyticsSnapshotWriter) stores per-scope summaries at
analytics_snapshots/{date}/{scope}/summary, so reportsByStatus is missing; fix
by first querying the latest date documents as you do, then for each date id
fetch the scope summary document at collection path
analytics_snapshots/{date}/{municipalityId}/summary (or getDoc(doc(db,
'analytics_snapshots', date, municipalityId, 'summary'))), merge that summary
into each returned snapshot object (include reportsByStatus), and return the
array of snapshots with the nested summary data so components using
snapshots/reportsByStatus receive the correct values.

In `@apps/admin-desktop/src/pages/MassAlertModal.tsx`:
- Around line 52-57: The idempotencyKey is being regenerated on every send call
(crypto.randomUUID() inline) which can cause duplicate sends on retries; fix by
generating and storing a single stable idempotency key for the current draft in
the MassAlertModal component (e.g., a useRef or useState like
draftIdempotencyKey) when the modal/draft is initialized and only regenerate it
when the payload changes (message, reachPlan, municipalityId) or when the modal
is reset/closed, then pass that stored key into callables.sendMassAlert instead
of calling crypto.randomUUID() inline; apply the same change to the other send
location (lines ~71-76) to ensure consistent reuse across retries.

In `@apps/admin-desktop/src/pages/TriageQueuePage.tsx`:
- Around line 81-82: The modalOpen flag is becoming true even when massAlertOpen
is set without a municipalityId; update the logic so massAlertOpen cannot be
true unless municipalityId exists and reflect that in modalOpen: change where
massAlertOpen is set (the handler that opens the mass alert) to no-op or set
false when municipalityId is falsy, and change the modalOpen computation to
include massAlertOpen && !!municipalityId (e.g., const modalOpen =
!!dispatchForReportId || !!closeForReportId || handoffModalOpen ||
(massAlertOpen && !!municipalityId)); keep references to dispatchForReportId,
closeForReportId, handoffModalOpen and municipalityId to locate the changes.

In `@apps/admin-desktop/src/services/callables.ts`:
- Around line 74-125: The mass-alert callable types (used by
massAlertReachPlanPreview, sendMassAlert, requestMassAlertEscalation,
forwardMassAlertToNDRRMC) are duplicated here and in the backend; extract the
shared request/response interfaces (targetScope, reachPlan, evidencePack, and
the literal union types like 'direct'|'ndrrmc_escalation' and
'email'|'sms'|'portal') into a shared package/module, export them, and replace
the inline type literals in these functions with imports from that shared module
so both client and functions/src/callables/mass-alert.ts reference the same
canonical types; update imports and build configs accordingly to ensure both
packages compile against the shared types.

In `@docs/learnings.md`:
- Line 88: Change the wording at Line 88 from "SpyOn `mockImplementation` on
`collRef.where`" to use standard phrasing/casing—e.g. "Spy on `collRef.where`
and mock its implementation in Firestore admin SDK tests: the `.where` method
signature changed in firebase-admin v12+, so use `vi.spyOn(collRef, 'where' as
any)` to bypass TypeScript overload resolution that causes `TS2345: Target
signature provides too few arguments`."

In `@functions/src/callables/mass-alert.ts`:
- Around line 111-136: The code currently trusts client-supplied reach metrics
(input.reachPlan) when writing the mass_alert_requests document; instead, use
the server-computed reach plan from serverPreview (result of
massAlertReachPlanPreviewCore) or fail on mismatch: replace uses of
input.reachPlan.{fcmCount,smsCount} (and any estimatedReach derived from
input.reachPlan) with values from serverPreview.reachPlan, or compare
input.reachPlan to serverPreview.reachPlan and throw/return an error if they
differ before calling db.collection('mass_alert_requests').doc(requestId).set;
ensure the field estimatedReach is computed from serverPreview.reachPlan so
client cannot persist arbitrary numbers.
- Around line 126-153: The mass-alert flow currently marks mass_alert_requests
as sent and only calls sendMassAlertFcm, so SMS-consented recipients counted in
estimatedReach (set on the document) are never queued; update the code path
around the document write and subsequent sends (references: sendMassAlertFcm,
estimatedReach, mass_alert_requests, requestId) to also queue SMS sends when
input.reachPlan.smsCount > 0 by calling the existing SMS send/queue function (or
implement sendMassAlertSms) and await or handle its promise, and only set
status:'sent' after both FCM and SMS send/queue operations are initiated or mark
status per-channel (e.g., sent_fcm / queued_sms) and log/send errors from SMS
the same way FCM errors are handled. Ensure the mass_alert_requests document
reflects actual queued channels and errors so SMS audiences are not omitted.
- Around line 244-256: The status check and subsequent snap.ref.update() are not
atomic, allowing race conditions; wrap the read/check/update in a Firestore
transaction (db.runTransaction) or use an update precondition based on the
document version (e.g., precondition with snap.updateTime) so only one
concurrent actor can transition from 'pending_ndrrmc_review' to
'forwarded_to_ndrrmc'. Specifically, inside the transaction read the document
for requestId, verify it exists and status === 'pending_ndrrmc_review', then
perform the update setting status, forwardedAt, forwardedBy (actor.uid),
forwardMethod (input.forwardMethod), and ndrrrcRecipient
(input.ndrrrcRecipient); if the check fails, abort/throw to return the
appropriate error.
- Around line 51-71: The current code uses responder document counts
(fcmSnap.data().count) which undercounts FCM tokens because responders may have
multiple tokens; change the FCM counting logic to iterate responder documents
and sum the lengths of each responder.fcmTokens array (or the number of token
entries) to derive fcmTokenCount (use the existing symbol names fcmSnap -> fetch
responder docs, compute fcmTokenCount instead of fcmCount), and similarly ensure
smsCount counts phone entries (e.g., lengths of phone arrays or tokenized
entries) rather than document count; then compute total = fcmTokenCount +
smsCount and keep the route decision using MAX_DIRECT_ROUTE and municipalityIds
as before, capping the summed token counts early (e.g., stop summing once >
MAX_DIRECT_ROUTE) to avoid reading unnecessary data.
- Around line 210-214: The current call to sendMassAlertFcm with
input.targetScope.municipalityIds is sending the "review required" notification
to field responders (sendMassAlertFcm filters by responder municipality), but
this should go to provincial/NDRRMC reviewers who can act on
pending_ndrrmc_review; update the call to target the reviewer audience
instead—either invoke the FCM helper that filters by reviewer role (e.g., a
send-to-reviewers variant) or change the sendMassAlertFcm parameters to include
the reviewer role/region (province or reviewer role filter) so the notification
is delivered to NDRRMC/provincial reviewers rather than responders (reference:
sendMassAlertFcm, input.targetScope.municipalityIds, pending_ndrrmc_review).

In `@functions/src/services/fcm-mass-send.ts`:
- Around line 23-37: Add a defensive early guard in sendMassAlertFcm to check
opts.municipalityIds (handle undefined/null and empty array) before performing
the Firestore query: if the list is empty, short-circuit and return an empty
MassSendResult (i.e., no tokens processed) instead of running the
.where('municipalityId', 'in', ...) query; place this check immediately before
the code that constructs snaps so the function exits early when there are no
municipality IDs.
- Around line 39-55: The code currently appends raw fcmTokens from snaps.docs
into allTokens which can produce duplicates and then silently truncates at
MAX_BATCHES*TOKEN_BATCH_SIZE when slicing into batches (using TOKEN_BATCH_SIZE
and MAX_BATCHES), so deduplicate tokens after aggregation (e.g., use a Set to
build uniqueTokens from allTokens), compute totalUnique = uniqueTokens.size,
then limit to allowedCount = MAX_BATCHES * TOKEN_BATCH_SIZE and explicitly take
the first allowedCount tokens for batching; record droppedCount = totalUnique -
allowedCount and include that information in the returned result (or log it via
getMessaging/process logger) and ensure batch loop uses the deduplicated array
(not allTokens) and still updates successCount/failureCount/batchCount
accordingly.

In `@functions/src/services/fcm-send.ts`:
- Around line 94-101: The current update uses stale pre-send tokens and can
clear hasFcmToken incorrectly; wrap the removal and flag recompute in a
Firestore transaction: inside a transaction read the latest responder document
(adminDb.collection('responders').doc(uid)), compute the remaining tokens by
taking snapshot.data().fcmTokens and filtering out invalidTokens, then call
transaction.update on that doc with FieldValue.arrayRemove(...invalidTokens) and
set hasFcmToken to remaining.length > 0 so the flag is based on the latest
snapshot rather than the old tokens variable.

In `@functions/src/services/send-sms.ts`:
- Around line 103-109: EnqueueBroadcastSmsArgs currently types providerId as a
plain string which allows invalid IDs into sms_outbox; change
EnqueueBroadcastSmsArgs.providerId from string to the specific provider ID type
used elsewhere in this file (the typed provider contract declared near the top,
e.g., SmsProviderId or the exact exported type used in other SMS functions),
update the import/exports to reference that type, and adjust any callsites that
construct EnqueueBroadcastSmsArgs to pass the typed provider value so only
supported provider IDs are allowed.

In `@infra/firebase/firestore.rules`:
- Line 406: The update rule currently allows updates with only isSuperadmin(),
causing suspended superadmins to still modify mass alert requests; modify the
rule so it requires both isSuperadmin() && isActivePrivileged() (matching the
checks used at lines with similar rules) by updating the allow update condition
to include isActivePrivileged(), ensuring the same guard as other
superadmin-protected rules.

In `@packages/shared-validators/src/sms-templates.ts`:
- Around line 47-50: The mass_alert SMS templates (mass_alert.tl and
mass_alert.en) use an em dash (—) which forces UCS-2; replace the em dash with
GSM-7-safe punctuation such as a simple hyphen (-) or colon (:) and ensure
spacing remains correct (e.g., "ALERTO: {municipalityName} - {body}" or "ALERT:
{municipalityName} - {body}") so the templates remain GSM-7 friendly.

---

Outside diff comments:
In `@functions/src/services/send-sms.ts`:
- Around line 50-57: VALID_PURPOSES currently includes 'mass_alert', which lets
buildEnqueueSmsPayload accept mass alerts and route them through renderTemplate
(publicRef template contract) instead of the broadcast renderer; remove
'mass_alert' from the VALID_PURPOSES set (or adjust the validation in
buildEnqueueSmsPayload) so mass_alert is not permitted by that gate, and ensure
mass_alert flows only through the broadcast renderer path (the
broadcast-specific renderer function) or its dedicated enqueue flow.
🪄 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: 84ae635b-b1bb-4a90-9a03-313fbc2a3e15

📥 Commits

Reviewing files that changed from the base of the PR and between 780b8bd and 1e183f7.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (35)
  • apps/admin-desktop/package.json
  • apps/admin-desktop/src/__tests__/analytics-dashboard.test.tsx
  • apps/admin-desktop/src/__tests__/mass-alert-modal.test.tsx
  • apps/admin-desktop/src/__tests__/shift-handoff-modal.test.tsx
  • apps/admin-desktop/src/__tests__/triage-queue.test.tsx
  • apps/admin-desktop/src/pages/AnalyticsDashboardPage.tsx
  • apps/admin-desktop/src/pages/MassAlertModal.tsx
  • apps/admin-desktop/src/pages/TriageQueuePage.tsx
  • apps/admin-desktop/src/routes.tsx
  • apps/admin-desktop/src/services/callables.ts
  • apps/responder-app/src/hooks/useRegisterFcmToken.ts
  • docs/learnings.md
  • docs/progress.md
  • functions/src/__tests__/callables/mass-alert.test.ts
  • functions/src/__tests__/rules/mass-alert-requests.rules.test.ts
  • functions/src/__tests__/services/fcm-send.test.ts
  • functions/src/__tests__/triggers/analytics-snapshot-writer.test.ts
  • functions/src/__tests__/triggers/process-inbox-item-prc2.test.ts
  • functions/src/callables/mass-alert.ts
  • functions/src/index.ts
  • functions/src/scheduled/analytics-snapshot-writer.ts
  • functions/src/services/fcm-mass-send.ts
  • functions/src/services/fcm-send.ts
  • functions/src/services/send-sms.ts
  • functions/src/triggers/process-inbox-item.ts
  • infra/firebase/firestore.rules
  • infra/firebase/firestore.rules.template
  • packages/shared-data/src/index.ts
  • packages/shared-validators/src/coordination.ts
  • packages/shared-validators/src/index.ts
  • packages/shared-validators/src/reports.test.ts
  • packages/shared-validators/src/reports.ts
  • packages/shared-validators/src/responders.test.ts
  • packages/shared-validators/src/responders.ts
  • packages/shared-validators/src/sms-templates.ts

Comment thread apps/admin-desktop/src/__tests__/analytics-dashboard.test.tsx
Comment thread apps/admin-desktop/src/__tests__/mass-alert-modal.test.tsx
Comment thread apps/admin-desktop/src/pages/AnalyticsDashboardPage.tsx Outdated
Comment thread apps/admin-desktop/src/pages/MassAlertModal.tsx
Comment thread apps/admin-desktop/src/pages/TriageQueuePage.tsx Outdated
Comment thread functions/src/services/fcm-mass-send.ts Outdated
Comment thread functions/src/services/fcm-send.ts Outdated
Comment thread functions/src/services/send-sms.ts
Comment thread infra/firebase/firestore.rules Outdated
Comment thread packages/shared-validators/src/sms-templates.ts
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 `@functions/src/__tests__/callables/mass-alert.test.ts`:
- Around line 377-392: The test currently only checks that sendMassAlertFcm was
called; strengthen it by asserting the call count and the key payload fields to
ensure correct routing and content. After invoking
requestMassAlertEscalationCore, assert mockFcm was called exactly once (or
expected number) and validate the first call's arguments include expected
title/body/data/targetScope/idempotencyKey (use sendMassAlertFcm mock calls or
toHaveBeenCalledWith to check specific fields), referencing sendMassAlertFcm and
requestMassAlertEscalationCore to find where to update the assertions. Ensure
you unwrap/match nested objects (e.g., targetScope.municipalityIds) rather than
only checking invocation.
- Around line 417-418: Rename the misspelled parameter ndrrrcRecipient to
ndrrmcRecipient everywhere to match the rest of the codebase: update the
parameter name in the forwardMassAlertToNDRRMCCore function signature, adjust
the callable validation/schema for forwardMassAlertToNDRRMC to expect
ndrrmcRecipient, change the admin-desktop service type definition to use
ndrrmcRecipient, and fix all related tests (including the failing assertions in
the mass-alert.test cases) to pass the corrected key. Ensure all usages, logs,
and forwards reference ndrrmcRecipient consistently.
🪄 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: 6df27835-8dfc-4f35-a865-ac41ad514365

📥 Commits

Reviewing files that changed from the base of the PR and between 1e183f7 and 7bd9301.

📒 Files selected for processing (1)
  • functions/src/__tests__/callables/mass-alert.test.ts

Comment on lines +377 to +392
it('FCMs provincial superadmins', async () => {
const { sendMassAlertFcm } = await import('../../services/fcm-mass-send.js')
const mockFcm = vi.mocked(sendMassAlertFcm)
mockFcm.mockClear()
await requestMassAlertEscalationCore(
adminDb,
{
message: 'Alert',
targetScope: { municipalityIds: ['daet'] },
evidencePack: { linkedReportIds: [] },
idempotencyKey: crypto.randomUUID(),
},
muniAdminActor,
)
expect(mockFcm).toHaveBeenCalled()
})
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

Strengthen the FCM escalation assertion to validate payload, not just invocation.

toHaveBeenCalled() alone can pass even if title/body/data routing regresses. Assert the call count and key payload fields to lock contract behavior.

Suggested test hardening
   it('FCMs provincial superadmins', async () => {
     const { sendMassAlertFcm } = await import('../../services/fcm-mass-send.js')
     const mockFcm = vi.mocked(sendMassAlertFcm)
     mockFcm.mockClear()
     await requestMassAlertEscalationCore(
       adminDb,
       {
         message: 'Alert',
         targetScope: { municipalityIds: ['daet'] },
         evidencePack: { linkedReportIds: [] },
         idempotencyKey: crypto.randomUUID(),
       },
       muniAdminActor,
     )
-    expect(mockFcm).toHaveBeenCalled()
+    expect(mockFcm).toHaveBeenCalledTimes(1)
+    expect(mockFcm).toHaveBeenCalledWith(
+      adminDb,
+      expect.objectContaining({
+        municipalityIds: ['daet'],
+        title: 'NDRRMC Escalation Request',
+        data: expect.objectContaining({ type: 'escalation_review' }),
+      }),
+    )
   })

As per coding guidelines: "Write tests that verify the new code is actually invoked, not tests that pass trivially."

📝 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
it('FCMs provincial superadmins', async () => {
const { sendMassAlertFcm } = await import('../../services/fcm-mass-send.js')
const mockFcm = vi.mocked(sendMassAlertFcm)
mockFcm.mockClear()
await requestMassAlertEscalationCore(
adminDb,
{
message: 'Alert',
targetScope: { municipalityIds: ['daet'] },
evidencePack: { linkedReportIds: [] },
idempotencyKey: crypto.randomUUID(),
},
muniAdminActor,
)
expect(mockFcm).toHaveBeenCalled()
})
it('FCMs provincial superadmins', async () => {
const { sendMassAlertFcm } = await import('../../services/fcm-mass-send.js')
const mockFcm = vi.mocked(sendMassAlertFcm)
mockFcm.mockClear()
await requestMassAlertEscalationCore(
adminDb,
{
message: 'Alert',
targetScope: { municipalityIds: ['daet'] },
evidencePack: { linkedReportIds: [] },
idempotencyKey: crypto.randomUUID(),
},
muniAdminActor,
)
expect(mockFcm).toHaveBeenCalledTimes(1)
expect(mockFcm).toHaveBeenCalledWith(
adminDb,
expect.objectContaining({
municipalityIds: ['daet'],
title: 'NDRRMC Escalation Request',
data: expect.objectContaining({ type: 'escalation_review' }),
}),
)
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@functions/src/__tests__/callables/mass-alert.test.ts` around lines 377 - 392,
The test currently only checks that sendMassAlertFcm was called; strengthen it
by asserting the call count and the key payload fields to ensure correct routing
and content. After invoking requestMassAlertEscalationCore, assert mockFcm was
called exactly once (or expected number) and validate the first call's arguments
include expected title/body/data/targetScope/idempotencyKey (use
sendMassAlertFcm mock calls or toHaveBeenCalledWith to check specific fields),
referencing sendMassAlertFcm and requestMassAlertEscalationCore to find where to
update the assertions. Ensure you unwrap/match nested objects (e.g.,
targetScope.municipalityIds) rather than only checking invocation.

Comment on lines +417 to +418
ndrrrcRecipient: 'ndrrmc@gov.ph',
},
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify recipient field naming consistency for mass-alert forwarding contract.

# 1) Find both spellings with context across TS/TSX.
rg -nP -C3 '\bndrrrcRecipient\b|\bndrrmcRecipient\b' --type ts --type tsx

# 2) Inspect callable definition and schema for forward API input/output keys.
rg -nP -C4 'forwardMassAlertToNDRRMC|forwardMassAlertToNDRRMCCore|z\.object\(' functions/src/callables/mass-alert.ts

# 3) Inspect admin desktop caller payload fields (if present in this PR).
rg -nP -C4 'forwardMethod|ndrrr?mcRecipient|forwardMassAlertToNDRRMC' apps/admin-desktop

Repository: Exc1D/bantayog-alert

Length of output: 2704


🏁 Script executed:

# Read the test file to see context around lines 417-418, 432-433, 440
cat -n functions/src/__tests__/callables/mass-alert.test.ts | sed -n '410,450p'

Repository: Exc1D/bantayog-alert

Length of output: 1775


🏁 Script executed:

# Search for any comments or documentation about NDRRMC/NDRRRC in the codebase
rg -nP 'NDRR[MC]|ndrrr?mc' functions/src/callables/mass-alert.ts -A 2 -B 2

Repository: Exc1D/bantayog-alert

Length of output: 2336


🌐 Web query:

NDRRMC Philippines disaster alert system

💡 Result:

The National Disaster Risk Reduction and Management Council (NDRRMC) of the Philippines oversees the country's disaster alert system, primarily through the National Disaster Risk Reduction and Management Operations Center (NDRRMOC). The core component is the Emergency Alert and Warning Messages (EAWM) system, governed by a Standard Operating Procedure (SOP) outlined in NDRRMC Memorandum No. 78 s. 2021. This system disseminates near real-time warnings to the public via free mobile alerts under Republic Act (RA) 10639, "The Free Mobile Disaster Alerts Act," and complements RA 10121, the Philippine DRRM Act of 2010, which mandates a national early warning and emergency alert system across broadcast, wireless, and landline media. Key elements include: - Triggered by hazard parameters from warning agencies (e.g., PAGASA for weather, PHIVOLCS for seismic/volcanic). - NDRRMOC as the central hub for monitoring, coordination, and issuance. - Integration with tools like PhilAWARE (multi-hazard early warning platform institutionalized in 2024 NDRP and NDRRMOC SOPG), HazardHunterPH (public hazard assessment), Project NOAH (flood/landslide forecasting), and emerging systems like Unified 911. - Uses alert levels (12 levels noted in NDRP 2024) and response clusters under the National Disaster Response Plan (NDRP) 2024 for coordinated action. - Updated procedures in NDRRMOC SOPG 2024 cover emergency alerts, evacuation, and multi-hazard response. The system emphasizes proactive, people-centered warnings to enable LGU actions, reducing disaster impacts. Official site: ndrrmc.gov.ph.

Citations:


Fix parameter name spelling: ndrrrcRecipient should be ndrrmcRecipient.

The codebase consistently uses triple-r spelling (ndrrrcRecipient) in the callable contract, but this is a typo. The official entity is NDRRMC (National Disaster Risk Reduction and Management Council), and all other references in the code correctly use double-r spelling: function names (forwardMassAlertToNDRRMC), status fields (pending_ndrrmc_review, forwarded_to_ndrrmc), email domain (ndrrmc@gov.ph), and log messages. Update the parameter name across:

  • forwardMassAlertToNDRRMCCore function signature
  • forwardMassAlertToNDRRMC callable schema validation
  • Admin-desktop service type definition
  • All test cases (lines 417, 432, 440)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@functions/src/__tests__/callables/mass-alert.test.ts` around lines 417 - 418,
Rename the misspelled parameter ndrrrcRecipient to ndrrmcRecipient everywhere to
match the rest of the codebase: update the parameter name in the
forwardMassAlertToNDRRMCCore function signature, adjust the callable
validation/schema for forwardMassAlertToNDRRMC to expect ndrrmcRecipient, change
the admin-desktop service type definition to use ndrrmcRecipient, and fix all
related tests (including the failing assertions in the mass-alert.test cases) to
pass the corrected key. Ensure all usages, logs, and forwards reference
ndrrmcRecipient consistently.

claude added 5 commits April 25, 2026 22:15
- mass-alert.ts: cap municipalityIds at 10 (Firestore in-limit),
  count individual FCM tokens not docs, use server-computed reachPlan,
  queue SMS broadcasts, remove wrong-audience escalation FCM,
  atomic transaction for forwardMassAlertToNDRRMCCore
- fcm-mass-send.ts: guard empty municipalityIds, deduplicate tokens,
  chunk in-queries to 10, hard-cap refusal at 5000 tokens
- fcm-send.ts: transactional hasFcmToken recompute from latest snapshot
- send-sms.ts: type providerId as 'semaphore' | 'globelabs' union
- AnalyticsDashboardPage: read scope summaries at /{date}/{scope}/summary
  instead of root docs that have no data
- MassAlertModal: stable idempotency keys via useRef, regenerated only
  when message changes to prevent duplicate sends on retry
- TriageQueuePage: guard modalOpen and Mass Alert button when municipalityId
  is absent to prevent blocked keyboard navigation without visible UI
- mass-alert.test: verify server-computed reach (not client input),
  verify escalation does NOT FCM responders, add SMS queueing test
- fcm-send.test: mock runTransaction for hasFcmToken recompute tests
- fcm-mass-send.test: new file — empty guard, dedup, chunking,
  hard-cap refusal, batching, error handling
- analytics-dashboard.test: assert query scoping for municipalityId
- mass-alert-modal.test: assert mock call arguments (message, scope)
- firestore.rules: add isActivePrivileged() to superadmin-only update
  rule for mass_alert_requests (prevents suspended superadmins)
- firestore.rules.template: same fix for template consistency
- sms-templates.ts: replace UCS-2 em dash with GSM-7-safe hyphen
  to reduce SMS segment count/cost for mass_alert broadcasts
- Change 'SpyOn' to 'Spy on' for standard phrasing/casing
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: 6

♻️ Duplicate comments (2)
apps/admin-desktop/src/__tests__/analytics-dashboard.test.tsx (1)

58-65: ⚠️ Potential issue | 🟡 Minor

This still doesn't verify municipality scoping.

getCountFromServer being called with some query object is not enough—the test will still pass if the where('municipalityId', '==', 'daet') filter disappears. Assert on the mocked where/query arguments so the municipality constraint is part of the expectation. As per coding guidelines, "Write tests that verify the new code is actually invoked, not tests that pass trivially."

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

In `@apps/admin-desktop/src/__tests__/analytics-dashboard.test.tsx` around lines
58 - 65, The test currently only checks that mockGetCountFromServer was called,
not that it included the municipality filter; update the assertion to verify the
actual query includes where('municipalityId', '==', <expectedId>) by inspecting
the argument passed to mockGetCountFromServer (reference mockGetCountFromServer)
and asserting the query contains the municipality constraint (either by
asserting a mocked where helper was called with 'municipalityId' and the
expected value, or by checking the queryArg object for the field/filter entry).
Ensure you use the same expected municipality id used in the test render (e.g.,
'daet' or the wrapper's municipalityId) and replace the loose
expect(queryArg).toBeDefined() with a concrete assertion that the municipality
filter exists.
functions/src/callables/mass-alert.ts (1)

273-276: 🧹 Nitpick | 🔵 Trivial

TODO tracks missing reviewer notification - consider creating an issue.

The TODO correctly identifies that escalation notifications should target provincial_superadmin reviewers, not responders. This is an important feature gap for the escalation workflow to function properly.

Would you like me to open an issue to track implementing the reviewer notification channel?

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

In `@functions/src/callables/mass-alert.ts` around lines 273 - 276, The TODO notes
missing reviewer notification for escalation; implement a separate notification
path that queries users with role == 'provincial_superadmin' (and any NDRRMC
reviewer roles) and sends them targeted FCMs instead of using sendMassAlertFcm
which targets responders by municipality; add a new function (e.g.
sendReviewerNotifications or sendEscalationFcm) that performs the DB query for
users by role, builds reviewer-specific payloads, and calls the FCM send
routine, then invoke this from the escalation flow in mass-alert.ts where the
TODO is now so superadmins receive escalation alerts.
🤖 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/pages/AnalyticsDashboardPage.tsx`:
- Around line 78-95: The bars are currently capped with barH = Math.min(total,
70) which flattens values >70; compute the maximum total across snapshots first
(e.g., derive maxTotal from snapshots by mapping reportsByStatus -> totals and
using Math.max) and then scale each bar height proportionally to that max (barH
= maxTotal > 0 ? Math.round((total / maxTotal) * MAX_HEIGHT) : 0), keeping
MAX_HEIGHT as the current 70; update the mapping that produces barH and ensure
you guard against division by zero so days with zero totals render height 0.

In `@apps/admin-desktop/src/pages/MassAlertModal.tsx`:
- Around line 69-86: The escalation always sends an empty evidencePack; update
handleEscalate to pass real evidence by reading new/existing state fields (e.g.,
linkedReportIds, pagasaSignalRef, notes) instead of hardcoded { linkedReportIds:
[] } and wire those fields into the requestMassAlertEscalation call (keep
message, targetScope, and idempotencyKey the same). Add or use UI state (e.g.,
linkedReportIds state array, pagasaSignalRef string, notes string) and include
them in evidencePack: { linkedReportIds, pagasaSignalRef, notes } when calling
requestMassAlertEscalation in handleEscalate.

In `@apps/admin-desktop/src/pages/TriageQueuePage.tsx`:
- Around line 142-150: The button currently uses disabled={!canOpenMassAlert}
which makes the onClick branch that calls setBanner unreachable; instead remove
the disabled prop (or make the button enabled) and keep the onClick logic so
clicking when !canOpenMassAlert triggers setBanner('Mass Alert is only available
for municipality-scoped admins.') and clicking when canOpenMassAlert calls
setMassAlertOpen(true); update the button element containing canOpenMassAlert,
setBanner, and setMassAlertOpen accordingly so the informative banner is shown
on user click.

In `@functions/src/services/fcm-mass-send.ts`:
- Around line 38-55: The current aggregation flattens tokens into tokenSet
losing ownership, so instead build and populate a Map<string, string[]> (or
Map<string, Set<string>>) that maps each FCM token to its owner responder
document id while iterating in the loop that currently creates tokenSet (replace
tokenSet with this token→owner map in the block that reads responders and
fcmTokens); pass the flattened token list to sendEachForMulticast but keep the
token→owner map to translate per-token failures returned by sendEachForMulticast
back to responder IDs, then apply the same transactional cleanup pattern used in
functions/src/services/fcm-send.ts to remove invalid tokens and update
hasFcmToken per responder atomically.

In `@infra/firebase/firestore.rules.template`:
- Around line 379-386: The current allow create rule for this workflow/audit
collection (the allow create block that uses isActivePrivileged(),
isSuperadmin(), isMuniAdmin(), myMunicipality(), and allowedCreateStatus()) is
too permissive because it only checks role/scope and status; update it so
clients cannot inject arbitrary fields or pre-mark a request as sent: either (A)
deny client-side creates entirely (remove/replace the allow create path so only
callable/cloud functions can create documents), or (B) implement strict
server-side validation by whitelisting exact writable fields and constraining
status to pre-processing values inside allowedCreateStatus(), plus ensure
requestedByMunicipality equality check remains; choose one approach and apply it
to the allow create rule to prevent clients from setting arbitrary metadata or
terminal statuses.

In `@packages/shared-validators/src/sms-templates.ts`:
- Line 10: renderTemplate currently accepts all SmsPurpose values (including
'mass_alert' from SmsPurpose/RenderArgs) but only replaces {publicRef}, so
callers can pass purpose: 'mass_alert' and receive un-rendered placeholders;
update renderTemplate (the renderTemplate function referenced in this diff) to
either restrict its input to non-broadcast purposes or explicitly throw when
args.purpose === 'mass_alert'. Concretely: in renderTemplate, check the incoming
RenderArgs.purpose (or SmsPurpose) and if it equals 'mass_alert' throw a clear
error like "renderTemplate cannot render 'mass_alert' templates" (or narrow the
function's type signature to exclude 'mass_alert'), ensuring no code path
returns a template with unreplaced placeholders; apply the same change to the
analogous code block around lines 74-88 mentioned in the comment.

---

Duplicate comments:
In `@apps/admin-desktop/src/__tests__/analytics-dashboard.test.tsx`:
- Around line 58-65: The test currently only checks that mockGetCountFromServer
was called, not that it included the municipality filter; update the assertion
to verify the actual query includes where('municipalityId', '==', <expectedId>)
by inspecting the argument passed to mockGetCountFromServer (reference
mockGetCountFromServer) and asserting the query contains the municipality
constraint (either by asserting a mocked where helper was called with
'municipalityId' and the expected value, or by checking the queryArg object for
the field/filter entry). Ensure you use the same expected municipality id used
in the test render (e.g., 'daet' or the wrapper's municipalityId) and replace
the loose expect(queryArg).toBeDefined() with a concrete assertion that the
municipality filter exists.

In `@functions/src/callables/mass-alert.ts`:
- Around line 273-276: The TODO notes missing reviewer notification for
escalation; implement a separate notification path that queries users with role
== 'provincial_superadmin' (and any NDRRMC reviewer roles) and sends them
targeted FCMs instead of using sendMassAlertFcm which targets responders by
municipality; add a new function (e.g. sendReviewerNotifications or
sendEscalationFcm) that performs the DB query for users by role, builds
reviewer-specific payloads, and calls the FCM send routine, then invoke this
from the escalation flow in mass-alert.ts where the TODO is now so superadmins
receive escalation alerts.
🪄 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: 85786871-7274-4ce3-ae2f-990c643f6b68

📥 Commits

Reviewing files that changed from the base of the PR and between 7bd9301 and 23e7a3d.

📒 Files selected for processing (16)
  • apps/admin-desktop/src/__tests__/analytics-dashboard.test.tsx
  • apps/admin-desktop/src/__tests__/mass-alert-modal.test.tsx
  • apps/admin-desktop/src/pages/AnalyticsDashboardPage.tsx
  • apps/admin-desktop/src/pages/MassAlertModal.tsx
  • apps/admin-desktop/src/pages/TriageQueuePage.tsx
  • docs/learnings.md
  • functions/src/__tests__/callables/mass-alert.test.ts
  • functions/src/__tests__/services/fcm-mass-send.test.ts
  • functions/src/__tests__/services/fcm-send.test.ts
  • functions/src/callables/mass-alert.ts
  • functions/src/services/fcm-mass-send.ts
  • functions/src/services/fcm-send.ts
  • functions/src/services/send-sms.ts
  • infra/firebase/firestore.rules
  • infra/firebase/firestore.rules.template
  • packages/shared-validators/src/sms-templates.ts

Comment thread apps/admin-desktop/src/pages/AnalyticsDashboardPage.tsx
Comment on lines +69 to +86
const handleEscalate = () => {
setLoading(true)
void (async () => {
try {
await callables.requestMassAlertEscalation({
message,
targetScope: { municipalityIds: [municipalityId] },
evidencePack: { linkedReportIds: [] },
idempotencyKey: escalateKeyRef.current,
})
onClose()
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Escalation failed')
} finally {
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.

🧹 Nitpick | 🔵 Trivial

Consider allowing evidence attachment for escalations.

The evidencePack is hardcoded with empty linkedReportIds. For meaningful NDRRMC escalations, consider adding UI to link related reports or attach notes. The schema already supports pagasaSignalRef and notes fields.

Would you like me to draft a UI extension for evidence collection, or open an issue to track this enhancement?

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

In `@apps/admin-desktop/src/pages/MassAlertModal.tsx` around lines 69 - 86, The
escalation always sends an empty evidencePack; update handleEscalate to pass
real evidence by reading new/existing state fields (e.g., linkedReportIds,
pagasaSignalRef, notes) instead of hardcoded { linkedReportIds: [] } and wire
those fields into the requestMassAlertEscalation call (keep message,
targetScope, and idempotencyKey the same). Add or use UI state (e.g.,
linkedReportIds state array, pagasaSignalRef string, notes string) and include
them in evidencePack: { linkedReportIds, pagasaSignalRef, notes } when calling
requestMassAlertEscalation in handleEscalate.

Comment thread apps/admin-desktop/src/pages/TriageQueuePage.tsx
Comment thread functions/src/services/fcm-mass-send.ts Outdated
Comment thread infra/firebase/firestore.rules.template Outdated
Comment thread packages/shared-validators/src/sms-templates.ts
- firestore.rules + template: deny client creates on mass_alert_requests
  (callables use Admin SDK; no client write path is intended). Remove
  dead allowedCreateStatus() function.
- sms-templates.ts: export DirectSmsPurpose = Exclude<SmsPurpose,'mass_alert'>
  and narrow RenderArgs.purpose to it, so renderTemplate() cannot receive
  a broadcast purpose with un-renderable placeholders.
- send-sms.ts: adopt DirectSmsPurpose for EnqueueSmsArgs.purpose; remove
  'mass_alert' from VALID_PURPOSES (broadcast uses enqueueBroadcastSms).
- fcm-mass-send.ts: replace tokenSet with Map<token,ownerIds> to preserve
  ownership, collect invalid tokens from sendEachForMulticast responses, and
  clean up stale fcmTokens/hasFcmToken per-responder after send.
- AnalyticsDashboardPage.tsx: normalize SVG bar heights against maxTotal
  instead of capping at 70 so trend is meaningful when values exceed 70.
- MassAlertModal.tsx: wire pagasaSignalRef and notes into evidencePack for
  NDRRMC escalation (schema already supported them; UI fields now expose them).
- TriageQueuePage.tsx: remove disabled={!canOpenMassAlert} from Mass Alert
  button so the informative banner branch is reachable on click.
- analytics-dashboard.test.tsx: hoist mockWhere and assert the municipality
  filter (where('municipalityId','==','daet')) is actually applied.
- shared-validators/lib: rebuild to include renderBroadcastTemplate and
  DirectSmsPurpose in emitted declarations.

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: 10

♻️ Duplicate comments (1)
apps/admin-desktop/src/pages/MassAlertModal.tsx (1)

148-158: ⚠️ Potential issue | 🟠 Major

Regenerate escalation idempotency key when evidence fields change.

evidencePack includes pagasaSignalRef and notes (Lines 80-82), but changes in Lines 148-158 do not rotate escalateKeyRef. That can replay stale escalation payload under the same key.

Proposed fix
           <input
             id="pagasa-signal-ref"
             type="text"
             value={pagasaSignalRef}
             onChange={(e) => {
               setPagasaSignalRef(e.target.value)
+              escalateKeyRef.current = crypto.randomUUID()
             }}
           />
@@
           <textarea
             id="escalation-notes"
             value={notes}
             onChange={(e) => {
               setNotes(e.target.value)
+              escalateKeyRef.current = crypto.randomUUID()
             }}
             rows={2}
           />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-desktop/src/pages/MassAlertModal.tsx` around lines 148 - 158, The
idempotency key (escalateKeyRef) must be regenerated whenever evidence fields
change: update the onChange handlers for pagasaSignalRef (setPagasaSignalRef)
and notes (setNotes) to also rotate the escalateKeyRef, or add a useEffect that
watches evidencePack fields (pagasaSignalRef and notes) and sets a new
escalateKeyRef (e.g., via the same key generator used elsewhere) whenever they
change; ensure you reference and update the existing escalateKeyRef state so
each edit yields a fresh idempotency key and prevents replay of stale escalation
payloads.
🤖 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/__tests__/analytics-dashboard.test.tsx`:
- Around line 38-42: The beforeEach currently sets mockResolvedValue/Return for
mockGetCountFromServer, mockGetDocs and mockWhere but does not clear prior call
history, so add calls to clear the mocks' call history at the start of the
beforeEach (e.g., mockGetCountFromServer.mockClear(), mockGetDocs.mockClear(),
mockWhere.mockClear()) before reassigning mockResolvedValue/mockReturnValue so
assertions that inspect calls to mockWhere (and others) reflect only the current
test's activity; target the mocks by their identifiers mockGetCountFromServer,
mockGetDocs and mockWhere.
- Around line 40-41: The current tests stub mockGetDocs to always return an
empty docs array so the per-date summary/trend path is never exercised; update
the test to add one non-empty snapshot case by making
mockGetDocs.mockResolvedValue return an object with a non-empty docs array (each
doc should mimic the Firestore DocumentSnapshot shape with a data() method
returning the summary/date fields your component expects) and keep
mockWhere.mockReturnValue returning the query chain as before; then render the
AnalyticsDashboard component (or call the same test helper used elsewhere) and
add a snapshot assertion so the trend-fetch code path behind mockGetDocs and
mockWhere is actually executed and verified.

In `@apps/admin-desktop/src/pages/AnalyticsDashboardPage.tsx`:
- Around line 86-108: The current conditional shows "<p>No snapshot data
yet.</p>" whenever snapshots is falsy even while data is still being fetched;
update the render logic around snapshots (the ternary that checks snapshots &&
snapshots.length > 0) to also check a loading flag (e.g., isLoadingSnapshots or
similar) and only show the empty-state paragraph when loading is false and
snapshots is empty; if you don't already have a loading state, add one to the
component that tracks the fetch lifecycle and use it to render a loader or
nothing while snapshots are loading instead of the "No snapshot data yet."
message.

In `@functions/src/services/fcm-mass-send.ts`:
- Around line 43-47: The responders query in fcm-mass-send.ts currently filters
only by hasFcmToken and municipalityId (see db.collection('responders') and the
snaps variable); add an explicit active-filter by appending .where('isActive',
'==', true) to the query so deactivated responders don't receive mass alerts (or
alternatively, add a short comment above the query documenting the intentional
decision to include inactive responders if that is desired).
- Around line 123-138: The cleanup Promise.all over ownerToInvalidTokens can
reject if any db.runTransaction fails, which will abort all cleanups; change to
per-owner error handling by either wrapping each transaction in try/catch
(inside the map) or use Promise.allSettled to log failures without throwing, and
optionally collect failed ownerIds/tokens and surface them in MassSendResult so
callers (mass-alert.ts) can observe cleanup errors; refer to
ownerToInvalidTokens, db.runTransaction, FieldValue.arrayRemove and
MassSendResult when implementing the change.

In `@infra/firebase/firestore.rules.template`:
- Around line 381-384: The current rule allows client-side updates via "allow
update: if isSuperadmin() && isActivePrivileged()", which opens an unintended
client write path; either remove that client update allowance (change to "allow
update: if false") to force callable-only writes, or replace it with a strict
predicate that uses affectedKeys() and explicit status/field-transition checks
(e.g., verify only a small whitelist of keys can change and validate old/new
status transitions) while still requiring isSuperadmin() &&
isActivePrivileged(); reference the isSuperadmin(), isActivePrivileged(), and
affectedKeys() helpers when implementing the stricter predicate.

In `@packages/shared-validators/lib/coordination.js`:
- Around line 82-88: The evidencePack nested validator currently allows unknown
keys; update the zod schema for evidencePack (the z.object({...}) inside the
evidencePack property) to be strict so unknown keys cause validation failures:
replace the current z.object({...}) with z.object({...}).strict() while keeping
the outer .optional() intact (i.e., evidencePack:
z.object({...}).strict().optional()), ensuring malformed or extra fields are
rejected.

In `@packages/shared-validators/lib/reports.test.js`:
- Around line 361-374: Add a test that verifies followUpConsent: false is
accepted to ensure the schema enforces a boolean (not just truthy) — in the test
file where inboxPayloadSchema and basePayload are used, add an it() case similar
to the existing 'accepts followUpConsent boolean' that passes followUpConsent:
false along with a valid contact (e.g., contact: { phone: '+639171234567',
smsConsent: true }) and assert inboxPayloadSchema.parse(...) does not throw;
this ensures the schema (inboxPayloadSchema) accepts both true and false values.

In `@packages/shared-validators/lib/sms-templates.js`:
- Around line 53-61: The renderBroadcastTemplate function currently interpolates
args.vars.municipalityName and args.vars.body without guards; update
renderBroadcastTemplate to validate that args.vars exists and that
municipalityName and body are present and non-empty (e.g., not
undefined/null/empty string) before performing .replace; if validation fails,
throw an SmsTemplateError with a clear message referencing the missing field(s)
so callers can handle the error; keep the existing TEMPLATES.mass_alert lookup
and only perform interpolation after successful validation.

In `@packages/shared-validators/src/sms-templates.ts`:
- Around line 82-91: renderBroadcastTemplate currently only validates locale but
will accept empty municipalityName/body and produce bad SMS; update
renderBroadcastTemplate (and use BroadcastRenderArgs and SmsTemplateError) to
trim args.vars.municipalityName and args.vars.body, validate they are non-empty
after trimming, and throw a SmsTemplateError with a clear message (e.g.,
"Missing municipalityName" or "Missing body") if either is empty; then use the
trimmed values for the .replace() calls so replacements never insert blank
content.

---

Duplicate comments:
In `@apps/admin-desktop/src/pages/MassAlertModal.tsx`:
- Around line 148-158: The idempotency key (escalateKeyRef) must be regenerated
whenever evidence fields change: update the onChange handlers for
pagasaSignalRef (setPagasaSignalRef) and notes (setNotes) to also rotate the
escalateKeyRef, or add a useEffect that watches evidencePack fields
(pagasaSignalRef and notes) and sets a new escalateKeyRef (e.g., via the same
key generator used elsewhere) whenever they change; ensure you reference and
update the existing escalateKeyRef state so each edit yields a fresh idempotency
key and prevents replay of stale escalation payloads.
🪄 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: a43dd432-cc7c-4b05-99cd-cad47a0e4ba9

📥 Commits

Reviewing files that changed from the base of the PR and between 23e7a3d and ca3acd2.

⛔ Files ignored due to path filters (12)
  • packages/shared-validators/lib/coordination.d.ts.map is excluded by !**/*.map
  • packages/shared-validators/lib/coordination.js.map is excluded by !**/*.map
  • packages/shared-validators/lib/index.d.ts.map is excluded by !**/*.map
  • packages/shared-validators/lib/index.js.map is excluded by !**/*.map
  • packages/shared-validators/lib/reports.d.ts.map is excluded by !**/*.map
  • packages/shared-validators/lib/reports.js.map is excluded by !**/*.map
  • packages/shared-validators/lib/reports.test.js.map is excluded by !**/*.map
  • packages/shared-validators/lib/responders.d.ts.map is excluded by !**/*.map
  • packages/shared-validators/lib/responders.js.map is excluded by !**/*.map
  • packages/shared-validators/lib/responders.test.js.map is excluded by !**/*.map
  • packages/shared-validators/lib/sms-templates.d.ts.map is excluded by !**/*.map
  • packages/shared-validators/lib/sms-templates.js.map is excluded by !**/*.map
📒 Files selected for processing (22)
  • apps/admin-desktop/src/__tests__/analytics-dashboard.test.tsx
  • apps/admin-desktop/src/pages/AnalyticsDashboardPage.tsx
  • apps/admin-desktop/src/pages/MassAlertModal.tsx
  • apps/admin-desktop/src/pages/TriageQueuePage.tsx
  • functions/src/services/fcm-mass-send.ts
  • functions/src/services/send-sms.ts
  • infra/firebase/firestore.rules
  • infra/firebase/firestore.rules.template
  • packages/shared-validators/lib/coordination.d.ts
  • packages/shared-validators/lib/coordination.js
  • packages/shared-validators/lib/index.d.ts
  • packages/shared-validators/lib/index.js
  • packages/shared-validators/lib/reports.d.ts
  • packages/shared-validators/lib/reports.js
  • packages/shared-validators/lib/reports.test.js
  • packages/shared-validators/lib/responders.d.ts
  • packages/shared-validators/lib/responders.js
  • packages/shared-validators/lib/responders.test.js
  • packages/shared-validators/lib/sms-templates.d.ts
  • packages/shared-validators/lib/sms-templates.js
  • packages/shared-validators/src/index.ts
  • packages/shared-validators/src/sms-templates.ts

Comment thread apps/admin-desktop/src/__tests__/analytics-dashboard.test.tsx
Comment thread apps/admin-desktop/src/__tests__/analytics-dashboard.test.tsx
Comment thread apps/admin-desktop/src/pages/AnalyticsDashboardPage.tsx Outdated
Comment thread functions/src/services/fcm-mass-send.ts
Comment thread functions/src/services/fcm-mass-send.ts Outdated
Comment thread infra/firebase/firestore.rules.template
Comment on lines +82 to +88
evidencePack: z
.object({
linkedReportIds: z.array(z.string()),
pagasaSignalRef: z.string().optional(),
notes: z.string().max(2000).optional(),
})
.optional(),
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

Harden evidencePack validation by making the nested object strict.

Line 82-Line 88 currently strips unknown keys inside evidencePack silently. For escalation evidence, that can mask malformed inputs instead of failing fast.

Suggested fix
     evidencePack: z
         .object({
         linkedReportIds: z.array(z.string()),
         pagasaSignalRef: z.string().optional(),
         notes: z.string().max(2000).optional(),
     })
+        .strict()
         .optional(),

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
evidencePack: z
.object({
linkedReportIds: z.array(z.string()),
pagasaSignalRef: z.string().optional(),
notes: z.string().max(2000).optional(),
})
.optional(),
evidencePack: z
.object({
linkedReportIds: z.array(z.string()),
pagasaSignalRef: z.string().optional(),
notes: z.string().max(2000).optional(),
})
.strict()
.optional(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/shared-validators/lib/coordination.js` around lines 82 - 88, The
evidencePack nested validator currently allows unknown keys; update the zod
schema for evidencePack (the z.object({...}) inside the evidencePack property)
to be strict so unknown keys cause validation failures: replace the current
z.object({...}) with z.object({...}).strict() while keeping the outer
.optional() intact (i.e., evidencePack: z.object({...}).strict().optional()),
ensuring malformed or extra fields are rejected.

Comment on lines +361 to +374
it('accepts followUpConsent boolean', () => {
expect(() => inboxPayloadSchema.parse({
...basePayload,
contact: { phone: '+639171234567', smsConsent: true },
followUpConsent: true,
})).not.toThrow();
});
it('rejects non-boolean followUpConsent', () => {
expect(() => inboxPayloadSchema.parse({
...basePayload,
contact: { phone: '+639171234567', smsConsent: true },
followUpConsent: 'yes',
})).toThrow();
});
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

Add a positive test for followUpConsent: false to fully lock the boolean contract.

Right now the suite proves true passes and string fails; adding false prevents regressions that accidentally narrow this to a truthy/literal-true rule.

Suggested test addition
   it('accepts followUpConsent boolean', () => {
@@
   });
+  it('accepts followUpConsent=false', () => {
+      expect(() => inboxPayloadSchema.parse({
+          ...basePayload,
+          contact: { phone: '+639171234567', smsConsent: true },
+          followUpConsent: false,
+      })).not.toThrow();
+  });
   it('rejects non-boolean followUpConsent', () => {
📝 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
it('accepts followUpConsent boolean', () => {
expect(() => inboxPayloadSchema.parse({
...basePayload,
contact: { phone: '+639171234567', smsConsent: true },
followUpConsent: true,
})).not.toThrow();
});
it('rejects non-boolean followUpConsent', () => {
expect(() => inboxPayloadSchema.parse({
...basePayload,
contact: { phone: '+639171234567', smsConsent: true },
followUpConsent: 'yes',
})).toThrow();
});
it('accepts followUpConsent boolean', () => {
expect(() => inboxPayloadSchema.parse({
...basePayload,
contact: { phone: '+639171234567', smsConsent: true },
followUpConsent: true,
})).not.toThrow();
});
it('accepts followUpConsent=false', () => {
expect(() => inboxPayloadSchema.parse({
...basePayload,
contact: { phone: '+639171234567', smsConsent: true },
followUpConsent: false,
})).not.toThrow();
});
it('rejects non-boolean followUpConsent', () => {
expect(() => inboxPayloadSchema.parse({
...basePayload,
contact: { phone: '+639171234567', smsConsent: true },
followUpConsent: 'yes',
})).toThrow();
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/shared-validators/lib/reports.test.js` around lines 361 - 374, Add a
test that verifies followUpConsent: false is accepted to ensure the schema
enforces a boolean (not just truthy) — in the test file where inboxPayloadSchema
and basePayload are used, add an it() case similar to the existing 'accepts
followUpConsent boolean' that passes followUpConsent: false along with a valid
contact (e.g., contact: { phone: '+639171234567', smsConsent: true }) and assert
inboxPayloadSchema.parse(...) does not throw; this ensures the schema
(inboxPayloadSchema) accepts both true and false values.

Comment on lines +53 to +61
export function renderBroadcastTemplate(args) {
const purposeMap = TEMPLATES.mass_alert;
const template = purposeMap[args.locale];
if (!template) {
throw new SmsTemplateError(`Unknown locale: ${args.locale}`);
}
return template
.replace('{municipalityName}', args.vars.municipalityName)
.replace('{body}', args.vars.body);
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 broadcast vars before interpolation.

Line 60 and Line 61 interpolate values without runtime guards. If municipalityName or body is empty/undefined at runtime, malformed public alerts can be sent.

Suggested fix
 export function renderBroadcastTemplate(args) {
     const purposeMap = TEMPLATES.mass_alert;
     const template = purposeMap[args.locale];
     if (!template) {
         throw new SmsTemplateError(`Unknown locale: ${args.locale}`);
     }
+    const municipalityName = args?.vars?.municipalityName?.trim();
+    const body = args?.vars?.body?.trim();
+    if (!municipalityName || !body) {
+        throw new SmsTemplateError('Missing municipalityName or body');
+    }
     return template
-        .replace('{municipalityName}', args.vars.municipalityName)
-        .replace('{body}', args.vars.body);
+        .replace('{municipalityName}', municipalityName)
+        .replace('{body}', body);
 }

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

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

In `@packages/shared-validators/lib/sms-templates.js` around lines 53 - 61, The
renderBroadcastTemplate function currently interpolates
args.vars.municipalityName and args.vars.body without guards; update
renderBroadcastTemplate to validate that args.vars exists and that
municipalityName and body are present and non-empty (e.g., not
undefined/null/empty string) before performing .replace; if validation fails,
throw an SmsTemplateError with a clear message referencing the missing field(s)
so callers can handle the error; keep the existing TEMPLATES.mass_alert lookup
and only perform interpolation after successful validation.

Comment thread packages/shared-validators/src/sms-templates.ts
- mass-alert: regenerate idempotency key when evidence fields change
- analytics-dashboard: add mockClear in beforeEach, add non-empty snapshot test
- analytics-page: add loading state for snapshots during fetch
- fcm-mass-send: add isActive filter, use Promise.allSettled for cleanup
- firestore.rules: close client update path (allow update: if false)
- coordination: add strict() to evidencePack schema
- reports.test: add followUpConsent=false test case
- sms-templates: validate and trim municipalityName/body before interpolation
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: 10

🤖 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/__tests__/analytics-dashboard.test.tsx`:
- Around line 50-59: The test is stalling the wrong dependency: it mocks
mockGetCountFromServer but the test name says "snapshot data" so it never
exercises the snapshot-loading path; update the test to stall the snapshot fetch
mock used by AnalyticsDashboardPage (replace mockGetCountFromServer with the
snapshot-specific mock — e.g., mockGetSnapshotFromServer or whatever mock
corresponds to snapshot fetching in your test harness), ensure the rendered
component shows the snapshot loading state text, and rename the test to reflect
the mocked path (or alternatively, if you intend to test the page-level loading,
change the test name to "shows a loading state while analytics count is
fetching"). Ensure you target the AnalyticsDashboardPage snapshot-fetching
function and assert the snapshot-specific loading UI.
- Around line 19-28: The Firestore mock is missing doc() and getDoc() used by
AnalyticsDashboardPage and the test fixture documents lack an id field; add
mocked exports for doc and getDoc in the vi.mock replacement (matching the
existing mock helpers like mockGetDocs/mockGetCountFromServer) and update the
test snapshot/document fixture to include an id property so
AnalyticsDashboardPage can read d.id to build the reference path.

In `@apps/admin-desktop/src/pages/AnalyticsDashboardPage.tsx`:
- Around line 16-24: The project currently has two differing definitions of
"active" statuses causing inconsistent analytics: replace the local array
ACTIVE_STATUSES in AnalyticsDashboardPage.tsx with a shared constant (e.g.,
ACTIVE_REPORT_STATUSES) and import/use that same constant from the shared module
in apps/admin-desktop/src/hooks/useMuniReports.ts so both AnalyticsDashboardPage
and the useMuniReports hook reference the identical list; update exports where
you create ACTIVE_REPORT_STATUSES and adjust any import paths to use the unified
constant.

In `@apps/admin-desktop/src/pages/MassAlertModal.tsx`:
- Around line 53-54: The retry flows start new async requests with
setLoading(true) but never clear previous errors, so call setError(null)
immediately before each request begins (e.g., in the async IIFE that follows
setLoading(true) and likewise before the second request around lines that start
the escalate flow). Update the code paths that kick off send/escalate (identify
the async IIFE or functions wrapping setLoading(true) and the escalate/send
handlers) to call setError(null) just prior to setting loading and performing
the request so stale error state is cleared on retry.
- Around line 35-38: Trim and validate outbound text inputs at the UI boundary
before calling callables.massAlertReachPlanPreview (and the send/escalate
paths): ensure message is not empty or whitespace-only (e.g., message =
message?.trim() and reject if length === 0) and ensure notes is trimmed and
capped to the backend limit (e.g., if notes?.trim().length > 2000, show
validation error or truncate to 2000 chars) prior to invoking the RPC; also add
user-facing validation feedback and avoid empty catch blocks by logging or
surfacing errors in the preview/send/escalate handlers referenced around the
massAlertReachPlanPreview call and the related send/escalate functions.

In `@functions/src/services/fcm-mass-send.ts`:
- Around line 140-152: The current cleanup summary can overstate removals
because when failedCount > 0 you still log "Removed ..." using invalidTokens and
ownerToInvalidTokens; change the logging to accurately reflect partial failures:
inside the block where failedCount is computed and handled (variables/results:
failedCount, results), either (a) update the subsequent summary log to say
"Attempted to remove X invalid token(s) from Y responder(s); Z cleanup
transactions failed" or (b) split into two logs—one reporting successful
removals (compute successfulCount = total - failedCount) and one reporting
failures—so that the log messages involving invalidTokens and
ownerToInvalidTokens are not misleading.
- Around line 50-53: Validate the runtime shape of respDoc.data().fcmTokens
before treating it as string[]: check Array.isArray(tokens) and that every
element is a non-empty string (e.g., via tokens.every(t => typeof t === 'string'
&& t.trim() !== '')) and if the check fails skip/clean up that doc and log a
warning; apply the same validation before using currentTokens.filter(...) and in
the other similar block handling fcmTokens, and avoid swallowing errors—surface
or log exceptions instead of empty catches so malformed data doesn't produce
incorrect fanout or silent failures.

In `@infra/firebase/firestore.rules.template`:
- Around line 376-380: Add read-rule tests covering the new authorization path:
in functions/src/__tests__/rules/mass-alert-requests.rules.test.ts, add
assertions that access to read the mass-alert request document is allowed for an
active muni admin when myMunicipality() equals
resource.data.requestedByMunicipality (exercising isMassAlertMuniAdmin() and
isActivePrivileged()), allowed for an active superadmin (isSuperadmin() &&
isActivePrivileged()), and denied for inactive privileged accounts
(isActivePrivileged() false) even if muni admin or superadmin. Use the existing
test helpers/setup in that file to create request documents with
requestedByMunicipality set to match or not-match and add explicit
assertSucceeds/assertFails (or the repository’s equivalent) for each scenario.
Ensure you cover both matching and non-matching municipality cases so the
isMassAlertMuniAdmin() path is validated.

In `@packages/shared-validators/src/coordination.ts`:
- Line 88: The schema field linkedReportIds currently uses z.array(z.string())
which permits empty strings; update the validation to reject empty IDs by
changing it to z.array(z.string().min(1).trim()) (or at minimum
z.array(z.string().min(1))) so each item is non-empty and trimmed; locate the
linkedReportIds entry in the Zod schema in coordination.ts and replace the
validator accordingly, then run tests/type checks to ensure no other code
expects empty strings.

In `@packages/shared-validators/src/sms-templates.ts`:
- Around line 82-84: In renderBroadcastTemplate, validate and normalize
args.vars before calling .trim(): ensure args and args.vars exist, coerce
municipalityName and body to strings (e.g., fallback to empty string when
undefined/null or non-string) and then run the existing empty checks so
malformed input doesn't throw raw errors; update references to
args.vars.municipalityName and args.vars.body accordingly and throw the existing
SmsTemplateError where appropriate if values are empty after normalization.
🪄 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: 72552c6e-3d0b-4f9a-9b15-a1b794f0d186

📥 Commits

Reviewing files that changed from the base of the PR and between ca3acd2 and 3b7ccae.

📒 Files selected for processing (9)
  • apps/admin-desktop/src/__tests__/analytics-dashboard.test.tsx
  • apps/admin-desktop/src/pages/AnalyticsDashboardPage.tsx
  • apps/admin-desktop/src/pages/MassAlertModal.tsx
  • functions/src/services/fcm-mass-send.ts
  • infra/firebase/firestore.rules
  • infra/firebase/firestore.rules.template
  • packages/shared-validators/src/coordination.ts
  • packages/shared-validators/src/reports.test.ts
  • packages/shared-validators/src/sms-templates.ts

Comment thread apps/admin-desktop/src/__tests__/analytics-dashboard.test.tsx
Comment thread apps/admin-desktop/src/__tests__/analytics-dashboard.test.tsx Outdated
Comment thread apps/admin-desktop/src/pages/AnalyticsDashboardPage.tsx Outdated
Comment thread apps/admin-desktop/src/pages/MassAlertModal.tsx
Comment thread apps/admin-desktop/src/pages/MassAlertModal.tsx
Comment thread functions/src/services/fcm-mass-send.ts Outdated
Comment thread functions/src/services/fcm-mass-send.ts
Comment thread infra/firebase/firestore.rules.template
Comment thread packages/shared-validators/src/coordination.ts Outdated
Comment on lines +82 to +84
export function renderBroadcastTemplate(args: BroadcastRenderArgs): string {
const municipalityName = args.vars.municipalityName.trim()
const body = args.vars.body.trim()
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

Guard malformed vars before calling .trim().

This exported validator still throws a raw runtime error if a caller passes vars: undefined or non-string fields, so bad input can bypass SmsTemplateError. Normalize the nested values first, then reuse the existing empty checks.

Suggested hardening
 export function renderBroadcastTemplate(args: BroadcastRenderArgs): string {
-  const municipalityName = args.vars.municipalityName.trim()
-  const body = args.vars.body.trim()
+  const municipalityName =
+    typeof args?.vars?.municipalityName === 'string' ? args.vars.municipalityName.trim() : ''
+  const body = typeof args?.vars?.body === 'string' ? args.vars.body.trim() : ''

As per coding guidelines, "Defensive Programming: Assume external input is malicious/broken. Validate at the boundary. Never swallow errors with an empty catch block."

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

In `@packages/shared-validators/src/sms-templates.ts` around lines 82 - 84, In
renderBroadcastTemplate, validate and normalize args.vars before calling
.trim(): ensure args and args.vars exist, coerce municipalityName and body to
strings (e.g., fallback to empty string when undefined/null or non-string) and
then run the existing empty checks so malformed input doesn't throw raw errors;
update references to args.vars.municipalityName and args.vars.body accordingly
and throw the existing SmsTemplateError where appropriate if values are empty
after normalization.

- analytics-dashboard: add doc/getDoc mocks, fix loading test stall
- unify ACTIVE_REPORT_STATUSES in shared-types, update imports
- MassAlertModal: clear error before retry, trim/validate inputs
- fcm-mass-send: normalizeFcmTokens helper, fix cleanup log messaging
- mass-alert-requests rules: add read-rule tests
- coordination: linkedReportIds min(1) validation
- sms-templates: add missing vars guard
@Exc1D Exc1D merged commit 1b2320b into main Apr 26, 2026
14 checks passed
@Exc1D Exc1D deleted the phase5-cluster-c branch April 26, 2026 00:33
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.

2 participants