feat(i18n): add internationalization support with English and Spanish translations
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:
1 parent
3f6dff5f24
commit
3e964d1176
64 files changed
+10056
-267
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user