diff --git a/apps/admin-desktop/src/hooks/useEligibleResponders.ts b/apps/admin-desktop/src/hooks/useEligibleResponders.ts new file mode 100644 index 00000000..b9846557 --- /dev/null +++ b/apps/admin-desktop/src/hooks/useEligibleResponders.ts @@ -0,0 +1,53 @@ +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) 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) 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/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/services/callables.ts b/apps/admin-desktop/src/services/callables.ts new file mode 100644 index 00000000..ad32b0e0 --- /dev/null +++ b/apps/admin-desktop/src/services/callables.ts @@ -0,0 +1,40 @@ +import { httpsCallable } from 'firebase/functions' +import { functions } from '../app/firebase' + +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..28a969da 100644 --- a/apps/responder-app/package.json +++ b/apps/responder-app/package.json @@ -15,6 +15,7 @@ "@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" }, 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..f1695745 --- /dev/null +++ b/functions/src/__tests__/services/rate-limit.test.ts @@ -0,0 +1,70 @@ +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(), + }) + 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() + 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-unsafe-argument + const denied = await checkRateLimit(db, { + key: 'verifyReport:uid-1', + limit: 60, + windowSeconds: 60, + now, + }) + expect(denied.allowed).toBe(false) + expect(denied.retryAfterSeconds).toBeGreaterThan(0) + }) + }) +}) diff --git a/functions/src/services/rate-limit.ts b/functions/src/services/rate-limit.ts new file mode 100644 index 00000000..c0df30d0 --- /dev/null +++ b/functions/src/services/rate-limit.ts @@ -0,0 +1,38 @@ +import type { Firestore, Timestamp } from 'firebase-admin/firestore' + +export interface RateLimitCheck { + key: string + limit: number + windowSeconds: number + now: Timestamp +} + +export interface RateLimitResult { + allowed: boolean + remaining: number + retryAfterSeconds: number +} + +export async function checkRateLimit( + db: Firestore, + { key, limit, windowSeconds, now }: 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()) + tx.set(ref, { timestamps: fresh }, { merge: true }) + return { allowed: true, remaining: limit - fresh.length, retryAfterSeconds: 0 } + }) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de4f0072..099d7fe9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,6 +145,9 @@ 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