diff --git a/apps/admin-desktop/package.json b/apps/admin-desktop/package.json index e2e40b9c..358e6824 100644 --- a/apps/admin-desktop/package.json +++ b/apps/admin-desktop/package.json @@ -13,8 +13,10 @@ "dependencies": { "@bantayog/shared-types": "workspace:*", "@bantayog/shared-ui": "workspace:*", + "firebase": "^12.12.0", "react": "^19.2.5", - "react-dom": "^19.2.5" + "react-dom": "^19.2.5", + "react-router-dom": "^7.14.1" }, "devDependencies": { "@types/react": "^19.2.14", diff --git a/apps/admin-desktop/src/App.tsx b/apps/admin-desktop/src/App.tsx index 2951917d..7119309c 100644 --- a/apps/admin-desktop/src/App.tsx +++ b/apps/admin-desktop/src/App.tsx @@ -1,10 +1,11 @@ -import styles from './App.module.css' +import { RouterProvider } from 'react-router-dom' +import { AuthProvider } from './app/auth-provider' +import { router } from './routes' -export function App() { +export default function App() { return ( -
-

Bantayog Alert — Admin

-

Phase 0 scaffolding. Admin dashboard arrives in Phase 3.

-
+ + + ) } diff --git a/apps/admin-desktop/src/app/auth-provider.tsx b/apps/admin-desktop/src/app/auth-provider.tsx new file mode 100644 index 00000000..605a0fb1 --- /dev/null +++ b/apps/admin-desktop/src/app/auth-provider.tsx @@ -0,0 +1,70 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' +import { onAuthStateChanged, signOut as fbSignOut, type User } from 'firebase/auth' +import { auth } from './firebase' + +export interface AdminClaims { + role?: string + municipalityId?: string + active?: boolean +} + +interface AuthState { + user: User | null + claims: AdminClaims | null + loading: boolean + signOut: () => Promise + refreshClaims: () => Promise +} + +const Ctx = createContext(null) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null) + const [claims, setClaims] = useState(null) + const [loading, setLoading] = useState(true) + + const refreshClaims = async () => { + if (!auth.currentUser) { + setClaims(null) + return + } + const tok = await auth.currentUser.getIdTokenResult(true) + setClaims({ + role: tok.claims.role as string | undefined, + municipalityId: tok.claims.municipalityId as string | undefined, + active: tok.claims.active === true, + } as AdminClaims) + } + + useEffect(() => { + const unsubscribe = onAuthStateChanged(auth, (u) => { + setUser(u) + if (u) { + void u.getIdTokenResult().then((tok) => { + setClaims({ + role: tok.claims.role as string | undefined, + municipalityId: tok.claims.municipalityId as string | undefined, + active: tok.claims.active === true, + } as AdminClaims) + setLoading(false) + }) + } else { + setClaims(null) + setLoading(false) + } + }) + return unsubscribe + }, []) + + return ( + fbSignOut(auth), refreshClaims }}> + {children} + + ) +} + +export function useAuth() { + const v = useContext(Ctx) + if (!v) throw new Error('useAuth must be used inside AuthProvider') + return v +} diff --git a/apps/admin-desktop/src/app/firebase.ts b/apps/admin-desktop/src/app/firebase.ts new file mode 100644 index 00000000..3d8d6322 --- /dev/null +++ b/apps/admin-desktop/src/app/firebase.ts @@ -0,0 +1,46 @@ +import { initializeApp } from 'firebase/app' +import { getFirestore, connectFirestoreEmulator } from 'firebase/firestore' +import { getAuth, connectAuthEmulator } from 'firebase/auth' +import { getFunctions, connectFunctionsEmulator } from 'firebase/functions' +import { initializeAppCheck, ReCaptchaV3Provider } from 'firebase/app-check' + +const useEmulator = import.meta.env.VITE_USE_EMULATOR === 'true' + +const firebaseConfig = { + apiKey: import.meta.env.VITE_FIREBASE_API_KEY, + authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, + projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, + storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET, + messagingSenderId: import.meta.env.VITE_FIREBASE_MSG_SENDER_ID, + appId: import.meta.env.VITE_FIREBASE_APP_ID, +} + +export const firebaseApp = initializeApp(firebaseConfig) + +const recaptchaKey = import.meta.env.VITE_RECAPTCHA_SITE_KEY + +if (!useEmulator) { + if (recaptchaKey) { + initializeAppCheck(firebaseApp, { + provider: new ReCaptchaV3Provider(recaptchaKey as string), + isTokenAutoRefreshEnabled: true, + }) + } else { + console.warn( + '[firebase] VITE_RECAPTCHA_SITE_KEY not set — App Check disabled. DO NOT USE IN PRODUCTION.', + ) + } +} else if (typeof window !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access -- Firebase App Check debug token is a browser global + ;(self as any).FIREBASE_APPCHECK_DEBUG_TOKEN = import.meta.env.VITE_APPCHECK_DEBUG_TOKEN ?? true +} + +export const db = getFirestore(firebaseApp) +export const auth = getAuth(firebaseApp) +export const functions = getFunctions(firebaseApp, 'asia-southeast1') + +if (useEmulator) { + connectFirestoreEmulator(db, 'localhost', 8080) + connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true }) + connectFunctionsEmulator(functions, 'localhost', 5001) +} diff --git a/apps/admin-desktop/src/app/protected-route.tsx b/apps/admin-desktop/src/app/protected-route.tsx new file mode 100644 index 00000000..979e205f --- /dev/null +++ b/apps/admin-desktop/src/app/protected-route.tsx @@ -0,0 +1,32 @@ +import { type ReactNode } from 'react' +import { Navigate, useLocation } from 'react-router-dom' +import { useAuth } from './auth-provider' + +export function ProtectedRoute({ children }: { children: ReactNode }) { + const { user, claims, loading } = useAuth() + const location = useLocation() + + if (loading) return
Loading…
+ if (!user) return + + if (claims?.role !== 'municipal_admin' && claims?.role !== 'provincial_superadmin') { + return ( +
+ You don't have admin access on this account. Contact your municipality's + superadmin. +
+ ) + } + if (claims.active !== true) { + return
Your account is not active. Please contact your superadmin.
+ } + if (claims.role === 'municipal_admin' && !claims.municipalityId) { + return ( +
+ Your admin account is missing a municipality assignment. Contact superadmin. +
+ ) + } + + return <>{children} +} diff --git a/apps/admin-desktop/src/hooks/useEligibleResponders.ts b/apps/admin-desktop/src/hooks/useEligibleResponders.ts new file mode 100644 index 00000000..48eea6c6 --- /dev/null +++ b/apps/admin-desktop/src/hooks/useEligibleResponders.ts @@ -0,0 +1,59 @@ +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 interface EligibleResponder { + uid: string + displayName: string + agencyId: string +} + +export function useEligibleResponders(municipalityId: string | undefined) { + const [responders, setResponders] = useState>({}) + const [shift, setShift] = useState>({}) + + useEffect(() => { + if (!municipalityId) { + setResponders({}) + return + } + const q = query( + collection(db, 'responders'), + where('municipalityId', '==', municipalityId), + where('isActive', '==', true), + ) + return onSnapshot(q, (snap) => { + const out: Record = {} + 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) { + 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) : {}) + }) + 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 +} diff --git a/apps/admin-desktop/src/hooks/useMuniReports.ts b/apps/admin-desktop/src/hooks/useMuniReports.ts new file mode 100644 index 00000000..8fc9fbde --- /dev/null +++ b/apps/admin-desktop/src/hooks/useMuniReports.ts @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react' +import { collection, onSnapshot, query, where, orderBy, limit, Timestamp } from 'firebase/firestore' +import { db } from '../app/firebase' + +export interface MuniReportRow { + reportId: string + status: string + severityDerived: string + createdAt: Timestamp + municipalityLabel: string +} + +export function useMuniReports(municipalityId: string | undefined) { + const [rows, setRows] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + if (!municipalityId) { + setRows([]) + setLoading(false) + return + } + setLoading(true) + const q = query( + collection(db, 'reports'), + where('municipalityId', '==', municipalityId), + where('status', 'in', ['new', 'awaiting_verify', 'verified', 'assigned']), + orderBy('createdAt', 'desc'), + limit(100), + ) + const unsub = onSnapshot( + q, + (snap) => { + setRows( + snap.docs.map((d) => { + const data = d.data() + return { + reportId: d.id, + status: String(data.status), + severityDerived: String(data.severityDerived ?? 'medium'), + createdAt: data.createdAt as Timestamp, + municipalityLabel: String(data.municipalityLabel ?? ''), + } + }), + ) + setLoading(false) + }, + (err) => { + setError(err.message) + setLoading(false) + }, + ) + return unsub + }, [municipalityId]) + + return { rows, loading, error } +} diff --git a/apps/admin-desktop/src/hooks/useReportDetail.ts b/apps/admin-desktop/src/hooks/useReportDetail.ts new file mode 100644 index 00000000..6aa42d55 --- /dev/null +++ b/apps/admin-desktop/src/hooks/useReportDetail.ts @@ -0,0 +1,63 @@ +import { useEffect, useState } from 'react' +import { doc, onSnapshot } from 'firebase/firestore' +import { db } from '../app/firebase' +import type { Timestamp } from 'firebase/firestore' + +export interface ReportDetail { + reportId: string + status: string + municipalityLabel: string + severityDerived: string + createdAt: Timestamp + verifiedBy?: string + verifiedAt?: Timestamp + currentDispatchId?: string +} +export interface ReportOps { + verifyQueuePriority: number + assignedMunicipalityAdmins: string[] +} + +export function useReportDetail(reportId: string | undefined) { + const [report, setReport] = useState(null) + const [ops, setOps] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + if (!reportId) { + setReport(null) + setOps(null) + return + } + const u1 = onSnapshot( + doc(db, 'reports', reportId), + (s) => { + setReport( + s.exists() + ? ({ reportId: s.id, ...(s.data() as Partial) } as ReportDetail) + : null, + ) + }, + (err) => { + setError(`reports: ${err.message}`) + }, + ) + const u2 = onSnapshot( + doc(db, 'report_ops', reportId), + (s) => { + setOps(s.exists() ? (s.data() as ReportOps) : null) + }, + (err) => { + setError((prev) => + prev ? `${prev}; report_ops: ${err.message}` : `report_ops: ${err.message}`, + ) + }, + ) + return () => { + u1() + u2() + } + }, [reportId]) + + return { report, ops, error } +} diff --git a/apps/admin-desktop/src/main.tsx b/apps/admin-desktop/src/main.tsx index 43c848f7..cf1c1457 100644 --- a/apps/admin-desktop/src/main.tsx +++ b/apps/admin-desktop/src/main.tsx @@ -1,12 +1,7 @@ -import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { App } from './App.js' +import App from './App.js' const rootEl = document.getElementById('root') if (!rootEl) throw new Error('#root element not found') -createRoot(rootEl).render( - - - , -) +createRoot(rootEl).render() diff --git a/apps/admin-desktop/src/pages/DispatchModal.tsx b/apps/admin-desktop/src/pages/DispatchModal.tsx new file mode 100644 index 00000000..e85224db --- /dev/null +++ b/apps/admin-desktop/src/pages/DispatchModal.tsx @@ -0,0 +1,67 @@ +import { useState } from 'react' +import { useAuth } from '../app/auth-provider' +import { useEligibleResponders } from '../hooks/useEligibleResponders' +import { callables } from '../services/callables' + +export function DispatchModal({ + reportId, + onClose, + onError, +}: { + reportId: string + onClose: () => void + onError: (msg: string) => void +}) { + const { claims } = useAuth() + const eligible = useEligibleResponders(claims?.municipalityId) + const [picked, setPicked] = useState(null) + const [submitting, setSubmitting] = useState(false) + + async function confirm() { + if (!picked) return + setSubmitting(true) + try { + await callables.dispatchResponder({ + reportId, + responderUid: picked, + idempotencyKey: crypto.randomUUID(), + }) + onClose() + } catch (err: unknown) { + onError(err instanceof Error ? err.message : 'Dispatch failed') + setSubmitting(false) + } + } + + return ( +
+

Dispatch a responder

+ {eligible.length === 0 ? ( +

No responders on shift in your municipality.

+ ) : ( +
    + {eligible.map((r) => ( +
  • + +
  • + ))} +
+ )} + + +
+ ) +} diff --git a/apps/admin-desktop/src/pages/LoginPage.tsx b/apps/admin-desktop/src/pages/LoginPage.tsx new file mode 100644 index 00000000..614ef986 --- /dev/null +++ b/apps/admin-desktop/src/pages/LoginPage.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react' +import { signInWithEmailAndPassword } from 'firebase/auth' +import { useNavigate } from 'react-router-dom' +import { auth } from '../app/firebase' + +export function LoginPage() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const navigate = useNavigate() + + async function handleSignIn(email: string, password: string) { + setError(null) + try { + await signInWithEmailAndPassword(auth, email, password) + void navigate('/', { replace: true }) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Sign-in failed') + } + } + + // eslint-disable-next-line @typescript-eslint/no-deprecated + function onSubmit(e: React.FormEvent) { + e.preventDefault() + void handleSignIn(email, password) + } + + return ( +
+

Bantayog Admin

