Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
40bf3be
fix(shared-validators): make canonicalPayloadHash async using Web Crypto
claude Apr 23, 2026
da99f31
fix(functions): await canonicalPayloadHash in idempotency guard
claude Apr 23, 2026
ff0dc23
fix(decline-dispatch): rate limit, safe assignedTo access, conditiona…
claude Apr 23, 2026
8258d1d
test(dispatch-mirror): add declined dispatch integration test path
claude Apr 23, 2026
f061b3a
fix(shared-sms-parser): add JS entrypoint for functions emulator comp…
claude Apr 23, 2026
16deb3f
fix(responder-app): surface auth errors and refresh token before call…
claude Apr 23, 2026
5688452
fix(responder-app): App Check emulator token and correct functions re…
claude Apr 23, 2026
f717359
fix(responder-app): error handling, auto-advance guard, user-friendly…
claude Apr 23, 2026
bb81dc4
test(e2e): add decline smoke test, convert empty stubs to test.skip
claude Apr 23, 2026
a7ebf7e
docs: phase 5 responder review findings, remediation plan, and learnings
claude Apr 23, 2026
6a079da
Potential fix for pull request finding 'CodeQL / Unused variable, imp…
Exc1D Apr 23, 2026
3c5b057
Potential fix for pull request finding 'CodeQL / Unused variable, imp…
Exc1D Apr 23, 2026
a5cc839
fix: address all PR #60 CodeRabbit review comments
claude Apr 23, 2026
c5101f4
fix(ci): untrack src/lib/ dirs, populate complete Camarines Norte gaz…
claude Apr 23, 2026
ed25c5a
fix(responder): handle client-side validation errors in getActionErro…
claude Apr 23, 2026
b4b5021
fix(rules): regenerate firestore.rules to prevent responder updates
claude Apr 23, 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
16 changes: 12 additions & 4 deletions apps/responder-app/src/app/auth-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const unsub = onAuthStateChanged(auth, (u) => {
setUser(u)
if (u) {
void u.getIdTokenResult(true).then((token) => {
setClaims(token.claims as Record<string, unknown>)
setLoading(false)
})
void u
.getIdTokenResult(true)
.then((token) => {
setClaims(token.claims as Record<string, unknown>)
})
.catch((err: unknown) => {
console.error('[AuthProvider] token refresh failed:', err)
setClaims(null)
})
.finally(() => {
setLoading(false)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
setClaims(null)
setLoading(false)
Expand Down
19 changes: 19 additions & 0 deletions apps/responder-app/src/app/await-auth-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { onIdTokenChanged, type Auth, type User } from 'firebase/auth'

export async function awaitFreshAuthToken(auth: Auth): Promise<User | null> {
const user = auth.currentUser
if (!user) return null

const refreshed = new Promise<User | null>((resolve) => {
const unsubscribe = onIdTokenChanged(auth, (nextUser) => {
if (nextUser?.uid !== user.uid) {
return
}
unsubscribe()
resolve(nextUser)
})
})

await user.getIdToken(true)
return refreshed
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
68 changes: 55 additions & 13 deletions apps/responder-app/src/app/firebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getAuth } from 'firebase/auth'
import { getFirestore } from 'firebase/firestore'
import { getFunctions } from 'firebase/functions'
import { getDatabase } from 'firebase/database'
import { initializeAppCheck, ReCaptchaV3Provider, CustomProvider } from 'firebase/app-check'

const USE_EMULATOR = import.meta.env.VITE_USE_EMULATOR === 'true'
const PROJECT_ID = import.meta.env.VITE_FIREBASE_PROJECT_ID ?? 'bantayog-alert-dev'
Expand All @@ -16,23 +17,64 @@ export function getFirebaseApp(): FirebaseApp {
}

export const app = getFirebaseApp()

const RECAPTCHA_SITE_KEY = import.meta.env.VITE_RECAPTCHA_SITE_KEY as string | undefined

if (RECAPTCHA_SITE_KEY) {
initializeAppCheck(app, {
provider: new ReCaptchaV3Provider(RECAPTCHA_SITE_KEY),
isTokenAutoRefreshEnabled: true,
})
} else if (USE_EMULATOR) {
initializeAppCheck(app, {
provider: new CustomProvider({
getToken: () =>
Promise.resolve({
token: 'responder-emulator-app-check',
expireTimeMillis: Date.now() + 60 * 60 * 1000,
}),
}),
isTokenAutoRefreshEnabled: false,
})
} else {
console.warn(
'[firebase] VITE_RECAPTCHA_SITE_KEY not set - App Check disabled. DO NOT USE IN PRODUCTION.',
)
}

export const db = getFirestore(app)
export const auth = getAuth(app)
export const functions = getFunctions(app)
export const functions = getFunctions(app, 'asia-southeast1')
export const rtdb = getDatabase(app)

if (USE_EMULATOR) {
const FIRESTORE_EMULATOR_PORT = import.meta.env.VITE_FIRESTORE_EMULATOR_PORT ?? '8081'
void import('firebase/firestore').then(({ connectFirestoreEmulator }) => {
connectFirestoreEmulator(db, 'localhost', Number(FIRESTORE_EMULATOR_PORT))
})
void import('firebase/auth').then(({ connectAuthEmulator }) => {
connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true })
})
void import('firebase/functions').then(({ connectFunctionsEmulator }) => {
connectFunctionsEmulator(functions, 'localhost', 5001)
})
void import('firebase/database').then(({ connectDatabaseEmulator }) => {
connectDatabaseEmulator(rtdb, 'localhost', 9000)
})
void import('firebase/firestore')
.then(({ connectFirestoreEmulator }) => {
connectFirestoreEmulator(db, 'localhost', Number(FIRESTORE_EMULATOR_PORT))
})
.catch((err: unknown) => {
console.error('[firebase] firestore emulator connect failed:', err)
})
void import('firebase/auth')
.then(({ connectAuthEmulator }) => {
connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true })
})
.catch((err: unknown) => {
console.error('[firebase] auth emulator connect failed:', err)
})
void import('firebase/functions')
.then(({ connectFunctionsEmulator }) => {
connectFunctionsEmulator(functions, 'localhost', 5001)
})
.catch((err: unknown) => {
console.error('[firebase] functions emulator connect failed:', err)
})
void import('firebase/database')
.then(({ connectDatabaseEmulator }) => {
connectDatabaseEmulator(rtdb, 'localhost', 9000)
})
.catch((err: unknown) => {
console.error('[firebase] database emulator connect failed:', err)
})
}
5 changes: 4 additions & 1 deletion apps/responder-app/src/hooks/useAcceptDispatch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { httpsCallable } from 'firebase/functions'
import { functions } from '../app/firebase'
import { auth, functions } from '../app/firebase'
import { awaitFreshAuthToken } from '../app/await-auth-token'

export function useAcceptDispatch(dispatchId: string) {
const [loading, setLoading] = useState(false)
Expand All @@ -15,12 +16,14 @@ export function useAcceptDispatch(dispatchId: string) {
setLoading(true)
setError(undefined)
try {
await awaitFreshAuthToken(auth)
const fn = httpsCallable<{ dispatchId: string; idempotencyKey: string }, { status: string }>(
functions,
'acceptDispatch',
)
await fn({ dispatchId, idempotencyKey: keyRef.current })
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
} catch (err: unknown) {
console.error('[useAcceptDispatch] accept failed:', err)
if (err instanceof Error) setError(err)
else setError(new Error(String(err)))
} finally {
Expand Down
5 changes: 4 additions & 1 deletion apps/responder-app/src/hooks/useAdvanceDispatch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useCallback, useState } from 'react'
import { httpsCallable } from 'firebase/functions'
import { functions } from '../app/firebase'
import { auth, functions } from '../app/firebase'
import { awaitFreshAuthToken } from '../app/await-auth-token'
import type { DispatchStatus } from '@bantayog/shared-types'
import type { AdvanceDispatchRequest, AdvanceDispatchTarget } from '@bantayog/shared-validators'

Expand All @@ -16,6 +17,7 @@ export function useAdvanceDispatch(dispatchId: string) {
if (to === 'resolved' && !extras?.resolutionSummary) {
throw new Error('resolutionSummary_required')
}
await awaitFreshAuthToken(auth)
const advanceDispatch = httpsCallable<AdvanceDispatchRequest, { status: DispatchStatus }>(
functions,
'advanceDispatch',
Expand All @@ -27,6 +29,7 @@ export function useAdvanceDispatch(dispatchId: string) {
idempotencyKey: crypto.randomUUID(),
})
} catch (err: unknown) {
console.error('[useAdvanceDispatch] advance failed:', err)
if (err instanceof Error) setError(err)
else setError(new Error(String(err)))
} finally {
Expand Down
10 changes: 7 additions & 3 deletions apps/responder-app/src/hooks/useDeclineDispatch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { httpsCallable } from 'firebase/functions'
import { functions } from '../app/firebase'
import { auth, functions } from '../app/firebase'
import { awaitFreshAuthToken } from '../app/await-auth-token'

interface DeclineDispatchRequest {
dispatchId: string
Expand All @@ -20,13 +21,15 @@ export function useDeclineDispatch(dispatchId: string) {
async function decline(declineReason: string) {
const trimmedReason = declineReason.trim()
if (!trimmedReason) {
setError(new Error('declineReason_required'))
return
const error = new Error('declineReason_required')
setError(error)
throw error
}

setLoading(true)
setError(undefined)
try {
await awaitFreshAuthToken(auth)
const fn = httpsCallable<DeclineDispatchRequest, { status: string }>(
functions,
'declineDispatch',
Expand All @@ -37,6 +40,7 @@ export function useDeclineDispatch(dispatchId: string) {
idempotencyKey: keyRef.current,
})
} catch (err: unknown) {
console.error('[useDeclineDispatch] decline failed:', err)
if (err instanceof Error) setError(err)
else setError(new Error(String(err)))
} finally {
Expand Down
130 changes: 95 additions & 35 deletions apps/responder-app/src/hooks/useDispatch.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,90 @@
import { useEffect, useState } from 'react'
import { doc, onSnapshot } from 'firebase/firestore'
import { db } from '../app/firebase'
import type { DispatchStatus } from '@bantayog/shared-types'
import {
dispatchDocSchema,
type DispatchDoc as SharedDispatchDoc,
} from '@bantayog/shared-validators'
import {
getResponderUiState,
getTerminalSurface,
type ResponderUiState,
type TerminalSurface,
} from '../lib/dispatch-presentation'

export interface DispatchDoc {
export type DispatchDoc = SharedDispatchDoc & {
dispatchId: string
reportId: string
assignedTo: { uid: string; agencyId: string; municipalityId: string }
dispatchedBy: string
dispatchedByRole: string
dispatchedAt: number
status: DispatchStatus
lastStatusAt: number
acknowledgementDeadlineAt?: number
acknowledgedAt?: number
enRouteAt?: number
onSceneAt?: number
resolvedAt?: number
cancelledAt?: number
cancelledBy?: string
cancelReason?: string
declineReason?: string
resolutionSummary?: string
proofPhotoUrl?: string
requestedByMunicipalAdmin?: boolean
requestId?: string
idempotencyKey?: string
idempotencyPayloadHash?: string
schemaVersion?: number
uiStatus: ResponderUiState
terminalSurface: TerminalSurface
}

function toMillis(value: unknown): number | undefined {
if (typeof value === 'number') return value
if (value && typeof value === 'object' && 'toMillis' in value) {
const candidate = value as { toMillis: () => number }
if (typeof candidate.toMillis === 'function') {
return candidate.toMillis()
}
}
return undefined
}

function normalizeDispatchSnapshot(data: Record<string, unknown>): Record<string, unknown> {
const normalized: Record<string, unknown> = {}
const schemaKeys = [
'reportId',
'assignedTo',
'dispatchedBy',
'dispatchedByRole',
'dispatchedAt',
'status',
'statusUpdatedAt',
'acknowledgementDeadlineAt',
'acknowledgedAt',
'enRouteAt',
'onSceneAt',
'resolvedAt',
'cancelledAt',
'cancelledBy',
'cancelReason',
'timeoutReason',
'declineReason',
'resolutionSummary',
'proofPhotoUrl',
'requestedByMunicipalAdmin',
'requestId',
'idempotencyKey',
'idempotencyPayloadHash',
'schemaVersion',
] as const
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

for (const key of schemaKeys) {
if (key in data) {
normalized[key] = data[key]
}
}

const millisFields = [
'dispatchedAt',
'statusUpdatedAt',
'acknowledgementDeadlineAt',
'acknowledgedAt',
'enRouteAt',
'onSceneAt',
'resolvedAt',
'cancelledAt',
] as const

for (const field of millisFields) {
const value = toMillis(normalized[field])
if (typeof value === 'number') {
normalized[field] = value
}
}

return normalized
}

export function useDispatch(dispatchId: string | undefined) {
const [dispatch, setDispatch] = useState<DispatchDoc | undefined>(undefined)
const [loading, setLoading] = useState(true)
Expand All @@ -47,29 +94,42 @@ export function useDispatch(dispatchId: string | undefined) {
if (!dispatchId) {
queueMicrotask(() => {
setDispatch(undefined)
setError(undefined)
setLoading(false)
})
return
}
const unsub = onSnapshot(
doc(db, 'dispatches', dispatchId),
(snap) => {
if (!snap.exists()) {
setDispatch(undefined)
} else {
const data = snap.data()
const status = data.status as DispatchStatus
try {
if (!snap.exists()) {
setDispatch(undefined)
setError(undefined)
return
}

const parsed = dispatchDocSchema.parse(
normalizeDispatchSnapshot(snap.data() as Record<string, unknown>),
)
setDispatch({
...(data as Omit<DispatchDoc, 'dispatchId' | 'uiStatus' | 'terminalSurface'>),
...parsed,
dispatchId: snap.id,
status,
uiStatus: getResponderUiState(status),
terminalSurface: getTerminalSurface(status),
uiStatus: getResponderUiState(parsed.status),
terminalSurface: getTerminalSurface(parsed.status),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
setError(undefined)
} catch (err: unknown) {
console.error('[useDispatch] snapshot mapping failed:', err)
setDispatch(undefined)
setError(err instanceof Error ? err : new Error(String(err)))
} finally {
setLoading(false)
}
setLoading(false)
},
(err) => {
const error = err as { code?: string; message?: string }
console.error('[useDispatch] listener error:', error.code, error.message)
setError(err as Error)
setLoading(false)
},
Expand Down
Loading
Loading