Skip to content
Merged
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
3ba0a5c
docs(runbooks): add FCM VAPID key rotation runbook
claude Apr 19, 2026
7a30d78
docs(phase-3c): add verification guide and session summary
claude Apr 19, 2026
d19eac3
docs(phase-3c): add implementation readiness summary
claude Apr 19, 2026
400f14b
feat(state-machines): extend dispatch transitions for en_route/on_scene
claude Apr 19, 2026
657501c
feat(state-machines): add dispatchToReportState translation helper
claude Apr 19, 2026
151b41e
feat(functions): scaffold acceptDispatch request schema
claude Apr 19, 2026
6a3e7f1
feat(functions): add acceptDispatch callable with race-safe transaction
claude Apr 19, 2026
4edad12
feat(functions): enforce acceptDispatch rate limit (30/min per respon…
claude Apr 19, 2026
9706ec7
fix(functions): align acceptDispatch rate limit key with spec (accept…
claude Apr 19, 2026
2ef0e7c
feat(functions): widen cancelDispatch to cover accepted through on_scene
claude Apr 19, 2026
7304966
test(functions): cancelDispatch rejects cancel on terminal states
claude Apr 19, 2026
84c378c
feat(functions): scaffold closeReport request schema
claude Apr 19, 2026
7d64b7e
test(close-report): add non-UUID and whitespace-only rejection tests
claude Apr 19, 2026
341d219
feat(functions): add closeReport callable (resolved → closed)
claude Apr 19, 2026
7c91709
fix(close-report): validate municipalityId claim before calling close…
claude Apr 19, 2026
87d0664
feat(functions): scaffold dispatchMirrorToReport with pure computeMir…
claude Apr 19, 2026
dc599a0
feat(functions): implement dispatchMirrorToReport trigger with transa…
claude Apr 19, 2026
ad38044
feat(firestore-rules): responder direct-write rules for dispatch stat…
claude Apr 19, 2026
e442856
test(firestore-rules): pin responder-cannot-write reports.status inva…
claude Apr 19, 2026
dae0046
feat(responder-app): add useDispatch hook and DispatchDetailPage skel…
claude Apr 19, 2026
7e2143a
feat(responder-app): accept-dispatch flow with idempotency
claude Apr 19, 2026
c0e748c
feat(responder-app): progression buttons for acknowledged→en_route→on…
claude Apr 19, 2026
3381f9d
feat(responder-app): CancelledScreen + race-loss re-fetch UX
claude Apr 19, 2026
84c57de
fix(responder-app): remove dead guard and incorrect cancelledBy cast …
claude Apr 19, 2026
c37f4de
feat(responder-app): wire list→detail navigation (Task 19)
claude Apr 19, 2026
6992771
feat(responder-app): FCM service worker + token registration (Task 24)
claude Apr 19, 2026
4c5f70a
feat(functions): FCM push on dispatch with token cleanup
claude Apr 19, 2026
c43cd9b
feat(admin-desktop): add CloseReportModal and Close button
claude Apr 19, 2026
4eb2bbe
fix(code-review): address review findings from PR review
claude Apr 19, 2026
519a084
fix(validators): remove stale in_progress from dispatch state machine
claude Apr 19, 2026
885662b
feat(e2e): scaffold Playwright e2e test workspace
claude Apr 19, 2026
2b67514
test(e2e): citizen.spec.ts with form rendering and lookup flow
claude Apr 19, 2026
51375b7
test(e2e): admin.spec.ts — all skipped, SSL cert blocker
claude Apr 19, 2026
4735dc1
test(e2e): responder.spec.ts — all skipped, SSL cert + Firebase init …
claude Apr 19, 2026
fc975b0
test(e2e): full-loop.spec.ts + race-loss.spec.ts — skeleton tests
claude Apr 19, 2026
717ebda
test(e2e): phase-3c acceptance gate for responder loop
claude Apr 19, 2026
bfb66c0
docs: document Phase 3c completion in progress.md
claude Apr 19, 2026
a04864d
fix(callables): add advanceDispatch callable, fix untested NOT_FOUND …
claude Apr 19, 2026
e786e17
fix: resolve TypeScript build errors and lint issues for Phase 3c PR
claude Apr 19, 2026
abb0549
Potential fix for pull request finding 'CodeQL / Unused variable, imp…
Exc1D Apr 19, 2026
0319d45
Potential fix for pull request finding 'CodeQL / Unused variable, imp…
Exc1D Apr 19, 2026
89d83e6
Potential fix for pull request finding 'CodeQL / Unused variable, imp…
Exc1D Apr 19, 2026
bc3b31a
fix: address all PR #46 CodeRabbit review comments
claude Apr 19, 2026
ae54957
fix: attach eslint-disable to mockReturnValue call; regenerate firest…
claude Apr 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions apps/admin-desktop/src/pages/CancelDispatchModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { useState } from 'react'
import { callables } from '../services/callables'

type CancelReason =
| 'responder_unavailable'
| 'duplicate_report'
| 'admin_error'
| 'citizen_withdrew'

const REASONS: CancelReason[] = [
'responder_unavailable',
'duplicate_report',
'admin_error',
'citizen_withdrew',
]

export function CancelDispatchModal({
dispatchId,
currentStatus,
onClose,
onError,
}: {
dispatchId: string
currentStatus: string
onClose: () => void
onError: (msg: string) => void
}) {
const [reason, setReason] = useState<CancelReason>('admin_error')
const [submitting, setSubmitting] = useState(false)

async function confirm() {
setSubmitting(true)
try {
await callables.cancelDispatch({
dispatchId,
reason,
idempotencyKey: crypto.randomUUID(),
})
onClose()
} catch (err: unknown) {
onError(err instanceof Error ? err.message : 'Cancel failed')
setSubmitting(false)
}
}

const cancellable =
currentStatus === 'pending' ||
currentStatus === 'accepted' ||
currentStatus === 'acknowledged' ||
currentStatus === 'en_route' ||
currentStatus === 'on_scene'

return (
<div role="dialog" aria-modal="true">
<h2>Cancel Dispatch</h2>
<p>This will revert the report back to verified status. The responder will be notified.</p>
{cancellable ? (
<>
<fieldset>
<legend>Reason</legend>
{REASONS.map((r) => (
<label key={r}>
<input
type="radio"
name="reason"
value={r}
checked={reason === r}
onChange={() => {
setReason(r)
}}
/>
{r}
</label>
))}
Comment on lines +61 to +74
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider humanizing reason labels for better UX.

The radio button labels display raw enum values (e.g., responder_unavailable), which aren't user-friendly. Consider adding a display label:

const REASON_LABELS: Record<CancelReason, string> = {
  responder_unavailable: 'Responder unavailable',
  duplicate_report: 'Duplicate report',
  admin_error: 'Administrative error',
  citizen_withdrew: 'Citizen withdrew report',
}

// In JSX:
<label key={r}>
  <input ... />
  {REASON_LABELS[r]}
</label>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-desktop/src/pages/CancelDispatchModal.tsx` around lines 61 - 74,
The radio labels currently render raw enum values from REASONS (e.g.,
responder_unavailable); create a mapping from CancelReason to a human-friendly
string (e.g., REASON_LABELS: Record<CancelReason,string>) and use that mapping
when rendering the label text instead of the raw value in the JSX where
REASONS.map is used; keep the existing input props (name, value, checked,
onChange calling setReason) and reference the mapped string (REASON_LABELS[r])
for display so accessibility and state handling (reason, setReason) remain
unchanged.

</fieldset>
<button disabled={submitting} onClick={() => void confirm()}>
{submitting ? 'Cancelling…' : 'Cancel Dispatch'}
</button>
</>
) : (
<p>
Dispatch in <strong>{currentStatus}</strong> cannot be cancelled (only{' '}
{REASONS.slice(0, -1).join(', ')} or {REASONS[REASONS.length - 1]} from{' '}
pending/accepted/acknowledged/en_route/on_scene).
</p>
)}
Comment on lines +80 to +86
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Confusing error message: shows cancellation reasons instead of allowed statuses.

The explanatory message incorrectly references REASONS (cancellation reasons like responder_unavailable) when it should reference the cancellable statuses:

// Current (confusing):
{REASONS.slice(0, -1).join(', ')} or {REASONS[REASONS.length - 1]} from pending/...

// Shows: "responder_unavailable, duplicate_report, admin_error or citizen_withdrew from pending/..."
🐛 Proposed fix
       ) : (
         <p>
-          Dispatch in <strong>{currentStatus}</strong> cannot be cancelled (only{' '}
-          {REASONS.slice(0, -1).join(', ')} or {REASONS[REASONS.length - 1]} from{' '}
-          pending/accepted/acknowledged/en_route/on_scene).
+          Dispatch in <strong>{currentStatus}</strong> status cannot be cancelled.
+          Cancellation is only available for dispatches in pending, accepted,
+          acknowledged, en_route, or on_scene status.
         </p>
       )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-desktop/src/pages/CancelDispatchModal.tsx` around lines 80 - 86,
The message in CancelDispatchModal.tsx incorrectly displays REASONS
(cancellation reasons) instead of the list of statuses from which a dispatch can
be cancelled; update the JSX in the render (the paragraph near currentStatus) to
use the correct cancellable-status list (e.g., CANCELLABLE_STATUSES or a new
constant like ['pending','accepted','acknowledged','en_route','on_scene'])
instead of REASONS, and join them the same way
({CANCELLABLE_STATUSES.slice(0,-1).join(', ')} or
{CANCELLABLE_STATUSES[CANCELLABLE_STATUSES.length-1]}) so the message reads
“…only X, Y or Z from pending/accepted/acknowledged/en_route/on_scene.” Ensure
the constant name you choose is added/ exported where appropriate and used in
the CancelDispatchModal component.

<button onClick={onClose}>Keep Dispatch</button>
</div>
)
}
56 changes: 56 additions & 0 deletions apps/admin-desktop/src/pages/CloseReportModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useState } from 'react'
import { callables } from '../services/callables'

export function CloseReportModal({
reportId,
onClose,
onError,
}: {
reportId: string
onClose: () => void
onError: (msg: string) => void
}) {
const [summary, setSummary] = useState('')
const [submitting, setSubmitting] = useState(false)

async function confirm() {
setSubmitting(true)
try {
const trimmed = summary.trim()
await callables.closeReport({
reportId,
idempotencyKey: crypto.randomUUID(),
...(trimmed ? { closureSummary: trimmed } : {}),
})
onClose()
} catch (err: unknown) {
onError(err instanceof Error ? err.message : 'Close failed')
setSubmitting(false)
}
Comment on lines +16 to +29
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use a stable idempotency key per modal session and reset state in finally.

Generating a new key on every submit (Line 22) makes retries non-idempotent from the client perspective. Also, setSubmitting(false) should be in finally for consistent UI recovery.

Suggested fix
-import { useState } from 'react'
+import { useRef, useState } from 'react'
@@
   const [summary, setSummary] = useState('')
   const [submitting, setSubmitting] = useState(false)
+  const idempotencyKeyRef = useRef(crypto.randomUUID())
@@
       await callables.closeReport({
         reportId,
-        idempotencyKey: crypto.randomUUID(),
+        idempotencyKey: idempotencyKeyRef.current,
         ...(trimmed ? { closureSummary: trimmed } : {}),
       })
       onClose()
     } catch (err: unknown) {
       onError(err instanceof Error ? err.message : 'Close failed')
+    } finally {
       setSubmitting(false)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-desktop/src/pages/CloseReportModal.tsx` around lines 16 - 29, The
confirm() handler generates a new idempotencyKey on every submit and only clears
submitting on error; change to use a stable per-modal-session idempotency key
(e.g., create an idempotencyKey via useRef or useState when the modal opens and
pass that into callables.closeReport as idempotencyKey) so retries remain
idempotent, and move setSubmitting(false) into a finally block (and reset/rotate
the idempotency key when the modal is closed or reopened) to guarantee UI state
is restored regardless of success or failure.

}

return (
<div role="dialog" aria-modal="true">
<h2>Close Report</h2>
<p>
This will archive the report. Only close a report after the incident has been fully resolved
by responders.
</p>
<label>
Closure summary (optional)
<textarea
value={summary}
onChange={(e) => {
setSummary(e.target.value)
}}
maxLength={2000}
rows={3}
/>
</label>
<button disabled={submitting} onClick={() => void confirm()}>
{submitting ? 'Closing…' : 'Close Report'}
</button>
<button onClick={onClose}>Cancel</button>
</div>
)
}
11 changes: 11 additions & 0 deletions apps/admin-desktop/src/pages/ReportDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ export function ReportDetailPanel({
onVerify,
onReject,
onDispatch,
onClose,
}: {
reportId: string
onVerify: (reportId: string) => void
onReject: (reportId: string) => void
onDispatch: (reportId: string) => void
onClose: (reportId: string) => void
}) {
const { report, ops, error } = useReportDetail(reportId)
if (error) return <aside role="alert">Error loading report: {error}</aside>
Expand All @@ -18,6 +20,7 @@ export function ReportDetailPanel({
const canVerify = report.status === 'new' || report.status === 'awaiting_verify'
const canReject = report.status === 'awaiting_verify'
const canDispatch = report.status === 'verified'
const canClose = report.status === 'resolved'

return (
<aside>
Expand Down Expand Up @@ -63,6 +66,14 @@ export function ReportDetailPanel({
>
Dispatch
</button>
<button
disabled={!canClose}
onClick={() => {
onClose(reportId)
}}
>
Close
</button>
</div>
</aside>
)
Expand Down
14 changes: 14 additions & 0 deletions apps/admin-desktop/src/pages/TriageQueuePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import { useAuth } from '../app/auth-provider'
import { useMuniReports } from '../hooks/useMuniReports'
import { ReportDetailPanel } from './ReportDetailPanel'
import { DispatchModal } from './DispatchModal'
import { CloseReportModal } from './CloseReportModal'
import { callables } from '../services/callables'

export function TriageQueuePage() {
const { claims, signOut } = useAuth()
const { rows, loading, error } = useMuniReports(claims?.municipalityId)
const [selected, setSelected] = useState<string | null>(null)
const [dispatchForReportId, setDispatchForReportId] = useState<string | null>(null)
const [closeForReportId, setCloseForReportId] = useState<string | null>(null)
const [banner, setBanner] = useState<string | null>(null)

const handleVerify = (reportId: string) => {
Expand Down Expand Up @@ -83,6 +85,7 @@ export function TriageQueuePage() {
onVerify={handleVerify}
onReject={handleReject}
onDispatch={setDispatchForReportId}
onClose={setCloseForReportId}
/>
)}
</section>
Expand All @@ -97,6 +100,17 @@ export function TriageQueuePage() {
}}
/>
)}
{closeForReportId && (
<CloseReportModal
reportId={closeForReportId}
onClose={() => {
setCloseForReportId(null)
}}
onError={(msg: string) => {
setBanner(msg)
}}
/>
)}
</main>
)
}
9 changes: 9 additions & 0 deletions apps/admin-desktop/src/services/callables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,13 @@ export const callables = {
functions,
'cancelDispatch',
)(payload).then((r) => r.data),
closeReport: (payload: {
reportId: string
idempotencyKey: IdempotencyKey
closureSummary?: string
}) =>
httpsCallable<typeof payload, { status: ReportStatus; reportId: string }>(
functions,
'closeReport',
)(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 @@ -14,6 +14,7 @@
"dependencies": {
"@bantayog/shared-types": "workspace:*",
"@bantayog/shared-ui": "workspace:*",
"@bantayog/shared-validators": "workspace:*",
"@capacitor/core": "^8.3.1",
"firebase": "^12.12.0",
"react": "^19.2.5",
Expand Down
58 changes: 58 additions & 0 deletions apps/responder-app/public/firebase-messaging-sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* firebase-messaging-sw.js
*
* Firebase Cloud Messaging service worker.
* Handles background push notifications when the app is not in focus.
* Placed in /public so it is served at the root scope (/).
*/

import { initializeApp } from 'firebase/app'
import { getMessaging, onBackgroundMessage } from 'firebase/messaging/sw'

// Injected by Vite at build time.
const {
VITE_FIREBASE_API_KEY: apiKey,
VITE_FIREBASE_AUTH_DOMAIN: authDomain,
VITE_FIREBASE_PROJECT_ID: projectId,
VITE_FIREBASE_STORAGE_BUCKET: storageBucket,
VITE_FIREBASE_MESSAGING_SENDER_ID: messagingSenderId,
VITE_FIREBASE_APP_ID: appId,
} = import.meta.env

let messaging = null

try {
const app = initializeApp({
apiKey,
authDomain,
projectId,
storageBucket,
messagingSenderId,
appId,
})
messaging = getMessaging(app)
} catch {
// Config may not be available at SW load time in some environments.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

if (messaging) {
onBackgroundMessage(messaging, (payload) => {
const title = payload.notification?.title ?? 'New Dispatch'
const body = payload.notification?.body ?? 'Open the app for details'
const dispatchId = payload.data?.dispatchId

self.registration.showNotification(title, {
body,
data: payload.data,
icon: '/favicon.svg',
tag: dispatchId ? `dispatch-${dispatchId}` : 'dispatch',
})
})

self.addEventListener('notificationclick', (event) => {
event.notification.close()
const dispatchId = event.notification.data?.dispatchId
const target = dispatchId ? `/dispatches/${dispatchId}` : '/'
event.waitUntil(self.clients.openWindow(target))
})
}
29 changes: 28 additions & 1 deletion apps/responder-app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,33 @@
import './App.module.css'
import { useEffect } from 'react'
import { AppRouter } from './routes'
import { useAuth } from './app/auth-provider'
import { useRegisterFcmToken } from './hooks/useRegisterFcmToken'

function FcmSetup() {
const { user } = useAuth()
const { register } = useRegisterFcmToken({
responderDocPath: user ? `responders/${user.uid}` : '',
})

useEffect(() => {
if (!user) return
navigator.serviceWorker
.register('/firebase-messaging-sw.js')
.then(() => register())
.catch(() => {
// SW registration failure is non-fatal — app still works.
})
}, [user, register])
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return null
}

export default function App() {
return <AppRouter />
return (
<>
<FcmSetup />
<AppRouter />
</>
)
}
29 changes: 29 additions & 0 deletions apps/responder-app/src/hooks/useAcceptDispatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useRef, useState } from 'react'
import { httpsCallable } from 'firebase/functions'
import { functions } from '../app/firebase'

export function useAcceptDispatch(dispatchId: string) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | undefined>()
// Idempotency key generated once per hook mount — equivalent to 30-second client memory
const keyRef = useRef(crypto.randomUUID())
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async function accept() {
setLoading(true)
setError(undefined)
try {
const fn = httpsCallable<{ dispatchId: string; idempotencyKey: string }, { status: string }>(
functions,
'acceptDispatch',
)
await fn({ dispatchId, idempotencyKey: keyRef.current })
} catch (err: unknown) {
if (err instanceof Error) setError(err)
else setError(new Error(String(err)))
} finally {
setLoading(false)
}
}

return { accept, loading, error }
}
Loading
Loading