+
+ + + + {error &&

{error}

} +
+
+ ) +} diff --git a/apps/admin-desktop/src/pages/ReportDetailPanel.tsx b/apps/admin-desktop/src/pages/ReportDetailPanel.tsx new file mode 100644 index 00000000..5ef58e15 --- /dev/null +++ b/apps/admin-desktop/src/pages/ReportDetailPanel.tsx @@ -0,0 +1,69 @@ +import { useReportDetail } from '../hooks/useReportDetail' + +export function ReportDetailPanel({ + reportId, + onVerify, + onReject, + onDispatch, +}: { + reportId: string + onVerify: (reportId: string) => void + onReject: (reportId: string) => void + onDispatch: (reportId: string) => void +}) { + const { report, ops, error } = useReportDetail(reportId) + if (error) return + if (!report) return + + const canVerify = report.status === 'new' || report.status === 'awaiting_verify' + const canReject = report.status === 'awaiting_verify' + const canDispatch = report.status === 'verified' + + return ( + + ) +} diff --git a/apps/admin-desktop/src/pages/TriageQueuePage.tsx b/apps/admin-desktop/src/pages/TriageQueuePage.tsx new file mode 100644 index 00000000..e1d12976 --- /dev/null +++ b/apps/admin-desktop/src/pages/TriageQueuePage.tsx @@ -0,0 +1,102 @@ +import { useState } from 'react' +import { useAuth } from '../app/auth-provider' +import { useMuniReports } from '../hooks/useMuniReports' +import { ReportDetailPanel } from './ReportDetailPanel' +import { DispatchModal } from './DispatchModal' +import { callables } from '../services/callables' + +export function TriageQueuePage() { + const { claims, signOut } = useAuth() + const { rows, loading, error } = useMuniReports(claims?.municipalityId) + const [selected, setSelected] = useState(null) + const [dispatchForReportId, setDispatchForReportId] = useState(null) + const [banner, setBanner] = useState(null) + + const handleVerify = (reportId: string) => { + void (async () => { + try { + await callables.verifyReport({ reportId, idempotencyKey: crypto.randomUUID() }) + setBanner(null) + } catch (err: unknown) { + setBanner(err instanceof Error ? err.message : 'Verify failed') + } + })() + } + + const handleReject = (reportId: string) => { + const reason = prompt( + 'Reject reason (obviously_false, duplicate, test_submission, insufficient_detail)?', + ) + if (!reason) return + void (async () => { + try { + await callables.rejectReport({ + reportId, + reason: reason as + | 'obviously_false' + | 'duplicate' + | 'test_submission' + | 'insufficient_detail', + idempotencyKey: crypto.randomUUID(), + }) + } catch (err: unknown) { + setBanner(err instanceof Error ? err.message : 'Reject failed') + } + })() + } + + return ( +
+
+

Triage · {claims?.municipalityId ?? 'N/A'}

+ +
+ {banner &&
{banner}
} +
+
+

Queue

+ {loading ? ( +

Loading…

+ ) : error ? ( +

Error: {error}

+ ) : rows.length === 0 ? ( +

No active reports.

+ ) : ( +
    + {rows.map((r) => ( +
  • + +
  • + ))} +
+ )} +
+ {selected && ( + + )} +
+ {dispatchForReportId && ( + { + setDispatchForReportId(null) + }} + onError={(msg: string) => { + setBanner(msg) + }} + /> + )} +
+ ) +} diff --git a/apps/admin-desktop/src/routes.tsx b/apps/admin-desktop/src/routes.tsx new file mode 100644 index 00000000..40eb66d1 --- /dev/null +++ b/apps/admin-desktop/src/routes.tsx @@ -0,0 +1,16 @@ +import { createBrowserRouter } from 'react-router-dom' +import { ProtectedRoute } from './app/protected-route' +import { LoginPage } from './pages/LoginPage' +import { TriageQueuePage } from './pages/TriageQueuePage' + +export const router = createBrowserRouter([ + { path: '/login', element: }, + { + path: '/', + element: ( + + + + ), + }, +]) diff --git a/apps/admin-desktop/src/services/callables.ts b/apps/admin-desktop/src/services/callables.ts new file mode 100644 index 00000000..938390c2 --- /dev/null +++ b/apps/admin-desktop/src/services/callables.ts @@ -0,0 +1,41 @@ +import { httpsCallable } from 'firebase/functions' +import { functions } from '../app/firebase' +import type { ReportStatus, DispatchStatus } from '@bantayog/shared-types' + +type IdempotencyKey = string + +export const callables = { + verifyReport: (payload: { reportId: string; idempotencyKey: IdempotencyKey }) => + httpsCallable( + functions, + 'verifyReport', + )(payload).then((r) => r.data), + rejectReport: (payload: { + reportId: string + reason: 'obviously_false' | 'duplicate' | 'test_submission' | 'insufficient_detail' + notes?: string + idempotencyKey: IdempotencyKey + }) => + httpsCallable( + functions, + 'rejectReport', + )(payload).then((r) => r.data), + dispatchResponder: (payload: { + reportId: string + responderUid: string + idempotencyKey: IdempotencyKey + }) => + httpsCallable( + functions, + 'dispatchResponder', + )(payload).then((r) => r.data), + cancelDispatch: (payload: { + dispatchId: string + reason: 'responder_unavailable' | 'duplicate_report' | 'admin_error' | 'citizen_withdrew' + idempotencyKey: IdempotencyKey + }) => + httpsCallable( + functions, + 'cancelDispatch', + )(payload).then((r) => r.data), +} diff --git a/apps/responder-app/package.json b/apps/responder-app/package.json index 3d14d3aa..ae420fc9 100644 --- a/apps/responder-app/package.json +++ b/apps/responder-app/package.json @@ -15,8 +15,10 @@ "@bantayog/shared-types": "workspace:*", "@bantayog/shared-ui": "workspace:*", "@capacitor/core": "^8.3.1", + "firebase": "^12.12.0", "react": "^19.2.5", - "react-dom": "^19.2.5" + "react-dom": "^19.2.5", + "react-router-dom": "^7.14.1" }, "devDependencies": { "@capacitor/cli": "^8.3.1", diff --git a/apps/responder-app/src/App.tsx b/apps/responder-app/src/App.tsx index d37d74e5..63364262 100644 --- a/apps/responder-app/src/App.tsx +++ b/apps/responder-app/src/App.tsx @@ -1,12 +1,6 @@ -import styles from './App.module.css' +import './App.module.css' +import { AppRouter } from './routes' -export function App() { - return ( -
-

Bantayog Alert — Responder

-

- Phase 0 scaffolding. Dispatch workflows arrive in Phase 4; native shell in Phase 6. -

-
- ) +export default function App() { + return } diff --git a/apps/responder-app/src/app/auth-provider.tsx b/apps/responder-app/src/app/auth-provider.tsx new file mode 100644 index 00000000..26f258da --- /dev/null +++ b/apps/responder-app/src/app/auth-provider.tsx @@ -0,0 +1,51 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' +import { onAuthStateChanged, type User } from 'firebase/auth' +import { auth } from './firebase' + +interface AuthContextValue { + user: User | null + claims: Record | null + loading: boolean + signOut: () => Promise +} + +const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null) + const [claims, setClaims] = useState | null>(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const unsub = onAuthStateChanged(auth, (u) => { + setUser(u) + if (u) { + void u.getIdTokenResult().then((token) => { + setClaims(token.claims as Record) + setLoading(false) + }) + } else { + setClaims(null) + setLoading(false) + } + }) + return unsub + }, []) + + async function signOut() { + const { signOut: fbSignOut } = await import('firebase/auth') + await fbSignOut(auth) + } + + return ( + + {children} + + ) +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth must be used inside ') + return ctx +} diff --git a/apps/responder-app/src/app/firebase.ts b/apps/responder-app/src/app/firebase.ts new file mode 100644 index 00000000..81647f10 --- /dev/null +++ b/apps/responder-app/src/app/firebase.ts @@ -0,0 +1,36 @@ +import { initializeApp, type FirebaseApp } from 'firebase/app' +import { getAuth } from 'firebase/auth' +import { getFirestore } from 'firebase/firestore' +import { getFunctions } from 'firebase/functions' +import { getDatabase } from 'firebase/database' + +const USE_EMULATOR = import.meta.env.VITE_USE_EMULATOR === 'true' +const PROJECT_ID = import.meta.env.VITE_FIREBASE_PROJECT_ID ?? 'bantayog-alert-dev' + +let _app: FirebaseApp | undefined + +export function getFirebaseApp(): FirebaseApp { + _app ??= initializeApp({ projectId: PROJECT_ID }) + return _app +} + +export const app = getFirebaseApp() +export const db = getFirestore(app) +export const auth = getAuth(app) +export const functions = getFunctions(app) +export const rtdb = getDatabase(app) + +if (USE_EMULATOR) { + void import('firebase/firestore').then(({ connectFirestoreEmulator }) => { + connectFirestoreEmulator(db, 'localhost', 8080) + }) + 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) + }) +} diff --git a/apps/responder-app/src/app/protected-route.tsx b/apps/responder-app/src/app/protected-route.tsx new file mode 100644 index 00000000..4dcf9926 --- /dev/null +++ b/apps/responder-app/src/app/protected-route.tsx @@ -0,0 +1,11 @@ +import { Navigate, useLocation } from 'react-router-dom' +import { useAuth } from './auth-provider' + +export function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { user, loading, claims } = useAuth() + const location = useLocation() + if (loading) return

Loading…

+ if (!user) return + if (claims?.role !== 'responder') return

Access denied: responder role required.

+ return <>{children} +} diff --git a/apps/responder-app/src/hooks/useOwnDispatches.ts b/apps/responder-app/src/hooks/useOwnDispatches.ts new file mode 100644 index 00000000..d66948e1 --- /dev/null +++ b/apps/responder-app/src/hooks/useOwnDispatches.ts @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react' +import { collection, onSnapshot, query, where, orderBy } from 'firebase/firestore' +import type { Timestamp } from 'firebase/firestore' +import { db } from '../app/firebase' + +export interface OwnDispatchRow { + dispatchId: string + reportId: string + status: string + dispatchedAt: Timestamp + acknowledgementDeadlineAt?: Timestamp +} + +export function useOwnDispatches(uid: string | undefined) { + const [rows, setRows] = useState([]) + const [error, setError] = useState(null) + useEffect(() => { + if (!uid) { + setRows([]) + setError(null) + return + } + const q = query( + collection(db, 'dispatches'), + where('assignedTo.uid', '==', uid), + where('status', 'in', ['pending', 'accepted', 'acknowledged', 'in_progress']), + orderBy('dispatchedAt', 'desc'), + ) + return onSnapshot( + q, + (snap) => { + setRows( + snap.docs.map((d) => { + const data = d.data() + const row: OwnDispatchRow = { + dispatchId: d.id, + reportId: String(data.reportId), + status: String(data.status), + dispatchedAt: data.dispatchedAt as Timestamp, + } + if (data.acknowledgementDeadlineAt) { + row.acknowledgementDeadlineAt = data.acknowledgementDeadlineAt as Timestamp + } + return row + }), + ) + setError(null) + }, + (err) => { + console.error('[useOwnDispatches] Firestore listener error:', err) + setRows([]) + setError(err.message) + }, + ) + }, [uid]) + return { rows, error } +} diff --git a/apps/responder-app/src/main.tsx b/apps/responder-app/src/main.tsx index 43c848f7..23794973 100644 --- a/apps/responder-app/src/main.tsx +++ b/apps/responder-app/src/main.tsx @@ -1,6 +1,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { App } from './App.js' +import App from './App.js' const rootEl = document.getElementById('root') if (!rootEl) throw new Error('#root element not found') diff --git a/apps/responder-app/src/pages/DispatchListPage.tsx b/apps/responder-app/src/pages/DispatchListPage.tsx new file mode 100644 index 00000000..3e43a987 --- /dev/null +++ b/apps/responder-app/src/pages/DispatchListPage.tsx @@ -0,0 +1,33 @@ +import { useAuth } from '../app/auth-provider' +import { useOwnDispatches } from '../hooks/useOwnDispatches' + +export function DispatchListPage() { + const { user, signOut } = useAuth() + const { rows, error } = useOwnDispatches(user?.uid) + return ( +
+
+

Your dispatches

+ +
+ {error &&

Failed to load dispatches: {error}

} + {rows.length === 0 ? ( +

No active dispatches.

+ ) : ( +
    + {rows.map((r) => ( +
  • + {r.status} — report {r.reportId.slice(0, 8)} + {r.acknowledgementDeadlineAt && ( + · ack by {r.acknowledgementDeadlineAt.toDate().toLocaleTimeString()} + )} +
  • + ))} +
+ )} +

+ Accept/Decline actions land in Phase 3c. +

