feat(i18n): add internationalization support with English and Spanish translations
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s

feat(kiosk): implement kiosk report submission functionality with offline support

test(outbox): add tests for outbox functionality including report submission and error handling

feat(outbox): create outbox management for queued reports with IndexedDB persistence

feat(uuid): add UUID generation utility for client IDs

feat(qr): implement QR code generation for reporting links by site and area

feat(kiosk): create Kiosk page for reporting incidents without an account
This commit is contained in:
jayjennings committed 2026-10-06 11:38:40 -07:00
1 parent 3f6dff5f24
commit 3e964d1176
64 files changed
+10056 -267

No files matched your search

+213
View File
@@ -0,0 +1,213 @@
// 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<IDBDatabase> {
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<T>(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest<T>): Promise<T> {
const db = await openDb()
try {
return await new Promise<T>((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<void>): Promise<SendResult> {
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<SendResult> {
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<void> | undefined
getState = () => this.state
subscribe = (listener: () => void) => {
this.listeners.add(listener)
void this.load()
return () => this.listeners.delete(listener)
}
private set(partial: Partial<OutboxState>) {
this.state = { ...this.state, ...partial }
for (const listener of this.listeners) listener()
}
load(): Promise<void> {
this.loaded ??= this.refresh()
return this.loaded
}
private async refresh() {
try {
const items = await withStore<QueuedItem[]>('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<SendResult> {
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<SendResult> {
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<void> {
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()