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
214 lines
7.1 KiB
TypeScript
214 lines
7.1 KiB
TypeScript
// 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()
|