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

+39
View File
@@ -0,0 +1,39 @@
import { count, eq } from 'drizzle-orm'
import type { Config } from '../config.ts'
import type { Db } from '../db/client.ts'
import { attachments } from '../db/schema.ts'
import type { FileStorage } from '../storage.ts'
import { detectFileType } from './file-type.ts'
import { fail } from './http.ts'
export const MAX_ATTACHMENTS_PER_INCIDENT = 20
/** Validates and stores one uploaded file against an incident. */
export async function saveAttachment(
{ db, storage, config }: { db: Db; storage: FileStorage; config: Config },
incidentId: string,
file: unknown,
uploadedById: string | null,
) {
if (!(file instanceof File)) throw fail(400, 'Attach a file in the "file" field')
if (file.size > config.maxUploadBytes) throw fail(413, `Files can be at most ${Math.round(config.maxUploadBytes / 1024 / 1024)} MB`)
const [{ existing }] = await db.select({ existing: count() }).from(attachments).where(eq(attachments.incidentId, incidentId))
if (existing >= MAX_ATTACHMENTS_PER_INCIDENT) throw fail(409, `An incident can have at most ${MAX_ATTACHMENTS_PER_INCIDENT} attachments`)
const data = new Uint8Array(await file.arrayBuffer())
const type = detectFileType(data)
if (!type) throw fail(400, 'Only photos (JPEG, PNG, WebP, GIF, HEIC) and PDFs can be attached')
const storageKey = await storage.save(data, type.extension)
const fileName = (file.name || `attachment.${type.extension}`).replace(/[\r\n"\\/]/g, '_').slice(0, 200)
try {
const [row] = await db
.insert(attachments)
.values({ incidentId, fileName, contentType: type.contentType, sizeBytes: data.byteLength, storageKey, uploadedById })
.returning()
return row
} catch (error) {
await storage.remove(storageKey)
throw error
}
}
+7 -3
View File
@@ -6,7 +6,7 @@ import { incidentReference } from '../../shared/format.ts'
import { availableTransitions } from '../../shared/workflow.ts'
import type { SessionUser } from '../auth/sessions.ts'
import type { Db } from '../db/client.ts'
import { areas, attachments, correctiveActions, incidents, investigations, rootCauses, sites, users } from '../db/schema.ts'
import { areas, attachments, correctiveActions, incidents, investigations, kiosks, rootCauses, sites, users } from '../db/schema.ts'
import { audit } from './audit.ts'
import { iso, localDate } from './dates.ts'
import { fail } from './http.ts'
@@ -15,16 +15,17 @@ const reporters = alias(users, 'reporter')
export function selectIncidents(db: Db) {
return db
.select({ incident: incidents, siteName: sites.name, areaName: areas.name, reporterName: reporters.name })
.select({ incident: incidents, siteName: sites.name, areaName: areas.name, reporterName: reporters.name, kioskName: kiosks.name })
.from(incidents)
.innerJoin(sites, eq(sites.id, incidents.siteId))
.leftJoin(areas, eq(areas.id, incidents.areaId))
.leftJoin(reporters, eq(reporters.id, incidents.reporterId))
.leftJoin(kiosks, eq(kiosks.id, incidents.kioskId))
}
type IncidentRow = Awaited<ReturnType<ReturnType<typeof selectIncidents>['execute']>>[number]
export function toIncident({ incident, siteName, areaName, reporterName }: IncidentRow): Incident {
export function toIncident({ incident, siteName, areaName, reporterName, kioskName }: IncidentRow): Incident {
return {
id: incident.id,
number: incident.number,
@@ -44,6 +45,9 @@ export function toIncident({ incident, siteName, areaName, reporterName }: Incid
immediateAction: incident.immediateAction,
anonymous: incident.anonymous,
reporter: incident.reporterId ? { id: incident.reporterId, name: reporterName ?? '' } : null,
reporterName: incident.reporterName,
channel: incident.channel,
kiosk: incident.kioskId ? { id: incident.kioskId, name: kioskName ?? '' } : null,
}
}
+115
View File
@@ -0,0 +1,115 @@
import { and, eq, isNull, sql } from 'drizzle-orm'
import type { IncidentType, Severity } from '../../shared/enums.ts'
import type { SessionUser } from '../auth/sessions.ts'
import { isUniqueViolation, type Db } from '../db/client.ts'
import { areas, incidentCounters, incidents, sites } from '../db/schema.ts'
import type { Notifier } from '../notifications.ts'
import { fail } from './http.ts'
import { auditIncident } from './incidents.ts'
/** Reports may be dated slightly ahead to absorb clock differences between devices. */
const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000
export function parseOccurredAt(value: string): Date {
const date = new Date(value)
if (date.getTime() > Date.now() + MAX_CLOCK_SKEW_MS) throw fail(400, 'Occurrence time cannot be in the future')
return date
}
export async function assertLocation(db: Db, organizationId: string, siteId: string, areaId: string | null | undefined) {
const [site] = await db
.select({ id: sites.id })
.from(sites)
.where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId), isNull(sites.archivedAt)))
if (!site) throw fail(400, 'Unknown site')
if (areaId) {
const [area] = await db
.select({ id: areas.id })
.from(areas)
.where(and(eq(areas.id, areaId), eq(areas.siteId, siteId)))
if (!area) throw fail(400, 'Area must belong to the selected site')
}
}
export interface NewReport {
organizationId: string
title: string
type: IncidentType
severity: Severity
occurredAt: string
siteId: string
areaId?: string | null
locationDetail?: string | null
description?: string | null
immediateAction?: string | null
clientId?: string
/** Signed-in reporter, or null for anonymous and kiosk reports. */
reporter: SessionUser | null
reporterName?: string | null
anonymous: boolean
kioskId?: string
}
/**
* Files a report with the next incident number. If `clientId` was already used in the organization
* (a retried submission), returns the existing incident instead of creating a duplicate.
*/
export async function fileReport(db: Db, notifier: Notifier, report: NewReport): Promise<{ id: string; number: number; created: boolean }> {
const { organizationId, reporter, anonymous, kioskId, occurredAt, clientId, ...fields } = report
await assertLocation(db, organizationId, fields.siteId, fields.areaId)
const occurred = parseOccurredAt(occurredAt)
const existing = async () => {
if (!clientId) return undefined
const [row] = await db
.select({ id: incidents.id, number: incidents.number })
.from(incidents)
.where(and(eq(incidents.organizationId, organizationId), eq(incidents.clientId, clientId)))
return row && { ...row, created: false }
}
const previous = await existing()
if (previous) return previous
try {
const created = await db.transaction(async (tx) => {
// Atomic increment: concurrent reports always get distinct, gap-free numbers.
const [{ lastNumber }] = await tx
.insert(incidentCounters)
.values({ organizationId, lastNumber: 1 })
.onConflictDoUpdate({ target: incidentCounters.organizationId, set: { lastNumber: sql`${incidentCounters.lastNumber} + 1` } })
.returning({ lastNumber: incidentCounters.lastNumber })
const [incident] = await tx
.insert(incidents)
.values({
...fields,
occurredAt: occurred,
organizationId,
number: lastNumber,
clientId,
anonymous,
reporterId: anonymous ? null : (reporter?.id ?? null),
reporterName: anonymous ? null : (fields.reporterName ?? null),
channel: kioskId ? 'kiosk' : 'web',
kioskId,
})
.returning({ id: incidents.id, number: incidents.number })
await auditIncident(tx, anonymous ? null : reporter, organizationId, incident.id, 'create', {
number: lastNumber,
title: fields.title,
type: fields.type,
severity: fields.severity,
...(kioskId ? { kioskId } : {}),
})
return incident
})
notifier.incidentReported(created.id)
return { ...created, created: true }
} catch (error) {
// Two copies of the same submission raced; the other one won.
if (clientId && isUniqueViolation(error)) {
const winner = await existing()
if (winner) return winner
}
throw error
}
}