diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a296ad5b..b6a6e41a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,13 @@ jobs: - run: corepack prepare pnpm@${PNPM_VERSION} --activate - run: pnpm install --frozen-lockfile - run: pnpm exec tsx scripts/check-rule-coverage.ts + - run: pnpm exec tsx scripts/build-rules.ts + - name: Verify firestore.rules is not out of date + run: | + if ! git diff --exit-code -- infra/firebase/firestore.rules; then + echo "::error::firestore.rules is out of sync with scripts/build-rules.ts. Run 'pnpm exec tsx scripts/build-rules.ts' locally and commit." + exit 1 + fi build: name: Build diff --git a/apps/citizen-pwa/package.json b/apps/citizen-pwa/package.json index 29af0e68..d7d6beb6 100644 --- a/apps/citizen-pwa/package.json +++ b/apps/citizen-pwa/package.json @@ -15,8 +15,10 @@ "@bantayog/shared-firebase": "workspace:*", "@bantayog/shared-types": "workspace:*", "@bantayog/shared-ui": "workspace:*", + "firebase": "^12.12.0", "react": "^19.2.5", - "react-dom": "^19.2.5" + "react-dom": "^19.2.5", + "react-router-dom": "^7.14.1" }, "devDependencies": { "@testing-library/jest-dom": "^6.4.0", diff --git a/apps/citizen-pwa/src/App.test.tsx b/apps/citizen-pwa/src/App.test.tsx index 3ce7aeee..592d580c 100644 --- a/apps/citizen-pwa/src/App.test.tsx +++ b/apps/citizen-pwa/src/App.test.tsx @@ -1,75 +1,10 @@ import '@testing-library/jest-dom/vitest' -import { describe, it, expect, vi } from 'vitest' -import { render, screen } from '@testing-library/react' +import { describe, it, expect } from 'vitest' +import { render } from '@testing-library/react' import { App } from './App.js' -const { useCitizenShell } = vi.hoisted(() => { - const defaultShellState = { - status: 'ready', - authState: 'signed-in', - appCheckState: 'active', - user: { uid: 'anon-123' }, - minAppVersion: { - citizen: '0.1.0', - admin: '0.1.0', - responder: '0.1.0', - updatedAt: 1713350400000, - }, - alerts: [ - { - id: 'phase1-hello', - title: 'System online', - body: 'Citizen shell wired for Phase 1.', - severity: 'info', - publishedAt: 1713350400000, - publishedBy: 'phase-1-bootstrap', - }, - ], - error: null, - } - return { - useCitizenShell: vi.fn().mockReturnValue(defaultShellState), - } -}) - -vi.mock('./useCitizenShell.js', () => ({ - useCitizenShell, -})) - describe('App', () => { - it('renders auth status, app version, and the hello-world alert feed', () => { - render() - expect(screen.getByText('anon-123')).toBeInTheDocument() - expect(screen.getByText('System online')).toBeInTheDocument() - expect(screen.getByText('0.1.0')).toBeInTheDocument() - expect(screen.getByText('signed-in')).toBeInTheDocument() - }) - - it('renders error message when status is error', () => { - useCitizenShell.mockReturnValueOnce({ - status: 'error', - authState: 'signed-out', - appCheckState: 'failed', - user: null, - minAppVersion: null, - alerts: [], - error: 'Firebase initialization failed', - }) - render() - expect(screen.getByText('Firebase initialization failed')).toBeInTheDocument() - }) - - it('renders signed-out state correctly', () => { - useCitizenShell.mockReturnValueOnce({ - status: 'ready', - authState: 'signed-out', - appCheckState: 'pending', - user: null, - minAppVersion: null, - alerts: [], - error: null, - }) - render() - expect(screen.getByText('signed-out')).toBeInTheDocument() + it('renders without throwing', () => { + expect(() => render()).not.toThrow() }) }) diff --git a/apps/citizen-pwa/src/App.tsx b/apps/citizen-pwa/src/App.tsx index b11c0275..ca9203ba 100644 --- a/apps/citizen-pwa/src/App.tsx +++ b/apps/citizen-pwa/src/App.tsx @@ -1,53 +1,5 @@ -import styles from './App.module.css' -import { useCitizenShell } from './useCitizenShell.js' +import { AppRoutes } from './routes.js' export function App() { - const state = useCitizenShell() - - return ( -
-
-

Bantayog Alert

-

Citizen Phase 1 shell

-

- Pseudonymous sign-in, app health, and a hello-world alert feed. -

- -
-
-
Status
-
{state.status}
-
-
-
Auth
-
{state.authState}
-
-
-
App Check
-
{state.appCheckState}
-
-
-
User UID
-
{state.user?.uid ?? 'unavailable'}
-
-
-
Minimum citizen version
-
{state.minAppVersion?.citizen ?? 'unavailable'}
-
-
- - {state.error ?

{state.error}

: null} - -
- {state.alerts.map((alert) => ( -
-

{alert.title}

-

{alert.body}

- {alert.severity} -
- ))} -
-
-
- ) + return } diff --git a/apps/citizen-pwa/src/components/LookupScreen.tsx b/apps/citizen-pwa/src/components/LookupScreen.tsx new file mode 100644 index 00000000..c1f86776 --- /dev/null +++ b/apps/citizen-pwa/src/components/LookupScreen.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react' +import { httpsCallable } from 'firebase/functions' +import { fns } from '../services/firebase.js' + +interface LookupResult { + status: string + lastStatusAt: number + municipalityLabel: string +} + +export function LookupScreen() { + const [publicRef, setPublicRef] = useState('') + const [secret, setSecret] = useState('') + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + + async function handleSubmit(e: React.SubmitEvent): Promise { + e.preventDefault() + setError(null) + setResult(null) + try { + const res = await httpsCallable(fns(), 'requestLookup')({ publicRef, secret }) + setResult(res.data as LookupResult) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'lookup failed') + } + } + + return ( +
+

Check report status

+
{ + void handleSubmit(e) + }} + > + + + +
+ {error &&

{error}

} + {result && ( +
+
Status
+
{result.status}
+
Municipality
+
{result.municipalityLabel}
+
Last update
+
{new Date(result.lastStatusAt).toLocaleString()}
+
+ )} +
+ ) +} diff --git a/apps/citizen-pwa/src/components/ReceiptScreen.tsx b/apps/citizen-pwa/src/components/ReceiptScreen.tsx new file mode 100644 index 00000000..e492b5cc --- /dev/null +++ b/apps/citizen-pwa/src/components/ReceiptScreen.tsx @@ -0,0 +1,26 @@ +import { useLocation, Link } from 'react-router-dom' + +export function ReceiptScreen() { + const { state } = useLocation() as { state: { publicRef: string; secret: string } | null } + if (!state) return

No submission to display.

+ return ( +
+

Report submitted

+

Save these two values. You will need them to check status.

+
+
Reference
+
+ {state.publicRef} +
+
Secret
+
+ {state.secret} +
+
+

+ We'll notify you when we can. For now, check back with your reference number via the{' '} + lookup page. +

+
+ ) +} diff --git a/apps/citizen-pwa/src/components/SubmitReportForm.tsx b/apps/citizen-pwa/src/components/SubmitReportForm.tsx new file mode 100644 index 00000000..52c8b11c --- /dev/null +++ b/apps/citizen-pwa/src/components/SubmitReportForm.tsx @@ -0,0 +1,187 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { addDoc, collection } from 'firebase/firestore' +import { httpsCallable } from 'firebase/functions' +import { db, fns, ensureSignedIn } from '../services/firebase.js' +import { submitReport, type SubmitReportDeps } from '../services/submit-report.js' +import type { ReportType, Severity } from '@bantayog/shared-types' + +function randomPublicRef(): string { + const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789' + const bytes = crypto.getRandomValues(new Uint8Array(8)) + return Array.from(bytes, (b) => alphabet[b % alphabet.length]).join('') +} + +function randomSecret(): string { + return crypto.randomUUID() +} + +async function sha256Hex(input: string | Blob): Promise { + const buf = + typeof input === 'string' + ? new TextEncoder().encode(input) + : new Uint8Array(await input.arrayBuffer()) + const digest = await crypto.subtle.digest('SHA-256', buf) + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} + +async function putBlob(url: string, blob: Blob): Promise { + const res = await fetch(url, { + method: 'PUT', + body: blob, + headers: { 'content-type': blob.type }, + }) + if (!res.ok) throw new Error('upload failed: ' + String(res.status)) +} + +export function SubmitReportForm() { + const nav = useNavigate() + const [reportType, setReportType] = useState('flood') + const [severity, setSeverity] = useState('medium') + const [description, setDescription] = useState('') + const [photo, setPhoto] = useState(null) + const [lat, setLat] = useState(null) + const [lng, setLng] = useState(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + async function getLocation(): Promise { + const pos = await new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition(resolve, reject, { + enableHighAccuracy: true, + timeout: 10000, + }) + }) + setLat(pos.coords.latitude) + setLng(pos.coords.longitude) + } + + async function onCaptureLocation(): Promise { + try { + await getLocation() + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'location failed') + } + } + + async function onSubmit(e: React.SubmitEvent): Promise { + e.preventDefault() + if (lat === null || lng === null) { + setError('Please capture your location.') + return + } + setBusy(true) + setError(null) + try { + const deps: SubmitReportDeps = { + ensureSignedIn, + requestUploadUrl: async (args) => + (await httpsCallable(fns(), 'requestUploadUrl')(args)).data as { + uploadUrl: string + uploadId: string + storagePath: string + expiresAt: number + }, + putBlob, + writeInbox: async (doc) => { + const ref = await addDoc(collection(db(), 'report_inbox'), doc) + return ref.id + }, + randomUUID: () => crypto.randomUUID(), + randomPublicRef, + randomSecret, + sha256Hex, + now: () => Date.now(), + } + const result = await submitReport(deps, { + reportType, + severity, + description, + publicLocation: { lat, lng }, + ...(photo ? { photo } : {}), + }) + // eslint-disable-next-line @typescript-eslint/no-floating-promises + nav('/receipt', { state: result }) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'submission failed') + } finally { + setBusy(false) + } + } + + return ( +
+ + +