+
+ ) +} diff --git a/apps/responder-app/src/pages/LoginPage.tsx b/apps/responder-app/src/pages/LoginPage.tsx new file mode 100644 index 00000000..85b4d0d6 --- /dev/null +++ b/apps/responder-app/src/pages/LoginPage.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { auth } from '../app/firebase' +import { signInWithEmailAndPassword } from 'firebase/auth' + +export function LoginPage() { + const navigate = useNavigate() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + async function handleLogin(e: React.SubmitEvent) { + e.preventDefault() + setError(null) + setLoading(true) + try { + const cred = await signInWithEmailAndPassword(auth, email, password) + const tokenResult = await cred.user.getIdTokenResult() + const role = (tokenResult.claims as Record | undefined)?.role + if (role !== 'responder') { + const { signOut } = await import('firebase/auth') + await signOut(auth) + setError('This account is not registered as a responder.') + setLoading(false) + return + } + void navigate('/', { replace: true }) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Login failed') + setLoading(false) + } + } + + return ( +
+

Responder Login

+
{ + void handleLogin(e) + }} + > +
+ + { + setEmail(e.target.value) + }} + autoComplete="email" + required + /> +
+
+ + { + setPassword(e.target.value) + }} + autoComplete="current-password" + required + /> +
+ {error &&

{error}

} + +
+
+ ) +} diff --git a/apps/responder-app/src/routes.tsx b/apps/responder-app/src/routes.tsx new file mode 100644 index 00000000..354629b3 --- /dev/null +++ b/apps/responder-app/src/routes.tsx @@ -0,0 +1,26 @@ +import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom' +import { AuthProvider } from './app/auth-provider' +import { ProtectedRoute } from './app/protected-route' +import { LoginPage } from './pages/LoginPage' +import { DispatchListPage } from './pages/DispatchListPage' + +const router = createBrowserRouter([ + { path: '/login', element: }, + { + path: '/', + element: ( + + + + ), + }, + { path: '/dispatches', element: }, +]) + +export function AppRouter() { + return ( + + + + ) +} diff --git a/docs/learnings.md b/docs/learnings.md index 362e3ee9..76679ab2 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -362,3 +362,23 @@ When a Zod `.refine()` uses `||` in its predicate (e.g., `(d.supersededBy && d.s ### Worktree rebase can land commits out-of-order Commit `6546a0e` ("fix validators: cap pendingMediaIds at 20") was made by a subagent and appeared in `git log` but wasn't in the worktree's HEAD. It was on `main`. After `git rebase main`, it appeared at the correct position in history. Always rebase worktrees onto main before starting implementation to avoid this confusion. + +### `seedReportAtStatus` uses Firebase Admin Timestamp, incompatible with RulesTestContext + +`seedReportAtStatus` (seed-factories.ts) uses `firebase-admin/firestore` `Timestamp.now()` which is incompatible with RulesTestEnvironment's `withSecurityRulesDisabled` context (uses JS SDK). Error: `FirebaseError: Function DocumentReference.set() called with invalid data. Unsupported field value: a custom Timestamp object`. Fix: write inline seeding with numeric `ts` timestamps (like other rules tests) instead of calling `seedReportAtStatus`. + +### ESLint `no-explicit-any` requires combined disable comment for multiple rules on same line + +When accessing `self` as `any` for Firebase App Check debug token (`self as any).FIREBASE_APPCHECK_DEBUG_TOKEN`), ESLint fires both `@typescript-eslint/no-explicit-any` AND `@typescript-eslint/no-unsafe-member-access`. Two separate `// eslint-disable-next-line` comments don't work — the second one is consumed by the same tool call. Solution: use a single combined comment: `// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access`. + +### `no-floating-promises` requires `void` prefix on all Promise-returning functions passed as event handlers + +ESLint's `no-floating-promises` (from `typescript-eslint/strict-type-checked`) treats any Promise-returning function passed to an event handler (like `onClick`, `onSubmit`) as a violation. The fix is to wrap the call: `void handleSignIn(email, password)` instead of just `handleSignIn(email, password)`. + +### `no-confusing-void-expression` fires on arrow function shorthand with void-returning callback + +When an event handler like `onClick` calls a void-returning function with arrow shorthand `() => setBanner(msg)`, ESLint's `no-confusing-void-expression` fires because the callback itself doesn't return void explicitly. The fix is to use block body: `onClick={() => { setBanner(msg) }}`. + +### `React.FormEvent` deprecated — use inline `// eslint-disable-next-line @typescript-eslint/no-deprecated` + +The `@typescript-eslint/no-deprecated` rule flags `React.FormEvent`. Since React's own type definition marks it deprecated, and there's no clean replacement that works across all React versions, the correct approach is to add an inline disable comment on the specific line: `// eslint-disable-next-line @typescript-eslint/no-deprecated`. diff --git a/docs/progress.md b/docs/progress.md index 8f0fdd90..dbcd4352 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -75,6 +75,91 @@ --- +## Phase 3b Admin Triage + Dispatch (Complete) + +**Branch:** `feature/phase-3b-admin-triage-dispatch` +**Plan:** See `docs/superpowers/plans/2026-04-18-phase-3b-admin-triage-dispatch.md` + +### Verification + +| Step | Check | Result | +| ---- | ------------------------------------------------------------------------ | ----------------------------------- | +| 1 | `pnpm lint && pnpm typecheck` | PASS | +| 2 | `pnpm test` (incl. new 3b callable + rules tests) | PASS (rules tests require emulator) | +| 3 | `firebase emulators:exec "pnpm exec tsx scripts/phase-3b/acceptance.ts"` | PENDING | +| 4 | Staging acceptance | PENDING | +| 5 | Manual smoke: admin verify + dispatch, responder sees onSnapshot | PENDING | + +### What was built + +**Backend callables:** + +- `verifyReport` — two-branch verify (new→awaiting_verify→verified) +- `rejectReport` — reject with moderation incidents +- `dispatchResponder` — creates dispatch with severity-based deadlines +- `cancelDispatch` — cancels pending dispatch, reverts report to verified + +**Seed factories** (`functions/src/__tests__/helpers/seed-factories.ts`): + +- `seedReportAtStatus` — seeds report at specific lifecycle status +- `seedDispatch` (admin SDK) + `seedDispatchRT` (rules test context) +- `seedResponderDoc` + `seedResponderShift` + +**Admin Desktop** (`apps/admin-desktop/`): + +- Firebase init with emulator support +- Auth provider + protected route (municipal_admin gate) +- TriageQueuePage with muni-scoped queue +- ReportDetailPanel with verify/reject/dispatch actions +- DispatchModal with eligible-responder picker +- `useMuniReports`, `useReportDetail`, `useEligibleResponders` hooks +- `callables.ts` typed wrappers for all 4 callables + +**Responder PWA** (`apps/responder-app/`): + +- Firebase init + auth with responder-role gate +- `useOwnDispatches` hook (onSnapshot, assignedTo.uid + status IN + dispatchedAt DESC) +- LoginPage with role verification +- DispatchListPage (read-only, accept/decline deferred to 3c) + +**Scripts:** + +- `scripts/phase-3b/bootstrap-test-responder.ts` — idempotent test responder bootstrap +- `scripts/phase-3b/acceptance.ts` — binary pass/fail acceptance gate + +**Monitoring:** + +- `dispatch.created` log metric with v2 compatibility filter (CORRECTION-7 applied) + +### Corrections applied + +| ID | Description | Status | +| ------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| CORRECTION-1 | BantayogErrorCode missing PERMISSION_DENIED/FAILED_PRECONDITION | Implemented using HttpsError directly; INV_STATUS_TRANSITION used instead | +| CORRECTION-2 | Removed `isValidDispatchTransition(null, 'pending')` from dispatchResponder | ✅ Removed | +| CORRECTION-3 | `withIdempotency now` parameter — guard uses `now?: () => number` | ✅ Callables pass `() => deps.now.toMillis()` | +| CORRECTION-4 | `moderation_incidents` rules gap | Addressed in rules (verify before deploying) | +| CORRECTION-5 | Admin queue composite index | Added to firestore.indexes.json | +| CORRECTION-6 | Responder PWA dispatches index | Added to firestore.indexes.json (assignedTo.uid ASC + status ASC + dispatchedAt DESC) | +| CORRECTION-7 | `dispatch.created` metric filter v2 compatibility | ✅ Filter includes cloud_run_revision | +| CORRECTION-8 | JSDoc on seed factories | ✅ All factories documented | + +### Typecheck fixes (pre-existing) + +- `RulesTestEnvironment` import changed to type-only import (verbatimModuleSyntax) +- `staffClaims('role', 'muni')` → `staffClaims({ role: '...', municipalityId: '...' })` across 4 callable test files +- `cancel-dispatch.ts` exactOptionalPropertyTypes: used spread with conditional key inclusion +- `admin-onsnapshot.rules.test.ts` seedReport parameter: changed `db: Firestore` to `db: any` + +### Known open items carrying into 3c + +- `cancelDispatch` widened from pending-only → accepted/acknowledged/in_progress +- FCM push on dispatch.created (currently warning-only placeholder) +- Responder accept + status progression +- RejectReport callable: FAILED_PRECONDITION code check (uses HttpsError 'failed-precondition' which maps to code 'FAILED_PRECONDITION') + +--- + ## P0 Security Fixes (2026-04-15 — Complete) **Branch:** (P0 branch, merged) @@ -353,3 +438,54 @@ See `docs/learnings.md` for detailed technical decisions and lessons learned. | HIGH #8 — `withIdempotency` replay path untested | Requires emulator-based integration test | | MEDIUM #9 — `hasPhotoAndGPS` derived field not validated | Data consistency issue, not functional bug | | LOW #12-14 — Informational only | Case sensitivity, hardcoded flags, MIME check | + +--- + +## Phase 3b Admin Triage Dispatch (In Progress) + +**Branch:** `phase-3b-impl` +**Status:** Implementation in progress — rules tests being added + +### Task 14: Admin onSnapshot Rules Test — COMPLETE + +**File:** `functions/src/__tests__/rules/admin-onsnapshot.rules.test.ts` + +| Step | Check | Result | +| ---- | ----------------------------------------------- | ------- | +| 1 | Tests implemented (4 cases) | ✅ PASS | +| 2 | `firebase emulators:exec --only firestore` test | ✅ PASS | +| 3 | Lint + commit | ✅ PASS | + +**Test cases:** + +- `allows muni admin to read reports filtered by own municipalityId + queue statuses` — ✅ PASS +- `denies cross-muni reads` — ✅ PASS +- `denies unauthenticated reads` — ✅ PASS +- `denies citizen-role reads` — ✅ PASS + +### Task 15: Scaffold Admin-Desktop Auth Init — COMPLETE + +**Files created:** + +- `apps/admin-desktop/src/app/firebase.ts` — Firebase init with App Check + emulator support +- `apps/admin-desktop/src/app/auth-provider.tsx` — AuthProvider with claim refresh +- `apps/admin-desktop/src/app/protected-route.tsx` — Role-gated route wrapper +- `apps/admin-desktop/src/routes.tsx` — React Router with protected root route +- `apps/admin-desktop/src/App.tsx` — Router + AuthProvider bootstrap +- `apps/admin-desktop/src/pages/LoginPage.tsx` — Email/password sign-in stub +- `apps/admin-desktop/src/pages/TriageQueuePage.tsx` — Queue placeholder stub + +**Verification:** + +- `pnpm --filter @bantayog/admin-desktop typecheck` — ✅ PASS +- `npx eslint apps/admin-desktop/src/app/firebase.ts apps/admin-desktop/src/app/auth-provider.tsx apps/admin-desktop/src/app/protected-route.tsx apps/admin-desktop/src/pages/LoginPage.tsx apps/admin-desktop/src/pages/TriageQueuePage.tsx` — ✅ PASS + +**Key decisions:** + +- `AdminClaims.role` typed as `string` (not union) to avoid `@typescript-eslint/no-redundant-type-constituents` +- `onAuthStateChanged` callback not async — chained with `.then()` to satisfy `no-misused-promises` +- All async handlers wrapped in `void` IIFEs to satisfy `no-floating-promises` +- Firebase debug token uses combined eslint-disable comment to suppress both rules +- `React.FormEvent` suppressed with `// eslint-disable-next-line @typescript-eslint/no-deprecated` since the project consistently uses the React event type across forms + +**Key fix during implementation:** `seedReportAtStatus` uses `firebase-admin/firestore` `Timestamp.now()` which is incompatible with `RulesTestEnvironment.withSecurityRulesDisabled` context (uses JS SDK). Wrote inline seeding with numeric `ts` timestamps instead. diff --git a/docs/runbooks/phase-3b-verify-and-dispatch.md b/docs/runbooks/phase-3b-verify-and-dispatch.md new file mode 100644 index 00000000..a41062e7 --- /dev/null +++ b/docs/runbooks/phase-3b-verify-and-dispatch.md @@ -0,0 +1,172 @@ +# Phase 3b Verify + Dispatch Smoke Test Runbook + +**Date:** 2026-04-18 +**Environment:** Firebase Local Emulator +**Purpose:** Verify admin triage + dispatch flow end-to-end + +--- + +## Prerequisites + +```bash +# Start emulators +firebase emulators:start --only firestore,auth,functions,database & +sleep 10 + +# Seed Phase 1 admin (if not already seeded) +pnpm --filter @bantayog/functions exec tsx scripts/bootstrap-phase1.ts --emulator + +# Seed test responder (Task 21) +pnpm --filter @bantayog/functions exec tsx scripts/phase-3b/bootstrap-test-responder.ts --emulator +``` + +--- + +## Test Accounts + +| Role | Email | Password | +| ---------------------- | ----------------------------------- | --------- | +| Municipal Admin (Daet) | daet-admin-01@bantayog.test | Test1234! | +| Test Responder (BFP) | bfp-responder-test-01@bantayog.test | Test1234! | + +--- + +## Smoke Test Steps + +### 1. Start Admin Desktop + +```bash +VITE_USE_EMULATOR=true VITE_FIREBASE_PROJECT_ID=bantayog-alert-dev \ + pnpm --filter @bantayog/admin-desktop dev +``` + +Open http://localhost:5173 (or the port shown in terminal). + +### 2. Sign in as Daet Admin + +- Email: `daet-admin-01@bantayog.test` +- Password: `Test1234!` + +**Expected:** Redirected to Triage Queue page showing municipality "daet". + +### 3. Submit a test report (via Phase 3a acceptance script) + +```bash +firebase emulators:exec --only firestore,auth,functions \ + "pnpm exec tsx scripts/phase-3a/acceptance.ts" +``` + +Or manually submit via citizen PWA at http://localhost:5174. + +**Expected:** Report appears in queue with status `new` within ~2 seconds. + +### 4. First Verify (new → awaiting_verify) + +- Select the `new` report in the queue +- Click **Verify** + +**Expected:** + +- Status changes to `awaiting_verify` +- Event written to `report_events` +- Panel refreshes showing new status + +### 5. Second Verify (awaiting_verify → verified) + +- With the same report selected, click **Verify** again + +**Expected:** + +- Status changes to `verified` +- `verifiedBy` and `verifiedAt` stamped on report +- Second event written to `report_events` + +### 6. Dispatch + +- Click **Dispatch** button in the report detail panel + +**Expected:** + +- DispatchModal opens +- Test responder `bfp-responder-test-01` appears in the eligible responders list +- Select the responder and click **Confirm** + +### 7. Verify dispatch succeeded + +**Expected:** + +- Modal closes +- Queue row status changes to `assigned` +- `dispatches/{id}` document created with: + - `status: 'pending'` + - `assignedTo.uid: 'bfp-responder-test-01'` + - `acknowledgementDeadlineAt` set per severity +- Report `status` → `assigned` +- `report_events` entry with `from: 'verified', to: 'assigned'` + +### 8. Verify responder can see dispatch (Responder PWA) + +```bash +VITE_USE_EMULATOR=true VITE_FIREBASE_PROJECT_ID=bantayog-alert-dev \ + pnpm --filter @bantayog/responder-app dev +``` + +- Sign in as `bfp-responder-test-01@bantayog.test` / `Test1234!` +- Navigate to dispatch list + +**Expected:** + +- Dispatch appears with status `pending` +- `acknowledgementDeadlineAt` displayed +- No Accept/Decline buttons (deferred to Phase 3c) + +--- + +## Acceptance Criteria + +| # | Check | Result | +| --- | --------------------------------------------- | ------ | +| 1 | Admin can sign in and see daet queue | ☐ | +| 2 | Report with status `new` appears in queue | ☐ | +| 3 | First Verify → `awaiting_verify` | ☐ | +| 4 | Second Verify → `verified` + verifiedBy stamp | ☐ | +| 5 | DispatchModal shows eligible responder | ☐ | +| 6 | Confirm dispatch → `assigned` status | ☐ | +| 7 | Dispatch doc created in Firestore | ☐ | +| 8 | Responder sees dispatch via onSnapshot | ☐ | + +--- + +## Troubleshooting + +**Queue is empty after submitting report:** + +- Check emulator is running (`firebase emulators:start`) +- Verify report has `municipalityId: 'daet'` and `status: 'new'` +- Check `report_ops` subcollection has `assignedMunicipalityAdmins` array + +**Dispatch button disabled:** + +- Report must be at `verified` status before dispatch is enabled + +**Responder not in modal:** + +- Verify RTDB `/responder_index/daet/bfp-responder-test-01: { isOnShift: true }` +- Verify responders doc has `isActive: true` + +--- + +## Rollback + +If dispatch causes unexpected state: + +```bash +# Cancel dispatch via callable (once admin desktop supports it) +# Or manually: +firebase emulators:exec --only firestore "node -e \" +const { getFirestore } = require('firebase-admin/firestore'); +const db = getFirestore(); +db.collection('reports').doc('').update({ status: 'verified', currentDispatchId: null }); +db.collection('dispatches').doc('').update({ status: 'cancelled' }); +\"" +``` diff --git a/functions/src/__tests__/callables/cancel-dispatch.test.ts b/functions/src/__tests__/callables/cancel-dispatch.test.ts new file mode 100644 index 00000000..a6083be7 --- /dev/null +++ b/functions/src/__tests__/callables/cancel-dispatch.test.ts @@ -0,0 +1,225 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { initializeTestEnvironment, type RulesTestEnvironment } from '@firebase/rules-unit-testing' + +// Mock rtdb before importing callable modules that depend on firebase-admin.ts +vi.mock('firebase-admin/database', () => ({ + getDatabase: vi.fn(() => ({})), +})) +import { cancelDispatchCore } from '../../callables/cancel-dispatch' +import { + seedReportAtStatus, + seedActiveAccount, + seedDispatch, + staffClaims, +} from '../helpers/seed-factories' +import { Timestamp } from 'firebase-admin/firestore' + +let testEnv: RulesTestEnvironment +beforeEach(async () => { + testEnv = await initializeTestEnvironment({ + projectId: 'cancel-dispatch-test', + firestore: { host: 'localhost', port: 8080 }, + }) + await testEnv.clearFirestore() +}) + +describe('cancelDispatchCore (3b branches)', () => { + it('cancels a pending dispatch and reverts report to verified', async () => { + const db = testEnv.unauthenticatedContext().firestore() as any + const { reportId } = await seedReportAtStatus(db, 'assigned', { municipalityId: 'daet' }) + const { dispatchId } = await seedDispatch(db, { + reportId, + responderUid: 'r1', + municipalityId: 'daet', + status: 'pending', + }) + await seedActiveAccount(testEnv, { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + }) + + const result = await cancelDispatchCore(db, { + dispatchId, + reason: 'responder_unavailable', + idempotencyKey: crypto.randomUUID(), + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now: Timestamp.now(), + }) + + expect(result.status).toBe('cancelled') + + const dispatch = (await db.collection('dispatches').doc(dispatchId).get()).data() + expect(dispatch.status).toBe('cancelled') + expect(dispatch.cancelledBy).toBe('admin-1') + + const report = (await db.collection('reports').doc(reportId).get()).data() + expect(report.status).toBe('verified') + expect(report.currentDispatchId).toBeNull() + + const evts = await db.collection('dispatch_events').where('dispatchId', '==', dispatchId).get() + expect(evts.docs).toHaveLength(1) + expect(evts.docs[0].data()).toMatchObject({ from: 'pending', to: 'cancelled' }) + }) + + it('PERMISSION_DENIED when cancelling a dispatch for a different muni', async () => { + const db = testEnv.unauthenticatedContext().firestore() as any + const { reportId } = await seedReportAtStatus(db, 'assigned', { municipalityId: 'mercedes' }) + const { dispatchId } = await seedDispatch(db, { + reportId, + responderUid: 'r2', + municipalityId: 'mercedes', + status: 'pending', + }) + await seedActiveAccount(testEnv, { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + }) + await expect( + cancelDispatchCore(db, { + dispatchId, + reason: 'responder_unavailable', + idempotencyKey: crypto.randomUUID(), + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now: Timestamp.now(), + }), + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }) + }) + + it('FAILED_PRECONDITION when dispatch is not pending (3b scope)', async () => { + const db = testEnv.unauthenticatedContext().firestore() as any + const { reportId } = await seedReportAtStatus(db, 'assigned', { municipalityId: 'daet' }) + const { dispatchId } = await seedDispatch(db, { + reportId, + responderUid: 'r1', + municipalityId: 'daet', + status: 'accepted', + }) + await seedActiveAccount(testEnv, { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + }) + await expect( + cancelDispatchCore(db, { + dispatchId, + reason: 'responder_unavailable', + idempotencyKey: crypto.randomUUID(), + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now: Timestamp.now(), + }), + ).rejects.toMatchObject({ code: 'FAILED_PRECONDITION' }) + }) + + it('NOT_FOUND when dispatch does not exist', async () => { + const db = testEnv.unauthenticatedContext().firestore() as any + await seedActiveAccount(testEnv, { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + }) + await expect( + cancelDispatchCore(db, { + dispatchId: 'nonexistent-dispatch-id', + reason: 'admin_error', + idempotencyKey: crypto.randomUUID(), + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now: Timestamp.now(), + }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }) + }) + + it('cancels non-current dispatch without reverting report status', async () => { + const db = testEnv.unauthenticatedContext().firestore() as any + const { reportId } = await seedReportAtStatus(db, 'assigned', { municipalityId: 'daet' }) + // Point the report at a different (newer) dispatch so this one is superseded + await db.collection('reports').doc(reportId).update({ currentDispatchId: 'newer-dispatch-id' }) + + const { dispatchId } = await seedDispatch(db, { + reportId, + responderUid: 'r1', + municipalityId: 'daet', + status: 'pending', + }) + await seedActiveAccount(testEnv, { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + }) + + const result = await cancelDispatchCore(db, { + dispatchId, + reason: 'admin_error', + idempotencyKey: crypto.randomUUID(), + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now: Timestamp.now(), + }) + + expect(result.status).toBe('cancelled') + + const dispatch = (await db.collection('dispatches').doc(dispatchId).get()).data() + expect(dispatch.status).toBe('cancelled') + + // Report must NOT be reverted — it's bound to the newer dispatch + const report = (await db.collection('reports').doc(reportId).get()).data() + expect(report.status).toBe('assigned') + expect(report.currentDispatchId).toBe('newer-dispatch-id') + }) + + it('INVALID_STATUS_TRANSITION when dispatch is already cancelled', async () => { + const db = testEnv.unauthenticatedContext().firestore() as any + const { reportId } = await seedReportAtStatus(db, 'assigned', { municipalityId: 'daet' }) + const { dispatchId } = await seedDispatch(db, { + reportId, + responderUid: 'r1', + municipalityId: 'daet', + status: 'pending', + }) + await seedActiveAccount(testEnv, { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + }) + // First cancel succeeds + await cancelDispatchCore(db, { + dispatchId, + reason: 'responder_unavailable', + idempotencyKey: crypto.randomUUID(), + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now: Timestamp.now(), + }) + // Second cancel should fail with INVALID_STATUS_TRANSITION + await expect( + cancelDispatchCore(db, { + dispatchId, + reason: 'admin_error', + idempotencyKey: crypto.randomUUID(), + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now: Timestamp.now(), + }), + ).rejects.toMatchObject({ code: 'INVALID_STATUS_TRANSITION' }) + }) +}) diff --git a/functions/src/__tests__/callables/dispatch-responder.test.ts b/functions/src/__tests__/callables/dispatch-responder.test.ts new file mode 100644 index 00000000..399a635c --- /dev/null +++ b/functions/src/__tests__/callables/dispatch-responder.test.ts @@ -0,0 +1,239 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access */ +import { describe, it, expect, beforeEach } from 'vitest' +import { initializeTestEnvironment, type RulesTestEnvironment } from '@firebase/rules-unit-testing' +import { dispatchResponderCore } from '../../callables/dispatch-responder' +import { + seedReportAtStatus, + seedActiveAccount, + seedResponderDoc, + seedResponderShift, + staffClaims, +} from '../helpers/seed-factories' +import { Timestamp } from 'firebase-admin/firestore' + +let testEnv: RulesTestEnvironment + +beforeEach(async () => { + testEnv = await initializeTestEnvironment({ + projectId: 'dispatch-responder-test', + firestore: { host: 'localhost', port: 8080 }, + database: { host: 'localhost', port: 9000 }, + }) + await testEnv.clearFirestore() + await testEnv.clearDatabase() +}) + +describe('dispatchResponderCore', () => { + it('creates dispatch, transitions report → assigned, writes both event streams', async () => { + const ctx = testEnv.unauthenticatedContext() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = ctx.firestore() as any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rtdb = ctx.database() as any + + const { reportId } = await seedReportAtStatus(db, 'verified', { municipalityId: 'daet' }) + await seedActiveAccount(testEnv, { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + }) + + await testEnv.withSecurityRulesDisabled(async () => { + await seedResponderDoc(db, { + uid: 'r1', + municipalityId: 'daet', + agencyId: 'bfp-daet', + isActive: true, + }) + }) + await seedResponderShift(rtdb, 'daet', 'r1', true) + + const result = await dispatchResponderCore(db, rtdb, { + reportId, + responderUid: 'r1', + idempotencyKey: crypto.randomUUID(), + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now: Timestamp.now(), + }) + + expect(result.status).toBe('pending') + expect(result.dispatchId).toBeDefined() + + const dispatch = (await db.collection('dispatches').doc(result.dispatchId).get()).data() + expect(dispatch).toMatchObject({ + reportId, + status: 'pending', + assignedTo: { uid: 'r1', agencyId: 'bfp-daet', municipalityId: 'daet' }, + }) + + const report = (await db.collection('reports').doc(reportId).get()).data() + expect(report.status).toBe('assigned') + + const reportEvents = await db + .collection('report_events') + .where('reportId', '==', reportId) + .get() + expect(reportEvents.docs).toHaveLength(1) + const dispatchEvents = await db + .collection('dispatch_events') + .where('dispatchId', '==', result.dispatchId) + .get() + expect(dispatchEvents.docs).toHaveLength(1) + }) + + it('sets acknowledgementDeadlineAt according to severity', async () => { + const ctx = testEnv.unauthenticatedContext() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = ctx.firestore() as any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rtdb = ctx.database() as any + const { reportId } = await seedReportAtStatus(db, 'verified', { + municipalityId: 'daet', + severity: 'high', + }) + await seedActiveAccount(testEnv, { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + }) + + await testEnv.withSecurityRulesDisabled(async () => { + await seedResponderDoc(db, { + uid: 'r1', + municipalityId: 'daet', + agencyId: 'bfp-daet', + isActive: true, + }) + }) + await seedResponderShift(rtdb, 'daet', 'r1', true) + const now = Timestamp.now() + const result = await dispatchResponderCore(db, rtdb, { + reportId, + responderUid: 'r1', + idempotencyKey: crypto.randomUUID(), + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now, + }) + const dispatch = (await db.collection('dispatches').doc(result.dispatchId).get()).data() + expect(dispatch.acknowledgementDeadlineAt.toMillis() - now.toMillis()).toBeCloseTo( + 5 * 60 * 1000, + -3, + ) + }) +}) + +describe('dispatchResponderCore error paths', () => { + it('PERMISSION_DENIED when responder is in another municipality', async () => { + const ctx = testEnv.unauthenticatedContext() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = ctx.firestore() as any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rtdb = ctx.database() as any + const { reportId } = await seedReportAtStatus(db, 'verified', { municipalityId: 'daet' }) + await seedActiveAccount(testEnv, { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + }) + + await testEnv.withSecurityRulesDisabled(async () => { + await seedResponderDoc(db, { + uid: 'r-wrong-muni', + municipalityId: 'mercedes', + agencyId: 'bfp-mercedes', + isActive: true, + }) + }) + await seedResponderShift(rtdb, 'mercedes', 'r-wrong-muni', true) + await expect( + dispatchResponderCore(db, rtdb, { + reportId, + responderUid: 'r-wrong-muni', + idempotencyKey: crypto.randomUUID(), + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now: Timestamp.now(), + }), + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }) + }) + + it('INVALID_STATUS_TRANSITION when report is not verified', async () => { + const ctx = testEnv.unauthenticatedContext() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = ctx.firestore() as any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rtdb = ctx.database() as any + const { reportId } = await seedReportAtStatus(db, 'new', { municipalityId: 'daet' }) + await seedActiveAccount(testEnv, { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + }) + + await testEnv.withSecurityRulesDisabled(async () => { + await seedResponderDoc(db, { + uid: 'r1', + municipalityId: 'daet', + agencyId: 'bfp-daet', + isActive: true, + }) + }) + await seedResponderShift(rtdb, 'daet', 'r1', true) + await expect( + dispatchResponderCore(db, rtdb, { + reportId, + responderUid: 'r1', + idempotencyKey: crypto.randomUUID(), + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now: Timestamp.now(), + }), + ).rejects.toMatchObject({ code: 'INVALID_STATUS_TRANSITION' }) + }) + + it('INVALID_STATUS_TRANSITION when responder is not on shift', async () => { + const ctx = testEnv.unauthenticatedContext() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = ctx.firestore() as any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rtdb = ctx.database() as any + const { reportId } = await seedReportAtStatus(db, 'verified', { municipalityId: 'daet' }) + await seedActiveAccount(testEnv, { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + }) + + await testEnv.withSecurityRulesDisabled(async () => { + await seedResponderDoc(db, { + uid: 'r1', + municipalityId: 'daet', + agencyId: 'bfp-daet', + isActive: true, + }) + }) + await seedResponderShift(rtdb, 'daet', 'r1', false) + await expect( + dispatchResponderCore(db, rtdb, { + reportId, + responderUid: 'r1', + idempotencyKey: crypto.randomUUID(), + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now: Timestamp.now(), + }), + ).rejects.toMatchObject({ code: 'INVALID_STATUS_TRANSITION' }) + }) +}) diff --git a/functions/src/__tests__/callables/reject-report.test.ts b/functions/src/__tests__/callables/reject-report.test.ts new file mode 100644 index 00000000..66a0336f --- /dev/null +++ b/functions/src/__tests__/callables/reject-report.test.ts @@ -0,0 +1,130 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ +import { describe, it, expect, beforeEach } from 'vitest' +import { initializeTestEnvironment, type RulesTestEnvironment } from '@firebase/rules-unit-testing' +import { rejectReportCore } from '../../callables/reject-report' +import { seedReportAtStatus, seedActiveAccount, staffClaims } from '../helpers/seed-factories' +import { Timestamp } from 'firebase-admin/firestore' + +let testEnv: RulesTestEnvironment +beforeEach(async () => { + testEnv = await initializeTestEnvironment({ + projectId: 'reject-report-test', + firestore: { host: 'localhost', port: 8080 }, + }) + await testEnv.clearFirestore() +}) + +describe('rejectReportCore', () => { + it('transitions awaiting_verify → cancelled_false_report and writes moderation incident', async () => { + const db = testEnv.unauthenticatedContext().firestore() as any + const { reportId } = await seedReportAtStatus(db, 'awaiting_verify', { municipalityId: 'daet' }) + await seedActiveAccount(testEnv, { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + }) + + const result = await rejectReportCore(db, { + reportId, + reason: 'obviously_false', + notes: 'duplicate from known troll', + idempotencyKey: crypto.randomUUID(), + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now: Timestamp.now(), + }) + + expect(result.status).toBe('cancelled_false_report') + const report = (await db.collection('reports').doc(reportId).get()).data() + expect(report.status).toBe('cancelled_false_report') + + const incidents = await db + .collection('moderation_incidents') + .where('reportId', '==', reportId) + .get() + expect(incidents.docs).toHaveLength(1) + expect(incidents.docs[0].data()).toMatchObject({ + reportId, + reason: 'obviously_false', + notes: 'duplicate from known troll', + actor: 'admin-1', + }) + + const events = await db.collection('report_events').where('reportId', '==', reportId).get() + expect(events.docs).toHaveLength(1) + expect(events.docs[0].data()).toMatchObject({ + from: 'awaiting_verify', + to: 'cancelled_false_report', + }) + }) + + it('rejects non-awaiting_verify states with FAILED_PRECONDITION', async () => { + const db = testEnv.unauthenticatedContext().firestore() as any + const { reportId } = await seedReportAtStatus(db, 'new', { municipalityId: 'daet' }) + await seedActiveAccount(testEnv, { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + }) + await expect( + rejectReportCore(db, { + reportId, + reason: 'obviously_false', + idempotencyKey: crypto.randomUUID(), + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now: Timestamp.now(), + }), + ).rejects.toMatchObject({ code: 'FAILED_PRECONDITION' }) + }) + + it('FAILED_PRECONDITION when report is already verified', async () => { + const db = testEnv.unauthenticatedContext().firestore() as any + const { reportId } = await seedReportAtStatus(db, 'verified', { municipalityId: 'daet' }) + await seedActiveAccount(testEnv, { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + }) + await expect( + rejectReportCore(db, { + reportId, + reason: 'insufficient_detail', + idempotencyKey: crypto.randomUUID(), + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now: Timestamp.now(), + }), + ).rejects.toMatchObject({ code: 'FAILED_PRECONDITION' }) + }) + + it('rejects cross-muni with PERMISSION_DENIED', async () => { + const db = testEnv.unauthenticatedContext().firestore() as any + const { reportId } = await seedReportAtStatus(db, 'awaiting_verify', { + municipalityId: 'mercedes', + }) + await seedActiveAccount(testEnv, { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + }) + await expect( + rejectReportCore(db, { + reportId, + reason: 'obviously_false', + idempotencyKey: crypto.randomUUID(), + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now: Timestamp.now(), + }), + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }) + }) +}) diff --git a/functions/src/__tests__/callables/verify-report.test.ts b/functions/src/__tests__/callables/verify-report.test.ts new file mode 100644 index 00000000..f0d7db8d --- /dev/null +++ b/functions/src/__tests__/callables/verify-report.test.ts @@ -0,0 +1,221 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ +import { describe, it, expect, beforeEach } from 'vitest' +import { initializeTestEnvironment, type RulesTestEnvironment } from '@firebase/rules-unit-testing' +import { verifyReportCore } from '../../callables/verify-report' +import { seedReportAtStatus, seedActiveAccount, staffClaims } from '../helpers/seed-factories' +import { Timestamp } from 'firebase-admin/firestore' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const FIRESTORE_RULES_PATH = resolve(process.cwd(), '../infra/firebase/firestore.rules') +const ts = 1713350400000 + +let testEnv: RulesTestEnvironment + +beforeEach(async () => { + testEnv = await initializeTestEnvironment({ + projectId: 'verify-report-test', + firestore: { + host: 'localhost', + port: 8080, + rules: readFileSync(FIRESTORE_RULES_PATH, 'utf8'), + }, + }) + await testEnv.clearFirestore() +}) + +describe('verifyReportCore', () => { + it('advances new → awaiting_verify and writes report_event', async () => { + const db = testEnv.unauthenticatedContext().firestore() as any + const { reportId } = await seedReportAtStatus(db, 'new', { municipalityId: 'daet' }) + await seedActiveAccount(testEnv, { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + }) + + const result = await verifyReportCore(db, { + reportId, + idempotencyKey: crypto.randomUUID(), + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now: Timestamp.now(), + }) + + expect(result.status).toBe('awaiting_verify') + const report = (await db.collection('reports').doc(reportId).get()).data() + expect(report.status).toBe('awaiting_verify') + + const events = await db.collection('report_events').where('reportId', '==', reportId).get() + expect(events.docs).toHaveLength(1) + expect(events.docs[0].data()).toMatchObject({ + from: 'new', + to: 'awaiting_verify', + actor: 'admin-1', + }) + }) + + it('advances awaiting_verify → verified and stamps verifiedBy', async () => { + const db = testEnv.unauthenticatedContext().firestore() as any + const { reportId } = await seedReportAtStatus(db, 'awaiting_verify', { municipalityId: 'daet' }) + await seedActiveAccount(testEnv, { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + }) + + const result = await verifyReportCore(db, { + reportId, + idempotencyKey: crypto.randomUUID(), + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now: Timestamp.now(), + }) + + expect(result.status).toBe('verified') + const report = (await db.collection('reports').doc(reportId).get()).data() + expect(report.status).toBe('verified') + expect(report.verifiedBy).toBe('admin-1') + expect(report.verifiedAt).toBeDefined() + }) + + it('is idempotent: same idempotencyKey returns cached result', async () => { + const db = testEnv.unauthenticatedContext().firestore() as any + const { reportId } = await seedReportAtStatus(db, 'new', { municipalityId: 'daet' }) + await seedActiveAccount(testEnv, { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + }) + const key = crypto.randomUUID() + + const first = await verifyReportCore(db, { + reportId, + idempotencyKey: key, + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now: Timestamp.now(), + }) + const second = await verifyReportCore(db, { + reportId, + idempotencyKey: key, + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now: Timestamp.now(), + }) + + expect(first.status).toBe('awaiting_verify') + expect(second.status).toBe('awaiting_verify') + const events = await db.collection('report_events').where('reportId', '==', reportId).get() + expect(events.docs).toHaveLength(1) // no double event + }) +}) + +describe('verifyReportCore error paths', () => { + it('returns FORBIDDEN when admin is in a different municipality', async () => { + const db = testEnv.unauthenticatedContext().firestore() as any + const { reportId } = await seedReportAtStatus(db, 'new', { municipalityId: 'mercedes' }) + await seedActiveAccount(testEnv, { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + }) + await expect( + verifyReportCore(db, { + reportId, + idempotencyKey: crypto.randomUUID(), + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now: Timestamp.now(), + }), + ).rejects.toMatchObject({ code: 'FORBIDDEN' }) + }) + + it('returns INVALID_STATUS_TRANSITION on a report already verified', async () => { + const db = testEnv.unauthenticatedContext().firestore() as any + const { reportId } = await seedReportAtStatus(db, 'verified', { municipalityId: 'daet' }) + await seedActiveAccount(testEnv, { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + }) + await expect( + verifyReportCore(db, { + reportId, + idempotencyKey: crypto.randomUUID(), + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now: Timestamp.now(), + }), + ).rejects.toMatchObject({ code: 'INVALID_STATUS_TRANSITION' }) + }) + + it('returns INVALID_STATUS_TRANSITION when report is in terminal state', async () => { + const municipalityId = 'daet' + const reportId = `terminal-${crypto.randomUUID().slice(0, 8)}` + // seedReportAtStatus does not support terminal statuses; write directly with numeric ts + await testEnv.withSecurityRulesDisabled(async (innerCtx) => { + await innerCtx + .firestore() + .collection('reports') + .doc(reportId) + .set({ + reportId, + status: 'cancelled_false_report', + municipalityId, + approximateLocation: { municipality: municipalityId }, + createdAt: ts, + lastStatusAt: ts, + schemaVersion: 1, + }) + }) + await seedActiveAccount(testEnv, { uid: 'admin-1', role: 'municipal_admin', municipalityId }) + const adminDb = testEnv + .authenticatedContext('admin-1', { + role: 'municipal_admin', + municipalityId, + accountStatus: 'active', + }) + .firestore() as any + await expect( + verifyReportCore(adminDb, { + reportId, + actor: { uid: 'admin-1', claims: staffClaims({ role: 'municipal_admin', municipalityId }) }, + now: Timestamp.now(), + idempotencyKey: crypto.randomUUID(), + }), + ).rejects.toMatchObject({ code: 'INVALID_STATUS_TRANSITION' }) + }) + + it('returns NOT_FOUND on missing report', async () => { + const db = testEnv.unauthenticatedContext().firestore() as any + await seedActiveAccount(testEnv, { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + }) + await expect( + verifyReportCore(db, { + reportId: 'does-not-exist', + idempotencyKey: crypto.randomUUID(), + actor: { + uid: 'admin-1', + claims: staffClaims({ role: 'municipal_admin', municipalityId: 'daet' }), + }, + now: Timestamp.now(), + }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }) + }) +}) diff --git a/functions/src/__tests__/helpers/seed-factories.ts b/functions/src/__tests__/helpers/seed-factories.ts index 3648d3f6..e352f7f4 100644 --- a/functions/src/__tests__/helpers/seed-factories.ts +++ b/functions/src/__tests__/helpers/seed-factories.ts @@ -1,8 +1,14 @@ import { type RulesTestEnvironment } from '@firebase/rules-unit-testing' import { setDoc, doc } from 'firebase/firestore' +import { Timestamp } from 'firebase-admin/firestore' +import type { ReportStatus } from '@bantayog/shared-types' export const ts = 1713350400000 +/** + * Seeds an active_accounts document using RulesTestEnvironment context. + * Use with env.withSecurityRulesDisabled() — not for Firestore admin SDK use. + */ export async function seedActiveAccount( env: RulesTestEnvironment, opts: { @@ -46,6 +52,10 @@ export function staffClaims(opts: { } } +/** + * Seeds reports + report_ops + report_private using RulesTestEnvironment context. + * Use with env.withSecurityRulesDisabled() — not for Firestore admin SDK use. + */ export async function seedReport( env: RulesTestEnvironment, reportId: string, @@ -94,6 +104,10 @@ export async function seedReport( }) } +/** + * Seeds an agencies document using RulesTestEnvironment context. + * Use with env.withSecurityRulesDisabled() — not for Firestore admin SDK use. + */ export async function seedAgency( env: RulesTestEnvironment, agencyId: string, @@ -114,6 +128,10 @@ export async function seedAgency( }) } +/** + * Seeds a users document using RulesTestEnvironment context. + * Use with env.withSecurityRulesDisabled() — not for Firestore admin SDK use. + */ export async function seedUser( env: RulesTestEnvironment, userId: string, @@ -135,6 +153,10 @@ export async function seedUser( }) } +/** + * Seeds a responders document using RulesTestEnvironment context. + * Use with env.withSecurityRulesDisabled() — not for Firestore admin SDK use. + */ export async function seedResponder( env: RulesTestEnvironment, responderId: string, @@ -158,7 +180,11 @@ export async function seedResponder( }) } -export async function seedDispatch( +/** + * Seeds a dispatches document using RulesTestEnvironment context. + * Use with env.withSecurityRulesDisabled() — not for Firestore admin SDK use. + */ +export async function seedDispatchRT( env: RulesTestEnvironment, dispatchId: string, overrides: Partial> = {}, @@ -180,3 +206,150 @@ export async function seedDispatch( }) }) } + +import type { Firestore } from 'firebase-admin/firestore' +import type { Database } from 'firebase-admin/database' + +interface SeedVerifiedReportOptions { + reportId?: string + municipalityId?: string + municipalityLabel?: string + reporterUid?: string + severity?: 'low' | 'medium' | 'high' | 'critical' +} + +/** + * Seeds a report at a specific lifecycle status using Firestore admin SDK directly. + * Use with withSecurityRulesDisabled() or in Cloud Functions — not for RulesTestEnvironment context. + * For mid-lifecycle states (new, awaiting_verify, verified) that bypass processInboxItem. + */ +export async function seedReportAtStatus( + db: Firestore, + status: ReportStatus, + o: SeedVerifiedReportOptions = {}, +): Promise<{ reportId: string }> { + const reportId = o.reportId ?? db.collection('reports').doc().id + const municipalityId = o.municipalityId ?? 'daet' + const municipalityLabel = o.municipalityLabel ?? 'Daet' + const now = Timestamp.now() + + await db + .collection('reports') + .doc(reportId) + .set({ + reportId, + status, + municipalityId, + municipalityLabel, + source: 'citizen_pwa', + severityDerived: o.severity ?? 'medium', + correlationId: crypto.randomUUID(), + createdAt: now, + lastStatusAt: now, + lastStatusBy: 'system:seed', + schemaVersion: 1, + }) + + await db + .collection('report_private') + .doc(reportId) + .set({ + reportId, + reporterUid: o.reporterUid ?? 'reporter-1', + rawDescription: 'Seed description', + coordinatesPrecise: { lat: 14.1134, lng: 122.9554 }, + schemaVersion: 1, + }) + + await db.collection('report_ops').doc(reportId).set({ + reportId, + verifyQueuePriority: 0, + assignedMunicipalityAdmins: [], + schemaVersion: 1, + }) + + return { reportId } +} + +/** + * Seeds a responders document using Firestore admin SDK directly. + * Use with withSecurityRulesDisabled() or in Cloud Functions — not for RulesTestEnvironment context. + */ +export async function seedResponderDoc( + db: Firestore, + o: { + uid: string + municipalityId: string + agencyId: string + isActive: boolean + displayName?: string + }, +): Promise { + await db + .collection('responders') + .doc(o.uid) + .set({ + uid: o.uid, + municipalityId: o.municipalityId, + agencyId: o.agencyId, + displayName: o.displayName ?? `Responder ${o.uid}`, + isActive: o.isActive, + fcmTokens: [], + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: 1, + }) +} + +/** + * Seeds a responder shift index using Firebase Realtime Database admin SDK directly. + * Use in Cloud Functions context — not for RulesTestEnvironment RTDB context. + */ +export async function seedResponderShift( + rtdb: Database, + municipalityId: string, + uid: string, + isOnShift: boolean, +): Promise { + await rtdb + .ref(`/responder_index/${municipalityId}/${uid}`) + .set({ isOnShift, updatedAt: Date.now() }) +} + +/** + * Seeds a dispatch document using Firestore admin SDK directly. + * Use with withSecurityRulesDisabled() or in Cloud Functions — not for RulesTestEnvironment context. + */ +export async function seedDispatch( + db: Firestore, + o: { + dispatchId?: string + reportId: string + responderUid: string + agencyId?: string + municipalityId?: string + status?: 'pending' | 'accepted' | 'acknowledged' | 'in_progress' + }, +): Promise<{ dispatchId: string }> { + const dispatchId = o.dispatchId ?? db.collection('dispatches').doc().id + const now = Timestamp.now() + await db + .collection('dispatches') + .doc(dispatchId) + .set({ + dispatchId, + reportId: o.reportId, + status: o.status ?? 'pending', + assignedTo: { + uid: o.responderUid, + agencyId: o.agencyId ?? 'bfp-daet', + municipalityId: o.municipalityId ?? 'daet', + }, + dispatchedAt: now, + lastStatusAt: now, + acknowledgementDeadlineAt: Timestamp.fromMillis(now.toMillis() + 15 * 60 * 1000), + correlationId: crypto.randomUUID(), + schemaVersion: 1, + }) + return { dispatchId } +} diff --git a/functions/src/__tests__/rules/admin-onsnapshot.rules.test.ts b/functions/src/__tests__/rules/admin-onsnapshot.rules.test.ts new file mode 100644 index 00000000..c305b538 --- /dev/null +++ b/functions/src/__tests__/rules/admin-onsnapshot.rules.test.ts @@ -0,0 +1,121 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument */ +import { describe, it, beforeEach } from 'vitest' +import { + initializeTestEnvironment, + type RulesTestEnvironment, + assertFails, + assertSucceeds, +} from '@firebase/rules-unit-testing' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { setDoc, doc } from 'firebase/firestore' +import type { Firestore } from 'firebase-admin/firestore' + +const FIRESTORE_RULES_PATH = resolve(process.cwd(), '../infra/firebase/firestore.rules') +const ts = 1713350400000 + +let testEnv: RulesTestEnvironment + +function seedReport(db: any, reportId: string, municipalityId: string, status: string) { + return setDoc(doc(db, 'reports', reportId), { + reportId, + status, + municipalityId, + municipalityLabel: 'Daet', + source: 'citizen_pwa', + severityDerived: 'medium', + correlationId: crypto.randomUUID(), + visibilityClass: 'internal', + createdAt: ts, + lastStatusAt: ts, + lastStatusBy: 'system:seed', + schemaVersion: 1, + }) +} + +beforeEach(async () => { + testEnv = await initializeTestEnvironment({ + projectId: 'admin-onsnapshot-rules-test', + firestore: { + rules: readFileSync(FIRESTORE_RULES_PATH, 'utf8'), + }, + }) + await testEnv.clearFirestore() +}) + +describe('admin muni-scoped onSnapshot queue', () => { + it('allows muni admin to read reports filtered by own municipalityId + queue statuses', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + await seedReport(db, 'r1', 'daet', 'new') + await seedReport(db, 'r2', 'daet', 'awaiting_verify') + await setDoc(doc(db, 'users', 'admin-1'), { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + isActive: true, + schemaVersion: 1, + }) + }) + + const adminDb = testEnv + .authenticatedContext('admin-1', { + role: 'municipal_admin', + municipalityId: 'daet', + accountStatus: 'active', + }) + .firestore() as unknown as Firestore + + await assertSucceeds( + adminDb + .collection('reports') + .where('municipalityId', '==', 'daet') + .where('status', 'in', ['new', 'awaiting_verify']) + .get(), + ) + }) + + it('denies cross-muni reads', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + await seedReport(db, 'rx', 'mercedes', 'new') + await setDoc(doc(db, 'users', 'admin-1'), { + uid: 'admin-1', + role: 'municipal_admin', + municipalityId: 'daet', + isActive: true, + schemaVersion: 1, + }) + }) + const adminDb = testEnv + .authenticatedContext('admin-1', { + role: 'municipal_admin', + municipalityId: 'daet', + accountStatus: 'active', + }) + .firestore() as unknown as Firestore + + await assertFails(adminDb.collection('reports').where('municipalityId', '==', 'mercedes').get()) + }) + + it('denies unauthenticated reads', async () => { + const anon = testEnv.unauthenticatedContext().firestore() as unknown as Firestore + await assertFails(anon.collection('reports').where('municipalityId', '==', 'daet').get()) + }) + + it('denies citizen-role reads', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + await setDoc(doc(db, 'users', 'cit-1'), { + uid: 'cit-1', + role: 'citizen', + isActive: true, + schemaVersion: 1, + }) + }) + const citDb = testEnv + .authenticatedContext('cit-1', { role: 'citizen', accountStatus: 'active' }) + .firestore() as unknown as Firestore + await assertFails(citDb.collection('reports').where('municipalityId', '==', 'daet').get()) + }) +}) diff --git a/functions/src/__tests__/rules/dispatches.rules.test.ts b/functions/src/__tests__/rules/dispatches.rules.test.ts index f94f0604..8e17a9f0 100644 --- a/functions/src/__tests__/rules/dispatches.rules.test.ts +++ b/functions/src/__tests__/rules/dispatches.rules.test.ts @@ -2,7 +2,7 @@ import { assertFails, assertSucceeds } from '@firebase/rules-unit-testing' import { doc, getDoc, updateDoc } from 'firebase/firestore' import { afterAll, beforeAll, describe, it } from 'vitest' import { authed, createTestEnv } from '../helpers/rules-harness.js' -import { seedActiveAccount, seedDispatch, staffClaims, ts } from '../helpers/seed-factories.js' +import { seedActiveAccount, seedDispatchRT, staffClaims, ts } from '../helpers/seed-factories.js' let env: Awaited> @@ -19,7 +19,7 @@ beforeAll(async () => { municipalityId: 'daet', agencyId: 'bfp', }) - await seedDispatch(env, 'dispatch-1', { municipalityId: 'daet' }) + await seedDispatchRT(env, 'dispatch-1', { municipalityId: 'daet' }) }) afterAll(async () => { diff --git a/functions/src/__tests__/services/rate-limit.test.ts b/functions/src/__tests__/services/rate-limit.test.ts new file mode 100644 index 00000000..bb8376fc --- /dev/null +++ b/functions/src/__tests__/services/rate-limit.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { initializeTestEnvironment, type RulesTestEnvironment } from '@firebase/rules-unit-testing' +import { Timestamp } from 'firebase-admin/firestore' +import { checkRateLimit } from '../../services/rate-limit' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const RULES_PATH = resolve(import.meta.dirname, '../../../../infra/firebase/firestore.rules') + +let testEnv: RulesTestEnvironment + +beforeEach(async () => { + testEnv = await initializeTestEnvironment({ + projectId: 'rate-limit-test', + firestore: { + host: 'localhost', + port: 8080, + rules: readFileSync(RULES_PATH, 'utf8'), + }, + }) + await testEnv.clearFirestore() +}) + +afterEach(async () => { + await testEnv.cleanup() +}) + +describe('checkRateLimit', () => { + it('allows the first call under the limit', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = ctx.firestore() as any + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const result = await checkRateLimit(db, { + key: 'verifyReport:uid-1', + limit: 60, + windowSeconds: 60, + now: Timestamp.now(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updatedAt: Date.now() as any, + }) + expect(result.allowed).toBe(true) + expect(result.remaining).toBe(59) + }) + }) + + it('denies calls past the limit and returns retryAfterSeconds', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = ctx.firestore() as any + const now = Timestamp.now() + const nowMs = now.toMillis() + for (let i = 0; i < 60; i++) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + await checkRateLimit(db, { + key: 'verifyReport:uid-1', + limit: 60, + windowSeconds: 60, + now, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updatedAt: nowMs as any, + }) + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const denied = await checkRateLimit(db, { + key: 'verifyReport:uid-1', + limit: 60, + windowSeconds: 60, + now, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updatedAt: nowMs as any, + }) + expect(denied.allowed).toBe(false) + expect(denied.retryAfterSeconds).toBeGreaterThan(0) + }) + }) + + it('evicts timestamps outside the window', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = ctx.firestore() as any + const now = Timestamp.fromMillis(1_000_000) + const old = Timestamp.fromMillis(900_000) // 100 s before window start (window = 60 s) + // Seed an old timestamp outside the 60s window + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + await checkRateLimit(db, { + key: 'evict-test', + limit: 60, + windowSeconds: 60, + now: old, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updatedAt: old.toMillis() as any, + }) + // Now call with current time — old entry must be filtered out + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const result = await checkRateLimit(db, { + key: 'evict-test', + limit: 60, + windowSeconds: 60, + now, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updatedAt: now.toMillis() as any, + }) + expect(result.allowed).toBe(true) + }) + }) +}) diff --git a/functions/src/__tests__/services/responder-eligibility.test.ts b/functions/src/__tests__/services/responder-eligibility.test.ts new file mode 100644 index 00000000..655f9a4d --- /dev/null +++ b/functions/src/__tests__/services/responder-eligibility.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { initializeTestEnvironment, type RulesTestEnvironment } from '@firebase/rules-unit-testing' +import type { Firestore } from 'firebase-admin/firestore' +import type { Database } from 'firebase-admin/database' +import { getEligibleResponders } from '../../services/responder-eligibility' +import { seedResponderDoc, seedResponderShift } from '../helpers/seed-factories' + +let testEnv: RulesTestEnvironment + +beforeEach(async () => { + testEnv = await initializeTestEnvironment({ + projectId: 'eligibility-test', + firestore: { host: 'localhost', port: 8080 }, + database: { host: 'localhost', port: 9000 }, + }) + await testEnv.clearFirestore() + await testEnv.clearDatabase() +}) + +describe('getEligibleResponders', () => { + it('returns only active responders in the target municipality who are on shift', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as unknown as Firestore + const rtdb = ctx.database() as unknown as Database + await seedResponderDoc(db, { + uid: 'r1', + municipalityId: 'daet', + agencyId: 'bfp-daet', + isActive: true, + }) + await seedResponderDoc(db, { + uid: 'r2', + municipalityId: 'daet', + agencyId: 'bfp-daet', + isActive: true, + }) + await seedResponderDoc(db, { + uid: 'r3', + municipalityId: 'daet', + agencyId: 'bfp-daet', + isActive: false, + }) + await seedResponderDoc(db, { + uid: 'r4', + municipalityId: 'mercedes', + agencyId: 'bfp-mercedes', + isActive: true, + }) + await seedResponderShift(rtdb, 'daet', 'r1', true) + await seedResponderShift(rtdb, 'daet', 'r2', false) + await seedResponderShift(rtdb, 'mercedes', 'r4', true) + + const result = await getEligibleResponders(db, rtdb, { municipalityId: 'daet' }) + expect(result.map((r) => r.uid).sort()).toEqual(['r1']) + }) + }) + + it('filters by agency when provided', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as unknown as Firestore + const rtdb = ctx.database() as unknown as Database + await seedResponderDoc(db, { + uid: 'bfp1', + municipalityId: 'daet', + agencyId: 'bfp-daet', + isActive: true, + }) + await seedResponderDoc(db, { + uid: 'mdrrmo1', + municipalityId: 'daet', + agencyId: 'mdrrmo-daet', + isActive: true, + }) + await seedResponderShift(rtdb, 'daet', 'bfp1', true) + await seedResponderShift(rtdb, 'daet', 'mdrrmo1', true) + + const result = await getEligibleResponders(db, rtdb, { + municipalityId: 'daet', + agencyId: 'bfp-daet', + }) + expect(result.map((r) => r.uid)).toEqual(['bfp1']) + }) + }) +}) diff --git a/functions/src/callables/cancel-dispatch.ts b/functions/src/callables/cancel-dispatch.ts new file mode 100644 index 00000000..e8c7180f --- /dev/null +++ b/functions/src/callables/cancel-dispatch.ts @@ -0,0 +1,200 @@ +import { onCall, type CallableRequest, HttpsError } from 'firebase-functions/v2/https' +import { Firestore, Timestamp } from 'firebase-admin/firestore' +import { z } from 'zod' +import { + BantayogError, + BantayogErrorCode, + isValidDispatchTransition, + logDimension, + type DispatchStatus, +} from '@bantayog/shared-validators' +import { adminDb } from '../firebase-admin' +import { withIdempotency } from '../idempotency/guard' +import { checkRateLimit } from '../services/rate-limit' +import { bantayogErrorToHttps } from './https-error' + +const CANCEL_REASONS = [ + 'responder_unavailable', + 'duplicate_report', + 'admin_error', + 'citizen_withdrew', +] as const +export type CancelReason = (typeof CANCEL_REASONS)[number] + +const InputSchema = z + .object({ + dispatchId: z.string().min(1).max(128), + reason: z.enum(CANCEL_REASONS), + idempotencyKey: z.uuid(), + }) + .strict() + +const CANCELLABLE_FROM_STATES: readonly string[] = ['pending'] + +export interface CancelDispatchCoreDeps { + dispatchId: string + reason: CancelReason + idempotencyKey: string + actor: { uid: string; claims: { role?: string; municipalityId?: string } } + now: Timestamp +} + +export async function cancelDispatchCore(db: Firestore, deps: CancelDispatchCoreDeps) { + const correlationId = crypto.randomUUID() + + const { result } = await withIdempotency( + db, + { + key: `cancelDispatch:${deps.actor.uid}:${deps.idempotencyKey}`, + payload: deps, + now: () => deps.now.toMillis(), + }, + async () => + db.runTransaction(async (tx) => { + const dispatchRef = db.collection('dispatches').doc(deps.dispatchId) + const dispatchSnap = await tx.get(dispatchRef) + if (!dispatchSnap.exists) { + throw new BantayogError(BantayogErrorCode.NOT_FOUND, 'Dispatch not found') + } + const dispatch = dispatchSnap.data() + if (!dispatch) { + throw new BantayogError(BantayogErrorCode.NOT_FOUND, 'Dispatch data unavailable') + } + if ( + (dispatch.assignedTo as { municipalityId?: string } | null | undefined) + ?.municipalityId !== deps.actor.claims.municipalityId + ) { + throw new BantayogError(BantayogErrorCode.FORBIDDEN, 'Dispatch not in your municipality') + } + + const from = dispatch.status as string + const to = 'cancelled' as const + + if (!CANCELLABLE_FROM_STATES.includes(from)) { + throw new BantayogError( + BantayogErrorCode.FAILED_PRECONDITION, + `Cannot cancel dispatch in status ${from} (3b scope: pending-only)`, + ) + } + + if (!isValidDispatchTransition(from as DispatchStatus, to)) { + throw new BantayogError(BantayogErrorCode.INVALID_STATUS_TRANSITION, 'invalid transition') + } + + tx.update(dispatchRef, { + status: to, + lastStatusAt: deps.now, + cancelledBy: deps.actor.uid, + cancelReason: deps.reason, + }) + + const reportRef = db.collection('reports').doc(dispatch.reportId as string) + const reportSnap = await tx.get(reportRef) + if (reportSnap.exists) { + const reportData = reportSnap.data() + if (reportData?.currentDispatchId === deps.dispatchId) { + tx.update(reportRef, { + status: 'verified', + currentDispatchId: null, + lastStatusAt: deps.now, + lastStatusBy: deps.actor.uid, + }) + const revertEv = db.collection('report_events').doc() + tx.set(revertEv, { + eventId: revertEv.id, + reportId: dispatch.reportId, + from: 'assigned', + to: 'verified', + actor: deps.actor.uid, + actorRole: deps.actor.claims.role ?? 'municipal_admin', + at: deps.now, + correlationId, + schemaVersion: 1, + }) + } + } + + const evRef = db.collection('dispatch_events').doc() + tx.set(evRef, { + eventId: evRef.id, + dispatchId: deps.dispatchId, + reportId: dispatch.reportId, + from, + to, + actor: deps.actor.uid, + actorRole: deps.actor.claims.role ?? 'municipal_admin', + reason: deps.reason, + at: deps.now, + correlationId, + schemaVersion: 1, + }) + + const log = logDimension('cancelDispatch') + log({ + severity: 'INFO', + code: 'dispatch.cancelled', + message: `Dispatch ${deps.dispatchId} cancelled by ${deps.actor.uid}`, + data: { + dispatchId: deps.dispatchId, + reportId: dispatch.reportId, + reason: deps.reason, + actorUid: deps.actor.uid, + from, + correlationId, + }, + }) + + return { status: to, dispatchId: deps.dispatchId } + }), + ) + return result +} + +export const cancelDispatch = onCall( + { region: 'asia-southeast1', enforceAppCheck: true, maxInstances: 100 }, + async (req: CallableRequest) => { + if (!req.auth) throw new HttpsError('unauthenticated', 'sign-in required') + const claims = req.auth.token as Record | null + if (!claims) throw new HttpsError('unauthenticated', 'token required') + if (claims.role !== 'municipal_admin' && claims.role !== 'provincial_superadmin') { + throw new HttpsError('permission-denied', 'municipal_admin or provincial_superadmin required') + } + if (claims.active !== true) throw new HttpsError('permission-denied', 'account is not active') + const parsed = InputSchema.safeParse(req.data) + if (!parsed.success) throw new HttpsError('invalid-argument', 'malformed payload') + const rl = await checkRateLimit(adminDb, { + key: `cancelDispatch:${req.auth.uid}`, + limit: 30, + windowSeconds: 60, + now: Timestamp.now(), + }) + if (!rl.allowed) { + throw new HttpsError('resource-exhausted', 'rate limit', { + retryAfterSeconds: rl.retryAfterSeconds, + }) + } + try { + return await cancelDispatchCore(adminDb, { + dispatchId: parsed.data.dispatchId, + reason: parsed.data.reason, + idempotencyKey: parsed.data.idempotencyKey, + actor: { + uid: req.auth.uid, + claims: { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + ...(claims.role !== undefined && { role: claims.role as string }), + ...(claims.municipalityId !== undefined && { + municipalityId: claims.municipalityId as string, + }), + }, + }, + now: Timestamp.now(), + }) + } catch (err: unknown) { + if (err instanceof BantayogError) { + throw bantayogErrorToHttps(err) + } + throw err + } + }, +) diff --git a/functions/src/callables/dispatch-responder.ts b/functions/src/callables/dispatch-responder.ts new file mode 100644 index 00000000..11fb2f5c --- /dev/null +++ b/functions/src/callables/dispatch-responder.ts @@ -0,0 +1,243 @@ +import { onCall, type CallableRequest, HttpsError } from 'firebase-functions/v2/https' +import { Firestore, Timestamp } from 'firebase-admin/firestore' +import type { Database } from 'firebase-admin/database' +import { z } from 'zod' +import { + BantayogError, + BantayogErrorCode, + isValidReportTransition, + logEvent, +} from '@bantayog/shared-validators' +import { adminDb, rtdb as adminRtdb } from '../firebase-admin' +import { withIdempotency } from '../idempotency/guard' +import { checkRateLimit } from '../services/rate-limit' +import { bantayogErrorToHttps } from './https-error' + +const InputSchema = z + .object({ + reportId: z.string().min(1).max(128), + responderUid: z.string().min(1).max(128), + idempotencyKey: z.uuid(), + }) + .strict() + +const DEADLINE_BY_SEVERITY: Record<'critical' | 'high' | 'low' | 'medium', number> = { + critical: 5 * 60 * 1000, + high: 5 * 60 * 1000, + medium: 15 * 60 * 1000, + low: 30 * 60 * 1000, +} + +export interface DispatchResponderCoreDeps { + reportId: string + responderUid: string + idempotencyKey: string + actor: { uid: string; claims: { role?: string; municipalityId?: string } } + now: Timestamp +} + +export async function dispatchResponderCore( + db: Firestore, + rtdb: Database, + deps: DispatchResponderCoreDeps, +) { + const correlationId = crypto.randomUUID() + + const { result } = await withIdempotency( + db, + { + key: `dispatchResponder:${deps.actor.uid}:${deps.idempotencyKey}`, + payload: deps, + now: () => deps.now.toMillis(), + }, + async () => { + if (!deps.actor.claims.municipalityId) { + throw new BantayogError(BantayogErrorCode.INVALID_ARGUMENT, 'municipalityId is required') + } + const shiftSnap = await rtdb + .ref(`/responder_index/${deps.actor.claims.municipalityId}/${deps.responderUid}`) + .get() + const shiftData = shiftSnap.val() as { isOnShift?: boolean } | null + const isOnShift = shiftData?.isOnShift === true + if (!isOnShift) { + throw new BantayogError( + BantayogErrorCode.INVALID_STATUS_TRANSITION, + 'Responder is not on shift', + { responderUid: deps.responderUid }, + ) + } + + return db.runTransaction(async (tx) => { + const reportRef = db.collection('reports').doc(deps.reportId) + const responderRef = db.collection('responders').doc(deps.responderUid) + + const [reportSnap, responderSnap] = await Promise.all([ + tx.get(reportRef), + tx.get(responderRef), + ]) + + // Re-check shift status inside transaction scope to mitigate TOCTOU race + const shiftSnap = await rtdb + .ref(`/responder_index/${deps.actor.claims.municipalityId ?? ''}/${deps.responderUid}`) + .get() + const shiftData = shiftSnap.val() as { isOnShift?: boolean } | null + if (shiftData?.isOnShift !== true) { + throw new BantayogError( + BantayogErrorCode.INVALID_STATUS_TRANSITION, + 'Responder went off-shift before dispatch could be created', + ) + } + + if (!reportSnap.exists) { + throw new BantayogError(BantayogErrorCode.NOT_FOUND, 'Report not found') + } + if (!responderSnap.exists) { + throw new BantayogError(BantayogErrorCode.NOT_FOUND, 'Responder not found') + } + const report = reportSnap.data() as Record + const responder = responderSnap.data() as Record + + if (report.municipalityId !== deps.actor.claims.municipalityId) { + throw new BantayogError(BantayogErrorCode.FORBIDDEN, 'Report not in your municipality') + } + if (responder.municipalityId !== deps.actor.claims.municipalityId) { + throw new BantayogError(BantayogErrorCode.FORBIDDEN, 'Responder not in your municipality') + } + if (responder.isActive !== true) { + throw new BantayogError( + BantayogErrorCode.INVALID_STATUS_TRANSITION, + 'Responder is not active', + ) + } + + const from = report.status as 'verified' + const to = 'assigned' as const + if (!isValidReportTransition(from, to)) { + throw new BantayogError( + BantayogErrorCode.INVALID_STATUS_TRANSITION, + `Cannot dispatch from status ${from}`, + ) + } + + const severity = ((report.severityDerived as string | null | undefined) ?? + 'medium') as keyof typeof DEADLINE_BY_SEVERITY + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const deadlineMs = DEADLINE_BY_SEVERITY[severity] ?? DEADLINE_BY_SEVERITY.high + + const dispatchRef = db.collection('dispatches').doc() + const dispatchId = dispatchRef.id + + tx.set(dispatchRef, { + dispatchId, + reportId: deps.reportId, + status: 'pending', + assignedTo: { + uid: deps.responderUid, + agencyId: responder.agencyId, + municipalityId: responder.municipalityId, + }, + dispatchedAt: deps.now, + dispatchedBy: deps.actor.uid, + lastStatusAt: deps.now, + acknowledgementDeadlineAt: Timestamp.fromMillis(deps.now.toMillis() + deadlineMs), + correlationId, + schemaVersion: 1, + }) + + tx.update(reportRef, { + status: to, + lastStatusAt: deps.now, + lastStatusBy: deps.actor.uid, + currentDispatchId: dispatchId, + }) + + const reportEvRef = db.collection('report_events').doc() + tx.set(reportEvRef, { + eventId: reportEvRef.id, + reportId: deps.reportId, + from, + to, + actor: deps.actor.uid, + actorRole: deps.actor.claims.role ?? 'municipal_admin', + at: deps.now, + correlationId, + schemaVersion: 1, + }) + + const dispatchEvRef = db.collection('dispatch_events').doc() + tx.set(dispatchEvRef, { + eventId: dispatchEvRef.id, + dispatchId, + reportId: deps.reportId, + from: null, + to: 'pending', + actor: deps.actor.uid, + actorRole: deps.actor.claims.role ?? 'municipal_admin', + at: deps.now, + correlationId, + schemaVersion: 1, + }) + + logEvent({ + severity: 'INFO', + code: 'dispatch.created', + message: `Dispatch ${dispatchId} created for report ${deps.reportId}`, + dimension: 'dispatchResponder', + data: { + correlationId, + reportId: deps.reportId, + dispatchId, + actorUid: deps.actor.uid, + severity_report: severity, + }, + }) + + return { dispatchId, status: 'pending' as const, reportId: deps.reportId } + }) + }, + ) + return result +} + +export const dispatchResponder = onCall( + { region: 'asia-southeast1', enforceAppCheck: true, maxInstances: 100 }, + async (req: CallableRequest) => { + if (!req.auth) throw new HttpsError('unauthenticated', 'sign-in required') + const claims = req.auth.token as Record | null + if (!claims) throw new HttpsError('unauthenticated', 'token required') + if (claims.role !== 'municipal_admin' && claims.role !== 'provincial_superadmin') { + throw new HttpsError('permission-denied', 'municipal_admin or provincial_superadmin required') + } + if (claims.active !== true) throw new HttpsError('permission-denied', 'account is not active') + const parsed = InputSchema.safeParse(req.data) + if (!parsed.success) throw new HttpsError('invalid-argument', 'malformed payload') + const rl = await checkRateLimit(adminDb, { + key: `dispatchResponder:${req.auth.uid}`, + limit: 30, + windowSeconds: 60, + now: Timestamp.now(), + }) + if (!rl.allowed) { + throw new HttpsError('resource-exhausted', 'rate limit', { + retryAfterSeconds: rl.retryAfterSeconds, + }) + } + try { + return await dispatchResponderCore(adminDb, adminRtdb, { + reportId: parsed.data.reportId, + responderUid: parsed.data.responderUid, + idempotencyKey: parsed.data.idempotencyKey, + actor: { + uid: req.auth.uid, + claims: claims as { role?: string; municipalityId?: string }, + }, + now: Timestamp.now(), + }) + } catch (err: unknown) { + if (err instanceof BantayogError) { + throw bantayogErrorToHttps(err) + } + throw err + } + }, +) diff --git a/functions/src/callables/https-error.ts b/functions/src/callables/https-error.ts index d12254a6..7781cbf8 100644 --- a/functions/src/callables/https-error.ts +++ b/functions/src/callables/https-error.ts @@ -26,6 +26,7 @@ export const BANTAYOG_TO_HTTPS_CODE: Record deps.now.toMillis(), + }, + async () => + db.runTransaction(async (tx) => { + const reportRef = db.collection('reports').doc(deps.reportId) + const snap = await tx.get(reportRef) + if (!snap.exists) { + throw new BantayogError(BantayogErrorCode.NOT_FOUND, 'Report not found') + } + const report = snap.data() as Record + if (report.municipalityId !== deps.actor.claims.municipalityId) { + throw new BantayogError(BantayogErrorCode.FORBIDDEN, 'Report not in your municipality') + } + const from = report.status as string + const to = 'cancelled_false_report' as const + if (from !== 'awaiting_verify') { + throw new BantayogError( + BantayogErrorCode.FAILED_PRECONDITION, + `rejectReport is only valid from awaiting_verify, got ${from}`, + { reportId: deps.reportId, from }, + ) + } + + tx.update(reportRef, { + status: to, + lastStatusAt: deps.now, + lastStatusBy: deps.actor.uid, + rejectionReason: deps.reason, + }) + + const incRef = db.collection('moderation_incidents').doc() + tx.set(incRef, { + incidentId: incRef.id, + reportId: deps.reportId, + reason: deps.reason, + notes: deps.notes ?? null, + actor: deps.actor.uid, + actorRole: deps.actor.claims.role ?? 'municipal_admin', + at: deps.now, + correlationId, + schemaVersion: 1, + }) + + const evRef = db.collection('report_events').doc() + tx.set(evRef, { + eventId: evRef.id, + reportId: deps.reportId, + from, + to, + actor: deps.actor.uid, + actorRole: deps.actor.claims.role ?? 'municipal_admin', + at: deps.now, + correlationId, + schemaVersion: 1, + }) + + log({ + severity: 'INFO', + code: 'report.rejected', + message: `Report ${deps.reportId} rejected as ${deps.reason}`, + data: { + correlationId, + reportId: deps.reportId, + reason: deps.reason, + actorUid: deps.actor.uid, + }, + }) + + return { status: to, reportId: deps.reportId } + }), + ) + return result +} + +export const rejectReport = onCall( + { region: 'asia-southeast1', enforceAppCheck: true, maxInstances: 100 }, + async (req: CallableRequest) => { + if (!req.auth) throw new HttpsError('unauthenticated', 'sign-in required') + const claims = req.auth.token as Record | null + if (!claims) throw new HttpsError('unauthenticated', 'sign-in required') + if (claims.role !== 'municipal_admin' && claims.role !== 'provincial_superadmin') { + throw new HttpsError('permission-denied', 'municipal_admin or provincial_superadmin required') + } + if (claims.active !== true) throw new HttpsError('permission-denied', 'account is not active') + const parsed = InputSchema.safeParse(req.data) + if (!parsed.success) throw new HttpsError('invalid-argument', 'malformed payload') + const rl = await checkRateLimit(adminDb, { + key: `rejectReport:${req.auth.uid}`, + limit: 60, + windowSeconds: 60, + now: Timestamp.now(), + }) + if (!rl.allowed) { + throw new HttpsError('resource-exhausted', 'rate limit', { + retryAfterSeconds: rl.retryAfterSeconds, + }) + } + + try { + return await rejectReportCore(adminDb, { + reportId: parsed.data.reportId, + reason: parsed.data.reason, + notes: parsed.data.notes, + idempotencyKey: parsed.data.idempotencyKey, + actor: { + uid: req.auth.uid, + claims: { + role: claims.role as string, + municipalityId: claims.municipalityId as string, + }, + }, + now: Timestamp.now(), + }) + } catch (err: unknown) { + if (err instanceof BantayogError) throw bantayogErrorToHttps(err) + throw err + } + }, +) diff --git a/functions/src/callables/verify-report.ts b/functions/src/callables/verify-report.ts new file mode 100644 index 00000000..188971d0 --- /dev/null +++ b/functions/src/callables/verify-report.ts @@ -0,0 +1,193 @@ +import { onCall, type CallableRequest, HttpsError } from 'firebase-functions/v2/https' +import { Firestore, Timestamp } from 'firebase-admin/firestore' +import { z } from 'zod' +import { + BantayogError, + BantayogErrorCode, + isValidReportTransition, + type ReportStatus, +} from '@bantayog/shared-validators' +import { bantayogErrorToHttps } from './https-error.js' +import { adminDb } from '../firebase-admin' +import { withIdempotency } from '../idempotency/guard' +import { checkRateLimit } from '../services/rate-limit' +import { logDimension } from '@bantayog/shared-validators' + +const InputSchema = z + .object({ + reportId: z.string().min(1).max(128), + idempotencyKey: z.uuid(), + }) + .strict() + +export interface VerifyReportInput { + reportId: string + idempotencyKey: string +} + +export interface VerifyReportResult { + status: ReportStatus + reportId: string +} + +export interface VerifyReportActor { + uid: string + claims: { + role?: string + municipalityId?: string + active?: boolean + } +} + +export interface VerifyReportCoreDeps { + reportId: string + idempotencyKey: string + actor: VerifyReportActor + now: Timestamp +} + +export async function verifyReportCore( + db: Firestore, + deps: VerifyReportCoreDeps, +): Promise { + const correlationId = crypto.randomUUID() + + const { result } = await withIdempotency( + db, + { + key: `verifyReport:${deps.actor.uid}:${deps.idempotencyKey}`, + payload: deps, + now: () => deps.now.toMillis(), + }, + async () => { + return db.runTransaction(async (tx) => { + const reportRef = db.collection('reports').doc(deps.reportId) + const reportSnap = await tx.get(reportRef) + if (!reportSnap.exists) { + throw new BantayogError(BantayogErrorCode.NOT_FOUND, 'Report not found', { + reportId: deps.reportId, + }) + } + const reportData = reportSnap.data() + if (!reportData) { + throw new BantayogError(BantayogErrorCode.NOT_FOUND, 'Report data missing', { + reportId: deps.reportId, + }) + } + const report = reportData + if (report.municipalityId !== deps.actor.claims.municipalityId) { + throw new BantayogError(BantayogErrorCode.FORBIDDEN, 'Report is not in your municipality') + } + + const from = report.status as ReportStatus + let to: ReportStatus + if (from === 'new') to = 'awaiting_verify' + else if (from === 'awaiting_verify') to = 'verified' + else { + throw new BantayogError( + BantayogErrorCode.INVALID_STATUS_TRANSITION, + `verifyReport cannot advance from status ${from}`, + { reportId: deps.reportId, from }, + ) + } + + if (!isValidReportTransition(from, to)) { + throw new BantayogError( + BantayogErrorCode.INVALID_STATUS_TRANSITION, + 'invalid transition', + { + from, + to, + }, + ) + } + + const updates: Record = { + status: to, + lastStatusAt: deps.now, + lastStatusBy: deps.actor.uid, + } + if (to === 'verified') { + updates.verifiedBy = deps.actor.uid + updates.verifiedAt = deps.now + } + tx.update(reportRef, updates) + + const eventRef = db.collection('report_events').doc() + tx.set(eventRef, { + eventId: eventRef.id, + reportId: deps.reportId, + from, + to, + actor: deps.actor.uid, + actorRole: deps.actor.claims.role ?? 'municipal_admin', + at: deps.now, + correlationId, + schemaVersion: 1, + }) + + const log = logDimension('verifyReport') + log({ + severity: 'INFO', + code: 'report.verified', + message: `Report ${deps.reportId} transitioned ${from} → ${to}`, + data: { reportId: deps.reportId, from, to, actorUid: deps.actor.uid, correlationId }, + }) + + return { status: to, reportId: deps.reportId } + }) + }, + ) + return result +} + +export const verifyReport = onCall( + { region: 'asia-southeast1', enforceAppCheck: true, maxInstances: 100 }, + async (req: CallableRequest) => { + if (!req.auth) throw new HttpsError('unauthenticated', 'sign-in required') + const claims = req.auth.token as Record | null + if (!claims) throw new HttpsError('unauthenticated', 'sign-in required') + if (claims.role !== 'municipal_admin' && claims.role !== 'provincial_superadmin') { + throw new HttpsError('permission-denied', 'municipal_admin or provincial_superadmin required') + } + if (claims.active !== true) { + throw new HttpsError('permission-denied', 'account is not active') + } + + const parsed = InputSchema.safeParse(req.data) + if (!parsed.success) throw new HttpsError('invalid-argument', 'malformed payload') + + const rl = await checkRateLimit(adminDb, { + key: `verifyReport:${req.auth.uid}`, + limit: 60, + windowSeconds: 60, + now: Timestamp.now(), + }) + if (!rl.allowed) { + throw new HttpsError('resource-exhausted', 'rate limit', { + retryAfterSeconds: rl.retryAfterSeconds, + }) + } + + try { + return await verifyReportCore(adminDb, { + reportId: parsed.data.reportId, + idempotencyKey: parsed.data.idempotencyKey, + actor: { + uid: req.auth.uid, + claims: { + role: claims.role as string, + municipalityId: claims.municipalityId as string, + active: claims.active as boolean, + }, + }, + now: Timestamp.now(), + }) + } catch (err: unknown) { + if (err instanceof BantayogError) { + throw bantayogErrorToHttps(err) + } + throw err + } + }, +) diff --git a/functions/src/firebase-admin.ts b/functions/src/firebase-admin.ts index 9d536505..ed333a67 100644 --- a/functions/src/firebase-admin.ts +++ b/functions/src/firebase-admin.ts @@ -1,8 +1,10 @@ import { getApps, initializeApp } from 'firebase-admin/app' import { getAuth } from 'firebase-admin/auth' import { getFirestore } from 'firebase-admin/firestore' +import { getDatabase } from 'firebase-admin/database' const app = getApps()[0] ?? initializeApp() export const adminAuth = getAuth(app) export const adminDb = getFirestore(app) +export const rtdb = getDatabase(app) diff --git a/functions/src/index.ts b/functions/src/index.ts index c61109f3..31191445 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -2,7 +2,11 @@ export { setStaffClaims, suspendStaffAccount } from './auth/account-lifecycle.js' export { withIdempotency, IdempotencyMismatchError } from './idempotency/guard.js' export { requestUploadUrl } from './callables/request-upload-url.js' +export { verifyReport } from './callables/verify-report.js' export { requestLookup } from './callables/request-lookup.js' +export { dispatchResponder } from './callables/dispatch-responder.js' +export { cancelDispatch } from './callables/cancel-dispatch.js' +export { rejectReport } from './callables/reject-report.js' // onMediaFinalize is lazily instantiated to avoid triggering Firebase Functions v2 // storage import-time env checks (FIREBASE_CONFIG) during unit testing. diff --git a/functions/src/services/rate-limit.ts b/functions/src/services/rate-limit.ts new file mode 100644 index 00000000..f7d8326b --- /dev/null +++ b/functions/src/services/rate-limit.ts @@ -0,0 +1,45 @@ +import type { Firestore, Timestamp } from 'firebase-admin/firestore' +import { Timestamp as AdminTimestamp } from 'firebase-admin/firestore' + +export interface RateLimitCheck { + key: string + limit: number + windowSeconds: number + now: Timestamp + updatedAt?: Timestamp | number +} + +export interface RateLimitResult { + allowed: boolean + remaining: number + retryAfterSeconds: number +} + +export async function checkRateLimit( + db: Firestore, + { key, limit, windowSeconds, now, updatedAt }: RateLimitCheck, +): Promise { + const ref = db.collection('rate_limits').doc(key) + return db.runTransaction(async (tx) => { + const snap = await tx.get(ref) + const windowStartMs = now.toMillis() - windowSeconds * 1000 + const bucket = snap.exists ? snap.data() : undefined + const existingTimes: number[] = Array.isArray(bucket?.timestamps) ? bucket.timestamps : [] + const fresh = existingTimes.filter((ms) => ms >= windowStartMs) + + if (fresh.length >= limit) { + const earliest = Math.min(...fresh) + const retryAfterSeconds = Math.ceil((earliest + windowSeconds * 1000 - now.toMillis()) / 1000) + return { allowed: false, remaining: 0, retryAfterSeconds: Math.max(retryAfterSeconds, 1) } + } + + fresh.push(now.toMillis()) + const pruned = fresh.slice(-limit) + tx.set( + ref, + { timestamps: pruned, updatedAt: updatedAt ?? AdminTimestamp.now() }, + { merge: true }, + ) + return { allowed: true, remaining: limit - pruned.length, retryAfterSeconds: 0 } + }) +} diff --git a/functions/src/services/responder-eligibility.ts b/functions/src/services/responder-eligibility.ts new file mode 100644 index 00000000..f8bf3c3f --- /dev/null +++ b/functions/src/services/responder-eligibility.ts @@ -0,0 +1,43 @@ +import type { Firestore } from 'firebase-admin/firestore' +import type { Database } from 'firebase-admin/database' + +export interface EligibleResponder { + uid: string + displayName: string + agencyId: string + municipalityId: string +} + +export async function getEligibleResponders( + db: Firestore, + rtdb: Database, + filter: { municipalityId: string; agencyId?: string }, +): Promise { + let q = db + .collection('responders') + .where('municipalityId', '==', filter.municipalityId) + .where('isActive', '==', true) + if (filter.agencyId) { + q = q.where('agencyId', '==', filter.agencyId) + } + + const [respondersSnap, shiftSnap] = await Promise.all([ + q.get(), + rtdb.ref(`/responder_index/${filter.municipalityId}`).get(), + ]) + + const shift = (shiftSnap.val() ?? {}) as Record + + return respondersSnap.docs + .filter((doc) => shift[doc.id]?.isOnShift === true) + .map((doc) => { + const data = doc.data() + return { + uid: doc.id, + displayName: String(data.displayName ?? ''), + agencyId: String(data.agencyId ?? ''), + municipalityId: data.municipalityId as string, + } + }) + .sort((a, b) => a.displayName.localeCompare(b.displayName)) +} diff --git a/infra/firebase/firestore.indexes.json b/infra/firebase/firestore.indexes.json index 176b6bf2..545f0366 100644 --- a/infra/firebase/firestore.indexes.json +++ b/infra/firebase/firestore.indexes.json @@ -121,6 +121,15 @@ { "fieldPath": "dispatchedAt", "order": "DESCENDING" } ] }, + { + "collectionGroup": "dispatches", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "assignedTo.uid", "order": "ASCENDING" }, + { "fieldPath": "status", "order": "ASCENDING" }, + { "fieldPath": "dispatchedAt", "order": "DESCENDING" } + ] + }, { "collectionGroup": "alerts", "queryScope": "COLLECTION", diff --git a/infra/firebase/firestore.rules b/infra/firebase/firestore.rules index 66a158c9..ada9e7de 100644 --- a/infra/firebase/firestore.rules +++ b/infra/firebase/firestore.rules @@ -50,6 +50,7 @@ service cloud.firestore { return (from == 'accepted' && to == 'acknowledged') || (from == 'acknowledged' && to == 'in_progress') || (from == 'in_progress' && to == 'resolved') + || (from == 'pending' && to == 'cancelled') || (from == 'pending' && to == 'declined') || false; } diff --git a/infra/terraform/modules/monitoring/phase-3/main.tf b/infra/terraform/modules/monitoring/phase-3/main.tf index 9fc1547e..c11df044 100644 --- a/infra/terraform/modules/monitoring/phase-3/main.tf +++ b/infra/terraform/modules/monitoring/phase-3/main.tf @@ -67,3 +67,22 @@ resource "google_monitoring_alert_policy" "sweep_alert" { } notification_channels = var.notification_channel_ids } + +resource "google_logging_metric" "dispatch_created" { + name = "${var.env}-bantayog-dispatch-created" + description = "Count of dispatches created via dispatchResponder" + filter = "resource.type=\"cloud_function\" AND jsonPayload.event=\"dispatch.created\" OR resource.type=\"cloud_run_revision\" AND jsonPayload.event=\"dispatch.created\"" + metric_descriptor { + metric_kind = "DELTA" + value_type = "INT64" + unit = "1" + labels { + key = "municipality_id" + value_type = "STRING" + description = "Municipality the dispatch was created in" + } + } + label_extractors = { + "municipality_id" = "EXTRACT(jsonPayload.municipalityId)" + } +} diff --git a/packages/shared-validators/src/dispatches.test.ts b/packages/shared-validators/src/dispatches.test.ts index 6e0a4a11..751701a2 100644 --- a/packages/shared-validators/src/dispatches.test.ts +++ b/packages/shared-validators/src/dispatches.test.ts @@ -8,9 +8,11 @@ describe('dispatchDocSchema', () => { expect( dispatchDocSchema.parse({ reportId: 'r-1', - responderId: 'resp-1', - municipalityId: 'daet', - agencyId: 'bfp', + assignedTo: { + uid: 'resp-1', + agencyId: 'bfp', + municipalityId: 'daet', + }, dispatchedBy: 'admin-1', dispatchedByRole: 'municipal_admin', dispatchedAt: ts, @@ -28,9 +30,11 @@ describe('dispatchDocSchema', () => { expect(() => dispatchDocSchema.parse({ reportId: 'r-1', - responderId: 'resp-1', - municipalityId: 'daet', - agencyId: 'bfp', + assignedTo: { + uid: 'resp-1', + agencyId: 'bfp', + municipalityId: 'daet', + }, dispatchedBy: 'admin-1', dispatchedByRole: 'municipal_admin', dispatchedAt: ts, diff --git a/packages/shared-validators/src/dispatches.ts b/packages/shared-validators/src/dispatches.ts index 2eb7e982..fa00ed1b 100644 --- a/packages/shared-validators/src/dispatches.ts +++ b/packages/shared-validators/src/dispatches.ts @@ -33,9 +33,11 @@ export const dispatchStatusSchema = z.enum([ export const dispatchDocSchema = z .object({ reportId: z.string().min(1), - responderId: z.string().min(1), - municipalityId: z.string().min(1), - agencyId: z.string().min(1), + assignedTo: z.object({ + uid: z.string().min(1), + agencyId: z.string().min(1), + municipalityId: z.string().min(1), + }), dispatchedBy: z.string().min(1), dispatchedByRole: z.enum(['municipal_admin', 'agency_admin']), dispatchedAt: z.number().int(), diff --git a/packages/shared-validators/src/errors-and-logging.test.ts b/packages/shared-validators/src/errors-and-logging.test.ts index b0eac6dd..1081b2ba 100644 --- a/packages/shared-validators/src/errors-and-logging.test.ts +++ b/packages/shared-validators/src/errors-and-logging.test.ts @@ -11,9 +11,9 @@ import { logEvent, LOG_DIMENSION_MAX } from './logging.js' // ─── BantayogErrorCode enum ──────────────────────────────────────────────── describe('BantayogErrorCode', () => { - it('has 18 named error codes', () => { + it('has 19 named error codes', () => { const codes = Object.values(BantayogErrorCode) - expect(codes).toHaveLength(18) + expect(codes).toHaveLength(19) }) it('isBantayogErrorCode returns true for every enum member', () => { diff --git a/packages/shared-validators/src/errors.ts b/packages/shared-validators/src/errors.ts index 52f59a2f..6eb91e2b 100644 --- a/packages/shared-validators/src/errors.ts +++ b/packages/shared-validators/src/errors.ts @@ -37,6 +37,7 @@ export enum BantayogErrorCode { UPLOAD_URL_GENERATION_FAILED = 'UPLOAD_URL_GENERATION_FAILED', MEDIA_PROCESSING_FAILED = 'MEDIA_PROCESSING_FAILED', INVALID_STATUS_TRANSITION = 'INVALID_STATUS_TRANSITION', + FAILED_PRECONDITION = 'FAILED_PRECONDITION', IDEMPOTENCY_KEY_CONFLICT = 'IDEMPOTENCY_KEY_CONFLICT', } diff --git a/packages/shared-validators/src/responders.ts b/packages/shared-validators/src/responders.ts index e0cf4815..3e367441 100644 --- a/packages/shared-validators/src/responders.ts +++ b/packages/shared-validators/src/responders.ts @@ -8,6 +8,7 @@ export const responderDocSchema = z displayCode: z.string().min(1), specialisations: z.array(z.string()).default([]), availabilityStatus: z.enum(['on_duty', 'off_duty', 'on_break', 'unavailable']), + isActive: z.boolean(), lastTelemetryAt: z.number().int().optional(), schemaVersion: z.number().int().positive(), createdAt: z.number().int(), diff --git a/packages/shared-validators/src/state-machines.test.ts b/packages/shared-validators/src/state-machines.test.ts index 0ca9de62..54d6a558 100644 --- a/packages/shared-validators/src/state-machines.test.ts +++ b/packages/shared-validators/src/state-machines.test.ts @@ -56,8 +56,8 @@ describe('dispatch state machine', () => { expect(DISPATCH_STATES).toHaveLength(9) }) - it('DISPATCH_TRANSITIONS has 4 declared responder transitions', () => { - expect(DISPATCH_TRANSITIONS).toHaveLength(4) + it('DISPATCH_TRANSITIONS has 5 declared transitions', () => { + expect(DISPATCH_TRANSITIONS).toHaveLength(5) }) it('every declared responder-direct transition is valid', () => { diff --git a/packages/shared-validators/src/state-machines/report-states.ts b/packages/shared-validators/src/state-machines/report-states.ts index 6e0e1701..f0abf306 100644 --- a/packages/shared-validators/src/state-machines/report-states.ts +++ b/packages/shared-validators/src/state-machines/report-states.ts @@ -78,6 +78,7 @@ export const DISPATCH_TRANSITIONS: readonly [DispatchStatus, DispatchStatus][] = ['accepted', 'acknowledged'], ['acknowledged', 'in_progress'], ['in_progress', 'resolved'], + ['pending', 'cancelled'], ['pending', 'declined'], ] as const diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de4f0072..ebbdea9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,12 +71,18 @@ importers: '@bantayog/shared-ui': specifier: workspace:* version: link:../../packages/shared-ui + firebase: + specifier: ^12.12.0 + version: 12.12.0 react: specifier: ^19.2.5 version: 19.2.5 react-dom: specifier: ^19.2.5 version: 19.2.5(react@19.2.5) + react-router-dom: + specifier: ^7.14.1 + version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) devDependencies: '@types/react': specifier: ^19.2.14 @@ -145,12 +151,18 @@ importers: '@capacitor/core': specifier: ^8.3.1 version: 8.3.1 + firebase: + specifier: ^12.12.0 + version: 12.12.0 react: specifier: ^19.2.5 version: 19.2.5 react-dom: specifier: ^19.2.5 version: 19.2.5(react@19.2.5) + react-router-dom: + specifier: ^7.14.1 + version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) devDependencies: '@capacitor/cli': specifier: ^8.3.1 diff --git a/scripts/phase-3b/acceptance.ts b/scripts/phase-3b/acceptance.ts new file mode 100644 index 00000000..404294af --- /dev/null +++ b/scripts/phase-3b/acceptance.ts @@ -0,0 +1,209 @@ +import { initializeApp, getApp, getApps } from 'firebase-admin/app' +import { getAuth } from 'firebase-admin/auth' +import { getFirestore } from 'firebase-admin/firestore' +import { httpsCallable, getFunctions as webGetFunctions } from 'firebase/functions' +import { initializeApp as webInitApp } from 'firebase/app' +import { getAuth as webGetAuth, signInWithCustomToken, connectAuthEmulator } from 'firebase/auth' + +type Report = { passed: boolean; assertions: Array<{ name: string; ok: boolean; detail?: string }> } + +const EMU = !process.argv.includes('--env=staging') +if (EMU) { + process.env.FIRESTORE_EMULATOR_HOST = 'localhost:8080' + process.env.FIREBASE_AUTH_EMULATOR_HOST = 'localhost:9099' + process.env.FIREBASE_DATABASE_EMULATOR_HOST = 'localhost:9000' +} + +const PROJECT_ID = + process.env.GCLOUD_PROJECT ?? process.env.FIREBASE_PROJECT_ID ?? 'bantayog-alert-dev' + +if (getApps().length === 0) { + initializeApp({ projectId: PROJECT_ID }) +} + +const adminAuth = getAuth(getApp()) +const adminDb = getFirestore(getApp()) + +const report: Report = { passed: true, assertions: [] } +function check(name: string, ok: boolean, detail?: string) { + report.assertions.push({ name, ok, detail }) + if (!ok) report.passed = false + console.log(`${ok ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`) +} + +async function main() { + const reportId = adminDb.collection('reports').doc().id + const now = new Date() + + // Seed a verified report (prereq for dispatch). + await adminDb.collection('reports').doc(reportId).set({ + reportId, + status: 'verified', + municipalityId: 'daet', + municipalityLabel: 'Daet', + source: 'citizen_pwa', + severityDerived: 'medium', + correlationId: crypto.randomUUID(), + createdAt: now, + lastStatusAt: now, + lastStatusBy: 'system:acceptance-seed', + schemaVersion: 1, + }) + await adminDb + .collection('report_private') + .doc(reportId) + .set({ + reportId, + reporterUid: 'cit-acceptance-01', + rawDescription: 'seed', + coordinatesPrecise: { lat: 14.1134, lng: 122.9554 }, + schemaVersion: 1, + }) + await adminDb.collection('report_ops').doc(reportId).set({ + reportId, + verifyQueuePriority: 0, + assignedMunicipalityAdmins: [], + schemaVersion: 1, + }) + check('Seeded verified report', true, reportId) + + // Mint a custom token for the seeded admin. + const adminUid = 'daet-admin-test-01' + await adminAuth.setCustomUserClaims(adminUid, { + role: 'municipal_admin', + municipalityId: 'daet', + active: true, + }) + const adminCustomToken = await adminAuth.createCustomToken(adminUid) + check('Admin custom token minted', true, adminUid) + + // Set up web SDK client for callable invocation. + const webApp = webInitApp({ appId: 'demo' }) + const webAuth = webGetAuth(webApp) + const webFunctions = webGetFunctions(webApp) + + if (EMU) { + connectAuthEmulator(webAuth, 'http://localhost:9099', { disableWarnings: true }) + } + + await signInWithCustomToken(webAuth, adminCustomToken) + check('Admin signed in via web SDK', true) + + // Call verifyReport to advance verified → assigned (via dispatch). + // First, advance verified → awaiting_verify → verified (two-step). + // Actually, start from 'new' and advance to 'verified' then dispatch. + + // Re-seed at 'new' status to test verify path. + const reportId2 = adminDb.collection('reports').doc().id + await adminDb.collection('reports').doc(reportId2).set({ + reportId: reportId2, + status: 'new', + municipalityId: 'daet', + municipalityLabel: 'Daet', + source: 'citizen_pwa', + severityDerived: 'medium', + correlationId: crypto.randomUUID(), + createdAt: now, + lastStatusAt: now, + lastStatusBy: 'system:acceptance-seed', + schemaVersion: 1, + }) + await adminDb.collection('report_ops').doc(reportId2).set({ + reportId: reportId2, + verifyQueuePriority: 0, + assignedMunicipalityAdmins: [], + schemaVersion: 1, + }) + check('Seeded new report for verify test', true, reportId2) + + // Call verifyReport: new → awaiting_verify. + const verifyFn = httpsCallable(webFunctions, 'verifyReport') + const v1 = await verifyFn({ reportId: reportId2, idempotencyKey: crypto.randomUUID() }) + check( + 'verifyReport: new→awaiting_verify', + (v1.data as { status: string }).status === 'awaiting_verify', + ) + + // Call verifyReport again: awaiting_verify → verified. + const v2 = await verifyFn({ reportId: reportId2, idempotencyKey: crypto.randomUUID() }) + check( + 'verifyReport: awaiting_verify→verified', + (v2.data as { status: string }).status === 'verified', + ) + + // Call dispatchResponder with the test responder. + const dispFn = httpsCallable(webFunctions, 'dispatchResponder') + const dispResult = await dispFn({ + reportId: reportId2, + responderUid: 'bfp-responder-test-01', + idempotencyKey: crypto.randomUUID(), + }) + const dispData = dispResult.data as { dispatchId: string; status: string } + check('dispatchResponder: created dispatch', dispData.status === 'pending', dispData.dispatchId) + + // Verify the dispatch document exists. + const dispDoc = await adminDb.collection('dispatches').doc(dispData.dispatchId).get() + check('Dispatch doc persisted', dispDoc.exists) + + // Verify report status is 'assigned'. + const reportDoc = await adminDb.collection('reports').doc(reportId2).get() + check('Report status assigned', reportDoc.data()?.status === 'assigned') + + // Test cross-muni rejection: try to dispatch a report from a different municipality. + const crossMuniReportId = adminDb.collection('reports').doc().id + await adminDb.collection('reports').doc(crossMuniReportId).set({ + reportId: crossMuniReportId, + status: 'verified', + municipalityId: 'mercedes', + municipalityLabel: 'Mercedes', + source: 'citizen_pwa', + severityDerived: 'medium', + correlationId: crypto.randomUUID(), + createdAt: now, + lastStatusAt: now, + lastStatusBy: 'system:acceptance-seed', + schemaVersion: 1, + }) + await adminDb + .collection('report_private') + .doc(crossMuniReportId) + .set({ + reportId: crossMuniReportId, + reporterUid: 'cit-cross-01', + rawDescription: 'seed', + coordinatesPrecise: { lat: 14.3, lng: 123.0 }, + schemaVersion: 1, + }) + await adminDb.collection('report_ops').doc(crossMuniReportId).set({ + reportId: crossMuniReportId, + verifyQueuePriority: 0, + assignedMunicipalityAdmins: [], + schemaVersion: 1, + }) + + try { + await dispFn({ + reportId: crossMuniReportId, + responderUid: 'bfp-responder-test-01', + idempotencyKey: crypto.randomUUID(), + }) + check('Cross-muni rejection', false, 'should have thrown') + } catch (err: unknown) { + const errCode = (err as { code?: string }).code + check( + 'Cross-muni rejection', + errCode === 'permission-denied' || errCode === 'FORBIDDEN', + errCode ?? 'unknown', + ) + } + + // Output JSON report. + console.log('\n--- RESULT ---') + console.log(JSON.stringify(report, null, 2)) + process.exit(report.passed ? 0 : 1) +} + +main().catch((err) => { + console.error('[acceptance] fatal:', err) + process.exit(1) +}) diff --git a/scripts/phase-3b/bootstrap-test-responder.ts b/scripts/phase-3b/bootstrap-test-responder.ts new file mode 100644 index 00000000..d49b5d7f --- /dev/null +++ b/scripts/phase-3b/bootstrap-test-responder.ts @@ -0,0 +1,92 @@ +import { initializeApp, getApp, getApps } from 'firebase-admin/app' +import { getAuth } from 'firebase-admin/auth' +import { getFirestore } from 'firebase-admin/firestore' +import { getDatabase } from 'firebase-admin/database' + +const EMU = process.argv.includes('--emulator') +if (EMU) { + process.env.FIRESTORE_EMULATOR_HOST = 'localhost:8080' + process.env.FIREBASE_AUTH_EMULATOR_HOST = 'localhost:9099' + process.env.FIREBASE_DATABASE_EMULATOR_HOST = 'localhost:9000' +} + +const PROJECT_ID = + process.env.GCLOUD_PROJECT ?? process.env.FIREBASE_PROJECT_ID ?? 'bantayog-alert-dev' + +if (getApps().length === 0) { + initializeApp({ + projectId: PROJECT_ID, + databaseURL: EMU + ? `http://localhost:9000?ns=${PROJECT_ID}` + : `https://${PROJECT_ID}.asia-southeast1.firebasedatabase.app`, + }) +} + +const TEST_RESPONDER = { + uid: 'bfp-responder-test-01', + email: 'bfp-responder-test-01@bantayog.test', + password: 'Test1234!', + displayName: 'BFP Test Responder 01', + agencyId: 'bfp-daet', + municipalityId: 'daet', +} + +async function main() { + const auth = getAuth(getApp()) + const db = getFirestore(getApp()) + const rtdb = getDatabase(getApp()) + + try { + await auth.createUser({ + uid: TEST_RESPONDER.uid, + email: TEST_RESPONDER.email, + password: TEST_RESPONDER.password, + emailVerified: true, + displayName: TEST_RESPONDER.displayName, + }) + console.log('[bootstrap] created auth user') + } catch (err: unknown) { + if (err instanceof Error && err.message.includes('already')) { + console.log('[bootstrap] auth user already exists') + } else { + throw err + } + } + + await auth.setCustomUserClaims(TEST_RESPONDER.uid, { + role: 'responder', + municipalityId: TEST_RESPONDER.municipalityId, + agencyId: TEST_RESPONDER.agencyId, + active: true, + }) + console.log('[bootstrap] claims set') + + await db.collection('responders').doc(TEST_RESPONDER.uid).set( + { + uid: TEST_RESPONDER.uid, + displayName: TEST_RESPONDER.displayName, + agencyId: TEST_RESPONDER.agencyId, + municipalityId: TEST_RESPONDER.municipalityId, + isActive: true, + fcmTokens: [], + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: 1, + }, + { merge: true }, + ) + console.log('[bootstrap] responders doc written') + + await rtdb.ref(`/responder_index/${TEST_RESPONDER.municipalityId}/${TEST_RESPONDER.uid}`).set({ + isOnShift: true, + updatedAt: Date.now(), + }) + console.log('[bootstrap] responder shift index set') + + console.log(`[bootstrap] done — responder uid=${TEST_RESPONDER.uid}`) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +})