Skip to content
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
0f461bf
feat(phase6): Task 1 — lock native mobile foundation
claude Apr 26, 2026
e061bf4
feat(phase6): Task 3 — telemetry schema and RTDB rules
claude Apr 26, 2026
df155c1
feat(phase6): Task 2 — native push abstraction
claude Apr 26, 2026
ef8467a
docs(progress): Task 2 — native push abstraction
claude Apr 26, 2026
23ff79f
feat(phase6): Task 6 — responder field callables
claude Apr 26, 2026
346aaab
feat(phase6): Task 4 — native telemetry capture
claude Apr 26, 2026
a2c5254
docs(progress): Tasks 4 and 6 — telemetry + field callables
claude Apr 26, 2026
7ba4e00
feat(phase6): Task 5 — responder location projection
claude Apr 26, 2026
be61329
feat(phase6): Task 8 — responder handoff and availability
claude Apr 26, 2026
30bd1aa
feat(phase6): Task 7 — responder field UX
claude Apr 26, 2026
0e82790
feat(phase6): Task 9 — admin desktop responder operations
claude Apr 26, 2026
eaf0924
feat(phase6): Task 10 — drill spec and acceptance evidence
claude Apr 26, 2026
a890a4d
fix(functions): add rate-limit to triggerSOS, fix claims.active, enfo…
claude Apr 26, 2026
181cf62
fix(functions-tests): update Firestore emulator port to 8081
claude Apr 26, 2026
7ba5d47
fix(responder-app): remove duplicate telemetry hook, use router navig…
claude Apr 26, 2026
f76be20
test(functions): add responder-shift-handoff tests for initiate and a…
claude Apr 26, 2026
ec6424c
feat(functions): implement suspendResponder, revokeResponder, bulkAva…
claude Apr 26, 2026
f8461af
fix(telemetry): write responder_index on location emit, add RTDB rule…
claude Apr 26, 2026
54846d5
chore(shared-validators): rebuild lib artifacts after responder schem…
claude Apr 26, 2026
fa98982
Potential fix for pull request finding 'CodeQL / Creating biased rand…
Exc1D Apr 26, 2026
7f74e7f
chore(rules): regenerate firestore.rules with unable_to_complete tran…
claude Apr 26, 2026
d607b19
fix(validators): tighten telemetry schema bounds, add reason min(1), …
claude Apr 26, 2026
0c95fcb
fix(functions): admin accountStatus guard, bulk idempotency, handoff …
claude Apr 26, 2026
5853522
fix(admin-desktop): narrow status type, fix RTDB shift path, validate…
claude Apr 26, 2026
b2f70f9
fix(admin-desktop): add bulkLoading flag, confirm destructive actions…
claude Apr 26, 2026
7913469
fix(android,tests): disable backup, scope FileProvider paths, fix pac…
claude Apr 26, 2026
07591ab
fix(responder-app/hooks): loading cleanup, dispatchId guards, rethrow…
claude Apr 26, 2026
736ab8a
fix(responder-app/pages): route id guards, reason trim, shared REPORT…
claude Apr 26, 2026
5e909d5
fix(rules): strengthen RTDB telemetry validation, add availabilitySta…
claude Apr 26, 2026
6b02486
fix(review): address CodeRabbit PR review comments
claude Apr 26, 2026
9a1b93c
chore(rules): regenerate firestore.rules from template
claude Apr 26, 2026
9a9ffdb
fix(shared-validators): harden coordination schemas, add missing resp…
claude Apr 26, 2026
23ca286
fix(rules): RTDB enum validation and Firestore responder self-update …
claude Apr 26, 2026
6906024
fix(functions): roster field canonicalization and shift-handoff schem…
claude Apr 26, 2026
63787f3
fix(responder-app/services): push-client rejection handling and telem…
claude Apr 26, 2026
e3b6ea1
fix(responder-app/hooks): stale guards, cancellation checkpoints, inp…
claude Apr 26, 2026
d800155
fix(responder-app/pages): trim validation and sanitized error display
claude Apr 26, 2026
e709118
fix(admin-desktop/hooks): queue timing and freshness staleness
claude Apr 26, 2026
5b92722
fix(admin-desktop/pages): derive freshness at render time with period…
claude Apr 26, 2026
70350d9
docs: record Round 2 CodeRabbit review fixes and learnings
claude Apr 26, 2026
62dae79
Potential fix for pull request finding 'CodeQL / Useless conditional'
Exc1D Apr 26, 2026
011b39f
fix(rules): add availabilityReason type/length validation to responde…
claude Apr 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,5 @@ Thumbs.db
# Worktrees
.worktrees/
worktrees/
!apps/responder-app/ios/
!apps/responder-app/android/
125 changes: 125 additions & 0 deletions apps/admin-desktop/src/hooks/useAgencyAssistanceQueue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { useEffect, useState } from 'react'
import { collection, onSnapshot, query, where } from 'firebase/firestore'
import { db } from '../app/firebase'
import {
agencyAssistanceRequestDocSchema,
logDimension,
type AgencyAssistanceRequestDoc,
} from '@bantayog/shared-validators'

