From 85b6905b2f9b24f1a2f547b55ec9d115f6101db3 Mon Sep 17 00:00:00 2001 From: Anis Benabdallah Date: Thu, 27 Aug 2026 18:49:18 -0400 Subject: [PATCH] feat(reports): keep reports on the phone when the network is down --- src/app/(map)/signaler/report-form.tsx | 12 +- src/app/(map)/signaler/report-overlay.tsx | 33 ++++- src/app/api/reports/route.ts | 25 ++++ src/app/layout.tsx | 2 + src/components/report-sync.tsx | 20 +++ src/lib/reports/report-queue.ts | 117 +++++++++++++++++ src/lib/reports/send.ts | 94 ++++++++++++++ .../actions.ts => lib/reports/submit.ts} | 119 ++++++++++-------- tests/lib/reports/report-queue.test.ts | 75 +++++++++++ 9 files changed, 437 insertions(+), 60 deletions(-) create mode 100644 src/components/report-sync.tsx create mode 100644 src/lib/reports/report-queue.ts create mode 100644 src/lib/reports/send.ts rename src/{app/(map)/signaler/actions.ts => lib/reports/submit.ts} (87%) create mode 100644 tests/lib/reports/report-queue.test.ts diff --git a/src/app/(map)/signaler/report-form.tsx b/src/app/(map)/signaler/report-form.tsx index 35a085e..a251904 100644 --- a/src/app/(map)/signaler/report-form.tsx +++ b/src/app/(map)/signaler/report-form.tsx @@ -31,7 +31,8 @@ import { validateReport, type ReportErrors, } from '@/lib/reports/validation' -import { submitReport, type ReportFormState } from './actions' +import { sendReport } from '@/lib/reports/send' +import type { ReportFormState } from '@/lib/reports/submit' import { REPORT_THEMES } from './report-theme' const initialState: ReportFormState = {} @@ -79,10 +80,7 @@ export function ReportForm({ onBack: () => void photoRequired: boolean }) { - const [state, formAction, pending] = useActionState( - submitReport, - initialState, - ) + const [state, formAction, pending] = useActionState(sendReport, initialState) return (
@@ -122,7 +120,9 @@ export function ReportForm({ aria-live="polite" className="rounded-lg bg-canopee-green/10 px-3 py-2.5 text-sm font-medium text-canopee-forest" > - Signalement envoyé. Merci! + {state.queued + ? 'Signalement enregistré. Il partira au retour du réseau.' + : 'Signalement envoyé. Merci!'}

)} diff --git a/src/app/(map)/signaler/report-overlay.tsx b/src/app/(map)/signaler/report-overlay.tsx index 7c45430..180230b 100644 --- a/src/app/(map)/signaler/report-overlay.tsx +++ b/src/app/(map)/signaler/report-overlay.tsx @@ -1,11 +1,31 @@ 'use client' -import { useEffect } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useRouter } from 'next/navigation' +import { drainQueuedReports, pendingReportCount } from '@/lib/reports/send' import { ReportFlow } from './report-flow' export function ReportOverlay({ photoRequired }: { photoRequired: boolean }) { const router = useRouter() + const [pendingReports, setPendingReports] = useState(0) + + const drain = useCallback(() => { + drainQueuedReports() + .then(setPendingReports) + .catch(() => setPendingReports(0)) + }, []) + + useEffect(() => { + pendingReportCount() + .then(setPendingReports) + .catch(() => setPendingReports(0)) + + drain() + + window.addEventListener('online', drain) + + return () => window.removeEventListener('online', drain) + }, [drain]) useEffect(() => { function onKeyDown(event: KeyboardEvent) { @@ -67,6 +87,17 @@ export function ReportOverlay({ photoRequired }: { photoRequired: boolean }) { + {pendingReports > 0 && ( +

+ {pendingReports === 1 + ? '1 signalement en attente d’envoi.' + : `${pendingReports} signalements en attente d’envoi.`} +

+ )} +
diff --git a/src/app/api/reports/route.ts b/src/app/api/reports/route.ts index 4ebbdf0..f35a1f4 100644 --- a/src/app/api/reports/route.ts +++ b/src/app/api/reports/route.ts @@ -2,6 +2,7 @@ import type { NextRequest } from 'next/server' import { getCurrentUserProfile } from '@/lib/auth/current-user' import { canAccess } from '@/lib/auth/roles' import { listReportPins } from '@/lib/reports/queries' +import { createReport } from '@/lib/reports/submit' import { parseCategoriesParam, parseStatusParam, @@ -49,3 +50,27 @@ export async function GET(request: NextRequest) { reports, }) } + +export async function POST(request: NextRequest) { + const profile = await getCurrentUserProfile() + + if (!profile || !canAccess(profile, 'volunteer')) { + return Response.json({ error: 'Unauthorized.' }, { status: 401 }) + } + + let formData: FormData + + try { + formData = await request.formData() + } catch { + return Response.json({ error: 'Expected a form body.' }, { status: 400 }) + } + + const result = await createReport(profile, formData) + + if (result.errors || result.message) { + return Response.json(result, { status: 422 }) + } + + return Response.json(result) +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 651473d..13eb00e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata, Viewport } from 'next' import { Averia_Serif_Libre, Geist_Mono } from 'next/font/google' import localFont from 'next/font/local' import { PatrolControls } from '@/components/patrol-controls' +import { ReportSync } from '@/components/report-sync' import { getCurrentUserProfile } from '@/lib/auth/current-user' import { getActivePatrol } from '@/lib/patrols/queries' import 'mapbox-gl/dist/mapbox-gl.css' @@ -96,6 +97,7 @@ export default async function RootLayout({ {children} + ) diff --git a/src/components/report-sync.tsx b/src/components/report-sync.tsx new file mode 100644 index 0000000..03ad03d --- /dev/null +++ b/src/components/report-sync.tsx @@ -0,0 +1,20 @@ +'use client' + +import { useEffect } from 'react' +import { drainQueuedReports } from '@/lib/reports/send' + +export function ReportSync() { + useEffect(() => { + function drain() { + void drainQueuedReports().catch(() => {}) + } + + drain() + + window.addEventListener('online', drain) + + return () => window.removeEventListener('online', drain) + }, []) + + return null +} diff --git a/src/lib/reports/report-queue.ts b/src/lib/reports/report-queue.ts new file mode 100644 index 0000000..311a442 --- /dev/null +++ b/src/lib/reports/report-queue.ts @@ -0,0 +1,117 @@ +const DB_NAME = 'canopee-reports' + +const DB_VERSION = 1 + +const STORE = 'pending' + +export const MAX_QUEUED_REPORTS = 50 + +export type QueuedReport = { + id: string + fields: Record + photo: File | null + queuedAt: string +} + +let connection: Promise | null = null + +export function isReportQueueAvailable(): boolean { + return typeof indexedDB !== 'undefined' +} + +function open(): Promise { + if (connection) { + return connection + } + + connection = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION) + + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(STORE)) { + request.result.createObjectStore(STORE, { keyPath: 'id' }) + } + } + + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) + + return connection +} + +function run( + mode: IDBTransactionMode, + action: (store: IDBObjectStore) => IDBRequest, +): Promise { + return open().then( + (db) => + new Promise((resolve, reject) => { + const transaction = db.transaction(STORE, mode) + const pending = action(transaction.objectStore(STORE)) + + pending.onsuccess = () => resolve(pending.result) + transaction.onerror = () => reject(transaction.error) + }), + ) +} + +export async function countQueuedReports(): Promise { + return run('readonly', (store) => store.count()) +} + +export async function readQueuedReports(): Promise { + const rows = await run('readonly', (store) => store.getAll()) + + return (rows as QueuedReport[]).sort((a, b) => + a.queuedAt.localeCompare(b.queuedAt), + ) +} + +export async function deleteQueuedReport(id: string): Promise { + await run('readwrite', (store) => store.delete(id)) +} + +export async function appendQueuedReport( + report: QueuedReport, +): Promise { + await run('readwrite', (store) => store.put(report)) + + return countQueuedReports() +} + +export function toFormData(report: QueuedReport): FormData { + const formData = new FormData() + + for (const [name, value] of Object.entries(report.fields)) { + formData.set(name, value) + } + + formData.set('id', report.id) + + if (report.photo) { + formData.set('photo', report.photo) + } + + return formData +} + +export function fromFormData(formData: FormData, id: string): QueuedReport { + const fields: Record = {} + let photo: File | null = null + + for (const [name, value] of formData.entries()) { + if (value instanceof File) { + if (name === 'photo' && value.size > 0) { + photo = value + } + continue + } + + fields[name] = value + } + + delete fields.id + + return { id, fields, photo, queuedAt: new Date().toISOString() } +} diff --git a/src/lib/reports/send.ts b/src/lib/reports/send.ts new file mode 100644 index 0000000..37b0052 --- /dev/null +++ b/src/lib/reports/send.ts @@ -0,0 +1,94 @@ +import { + appendQueuedReport, + countQueuedReports, + deleteQueuedReport, + fromFormData, + isReportQueueAvailable, + readQueuedReports, + toFormData, +} from '@/lib/reports/report-queue' +import type { ReportFormState } from '@/lib/reports/submit' + +const ENDPOINT = '/api/reports' + +async function post(formData: FormData): Promise { + return fetch(ENDPOINT, { + method: 'POST', + body: formData, + redirect: 'manual', + }) +} + +export async function sendReport( + _prevState: ReportFormState, + formData: FormData, +): Promise { + const id = crypto.randomUUID() + + formData.set('id', id) + + try { + const response = await post(formData) + const state = (await response.json()) as ReportFormState + + if (!response.ok) { + return state + } + + return { submittedId: state.submittedId ?? id } + } catch { + if (!isReportQueueAvailable()) { + return { + message: 'Impossible d’envoyer le signalement. Réessayez.', + } + } + + await appendQueuedReport(fromFormData(formData, id)) + + return { submittedId: id, queued: true } + } +} + +export async function drainQueuedReports(): Promise { + if (!isReportQueueAvailable()) { + return 0 + } + + const pending = await readQueuedReports() + + for (const report of pending) { + let response: Response + + try { + response = await post(toFormData(report)) + } catch { + break + } + + if (response.ok) { + await deleteQueuedReport(report.id) + continue + } + + const state = (await response + .json() + .catch(() => null)) as ReportFormState | null + + if (response.status === 422 && state?.errors) { + await deleteQueuedReport(report.id) + continue + } + + break + } + + return countQueuedReports() +} + +export async function pendingReportCount(): Promise { + if (!isReportQueueAvailable()) { + return 0 + } + + return countQueuedReports() +} diff --git a/src/app/(map)/signaler/actions.ts b/src/lib/reports/submit.ts similarity index 87% rename from src/app/(map)/signaler/actions.ts rename to src/lib/reports/submit.ts index 13831e2..4dbbee3 100644 --- a/src/app/(map)/signaler/actions.ts +++ b/src/lib/reports/submit.ts @@ -1,7 +1,5 @@ -'use server' - import { db, reports } from '@/db' -import { requireApprovedAccess } from '@/lib/auth/current-user' +import type { UserProfile } from '@/lib/auth/current-user' import { isReportCategory } from '@/lib/reports/categories' import { REPORT_PHOTO_BUCKET, reportPhotoPath } from '@/lib/reports/photo' import { @@ -18,6 +16,20 @@ export type ReportFormState = { message?: string errors?: ReportErrors submittedId?: string + queued?: boolean +} + +const UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +export function readReportId(formData: FormData): string | null { + const id = formData.get('id') + + if (typeof id !== 'string' || !UUID.test(id)) { + return null + } + + return id } function parseCoordinate(value: FormDataEntryValue | null): number | null { @@ -40,12 +52,54 @@ function readPhoto(formData: FormData): File | null { return photo } -export async function submitReport( - _prevState: ReportFormState, +function parseQuantity(value: string): number | null { + if (value.trim() === '') { + return null + } + + const parsed = Number(value) + + return Number.isInteger(parsed) && parsed >= 1 ? parsed : null +} + +async function uploadPhoto( + authUserId: string, + photo: File, +): Promise { + const path = reportPhotoPath( + authUserId, + photo.type, + new Date(), + crypto.randomUUID(), + ) + + if (!path) { + return null + } + + try { + const supabase = await createClient() + + const { error } = await supabase.storage + .from(REPORT_PHOTO_BUCKET) + .upload(path, photo, { contentType: photo.type, upsert: false }) + + if (error) { + console.error('Report photo upload failed', error) + return null + } + + return path + } catch (cause) { + console.error('Report photo upload threw', cause) + return null + } +} + +export async function createReport( + profile: UserProfile, formData: FormData, ): Promise { - const profile = await requireApprovedAccess('volunteer') - const input = { category: String(formData.get('category') ?? ''), description: String(formData.get('description') ?? ''), @@ -66,7 +120,6 @@ export async function submitReport( if (photoError) { errors.photo = photoError } else if (!photo && profile.role !== 'admin') { - // photo required for volunteers and pros, optional for admins. errors.photo = 'Une photo est requise pour ce type de signalement. Ajoutez-la, puis réessayez.' } @@ -96,10 +149,13 @@ export async function submitReport( } } + const id = readReportId(formData) + try { const [created] = await db .insert(reports) .values({ + ...(id ? { id } : {}), userId: profile.id, category: input.category, description: input.description.trim(), @@ -113,9 +169,10 @@ export async function submitReport( latitude: input.latitude.toFixed(COORDINATE_SCALE), longitude: input.longitude.toFixed(COORDINATE_SCALE), }) + .onConflictDoNothing() .returning({ id: reports.id }) - return { submittedId: created.id } + return { submittedId: created?.id ?? id ?? undefined } } catch (cause) { console.error('Failed to insert a report row', cause) @@ -124,47 +181,3 @@ export async function submitReport( } } } - -function parseQuantity(value: string): number | null { - if (value.trim() === '') { - return null - } - - const parsed = Number(value) - - return Number.isInteger(parsed) && parsed >= 1 ? parsed : null -} - -async function uploadPhoto( - authUserId: string, - photo: File, -): Promise { - const path = reportPhotoPath( - authUserId, - photo.type, - new Date(), - crypto.randomUUID(), - ) - - if (!path) { - return null - } - - try { - const supabase = await createClient() - - const { error } = await supabase.storage - .from(REPORT_PHOTO_BUCKET) - .upload(path, photo, { contentType: photo.type, upsert: false }) - - if (error) { - console.error('Report photo upload failed', error) - return null - } - - return path - } catch (cause) { - console.error('Report photo upload threw', cause) - return null - } -} diff --git a/tests/lib/reports/report-queue.test.ts b/tests/lib/reports/report-queue.test.ts new file mode 100644 index 0000000..8ebf79f --- /dev/null +++ b/tests/lib/reports/report-queue.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { + fromFormData, + toFormData, + type QueuedReport, +} from '@/lib/reports/report-queue' + +const ID = '11111111-2222-4333-8444-555555555555' + +function buildForm(): FormData { + const formData = new FormData() + + formData.set('category', 'fallen_tree') + formData.set('description', 'Arbre en travers du sentier') + formData.set('latitude', '45.588500') + formData.set('longitude', '-73.723000') + + return formData +} + +describe('report queue serialisation', () => { + it('keeps the text fields and the photo', () => { + const formData = buildForm() + const photo = new File(['x'], 'photo.jpg', { type: 'image/jpeg' }) + + formData.set('photo', photo) + + const queued = fromFormData(formData, ID) + + expect(queued.id).toBe(ID) + expect(queued.fields.category).toBe('fallen_tree') + expect(queued.fields.description).toBe('Arbre en travers du sentier') + expect(queued.photo?.name).toBe('photo.jpg') + expect(queued.photo?.type).toBe('image/jpeg') + }) + + it('drops an empty photo', () => { + const formData = buildForm() + + formData.set('photo', new File([], 'empty.jpg', { type: 'image/jpeg' })) + + expect(fromFormData(formData, ID).photo).toBeNull() + }) + + it('never stores the id among the fields', () => { + const formData = buildForm() + + formData.set('id', 'something-else') + + expect(fromFormData(formData, ID).fields.id).toBeUndefined() + }) + + it('round trips back into a form carrying the same id', () => { + const queued = fromFormData(buildForm(), ID) + const rebuilt = toFormData(queued) + + expect(rebuilt.get('id')).toBe(ID) + expect(rebuilt.get('category')).toBe('fallen_tree') + expect(rebuilt.get('latitude')).toBe('45.588500') + }) + + it('puts the photo back when there is one', () => { + const queued: QueuedReport = { + id: ID, + fields: { category: 'littering' }, + photo: new File(['x'], 'photo.png', { type: 'image/png' }), + queuedAt: new Date().toISOString(), + } + + const photo = toFormData(queued).get('photo') + + expect(photo).toBeInstanceOf(File) + expect((photo as File).name).toBe('photo.png') + }) +})