-
Notifications
You must be signed in to change notification settings - Fork 0
feat(phase-3c): responder loop end-to-end #46
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 39 commits
3ba0a5c
7a30d78
d19eac3
400f14b
657501c
151b41e
6a3e7f1
4edad12
9706ec7
2ef0e7c
7304966
84c378c
7d64b7e
341d219
7c91709
87d0664
dc599a0
ad38044
e442856
dae0046
7e2143a
c0e748c
3381f9d
84c57de
c37f4de
6992771
4c5f70a
c43cd9b
4eb2bbe
519a084
885662b
2b67514
51375b7
4735dc1
fc975b0
717ebda
bfb66c0
a04864d
e786e17
abb0549
0319d45
89d83e6
bc3b31a
ae54957
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> | ||
| ))} | ||
| </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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confusing error message: shows cancellation reasons instead of allowed statuses. The explanatory message incorrectly references // 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 |
||
| <button onClick={onClose}>Keep Dispatch</button> | ||
| </div> | ||
| ) | ||
| } | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use a stable idempotency key per modal session and reset state in Generating a new key on every submit (Line 22) makes retries non-idempotent from the client perspective. Also, 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 |
||
| } | ||
|
|
||
| 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> | ||
| ) | ||
| } | ||
| 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. | ||
| } | ||
|
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)) | ||
| }) | ||
| } | ||
| 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]) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| return null | ||
| } | ||
|
|
||
| export default function App() { | ||
| return <AppRouter /> | ||
| return ( | ||
| <> | ||
| <FcmSetup /> | ||
| <AppRouter /> | ||
| </> | ||
| ) | ||
| } | ||
| 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()) | ||
|
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 } | ||
| } | ||
There was a problem hiding this comment.
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:🤖 Prompt for AI Agents