// Reports waiting to be sent, stored in IndexedDB so they survive going offline, closing the tab and // restarting the device. Each has a client-generated id that the server uses to ignore duplicates, // so retrying is always safe. import type { CreateIncidentInput, KioskReportInput } from '../../shared/schemas.ts' import type { Incident } from '../../shared/api-types.ts' import { incidentReference } from '../../shared/format.ts' import { api, ApiError, errorMessage } from './api' import { sendKioskReport } from './kiosk' import { uploadAttachment } from './queries' export interface QueuedFile { name: string type: string blob: Blob /** Set once uploaded, so a retry after a partial upload doesn't send it twice. */ uploaded?: boolean } interface QueuedBase { clientId: string createdAt: string files: QueuedFile[] attempts: number lastError?: string /** The server refused it for a reason retrying won't fix; waits for the user to discard it. */ rejected?: boolean } export interface QueuedReport extends QueuedBase { kind: 'report' /** Only sent while this user is signed in. */ userId: string report: CreateIncidentInput & { clientId: string } /** Known once the report itself is saved, while photos may still be pending. */ incidentNumber?: number } export interface QueuedKioskReport extends QueuedBase { kind: 'kiosk' token: string report: KioskReportInput & { clientId: string } } export type QueuedItem = QueuedReport | QueuedKioskReport export type SendResult = | { status: 'sent'; reference: string; number?: number; failedFiles: string[] } | { status: 'queued' } | { status: 'rejected'; error: ApiError } // ---- IndexedDB ---- const DB_NAME = 'safetylog' const STORE = 'outbox' function openDb(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, 1) request.onupgradeneeded = () => request.result.createObjectStore(STORE, { keyPath: 'clientId' }) request.onsuccess = () => resolve(request.result) request.onerror = () => reject(request.error) }) } async function withStore(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest): Promise { const db = await openDb() try { return await new Promise((resolve, reject) => { const request = run(db.transaction(STORE, mode).objectStore(STORE)) request.onsuccess = () => resolve(request.result) request.onerror = () => reject(request.error) }) } finally { db.close() } } // ---- Sending ---- /** Worth retrying later: no connection, server down or overloaded, or signed out. */ export function isTransient(error: unknown): boolean { if (error instanceof TypeError) return true // fetch() network failure return error instanceof ApiError && (error.status >= 500 || error.status === 401 || error.status === 408 || error.status === 429) } async function sendReport(item: QueuedReport, save: (item: QueuedItem) => Promise): Promise { if (item.incidentNumber === undefined) { const { incident } = await api<{ incident: Incident }>('/incidents', { method: 'POST', body: item.report }) item.incidentNumber = incident.number await save(item) } const failedFiles: string[] = [] for (const file of item.files.filter((f) => !f.uploaded)) { try { await uploadAttachment(item.incidentNumber, new File([file.blob], file.name, { type: file.type })) } catch (error) { if (isTransient(error)) throw error failedFiles.push(`${file.name}: ${errorMessage(error)}`) // e.g. unsupported type; don't retry forever } file.uploaded = true await save(item) } return { status: 'sent', reference: incidentReference(item.incidentNumber), number: item.incidentNumber, failedFiles } } async function sendKiosk(item: QueuedKioskReport): Promise { const result = await sendKioskReport(item.token, item.report, item.files) return { status: 'sent', reference: result.reference, failedFiles: result.failedFiles } } // ---- Store with subscribers ---- export interface OutboxState { items: QueuedItem[] syncing: boolean } class Outbox { private state: OutboxState = { items: [], syncing: false } private listeners = new Set<() => void>() private loaded: Promise | undefined getState = () => this.state subscribe = (listener: () => void) => { this.listeners.add(listener) void this.load() return () => this.listeners.delete(listener) } private set(partial: Partial) { this.state = { ...this.state, ...partial } for (const listener of this.listeners) listener() } load(): Promise { this.loaded ??= this.refresh() return this.loaded } private async refresh() { try { const items = await withStore('readonly', (store) => store.getAll()) this.set({ items: items.sort((a, b) => a.createdAt.localeCompare(b.createdAt)) }) } catch (error) { console.warn('[outbox] storage unavailable', error) } } private save = async (item: QueuedItem) => { await withStore('readwrite', (store) => store.put(item)) await this.refresh() } private async remove(clientId: string) { await withStore('readwrite', (store) => store.delete(clientId)) await this.refresh() } discard = (clientId: string) => this.remove(clientId) private async attempt(item: QueuedItem): Promise { try { const result = item.kind === 'report' ? await sendReport(item, this.save) : await sendKiosk(item) await this.remove(item.clientId) return result } catch (error) { if (isTransient(error)) { item.attempts += 1 item.lastError = error instanceof ApiError && error.status === 401 ? 'Sign in to send' : errorMessage(error) await this.save(item) return { status: 'queued' } } const apiError = error instanceof ApiError ? error : new ApiError(0, errorMessage(error)) item.lastError = apiError.message item.rejected = true await this.save(item) return { status: 'rejected', error: apiError } } } /** * Saves the report on the device first, then tries to send it straight away. Rejected reports * (e.g. a validation error the user can fix) are removed again so the form can show the error. */ async submit(item: QueuedItem): Promise { await this.load() await this.save(item) if (!navigator.onLine) return { status: 'queued' } const result = await this.attempt(item) if (result.status === 'rejected') await this.remove(item.clientId) return result } /** Sends every waiting item that `belongs` returns true for, oldest first, stopping when offline. */ async flush(belongs: (item: QueuedItem) => boolean): Promise { await this.load() if (this.state.syncing || !navigator.onLine) return const pending = this.state.items.filter((item) => belongs(item) && !item.rejected) if (pending.length === 0) return this.set({ syncing: true }) try { for (const item of pending) { const result = await this.attempt(item) if (result.status === 'queued') break } } finally { this.set({ syncing: false }) } } } export const outbox = new Outbox()