diff --git a/apps/citizen-pwa/src/components/AlertsTab.test.tsx b/apps/citizen-pwa/src/components/AlertsTab.test.tsx index f30de0ce..709ae19f 100644 --- a/apps/citizen-pwa/src/components/AlertsTab.test.tsx +++ b/apps/citizen-pwa/src/components/AlertsTab.test.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-return */ import '@testing-library/jest-dom/vitest' import { describe, it, expect, vi } from 'vitest' import { render, screen } from '@testing-library/react' diff --git a/apps/citizen-pwa/src/components/LookupScreen.test.tsx b/apps/citizen-pwa/src/components/LookupScreen.test.tsx index c105257a..8fe6dd02 100644 --- a/apps/citizen-pwa/src/components/LookupScreen.test.tsx +++ b/apps/citizen-pwa/src/components/LookupScreen.test.tsx @@ -5,11 +5,18 @@ import userEvent from '@testing-library/user-event' import { MemoryRouter } from 'react-router-dom' const mockNavigate = vi.fn() +const { mockLoadReports } = vi.hoisted(() => ({ + mockLoadReports: vi.fn(), +})) vi.mock('react-router-dom', async () => { const actual = await vi.importActual('react-router-dom') return { ...actual, useNavigate: () => mockNavigate } }) +vi.mock('../services/localForageReports.js', () => ({ + loadReports: mockLoadReports, +})) + vi.mock('../services/firebase.js', () => ({ fns: () => ({}), hasFirebaseConfig: () => true, @@ -53,7 +60,11 @@ function renderScreen() { ) } -beforeEach(() => mockNavigate.mockReset()) +beforeEach(() => { + mockNavigate.mockReset() + mockLoadReports.mockReset().mockResolvedValue([]) + vi.mocked(httpsCallable).mockClear() +}) describe('LookupScreen', () => { it('renders a single secret code input', () => { @@ -93,6 +104,7 @@ describe('LookupScreen', () => { await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith('/reports/a1b2c3d4') }) + expect(callableSecret).toBe('MYSECRETCODE') }) it('shows friendly error when lookup returns not-found', async () => { @@ -112,4 +124,28 @@ describe('LookupScreen', () => { expect(screen.getByRole('alert')).toHaveTextContent(/couldn't find/) }) }) + + it('navigates to locally saved report when a local match exists', async () => { + mockLoadReports.mockResolvedValue([ + { + publicRef: 'loc12345', + secret: 'LOCALSECRET', + reportType: 'flood', + severity: 'high', + lat: 14.1, + lng: 122.9, + submittedAt: 1713350400000, + }, + ]) + + const user = userEvent.setup() + renderScreen() + await user.type(screen.getByPlaceholderText('Your secret code'), 'localsecret') + await user.click(screen.getByRole('button', { name: /find my report/i })) + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/reports/loc12345') + }) + expect(vi.mocked(httpsCallable)).not.toHaveBeenCalled() + }) }) diff --git a/apps/citizen-pwa/src/components/LookupScreen.tsx b/apps/citizen-pwa/src/components/LookupScreen.tsx index d0919f7b..66c10586 100644 --- a/apps/citizen-pwa/src/components/LookupScreen.tsx +++ b/apps/citizen-pwa/src/components/LookupScreen.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'react' import { httpsCallable } from 'firebase/functions' import { ArrowLeft, KeyRound } from 'lucide-react' import { useNavigate } from 'react-router-dom' +import { loadReports } from '../services/localForageReports.js' import { fns, hasFirebaseConfig, @@ -19,6 +20,10 @@ interface LookupResult { const FRIENDLY_ERROR = "We couldn't find a report with that secret code. It may have expired (reports are tracked for 90 days)." +function normalizeSecretCode(secret: string): string { + return secret.replace(/[^a-z0-9]/gi, '').toUpperCase() +} + function friendlyLookupError(err: unknown): string { if (!hasFirebaseConfig()) return FIREBASE_ENV_ERROR_MESSAGE const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : '' @@ -50,13 +55,20 @@ export function LookupScreen() { async function handleSubmit(e: React.SyntheticEvent): Promise { e.preventDefault() setError(null) - const trimmedSecret = secret.trim() + const trimmedSecret = normalizeSecretCode(secret) if (!trimmedSecret) { setError('Please enter your secret code.') return } setLoading(true) try { + const localReports = await loadReports() + const localMatch = localReports.find((report) => report.secret === trimmedSecret) + if (localMatch) { + if (!isMountedRef.current) return + void navigate(`/reports/${localMatch.publicRef}`) + return + } if (!hasFirebaseConfig()) { throw new Error(FIREBASE_ENV_ERROR_MESSAGE) } diff --git a/apps/citizen-pwa/src/components/ProfileTab.test.tsx b/apps/citizen-pwa/src/components/ProfileTab.test.tsx index ad523448..b0e27152 100644 --- a/apps/citizen-pwa/src/components/ProfileTab.test.tsx +++ b/apps/citizen-pwa/src/components/ProfileTab.test.tsx @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-empty-function, @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-empty-function */ import '@testing-library/jest-dom/vitest' import { describe, it, expect, vi } from 'vitest' import { render, screen } from '@testing-library/react' diff --git a/apps/citizen-pwa/src/hooks/useOfflineQueueCount.test.ts b/apps/citizen-pwa/src/hooks/useOfflineQueueCount.test.ts index 9e023bcb..b0d649c6 100644 --- a/apps/citizen-pwa/src/hooks/useOfflineQueueCount.test.ts +++ b/apps/citizen-pwa/src/hooks/useOfflineQueueCount.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-return */ import '@testing-library/jest-dom/vitest' import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, act } from '@testing-library/react' diff --git a/apps/citizen-pwa/src/hooks/useReport.ts b/apps/citizen-pwa/src/hooks/useReport.ts index 4896935f..084fd78d 100644 --- a/apps/citizen-pwa/src/hooks/useReport.ts +++ b/apps/citizen-pwa/src/hooks/useReport.ts @@ -74,7 +74,10 @@ export function useReport(publicRef: string) { if (snapshot.exists()) { const data = snapshot.data() try { - queryClient.setQueryData(['reports', publicRef], mapReportFromFirestore(data)) + queryClient.setQueryData( + ['reports', publicRef], + mapReportFromFirestore(data, snapshot.id), + ) } catch (err: unknown) { console.error('Report mapping error:', err instanceof Error ? err.message : err) queryClient.setQueryData(['reports', publicRef], null) @@ -118,7 +121,7 @@ export function useReport(publicRef: string) { return } try { - resolve(mapReportFromFirestore(snap.data())) + resolve(mapReportFromFirestore(snap.data(), snap.id)) } catch (err: unknown) { console.error('Report mapping error:', err instanceof Error ? err.message : err) resolve(null) diff --git a/apps/citizen-pwa/src/lib/__tests__/mappers.test.ts b/apps/citizen-pwa/src/lib/__tests__/mappers.test.ts index 51006c55..3e3c948f 100644 --- a/apps/citizen-pwa/src/lib/__tests__/mappers.test.ts +++ b/apps/citizen-pwa/src/lib/__tests__/mappers.test.ts @@ -42,10 +42,16 @@ describe('mapReportFromFirestore', () => { expect(result.location).toEqual({ address: '123 St', lat: 14.5, lng: 121.0 }) }) - it('throws when id is missing', () => { + it('uses a fallback id when the live report doc omits one', () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { id: _id, ...noId } = minimal - expect(() => mapReportFromFirestore(noId)).toThrow('missing required fields') + expect(mapReportFromFirestore(noId).id).toBe('unknown') + }) + + it('uses docId as fallback when data.id is missing', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id: _id, ...noId } = minimal + expect(mapReportFromFirestore(noId, 'doc-123').id).toBe('doc-123') }) it('throws when status is missing', () => { @@ -54,6 +60,12 @@ describe('mapReportFromFirestore', () => { expect(() => mapReportFromFirestore(noStatus)).toThrow('missing required fields') }) + it('throws when status is invalid', () => { + expect(() => + mapReportFromFirestore({ id: 'r1', status: 'bogus_status' }), + ).toThrow('missing required fields') + }) + it('throws when timeline is not an array', () => { expect(() => mapReportFromFirestore({ id: 'r1', status: 'new', timeline: 'bad' }), @@ -118,4 +130,42 @@ describe('mapReportFromFirestore', () => { expect(result.location).not.toHaveProperty('address') expect(result.location).not.toHaveProperty('lng') }) + + it('maps the live report doc shape without requiring id or timeline', () => { + const result = mapReportFromFirestore( + { + status: 'verified', + reportType: 'flood', + severity: 'medium', + publicLocation: { lat: 14.11, lng: 122.95 }, + submittedAt: 1713350400000, + updatedAt: 1713350401000, + }, + 'live-doc-1', + ) + + expect(result.id).toBe('live-doc-1') + expect(result.createdAt).toBe(1713350400000) + expect(result.location).toEqual({ lat: 14.11, lng: 122.95 }) + expect(result.timeline).toEqual([ + { event: 'new', timestamp: 1713350400000 }, + { event: 'verified', timestamp: 1713350401000 }, + ]) + }) + + it('uses lastStatusAt timestamps from live Firestore docs to build the citizen timeline', () => { + const result = mapReportFromFirestore({ + status: 'resolved', + reportType: 'flood', + publicLocation: { lat: 14.11, lng: 122.95 }, + submittedAt: 1713350400000, + lastStatusAt: { toMillis: () => 1713350500000 }, + }) + + expect(result.updatedAt).toBe(1713350500000) + expect(result.timeline).toEqual([ + { event: 'new', timestamp: 1713350400000 }, + { event: 'resolved', timestamp: 1713350500000 }, + ]) + }) }) diff --git a/apps/citizen-pwa/src/lib/mappers.ts b/apps/citizen-pwa/src/lib/mappers.ts index 77d492b1..38f841e4 100644 --- a/apps/citizen-pwa/src/lib/mappers.ts +++ b/apps/citizen-pwa/src/lib/mappers.ts @@ -1,71 +1,124 @@ import type { ReportStatus } from '@bantayog/shared-types' import type { ReportData } from '../hooks/useReport' -export function mapReportFromFirestore(data: Record): ReportData { - if (!data.id || !data.status || !Array.isArray(data.timeline)) { - throw new Error('Invalid report data: missing required fields'); +const VALID_STATUSES: Set = new Set([ + 'draft_inbox', + 'new', + 'awaiting_verify', + 'verified', + 'assigned', + 'acknowledged', + 'en_route', + 'on_scene', + 'resolved', + 'closed', + 'reopened', + 'rejected', + 'cancelled', + 'cancelled_false_report', + 'merged_as_duplicate', +]) + +function toMillis(value: unknown): number | undefined { + if (typeof value === 'number') { + return value + } + if ( + value && + typeof value === 'object' && + 'toMillis' in value && + typeof value.toMillis === 'function' + ) { + const millis = value.toMillis() + return typeof millis === 'number' ? millis : undefined + } + return undefined +} + +function mapTimelineEvent(rawEvt: unknown, index: number) { + if (!rawEvt || typeof rawEvt !== 'object' || Array.isArray(rawEvt)) { + throw new Error(`Invalid timeline event at index ${index}`) + } + const evt = rawEvt as Record + const timestamp = toMillis(evt.timestamp) + if (typeof evt.event !== 'string' || timestamp === undefined) { + throw new Error(`Invalid timeline event fields at index ${index}`) + } + return { + event: evt.event, + timestamp, + ...(typeof evt.actor === 'string' && { actor: evt.actor }), + ...(typeof evt.note === 'string' && { note: evt.note }), + } +} + +export function mapReportFromFirestore( + data: Record, + docId?: string, +): ReportData { + if (typeof data.status !== 'string' || !VALID_STATUSES.has(data.status)) { + throw new Error('Invalid report data: missing required fields') } + const status = data.status as ReportStatus + + const createdAt = toMillis(data.createdAt) ?? toMillis(data.submittedAt) + const updatedAt = toMillis(data.updatedAt) ?? toMillis(data.lastStatusAt) + if (data.timeline !== undefined && !Array.isArray(data.timeline)) { + throw new Error('Invalid report data: missing required fields') + } + const timeline = Array.isArray(data.timeline) + ? data.timeline.map(mapTimelineEvent) + : [ + ...(typeof createdAt === 'number' ? [{ event: 'new', timestamp: createdAt }] : []), + ...(data.status !== 'new' && typeof updatedAt === 'number' + ? [{ event: data.status, timestamp: updatedAt }] + : []), + ] + const result: ReportData = { - id: data.id as string, - status: data.status as ReportStatus, - timeline: (data.timeline as unknown[]).map((rawEvt, index) => { - if (!rawEvt || typeof rawEvt !== 'object' || Array.isArray(rawEvt)) { - throw new Error(`Invalid timeline event at index ${index}`) - } - const evt = rawEvt as Record - if (typeof evt.event !== 'string' || typeof evt.timestamp !== 'number') { - throw new Error(`Invalid timeline event fields at index ${index}`) - } - return { - event: evt.event, - timestamp: evt.timestamp, - ...(typeof evt.actor === 'string' && { actor: evt.actor }), - ...(typeof evt.note === 'string' && { note: evt.note }), - } - }), - }; + id: typeof data.id === 'string' ? data.id : docId ?? 'unknown', + status, + timeline, + } if (data.type !== undefined) { - result.type = data.type as string; + result.type = data.type as string } if (data.reportType !== undefined) { - result.reportType = data.reportType as string; + result.reportType = data.reportType as string } if (data.severity !== undefined) { - result.severity = data.severity as string; + result.severity = data.severity as string } - if (data.createdAt !== undefined) { - result.createdAt = data.createdAt as number; + if (createdAt !== undefined) { + result.createdAt = createdAt } - if (data.updatedAt !== undefined) { - result.updatedAt = data.updatedAt as number; + if (updatedAt !== undefined) { + result.updatedAt = updatedAt } - if ( - data.location !== undefined && - data.location !== null && - typeof data.location === 'object' && - !Array.isArray(data.location) - ) { - const loc = data.location as Record; + const rawLocation = + data.location !== undefined && data.location !== null ? data.location : data.publicLocation + if (rawLocation !== undefined && rawLocation !== null && typeof rawLocation === 'object' && !Array.isArray(rawLocation)) { + const loc = rawLocation as Record result.location = { - ...(loc.address !== undefined && { address: loc.address as string }), - ...(loc.lat !== undefined && { lat: loc.lat as number }), - ...(loc.lng !== undefined && { lng: loc.lng as number }), - }; + ...(typeof loc.address === 'string' && { address: loc.address }), + ...(typeof loc.lat === 'number' && { lat: loc.lat }), + ...(typeof loc.lng === 'number' && { lng: loc.lng }), + } } if (data.reporterName !== undefined) { - result.reporterName = data.reporterName as string; + result.reporterName = data.reporterName as string } if (data.reporterPhone !== undefined) { - result.reporterPhone = data.reporterPhone as string; + result.reporterPhone = data.reporterPhone as string } if (data.resolutionNote !== undefined) { - result.resolutionNote = data.resolutionNote as string; + result.resolutionNote = data.resolutionNote as string } if (data.closedBy !== undefined) { - result.closedBy = data.closedBy as string; + result.closedBy = data.closedBy as string } - return result; + return result } diff --git a/apps/citizen-pwa/src/pages/SettingsPage.test.tsx b/apps/citizen-pwa/src/pages/SettingsPage.test.tsx index 9b8ebeb7..74c2cf95 100644 --- a/apps/citizen-pwa/src/pages/SettingsPage.test.tsx +++ b/apps/citizen-pwa/src/pages/SettingsPage.test.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-return */ import '@testing-library/jest-dom/vitest' import { describe, it, expect, vi, beforeAll } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' diff --git a/apps/citizen-pwa/src/services/callables.test.ts b/apps/citizen-pwa/src/services/callables.test.ts index 81148914..1202141f 100644 --- a/apps/citizen-pwa/src/services/callables.test.ts +++ b/apps/citizen-pwa/src/services/callables.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-return */ import '@testing-library/jest-dom/vitest' import { describe, it, expect, vi } from 'vitest' diff --git a/apps/citizen-pwa/src/services/submit-report.test.ts b/apps/citizen-pwa/src/services/submit-report.test.ts index dff95fbc..a8ca3268 100644 --- a/apps/citizen-pwa/src/services/submit-report.test.ts +++ b/apps/citizen-pwa/src/services/submit-report.test.ts @@ -1,7 +1,24 @@ -import { describe, it, expect, vi } from 'vitest' -import { submitReport, type SubmitReportDeps } from './submit-report.js' +import { describe, it, expect, vi, beforeEach } from 'vitest' +const { mockDraftStoreSave, mockDraftStoreSaveWithPhoto } = vi.hoisted(() => ({ + mockDraftStoreSave: vi.fn().mockResolvedValue(undefined), + mockDraftStoreSaveWithPhoto: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('./draft-store', () => ({ + draftStore: { + save: mockDraftStoreSave, + saveWithPhoto: mockDraftStoreSaveWithPhoto, + }, +})) + +import { createDraft, submitReport, type SubmitReportDeps } from './submit-report.js' import { normalizeMsisdn } from '@bantayog/shared-validators' +beforeEach(() => { + mockDraftStoreSave.mockClear() + mockDraftStoreSaveWithPhoto.mockClear() +}) + describe('submitReport', () => { it('calls requestUploadUrl when a photo is provided, PUTs the photo, and writes inbox', async () => { const deps: SubmitReportDeps = { @@ -30,11 +47,8 @@ describe('submitReport', () => { }) expect(result.publicRef).toBe('abcd1234') expect(result.secret).toBe('secret-plain') - // eslint-disable-next-line @typescript-eslint/unbound-method expect(deps.requestUploadUrl).toHaveBeenCalledOnce() - // eslint-disable-next-line @typescript-eslint/unbound-method expect(deps.putBlob).toHaveBeenCalledWith('https://put.example', photo) - // eslint-disable-next-line @typescript-eslint/unbound-method expect(deps.writeInbox).toHaveBeenCalledOnce() const inboxDoc = (deps.writeInbox as unknown as { mock: { calls: unknown[][] } }).mock .calls[0]![0]! as { @@ -47,6 +61,32 @@ describe('submitReport', () => { expect(inboxDoc.payload.pendingMediaIds).toEqual(['upl-1']) }) + it('normalizes public_disturbance to security before writing inbox', async () => { + const deps: SubmitReportDeps = { + ensureSignedIn: vi.fn().mockResolvedValue('citizen-1'), + requestUploadUrl: vi.fn(), + putBlob: vi.fn(), + writeInbox: vi.fn().mockResolvedValue('ibx-5'), + randomUUID: vi.fn().mockReturnValue('uuid-e'), + randomPublicRef: vi.fn().mockReturnValue('ref9999'), + randomSecret: vi.fn().mockReturnValue('s5'), + sha256Hex: vi.fn().mockResolvedValue('k'.repeat(64)), + now: () => 1, + } + await submitReport(deps, { + reportType: 'public_disturbance', + severity: 'medium', + description: 'disturbance report', + publicLocation: { lat: 14.1, lng: 122.9 }, + }) + expect(deps.writeInbox).toHaveBeenCalledOnce() + const inboxDoc = (deps.writeInbox as unknown as { mock: { calls: unknown[][] } }).mock + .calls[0]![0]! as { + payload: { reportType: string } + } + expect(inboxDoc.payload.reportType).toBe('security') + }) + it('skips upload path when no photo is provided', async () => { const deps: SubmitReportDeps = { ensureSignedIn: vi.fn().mockResolvedValue('citizen-1'), @@ -65,9 +105,7 @@ describe('submitReport', () => { description: 'y', publicLocation: { lat: 14.1, lng: 122.9 }, }) - // eslint-disable-next-line @typescript-eslint/unbound-method expect(deps.requestUploadUrl).not.toHaveBeenCalled() - // eslint-disable-next-line @typescript-eslint/unbound-method expect(deps.putBlob).not.toHaveBeenCalled() }) @@ -90,7 +128,6 @@ describe('submitReport', () => { publicLocation: { lat: 14.1, lng: 122.9 }, contact: { phone: '09171234567', smsConsent: true }, }) - // eslint-disable-next-line @typescript-eslint/unbound-method expect(deps.writeInbox).toHaveBeenCalledOnce() const inboxDoc = (deps.writeInbox as unknown as { mock: { calls: unknown[][] } }).mock .calls[0]![0]! as { @@ -120,10 +157,29 @@ describe('submitReport', () => { description: 'z', publicLocation: { lat: 14.1, lng: 122.9 }, }) - // eslint-disable-next-line @typescript-eslint/unbound-method expect(deps.writeInbox).toHaveBeenCalledOnce() const inboxDoc = (deps.writeInbox as unknown as { mock: { calls: unknown[][] } }).mock .calls[0]![0]! as { payload: Record } expect(inboxDoc.payload.contact).toBeUndefined() }) }) + +describe('createDraft', () => { + it('normalizes public_disturbance to security before persisting the draft', async () => { + const { draft } = await createDraft({ + reportType: 'public_disturbance' as never, + barangay: 'Bagasbas', + description: 'loud disturbance', + severity: 'medium', + location: { lat: 14.12, lng: 122.95 }, + clientDraftRef: 'client-ref-1', + }) + + expect(draft.reportType).toBe('security') + expect(mockDraftStoreSave).toHaveBeenCalledWith( + expect.objectContaining({ + reportType: 'security', + }), + ) + }) +}) diff --git a/apps/citizen-pwa/src/services/submit-report.ts b/apps/citizen-pwa/src/services/submit-report.ts index 72c2a32e..e2012072 100644 --- a/apps/citizen-pwa/src/services/submit-report.ts +++ b/apps/citizen-pwa/src/services/submit-report.ts @@ -1,4 +1,5 @@ import { normalizeMsisdn } from '@bantayog/shared-validators' +import type { ReportType } from '@bantayog/shared-types' import type { Draft } from './draft-store' import { draftStore } from './draft-store' @@ -51,6 +52,32 @@ export interface CreateDraftInput { photo?: Blob } +const VALID_REPORT_TYPES: readonly string[] = [ + 'flood', + 'fire', + 'earthquake', + 'typhoon', + 'landslide', + 'storm_surge', + 'medical', + 'accident', + 'structural', + 'security', + 'other', +] + +function canonicalizeReportType(reportType: string): ReportType { + // The citizen UI still carries a legacy "public_disturbance" alias, but the + // shared report schemas only accept "security". + if (reportType === 'public_disturbance') { + return 'security' + } + if (!VALID_REPORT_TYPES.includes(reportType)) { + throw new Error(`Unsupported report type: ${reportType}`) + } + return reportType as ReportType +} + function randomPublicRef(): string { const chars = 'abcdefghijklmnopqrstuvwxyz0123456789' const buf = new Uint8Array(8) @@ -81,6 +108,7 @@ export async function createDraft( input: CreateDraftInput, ): Promise<{ draft: Draft; secret: string }> { const now = Date.now() + const reportType = canonicalizeReportType(input.reportType) const publicRef = randomPublicRef() const secret = randomSecret() const secretHash = await sha256Hex(secret) @@ -89,7 +117,7 @@ export async function createDraft( const draft: Draft = { id: `BA-DA-${crypto.randomUUID().slice(0, 8).toUpperCase()}`, - reportType: input.reportType, + reportType, barangay: input.barangay, description: input.description, severity: input.severity, @@ -125,6 +153,7 @@ export async function submitReport( input: SubmitReportInput, ): Promise { const reporterUid = await deps.ensureSignedIn() + const reportType = canonicalizeReportType(input.reportType) const correlationId = deps.randomUUID() const publicRef = deps.randomPublicRef() const secret = deps.randomSecret() @@ -151,7 +180,7 @@ export async function submitReport( secretHash, correlationId, payload: { - reportType: input.reportType, + reportType, severity: input.severity, description: input.description, source: 'web', diff --git a/docs/learnings.md b/docs/learnings.md index a5dacafe..b32cc6f6 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -2,6 +2,9 @@ ## Citizen PWA / React Hooks +- Citizen PWA incident-type aliases must be normalized at the draft boundary. UI-only values like `public_disturbance` are rejected by shared report schemas and can make a report look "submitted" while disappearing from local active-report views. +- Citizen tracking pages cannot assume `reports/{id}` contains `id`, `timeline`, `location`, or `createdAt`. The live citizen-readable doc currently exposes `publicLocation` + `submittedAt`; synthesize the citizen timeline view from those fields instead of treating it like an ops projection. +- Secret-code lookup should normalize to uppercase alphanumeric before hashing/comparing, and same-device lookup should check locally saved reports before surfacing a server `not-found` while backend lookup docs are still catching up. - `react-hooks/set-state-in-effect` fires on synchronous `setState` inside `useEffect` early-return branches. Add `// eslint-disable-next-line react-hooks/set-state-in-effect` only where needed — `eslint --fix` (run by lint-staged) will remove unused disable directives automatically after running. - `vi.mock` at module top level does NOT cover newly routed components — add mocks for every new route's component in `App.routes.test.tsx` when replacing stub routes. - Passing navigation callbacks as props (e.g., `onReportSimilar={() => void navigate(...)}`) avoids `useNavigate` being called in components tested without a Router context — the pattern is cleaner than wrapping every test with a MemoryRouter. @@ -65,6 +68,7 @@ - `react-hooks/refs` flags `ref.current` reads during render; pass render-time values through state. - CodeQL `js/xss-through-dom` on blob previews: render via `createImageBitmap` + `canvas` instead of blob URL in JSX. - React Router v7 `useNavigate` returns `Promise`; wrap with `void` or `await`. +- Citizen report tracking maps live Firestore docs, not sanitized fixtures. Normalize timestamp-like values with `toMillis()` and treat `lastStatusAt` as the fallback status timestamp, or the timeline silently drops verified/resolved steps. ## TypeScript diff --git a/docs/progress.md b/docs/progress.md index e3013599..d558d6db 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -18,6 +18,20 @@ All 10 tasks complete. Residual risks: E2E dispatch progression, native push tok ## Recent Merged Work +### Citizen PWA -- Public Verification Wiring + Live Timeline (2026-05-04) + +- Verified reports now flip from `visibilityClass: internal` to `public_alertable` during admin verification, so creators still see fresh submissions immediately while the public Map/Feed sees them only after verification +- Citizen tracking now normalizes Firestore timestamp objects and uses `lastStatusAt` to synthesize the live timeline page/radar status state from real `reports/{id}` docs +- **Gate:** citizen-pwa focused vitest 49/49 pass, `pnpm lint`, `pnpm typecheck`; functions `pnpm typecheck` pass; functions verify-report emulator suite still blocked by pre-existing rules-unit-testing seed issues (Admin `Timestamp` writes / permission-denied harness path), functions lint still has 17 pre-existing warnings + +### Citizen PWA — Active Report + Tracking Fixes (2026-05-04) + +- Fixed 4 citizen-facing correctness bugs in the report-status flow +- Normalized legacy `public_disturbance` submissions to the supported `security` report type so new reports persist and appear in Map/Profile/pill again +- Tracking page now accepts the real live report doc shape (`publicLocation`, `submittedAt`, no inline `timeline`) and synthesizes a usable citizen timeline view instead of collapsing to the generic processing banner +- Find My Report now normalizes secret-code input and resolves same-device freshly submitted reports from local storage before backend lookup docs exist +- Gate: citizen-pwa focused vitest 43/43 pass, `pnpm lint`, `pnpm typecheck` + ### UX Bug Fixes — 10 Issues (2026-05-03) - **10 issues fixed:** TrackingScreen nav header (back + home), RevealSheet SMS iOS fix, button text → "Create Account", mt-4 spacing, FilterBar z-[800] above Leaflet, municipality chips filter (replaces severity/window), saveReport() wiring so reports appear on map + Profile, bantayog:report-saved event for live refresh, ProfileTab "Check report status" CTA diff --git a/functions/src/__tests__/callables/verify-report.test.ts b/functions/src/__tests__/callables/verify-report.test.ts index 4b699455..8b933977 100644 --- a/functions/src/__tests__/callables/verify-report.test.ts +++ b/functions/src/__tests__/callables/verify-report.test.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument */ import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest' import { initializeTestEnvironment, type RulesTestEnvironment } from '@firebase/rules-unit-testing' import { collection, getDocs } from 'firebase/firestore' @@ -24,7 +24,7 @@ beforeAll(async () => { projectId: 'verify-report-test', firestore: { host: 'localhost', - port: 8080, + port: 8081, rules: readFileSync(FIRESTORE_RULES_PATH, 'utf8'), }, }) @@ -59,8 +59,12 @@ describe('verifyReportCore', () => { }) expect(result.status).toBe('awaiting_verify') + expect(result.updatedAt).toBeDefined() const report = (await db.collection('reports').doc(reportId).get()).data() expect(report.status).toBe('awaiting_verify') + expect(report.visibilityClass).toBe('internal') + expect(report.updatedAt).toBeDefined() + expect(report.updatedAt).toBe(result.updatedAt) const events = await db.collection('report_events').where('reportId', '==', reportId).get() expect(events.docs).toHaveLength(1) @@ -71,7 +75,7 @@ describe('verifyReportCore', () => { }) }) - it('advances awaiting_verify → verified and stamps verifiedBy', async () => { + it('advances awaiting_verify → verified, stamps verifiedBy, and makes the report public', async () => { const db = testEnv.unauthenticatedContext().firestore() as any const { reportId } = await seedReportAtStatus(db, 'awaiting_verify', { municipalityId: 'daet' }) await seedActiveAccount(testEnv, { @@ -91,10 +95,14 @@ describe('verifyReportCore', () => { }) expect(result.status).toBe('verified') + expect(result.updatedAt).toBeDefined() const report = (await db.collection('reports').doc(reportId).get()).data() expect(report.status).toBe('verified') expect(report.verifiedBy).toBe('admin-1') expect(report.verifiedAt).toBeDefined() + expect(report.visibilityClass).toBe('public_alertable') + expect(report.updatedAt).toBeDefined() + expect(report.updatedAt).toBe(result.updatedAt) }) it('is idempotent: same idempotencyKey returns cached result', async () => { diff --git a/functions/src/callables/verify-report.ts b/functions/src/callables/verify-report.ts index 87b7bddb..2ec55830 100644 --- a/functions/src/callables/verify-report.ts +++ b/functions/src/callables/verify-report.ts @@ -31,6 +31,7 @@ export interface VerifyReportInput { export interface VerifyReportResult { status: ReportStatus reportId: string + updatedAt: number } export interface VerifyReportActor { @@ -139,6 +140,7 @@ export async function verifyReportCore( status: to, lastStatusAt: deps.now, lastStatusBy: deps.actor.uid, + updatedAt: deps.now.toMillis(), } if (deps.scrubbedDescription) { updates.description = deps.scrubbedDescription @@ -146,6 +148,7 @@ export async function verifyReportCore( if (to === 'verified') { updates.verifiedBy = deps.actor.uid updates.verifiedAt = deps.now + updates.visibilityClass = 'public_alertable' } tx.update(reportRef, updates) @@ -183,7 +186,7 @@ export async function verifyReportCore( data: { reportId: deps.reportId, from, to, actorUid: deps.actor.uid, correlationId }, }) - return { status: to, reportId: deps.reportId } + return { status: to, reportId: deps.reportId, updatedAt: deps.now.toMillis() } }) }, )