Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions apps/admin-desktop/src/hooks/useEligibleResponders.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, EligibleResponder>>({})
const [shift, setShift] = useState<Record<string, { isOnShift: boolean }>>({})

useEffect(() => {
if (!municipalityId) return
const q = query(
collection(db, 'responders'),
where('municipalityId', '==', municipalityId),
where('isActive', '==', true),
)
return onSnapshot(q, (snap) => {
const out: Record<string, EligibleResponder> = {}
snap.docs.forEach((d) => {
const data = d.data()
out[d.id] = {
uid: d.id,
displayName: String(data.displayName ?? d.id),
agencyId: String(data.agencyId ?? 'unknown'),
}
})
setResponders(out)
})
}, [municipalityId])

useEffect(() => {
if (!municipalityId) return
const rtdb = getDatabase(firebaseApp)
const node = ref(rtdb, `/responder_index/${municipalityId}`)
const unsub = onValue(node, (s) => {
const snapVal = s.val()
setShift(snapVal !== null ? (snapVal as Record<string, { isOnShift: boolean }>) : {})
})
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
}
67 changes: 67 additions & 0 deletions apps/admin-desktop/src/pages/DispatchModal.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div role="dialog" aria-modal="true">
<h2>Dispatch a responder</h2>
{eligible.length === 0 ? (
<p>No responders on shift in your municipality.</p>
) : (
<ul>
{eligible.map((r) => (
<li key={r.uid}>
<label>
<input
type="radio"
name="responder"
value={r.uid}
checked={picked === r.uid}
onChange={() => {
setPicked(r.uid)
}}
/>
{r.displayName} · {r.agencyId}
</label>
</li>
))}
</ul>
)}
<button disabled={!picked || submitting} onClick={() => void confirm()}>
{submitting ? 'Dispatching…' : 'Confirm'}
</button>
<button onClick={onClose}>Cancel</button>
</div>
)
}
40 changes: 40 additions & 0 deletions apps/admin-desktop/src/services/callables.ts
Original file line number Diff line number Diff line change
@@ -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<typeof payload, { status: string; reportId: string }>(
functions,
'verifyReport',
)(payload).then((r) => r.data),
rejectReport: (payload: {
reportId: string
reason: 'obviously_false' | 'duplicate' | 'test_submission' | 'insufficient_detail'
notes?: string
idempotencyKey: IdempotencyKey
}) =>
httpsCallable<typeof payload, { status: string; reportId: string }>(
functions,
'rejectReport',
)(payload).then((r) => r.data),
dispatchResponder: (payload: {
reportId: string
responderUid: string
idempotencyKey: IdempotencyKey
}) =>
httpsCallable<typeof payload, { dispatchId: string; status: string; reportId: string }>(
functions,
'dispatchResponder',
)(payload).then((r) => r.data),
cancelDispatch: (payload: {
dispatchId: string
reason: 'responder_unavailable' | 'duplicate_report' | 'admin_error' | 'citizen_withdrew'
idempotencyKey: IdempotencyKey
}) =>
httpsCallable<typeof payload, { status: string; dispatchId: string }>(
functions,
'cancelDispatch',
)(payload).then((r) => r.data),
}
1 change: 1 addition & 0 deletions apps/responder-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
70 changes: 70 additions & 0 deletions functions/src/__tests__/services/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
38 changes: 38 additions & 0 deletions functions/src/services/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -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<RateLimitResult> {
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 }
})
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.