const log = logDimension('useAgencyAssistanceQueue')

export interface BackupRequest {
id: string
reportId: string
municipalityId: string
reason: string
status: 'pending' | 'accepted' | 'declined' | 'fulfilled' | 'expired'
agencyId: string
createdAt: number
}

function parseBackupRequest(id: string, raw: Record<string, unknown>): BackupRequest | null {
if (
typeof raw.reportId !== 'string' ||
typeof raw.reason !== 'string' ||
typeof raw.agencyId !== 'string' ||
typeof raw.createdAt !== 'number'
) {
log({
severity: 'WARNING',
code: 'backup_request.invalid',
message: `Invalid backup_request ${id}`,
data: {},
})
return null
}
return {
id,
reportId: raw.reportId,
municipalityId: typeof raw.municipalityId === 'string' ? raw.municipalityId : '',
reason: raw.reason,
status: typeof raw.status === 'string' ? (raw.status as BackupRequest['status']) : 'pending',
agencyId: raw.agencyId,
createdAt: raw.createdAt,
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export function useAgencyAssistanceQueue(agencyId: string | undefined) {
const [requests, setRequests] = useState<(AgencyAssistanceRequestDoc & { id: string })[]>([])
const [backupRequests, setBackupRequests] = useState<BackupRequest[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)

useEffect(() => {
queueMicrotask(() => {
setError(null)
setRequests([])
setBackupRequests([])
})

if (!agencyId) {
queueMicrotask(() => {
setLoading(false)
})
return
}

queueMicrotask(() => {
setLoading(true)
})
Comment on lines +77 to +86
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

Clear the previous queue state when the agency context changes.

If agencyId becomes undefined or switches from one agency to another, this hook keeps returning the old agency's requests/backupRequests until another snapshot arrives. That leaks stale cross-agency data into the UI during logout or tenant switches.

♻️ Proposed fix
   if (!agencyId) {
-      queueMicrotask(() => {
-        setLoading(false)
-      })
+      setRequests([])
+      setBackupRequests([])
+      setLoading(false)
       return
     }
 
-    queueMicrotask(() => {
-      setLoading(true)
-    })
+    setRequests([])
+    setBackupRequests([])
+    setLoading(true)
📝 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
if (!agencyId) {
queueMicrotask(() => {
setLoading(false)
})
return
}
queueMicrotask(() => {
setLoading(true)
})
if (!agencyId) {
setRequests([])
setBackupRequests([])
setLoading(false)
return
}
setRequests([])
setBackupRequests([])
setLoading(true)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-desktop/src/hooks/useAgencyAssistanceQueue.ts` around lines 37 -
46, When agencyId becomes undefined or changes we must clear stale queue state
and cancel any previous snapshot subscription so old requests don't show; inside
useAgencyAssistanceQueue (where you currently early-return when !agencyId and
call setLoading), also call setRequests([]) and setBackupRequests([]) and
cancel/unsubscribe the previous snapshot listener (the unsubscribe returned by
your snapshot setup) before starting a new one so the hook resets immediately on
logout/tenant switch.

Comment on lines +68 to +86
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

State reset via queueMicrotask has subtle timing.

The queueMicrotask on lines 55-59 clears state, but this runs asynchronously after the effect body continues. If agencyId is falsy, the early return on line 65 happens before the microtask executes, which is fine. However, if agencyId is valid, setLoading(true) (line 69) may race with setRequests([]) (line 57) depending on microtask ordering.

Consider synchronous state reset for clarity:

♻️ Simpler synchronous reset
   useEffect(() => {
-    queueMicrotask(() => {
-      setError(null)
-      setRequests([])
-      setBackupRequests([])
-    })
+    setError(null)
+    setRequests([])
+    setBackupRequests([])

     if (!agencyId) {
-      queueMicrotask(() => {
-        setLoading(false)
-      })
+      setLoading(false)
       return
     }

-    queueMicrotask(() => {
-      setLoading(true)
-    })
+    setLoading(true)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-desktop/src/hooks/useAgencyAssistanceQueue.ts` around lines 54 -
70, The initial state resets using queueMicrotask can race with the later
setLoading(true); make the resets synchronous: remove the queueMicrotask
wrappers around setError(null), setRequests([]), setBackupRequests([]) and call
them directly at the top of the useEffect; likewise call setLoading(false)
synchronously in the if (!agencyId) branch and setLoading(true) synchronously
before kicking off any async fetches. Update the useEffect that references
useEffect, agencyId, setError, setRequests, setBackupRequests, and setLoading to
eliminate the microtask timing and perform state resets and loading toggles
immediately.


const q = query(
collection(db, 'agency_assistance_requests'),
where('targetAgencyId', '==', agencyId),
)

const unsubscribe = onSnapshot(
q,
(snapshot) => {
const parsed = snapshot.docs.flatMap((doc) => {
const result = agencyAssistanceRequestDocSchema.safeParse(doc.data())
if (!result.success) {
log({
severity: 'WARNING',
code: 'agency_assistance.invalid',
message: `Doc ${doc.id} failed schema`,
data: {},
})
return []
}
return [{ ...result.data, id: doc.id }]
})
setRequests(parsed)
setLoading(false)
},
(err) => {
setError(err.message)
setLoading(false)
},
)

const backupQ = query(collection(db, 'backup_requests'), where('agencyId', '==', agencyId))

const unsubscribeBackup = onSnapshot(
backupQ,
(snapshot) => {
const parsed = snapshot.docs.flatMap((doc) => {
const req = parseBackupRequest(doc.id, doc.data() as Record<string, unknown>)
return req ? [req] : []
})
setBackupRequests(parsed)
},
() => {
// Backup request errors are non-fatal
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)

return () => {
unsubscribe()
unsubscribeBackup()
}
}, [agencyId])

return { requests, backupRequests, loading, error }
}
84 changes: 49 additions & 35 deletions apps/admin-desktop/src/hooks/useEligibleResponders.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,41 @@
import { useEffect, useState } from 'react'
import { collection, onSnapshot, query, where } from 'firebase/firestore'
import { db } from '../app/firebase'
import { getDatabase, ref, onValue } from 'firebase/database'
import { firebaseApp } from '../app/firebase'

export type Freshness = 'fresh' | 'degraded' | 'stale' | 'offline'

function computeFreshness(lastTelemetryAt: number | null): Freshness {
if (lastTelemetryAt == null) return 'offline'
const ageMs = Date.now() - lastTelemetryAt
if (ageMs < 30_000) return 'fresh'
if (ageMs < 90_000) return 'degraded'
if (ageMs < 300_000) return 'stale'
return 'offline'
}

const FRESHNESS_ORDER: Record<Freshness, number> = {
fresh: 0,
degraded: 1,
stale: 2,
offline: 3,
}

export interface EligibleResponder {
uid: string
displayName: string
agencyId: string
availabilityStatus: string
lastTelemetryAt: number | null
freshness: Freshness
}

export function useEligibleResponders(municipalityId: string | undefined) {
const [responders, setResponders] = useState<Record<string, EligibleResponder>>({})
const [shift, setShift] = useState<Record<string, { isOnShift: boolean }>>({})
const [responders, setResponders] = useState<EligibleResponder[]>([])

useEffect(() => {
if (!municipalityId) {
queueMicrotask(() => {
setResponders({})
setResponders([])
})
return
}
Expand All @@ -27,37 +45,33 @@ export function useEligibleResponders(municipalityId: string | undefined) {
where('isActive', '==', true),
)
return onSnapshot(q, (snap) => {
const out: Record<string, EligibleResponder> = {}
snap.docs.forEach((d) => {
const data = d.data()
out[d.id] = {
uid: d.id,
displayName: String(data.displayName ?? d.id),
agencyId: String(data.agencyId ?? 'unknown'),
}
})
setResponders(out)
})
}, [municipalityId])

useEffect(() => {
if (!municipalityId) {
queueMicrotask(() => {
setShift({})
})
return
}
const rtdb = getDatabase(firebaseApp)
const node = ref(rtdb, `/responder_index/${municipalityId}`)
const unsub = onValue(node, (s) => {
const snapVal = s.val()
setShift(snapVal !== null ? (snapVal as Record<string, { isOnShift: boolean }>) : {})
const eligible = snap.docs
.map((d) => {
const data = d.data()
const lastTelemetryAt =
typeof data.lastTelemetryAt === 'number' ? data.lastTelemetryAt : null
return {
uid: d.id,
displayName: String(data.displayName ?? d.id),
agencyId: String(data.agencyId ?? 'unknown'),
availabilityStatus: String(data.availabilityStatus ?? 'unknown'),
lastTelemetryAt,
freshness: computeFreshness(lastTelemetryAt),
}
})
.filter((r) => r.availabilityStatus !== 'off_duty')
.sort((a, b) => {
const aAvailable = a.availabilityStatus === 'available' ? 0 : 1
const bAvailable = b.availabilityStatus === 'available' ? 0 : 1
if (aAvailable !== bAvailable) return aAvailable - bAvailable
const aFresh = FRESHNESS_ORDER[a.freshness]
const bFresh = FRESHNESS_ORDER[b.freshness]
if (aFresh !== bFresh) return aFresh - bFresh
return a.displayName.localeCompare(b.displayName)
})
setResponders(eligible)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
return unsub
}, [municipalityId])

const eligible = Object.values(responders)
.filter((r) => shift[r.uid]?.isOnShift === true)
.sort((a, b) => a.displayName.localeCompare(b.displayName))
return eligible
return responders
}
85 changes: 85 additions & 0 deletions apps/admin-desktop/src/hooks/useRosterManagement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useEffect, useState } from 'react'
import { collection, onSnapshot, query, where } from 'firebase/firestore'
import { db } from '../app/firebase'
import { callables } from '../services/callables'

export interface RosterResponder {
uid: string
displayName: string
availabilityStatus: string
lastTelemetryAt: number | null
municipalityId: string
}

export function useRosterManagement(agencyId: string | undefined) {
const [responders, setResponders] = useState<RosterResponder[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)

useEffect(() => {
queueMicrotask(() => {
setResponders([])
setError(null)
})

if (!agencyId) {
queueMicrotask(() => {
setLoading(false)
})
return
}

queueMicrotask(() => {
setLoading(true)
})

const q = query(collection(db, 'responders'), where('agencyId', '==', agencyId))

const unsubscribe = onSnapshot(
q,
(snapshot) => {
const docs: RosterResponder[] = snapshot.docs.map((d) => {
const data = d.data()
return {
uid: d.id,
displayName: String(data.displayName ?? d.id),
availabilityStatus: String(data.availabilityStatus ?? 'unknown'),
lastTelemetryAt: typeof data.lastTelemetryAt === 'number' ? data.lastTelemetryAt : null,
municipalityId: String(data.municipalityId ?? ''),
}
})
setResponders(docs)
setLoading(false)
},
(err) => {
setError(err.message)
setLoading(false)
},
)

return () => {
unsubscribe()
}
}, [agencyId])

const suspendResponder = async (uid: string) => {
await callables.suspendResponder({ uid, idempotencyKey: crypto.randomUUID() })
}

const revokeResponder = async (uid: string) => {
await callables.revokeResponder({ uid, idempotencyKey: crypto.randomUUID() })
}

const bulkAvailabilityOverride = async (
uids: string[],
status: 'available' | 'unavailable' | 'off_duty',
) => {
await callables.bulkAvailabilityOverride({
uids,
status,
idempotencyKey: crypto.randomUUID(),
})
}

return { responders, loading, error, suspendResponder, revokeResponder, bulkAvailabilityOverride }
}
38 changes: 17 additions & 21 deletions apps/admin-desktop/src/pages/AgencyAssistanceQueuePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,17 @@ import '@testing-library/jest-dom'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { AgencyAssistanceQueuePage } from './AgencyAssistanceQueuePage.js'

const { mockOnSnapshot, mockCallable, mockHttpsCallable } = vi.hoisted(() => {
const { mockUseAgencyAssistanceQueue, mockCallable, mockHttpsCallable } = vi.hoisted(() => {
return {
mockOnSnapshot: vi.fn(),
mockUseAgencyAssistanceQueue: vi.fn(),
mockCallable: vi.fn(),
mockHttpsCallable: vi.fn(() => mockCallable),
}
})

vi.mock('firebase/firestore', () => ({
collection: vi.fn(),
query: vi.fn(),
where: vi.fn(),
onSnapshot: mockOnSnapshot,
getFirestore: vi.fn(),
vi.mock('../hooks/useAgencyAssistanceQueue', () => ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
useAgencyAssistanceQueue: (...args: unknown[]) => mockUseAgencyAssistanceQueue(...args),
}))

vi.mock('firebase/functions', () => ({
Expand All @@ -39,21 +36,21 @@ vi.mock('../app/firebase', () => ({

const pendingRequest = {
id: 'ar1',
data: () => ({
reportId: 'r1',
requestedByMunicipality: 'Daet',
message: 'Need BFP assistance',
priority: 'urgent',
status: 'pending',
targetAgencyId: 'bfp',
createdAt: 1713350400000,
}),
reportId: 'r1',
requestedByMunicipality: 'Daet',
message: 'Need BFP assistance',
priority: 'urgent' as const,
status: 'pending' as const,
targetAgencyId: 'bfp',
createdAt: 1713350400000,
}

beforeEach(() => {
mockOnSnapshot.mockImplementation((_q, cb) => {
cb({ docs: [pendingRequest] })
return vi.fn() // unsubscribe
mockUseAgencyAssistanceQueue.mockReturnValue({
requests: [pendingRequest],
backupRequests: [],
loading: false,
error: null,
})
mockCallable.mockResolvedValue({ data: { status: 'accepted' } })
})
Expand All @@ -66,7 +63,6 @@ describe('AgencyAssistanceQueuePage', () => {

it('shows Accept and Decline buttons on pending requests', () => {
render(<AgencyAssistanceQueuePage />)
// Use getAllByRole and pick the ones with exact text (action buttons)
const acceptButtons = screen.getAllByRole('button', { name: /^Accept$/ })
const declineButtons = screen.getAllByRole('button', { name: /^Decline$/ })
expect(acceptButtons).toHaveLength(1)
Expand Down
Loading
Loading