Skip to content
Merged
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
1 change: 0 additions & 1 deletion apps/citizen-pwa/src/components/AlertsTab.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
38 changes: 37 additions & 1 deletion apps/citizen-pwa/src/components/LookupScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('react-router-dom')>('react-router-dom')
return { ...actual, useNavigate: () => mockNavigate }
})

vi.mock('../services/localForageReports.js', () => ({
loadReports: mockLoadReports,
}))

vi.mock('../services/firebase.js', () => ({
fns: () => ({}),
hasFirebaseConfig: () => true,
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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()
})
})
14 changes: 13 additions & 1 deletion apps/citizen-pwa/src/components/LookupScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) : ''
Expand Down Expand Up @@ -50,13 +55,20 @@ export function LookupScreen() {
async function handleSubmit(e: React.SyntheticEvent): Promise<void> {
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)
}
Expand Down
2 changes: 1 addition & 1 deletion apps/citizen-pwa/src/components/ProfileTab.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
1 change: 0 additions & 1 deletion apps/citizen-pwa/src/hooks/useOfflineQueueCount.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
7 changes: 5 additions & 2 deletions apps/citizen-pwa/src/hooks/useReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
54 changes: 52 additions & 2 deletions apps/citizen-pwa/src/lib/__tests__/mappers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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' }),
Expand Down Expand Up @@ -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 },
])
})
})
141 changes: 97 additions & 44 deletions apps/citizen-pwa/src/lib/mappers.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,124 @@
import type { ReportStatus } from '@bantayog/shared-types'
import type { ReportData } from '../hooks/useReport'

export function mapReportFromFirestore(data: Record<string, unknown>): ReportData {
if (!data.id || !data.status || !Array.isArray(data.timeline)) {
throw new Error('Invalid report data: missing required fields');
const VALID_STATUSES: Set<string> = 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<string, unknown>
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<string, unknown>,
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<string, unknown>
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,
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<string, unknown>;
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<string, unknown>
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
}
1 change: 0 additions & 1 deletion apps/citizen-pwa/src/pages/SettingsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
Loading
Loading