Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
44 changes: 43 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,10 @@ function renderScreen() {
)
}

beforeEach(() => mockNavigate.mockReset())
beforeEach(() => {
mockNavigate.mockReset()
mockLoadReports.mockReset().mockResolvedValue([])
})

describe('LookupScreen', () => {
it('renders a single secret code input', () => {
Expand Down Expand Up @@ -93,6 +103,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 +123,35 @@ describe('LookupScreen', () => {
expect(screen.getByRole('alert')).toHaveTextContent(/couldn't find/)
})
})

it('falls back to a locally saved report when the server lookup is not ready yet', async () => {
mockLoadReports.mockResolvedValue([
{
publicRef: 'loc12345',
secret: 'LOCALSECRET',
reportType: 'flood',
severity: 'high',
lat: 14.1,
lng: 122.9,
submittedAt: 1713350400000,
},
])
vi.mocked(httpsCallable).mockImplementationOnce(
() =>
(() => {
const err = new Error('not-found')
;(err as unknown as { code: string }).code = 'functions/not-found'
return Promise.reject(err)
}) as unknown as import('firebase/functions').HttpsCallable<unknown, unknown>,
)

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')
})
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
})
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
39 changes: 37 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,10 @@ 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('throws when status is missing', () => {
Expand Down Expand Up @@ -118,4 +118,39 @@ 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,
})

expect(result.id).toBe('unknown')
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 },
])
})
})
114 changes: 72 additions & 42 deletions apps/citizen-pwa/src/lib/mappers.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,101 @@
import type { ReportStatus } from '@bantayog/shared-types'
import type { ReportData } from '../hooks/useReport'

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>): ReportData {
if (!data.id || !data.status || !Array.isArray(data.timeline)) {
throw new Error('Invalid report data: missing required fields');
if (typeof data.status !== 'string') {
throw new Error('Invalid report data: missing required fields')
}

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,
id: typeof data.id === 'string' ? data.id : 'unknown',
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 }),
}
}),
};
timeline,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
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
1 change: 0 additions & 1 deletion apps/citizen-pwa/src/services/callables.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 } from 'vitest'

Expand Down
Loading