diff --git a/.gitignore b/.gitignore index c65dc6ff..ed941d1c 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,5 @@ Thumbs.db # Worktrees .worktrees/ worktrees/ +!apps/responder-app/ios/ +!apps/responder-app/android/ diff --git a/apps/admin-desktop/src/hooks/useAgencyAssistanceQueue.ts b/apps/admin-desktop/src/hooks/useAgencyAssistanceQueue.ts new file mode 100644 index 00000000..ec752ab7 --- /dev/null +++ b/apps/admin-desktop/src/hooks/useAgencyAssistanceQueue.ts @@ -0,0 +1,150 @@ +import { useEffect, useState } from 'react' +import { collection, onSnapshot, query, where } from 'firebase/firestore' +import { db } from '../app/firebase' +import { + agencyAssistanceRequestDocSchema, + logDimension, + type AgencyAssistanceRequestDoc, +} from '@bantayog/shared-validators' + +const log = logDimension('useAgencyAssistanceQueue') + +export interface BackupRequest { + id: string + reportId: string + municipalityId: string + reason: string + status: 'pending' | 'accepted' | 'declined' | 'fulfilled' | 'expired' + agencyId: string + createdAt: number +} + +const VALID_BACKUP_STATUSES: BackupRequest['status'][] = [ + 'pending', + 'accepted', + 'declined', + 'fulfilled', + 'expired', +] + +function parseBackupRequest(id: string, raw: Record): BackupRequest | null { + if ( + typeof raw.reportId !== 'string' || + typeof raw.reason !== 'string' || + typeof raw.agencyId !== 'string' || + typeof raw.createdAt !== 'number' + ) { + log({ + severity: 'WARNING', + code: 'backup_request.invalid', + message: `Invalid backup_request ${id}`, + data: {}, + }) + return null + } + return { + id, + reportId: raw.reportId, + municipalityId: typeof raw.municipalityId === 'string' ? raw.municipalityId : '', + reason: raw.reason, + status: + typeof raw.status === 'string' && + VALID_BACKUP_STATUSES.includes(raw.status as BackupRequest['status']) + ? (raw.status as BackupRequest['status']) + : 'pending', + agencyId: raw.agencyId, + createdAt: raw.createdAt, + } +} + +export function useAgencyAssistanceQueue(agencyId: string | undefined) { + const [requests, setRequests] = useState<(AgencyAssistanceRequestDoc & { id: string })[]>([]) + const [backupRequests, setBackupRequests] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [requestsReady, setRequestsReady] = useState(false) + const [backupReady, setBackupReady] = useState(false) + + useEffect(() => { + queueMicrotask(() => { + setError(null) + setRequests([]) + setBackupRequests([]) + setRequestsReady(false) + setBackupReady(false) + }) + + if (!agencyId) { + queueMicrotask(() => { + setLoading(false) + }) + return + } + + queueMicrotask(() => { + setLoading(true) + }) + + const q = query( + collection(db, 'agency_assistance_requests'), + where('targetAgencyId', '==', agencyId), + ) + + const unsubscribe = onSnapshot( + q, + (snapshot) => { + const parsed = snapshot.docs.flatMap((doc) => { + const result = agencyAssistanceRequestDocSchema.safeParse(doc.data()) + if (!result.success) { + log({ + severity: 'WARNING', + code: 'agency_assistance.invalid', + message: `Doc ${doc.id} failed schema`, + data: {}, + }) + return [] + } + return [{ ...result.data, id: doc.id }] + }) + setRequests(parsed) + setRequestsReady(true) + }, + (err) => { + setError(err.message) + setRequestsReady(true) + }, + ) + + const backupQ = query(collection(db, 'backup_requests'), where('agencyId', '==', agencyId)) + + const unsubscribeBackup = onSnapshot( + backupQ, + (snapshot) => { + const parsed = snapshot.docs.flatMap((doc) => { + const req = parseBackupRequest(doc.id, doc.data() as Record) + return req ? [req] : [] + }) + setBackupRequests(parsed) + setBackupReady(true) + }, + (err) => { + setError(err.message) + setBackupReady(true) + }, + ) + + return () => { + unsubscribe() + unsubscribeBackup() + } + }, [agencyId]) + + useEffect(() => { + if (requestsReady && backupReady) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setLoading(false) + } + }, [requestsReady, backupReady]) + + return { requests, backupRequests, loading, error } +} diff --git a/apps/admin-desktop/src/hooks/useEligibleResponders.ts b/apps/admin-desktop/src/hooks/useEligibleResponders.ts index 39828cae..c438259a 100644 --- a/apps/admin-desktop/src/hooks/useEligibleResponders.ts +++ b/apps/admin-desktop/src/hooks/useEligibleResponders.ts @@ -1,23 +1,22 @@ 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 + availabilityStatus: string + lastTelemetryAt: number | null } export function useEligibleResponders(municipalityId: string | undefined) { - const [responders, setResponders] = useState>({}) - const [shift, setShift] = useState>({}) + const [responders, setResponders] = useState([]) useEffect(() => { if (!municipalityId) { queueMicrotask(() => { - setResponders({}) + setResponders([]) }) return } @@ -27,37 +26,33 @@ export function useEligibleResponders(municipalityId: string | undefined) { 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) { - queueMicrotask(() => { - 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) : {}) + const eligible = snap.docs + .map((d) => { + const data = d.data() + const lastTelemetryAt = + typeof data.lastTelemetryAt === 'number' ? data.lastTelemetryAt : null + return { + uid: d.id, + displayName: String(data.displayName ?? d.id), + agencyId: String(data.agencyId ?? 'unknown'), + availabilityStatus: String(data.availabilityStatus ?? 'unknown'), + lastTelemetryAt, + } + }) + .filter((r) => r.availabilityStatus !== 'off_duty') + .sort((a, b) => { + const aAvailable = a.availabilityStatus === 'available' ? 0 : 1 + const bAvailable = b.availabilityStatus === 'available' ? 0 : 1 + if (aAvailable !== bAvailable) return aAvailable - bAvailable + // More recent telemetry first (nulls last) + const aTime = a.lastTelemetryAt ?? 0 + const bTime = b.lastTelemetryAt ?? 0 + if (aTime !== bTime) return bTime - aTime + return a.displayName.localeCompare(b.displayName) + }) + setResponders(eligible) }) - 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 + return responders } diff --git a/apps/admin-desktop/src/hooks/useRosterManagement.ts b/apps/admin-desktop/src/hooks/useRosterManagement.ts new file mode 100644 index 00000000..ce933ab6 --- /dev/null +++ b/apps/admin-desktop/src/hooks/useRosterManagement.ts @@ -0,0 +1,85 @@ +import { useEffect, useState } from 'react' +import { collection, onSnapshot, query, where } from 'firebase/firestore' +import { db } from '../app/firebase' +import { callables } from '../services/callables' + +export interface RosterResponder { + uid: string + displayName: string + availabilityStatus: string + lastTelemetryAt: number | null + municipalityId: string +} + +export function useRosterManagement(agencyId: string | undefined) { + const [responders, setResponders] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + queueMicrotask(() => { + setResponders([]) + setError(null) + }) + + if (!agencyId) { + queueMicrotask(() => { + setLoading(false) + }) + return + } + + queueMicrotask(() => { + setLoading(true) + }) + + const q = query(collection(db, 'responders'), where('agencyId', '==', agencyId)) + + const unsubscribe = onSnapshot( + q, + (snapshot) => { + const docs: RosterResponder[] = snapshot.docs.map((d) => { + const data = d.data() + return { + uid: d.id, + displayName: String(data.displayName ?? d.id), + availabilityStatus: String(data.availabilityStatus ?? 'unknown'), + lastTelemetryAt: typeof data.lastTelemetryAt === 'number' ? data.lastTelemetryAt : null, + municipalityId: String(data.municipalityId ?? ''), + } + }) + setResponders(docs) + setLoading(false) + }, + (err) => { + setError(err.message) + setLoading(false) + }, + ) + + return () => { + unsubscribe() + } + }, [agencyId]) + + const suspendResponder = async (uid: string) => { + await callables.suspendResponder({ uid, idempotencyKey: crypto.randomUUID() }) + } + + const revokeResponder = async (uid: string) => { + await callables.revokeResponder({ uid, idempotencyKey: crypto.randomUUID() }) + } + + const bulkAvailabilityOverride = async ( + uids: string[], + status: 'available' | 'unavailable' | 'off_duty', + ) => { + await callables.bulkAvailabilityOverride({ + uids, + status, + idempotencyKey: crypto.randomUUID(), + }) + } + + return { responders, loading, error, suspendResponder, revokeResponder, bulkAvailabilityOverride } +} diff --git a/apps/admin-desktop/src/pages/AgencyAssistanceQueuePage.test.tsx b/apps/admin-desktop/src/pages/AgencyAssistanceQueuePage.test.tsx index 041f843d..09065869 100644 --- a/apps/admin-desktop/src/pages/AgencyAssistanceQueuePage.test.tsx +++ b/apps/admin-desktop/src/pages/AgencyAssistanceQueuePage.test.tsx @@ -3,20 +3,17 @@ import '@testing-library/jest-dom' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { AgencyAssistanceQueuePage } from './AgencyAssistanceQueuePage.js' -const { mockOnSnapshot, mockCallable, mockHttpsCallable } = vi.hoisted(() => { +const { mockUseAgencyAssistanceQueue, mockCallable, mockHttpsCallable } = vi.hoisted(() => { return { - mockOnSnapshot: vi.fn(), + mockUseAgencyAssistanceQueue: vi.fn(), mockCallable: vi.fn(), mockHttpsCallable: vi.fn(() => mockCallable), } }) -vi.mock('firebase/firestore', () => ({ - collection: vi.fn(), - query: vi.fn(), - where: vi.fn(), - onSnapshot: mockOnSnapshot, - getFirestore: vi.fn(), +vi.mock('../hooks/useAgencyAssistanceQueue', () => ({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + useAgencyAssistanceQueue: (...args: unknown[]) => mockUseAgencyAssistanceQueue(...args), })) vi.mock('firebase/functions', () => ({ @@ -39,21 +36,21 @@ vi.mock('../app/firebase', () => ({ const pendingRequest = { id: 'ar1', - data: () => ({ - reportId: 'r1', - requestedByMunicipality: 'Daet', - message: 'Need BFP assistance', - priority: 'urgent', - status: 'pending', - targetAgencyId: 'bfp', - createdAt: 1713350400000, - }), + reportId: 'r1', + requestedByMunicipality: 'Daet', + message: 'Need BFP assistance', + priority: 'urgent' as const, + status: 'pending' as const, + targetAgencyId: 'bfp', + createdAt: 1713350400000, } beforeEach(() => { - mockOnSnapshot.mockImplementation((_q, cb) => { - cb({ docs: [pendingRequest] }) - return vi.fn() // unsubscribe + mockUseAgencyAssistanceQueue.mockReturnValue({ + requests: [pendingRequest], + backupRequests: [], + loading: false, + error: null, }) mockCallable.mockResolvedValue({ data: { status: 'accepted' } }) }) @@ -66,7 +63,6 @@ describe('AgencyAssistanceQueuePage', () => { it('shows Accept and Decline buttons on pending requests', () => { render() - // Use getAllByRole and pick the ones with exact text (action buttons) const acceptButtons = screen.getAllByRole('button', { name: /^Accept$/ }) const declineButtons = screen.getAllByRole('button', { name: /^Decline$/ }) expect(acceptButtons).toHaveLength(1) diff --git a/apps/admin-desktop/src/pages/AgencyAssistanceQueuePage.tsx b/apps/admin-desktop/src/pages/AgencyAssistanceQueuePage.tsx index c5a442aa..96f1dc0e 100644 --- a/apps/admin-desktop/src/pages/AgencyAssistanceQueuePage.tsx +++ b/apps/admin-desktop/src/pages/AgencyAssistanceQueuePage.tsx @@ -1,19 +1,7 @@ -import { useState, useEffect } from 'react' -import { collection, query, where, onSnapshot, type Unsubscribe } from 'firebase/firestore' -import { db } from '../app/firebase' +import { useState } from 'react' import { callables } from '../services/callables' import { useAuth } from '@bantayog/shared-ui' - -interface AgencyAssistanceRequest { - id: string - reportId: string - requestedByMunicipality: string - message: string - priority: 'urgent' | 'normal' - status: 'pending' | 'accepted' | 'declined' | 'fulfilled' | 'expired' - targetAgencyId: string - createdAt: number -} +import { useAgencyAssistanceQueue } from '../hooks/useAgencyAssistanceQueue' type FilterTab = 'pending' | 'accepted' | 'all' @@ -25,64 +13,23 @@ interface DeclineState { export function AgencyAssistanceQueuePage() { const { claims } = useAuth() const agencyId = typeof claims?.agencyId === 'string' ? claims.agencyId : undefined - const [requests, setRequests] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const { requests, backupRequests, loading, error } = useAgencyAssistanceQueue(agencyId) const [filter, setFilter] = useState('pending') const [declineState, setDeclineState] = useState(null) const [banner, setBanner] = useState(null) - useEffect(() => { - queueMicrotask(() => { - setRequests([]) - setError(null) - setDeclineState(null) - setBanner(null) - }) - - if (!agencyId) { - queueMicrotask(() => { - setLoading(false) - }) - return - } - - queueMicrotask(() => { - setLoading(true) - }) - - const q = query( - collection(db, 'agency_assistance_requests'), - where('targetAgencyId', '==', agencyId), - ) - - const unsubscribe: Unsubscribe = onSnapshot( - q, - (snapshot) => { - const docs: AgencyAssistanceRequest[] = snapshot.docs.map((doc) => ({ - id: doc.id, - ...doc.data(), - })) as AgencyAssistanceRequest[] - setRequests(docs) - setLoading(false) - }, - (err) => { - setError(err.message) - setLoading(false) - }, - ) - - return () => { - unsubscribe() - } - }, [agencyId]) - const filteredRequests = requests.filter((r) => { if (filter === 'pending') return r.status === 'pending' if (filter === 'accepted') return r.status === 'accepted' return true }) + const filteredBackupRequests = backupRequests.filter((r) => { + if (filter === 'pending') return r.status === 'pending' + if (filter === 'accepted') return r.status === 'accepted' + return true + }) + const handleAccept = (requestId: string) => { void (async () => { try { @@ -122,6 +69,62 @@ export function AgencyAssistanceQueuePage() { setDeclineState(null) } + const renderRequestActions = ( + reqId: string, + status: 'pending' | 'accepted' | 'declined' | 'fulfilled' | 'expired', + kind: 'agency' | 'backup', + ) => { + if (status !== 'pending') return null + if (kind === 'backup') { + return ( +
+ + Backup request actions not yet available. + +
+ ) + } + return ( +
+ {declineState?.requestId === reqId ? ( +
+