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

+3
View File
@@ -11,6 +11,7 @@ import { actionRoutes } from './routes/actions.ts'
import { attachmentRoutes } from './routes/attachments.ts'
import { authRoutes } from './routes/auth.ts'
import { incidentRoutes } from './routes/incidents.ts'
import { kioskAdminRoutes, kioskPublicRoutes } from './routes/kiosks.ts'
import { rootCauseRoutes } from './routes/root-causes.ts'
import { setupRoutes } from './routes/setup.ts'
import { areaRoutes, siteRoutes } from './routes/sites.ts'
@@ -54,6 +55,8 @@ export function createApp({ db, config, notifier, storage }: AppDeps) {
app.route('/api/actions', actionRoutes)
app.route('/api/attachments', attachmentRoutes)
app.route('/api/stats', statsRoutes)
app.route('/api/kiosks', kioskAdminRoutes)
app.route('/api/kiosk', kioskPublicRoutes())
app.all('/api/*', (c) => c.json({ error: 'Not found' }, 404))
app.onError((error, c) => {
@@ -0,0 +1,24 @@
CREATE TYPE "public"."report_channel" AS ENUM('web', 'kiosk');--> statement-breakpoint
CREATE TABLE "kiosks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"name" text NOT NULL,
"token" text NOT NULL,
"site_id" uuid,
"created_by_id" uuid,
"last_used_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "incidents" ADD COLUMN "reporter_name" text;--> statement-breakpoint
ALTER TABLE "incidents" ADD COLUMN "channel" "report_channel" DEFAULT 'web' NOT NULL;--> statement-breakpoint
ALTER TABLE "incidents" ADD COLUMN "kiosk_id" uuid;--> statement-breakpoint
ALTER TABLE "incidents" ADD COLUMN "client_id" uuid;--> statement-breakpoint
ALTER TABLE "kiosks" ADD CONSTRAINT "kiosks_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "kiosks" ADD CONSTRAINT "kiosks_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "kiosks" ADD CONSTRAINT "kiosks_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "kiosks_token_unique" ON "kiosks" USING btree ("token");--> statement-breakpoint
CREATE INDEX "kiosks_organization_idx" ON "kiosks" USING btree ("organization_id");--> statement-breakpoint
ALTER TABLE "incidents" ADD CONSTRAINT "incidents_kiosk_id_kiosks_id_fk" FOREIGN KEY ("kiosk_id") REFERENCES "public"."kiosks"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "incidents_organization_client_id_unique" ON "incidents" USING btree ("organization_id","client_id");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -15,6 +15,13 @@
"when": 1791309275942,
"tag": "0001_workflow_and_email",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1791310953907,
"tag": "0002_kiosks_and_offline",
"breakpoints": true
}
]
}
+31
View File
@@ -13,6 +13,7 @@ export const rootCauseCategoryEnum = pgEnum('root_cause_category', vocab.rootCau
export const actionKindEnum = pgEnum('action_kind', vocab.actionKinds)
export const actionStatusEnum = pgEnum('action_status', vocab.actionStatuses)
export const authTokenPurposeEnum = pgEnum('auth_token_purpose', ['magic_link', 'password_reset'])
export const reportChannelEnum = pgEnum('report_channel', ['web', 'kiosk'])
const id = () => uuid().primaryKey().defaultRandom()
const timestamptz = () => timestamp({ withTimezone: true })
@@ -115,6 +116,29 @@ export const areas = pgTable(
(t) => [index('areas_site_idx').on(t.siteId), index('areas_parent_idx').on(t.parentId)],
)
/**
* Reporting links let people without accounts file reports: on a shared kiosk tablet, or by scanning
* a QR code. The token only allows creating reports, so it is stored as-is to let admins reprint QR codes.
*/
export const kiosks = pgTable(
'kiosks',
{
id: id(),
organizationId: uuid()
.notNull()
.references(() => organizations.id),
name: text().notNull(),
token: text().notNull(),
/** Limits the link to one site; null allows any. */
siteId: uuid().references(() => sites.id, { onDelete: 'set null' }),
createdById: uuid().references(() => users.id, { onDelete: 'set null' }),
lastUsedAt: timestamptz(),
revokedAt: timestamptz(),
createdAt: createdAt(),
},
(t) => [uniqueIndex('kiosks_token_unique').on(t.token), index('kiosks_organization_idx').on(t.organizationId)],
)
/** Per-organization counter for gap-free, human-friendly incident numbers. */
export const incidentCounters = pgTable('incident_counters', {
organizationId: uuid()
@@ -146,12 +170,19 @@ export const incidents = pgTable(
/** Null when reported anonymously. */
reporterId: uuid().references(() => users.id, { onDelete: 'set null' }),
anonymous: boolean().notNull().default(false),
/** Name typed by someone reporting without an account (kiosk/QR). */
reporterName: text(),
channel: reportChannelEnum().notNull().default('web'),
kioskId: uuid().references(() => kiosks.id, { onDelete: 'set null' }),
/** Generated on the reporting device so retries after a lost response don't create duplicates. */
clientId: uuid(),
closedAt: timestamptz(),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [
uniqueIndex('incidents_organization_number_unique').on(t.organizationId, t.number),
uniqueIndex('incidents_organization_client_id_unique').on(t.organizationId, t.clientId),
index('incidents_organization_occurred_idx').on(t.organizationId, t.occurredAt),
index('incidents_organization_status_idx').on(t.organizationId, t.status),
index('incidents_reporter_idx').on(t.reporterId),
+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
}
}
+12 -79
View File
@@ -1,4 +1,4 @@
import { and, asc, count, desc, eq, ilike, isNull, or, sql, type SQL } from 'drizzle-orm'
import { and, asc, count, desc, eq, ilike, or, type SQL } from 'drizzle-orm'
import { Hono } from 'hono'
import { bodyLimit } from 'hono/body-limit'
import { z } from 'zod'
@@ -19,42 +19,19 @@ import { transitionBlocker, transitions } from '../../shared/workflow.ts'
import { requireAuth, requireRole } from '../auth/middleware.ts'
import type { SessionUser } from '../auth/sessions.ts'
import type { Db } from '../db/client.ts'
import { areas, attachments, auditLog, comments, correctiveActions, incidentCounters, incidents, investigations, rootCauses, sites, users } from '../db/schema.ts'
import { auditLog, comments, correctiveActions, incidents, investigations, rootCauses, users } from '../db/schema.ts'
import { diff } from '../lib/audit.ts'
import { toCsv } from '../lib/csv.ts'
import { saveAttachment } from '../lib/attachments.ts'
import { iso } from '../lib/dates.ts'
import { detectFileType } from '../lib/file-type.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
import { assertActiveUser, auditIncident, findIncident, loadDetail, selectIncidents, toAttachment, toIncident, visibleTo } from '../lib/incidents.ts'
import { assertLocation, fileReport, parseOccurredAt } from '../lib/reporting.ts'
const numberParam = z.object({ number: z.coerce.number().int().positive() })
/** Reports may be dated slightly ahead to absorb clock differences between devices. */
const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000
const MAX_ATTACHMENTS_PER_INCIDENT = 20
const EXPORT_LIMIT = 10_000
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')
}
}
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
}
const escapeLike = (value: string) => value.replace(/[\\%_]/g, '\\$&')
function listFilter(db: Db, user: SessionUser, { q, type, severity, status, siteId }: Omit<IncidentListQuery, 'page' | 'pageSize'>) {
@@ -122,7 +99,7 @@ export const incidentRoutes = new Hono<AppEnv>()
i.site.name,
i.area?.name,
i.locationDetail,
i.anonymous ? 'Anonymous' : i.reporter?.name,
i.anonymous ? 'Anonymous' : (i.reporter?.name ?? i.reporterName ?? (i.kiosk ? `Reporting link: ${i.kiosk.name}` : null)),
i.description,
i.immediateAction,
]),
@@ -133,34 +110,11 @@ export const incidentRoutes = new Hono<AppEnv>()
.post('/', requireAuth, validate('json', createIncidentSchema), async (c) => {
const me = currentUser(c)
const { anonymous, occurredAt, ...input } = c.req.valid('json')
const input = c.req.valid('json')
const db = c.get('db')
await assertLocation(db, me.organizationId, input.siteId, input.areaId)
const occurred = parseOccurredAt(occurredAt)
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: me.organizationId, lastNumber: 1 })
.onConflictDoUpdate({ target: incidentCounters.organizationId, set: { lastNumber: sql`${incidentCounters.lastNumber} + 1` } })
.returning({ lastNumber: incidentCounters.lastNumber })
const [incident] = await tx
.insert(incidents)
.values({ ...input, occurredAt: occurred, organizationId: me.organizationId, number: lastNumber, anonymous, reporterId: anonymous ? null : me.id })
.returning({ id: incidents.id, number: incidents.number })
await auditIncident(tx, anonymous ? null : me, me.organizationId, incident.id, 'create', {
number: lastNumber,
title: input.title,
type: input.type,
severity: input.severity,
})
return incident
})
c.get('notifier').incidentReported(created.id)
const [row] = await selectIncidents(db).where(eq(incidents.id, created.id))
return c.json({ incident: toIncident(row) }, 201)
const { id, created } = await fileReport(db, c.get('notifier'), { ...input, organizationId: me.organizationId, reporter: me })
const [row] = await selectIncidents(db).where(eq(incidents.id, id))
return c.json({ incident: toIncident(row) }, created ? 201 : 200)
})
.get('/:number', requireAuth, validate('param', numberParam), async (c) => {
@@ -330,29 +284,8 @@ export const incidentRoutes = new Hono<AppEnv>()
const { incident } = await findIncident(db, me, c.req.valid('param').number)
const body = await c.req.parseBody()
const file = body.file
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, incident.id))
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 storage = c.get('storage')
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: incident.id, fileName, contentType: type.contentType, sizeBytes: data.byteLength, storageKey, uploadedById: me.id })
.returning()
await auditIncident(db, me, me.organizationId, incident.id, 'attachment.add', { attachmentId: row.id, fileName })
return c.json({ attachment: toAttachment(row, me.name) }, 201)
} catch (error) {
await storage.remove(storageKey)
throw error
}
const row = await saveAttachment({ db, storage: c.get('storage'), config }, incident.id, body.file, me.id)
await auditIncident(db, me, me.organizationId, incident.id, 'attachment.add', { attachmentId: row.id, fileName: row.fileName })
return c.json({ attachment: toAttachment(row, me.name) }, 201)
},
)
+124
View File
@@ -0,0 +1,124 @@
// oxlint-disable typescript/no-explicit-any -- tests read arbitrary JSON
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createTestContext, hoursAgo } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
let north: { id: string }
let south: { id: string }
const json = (res: Response): Promise<any> => res.json()
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52])
/** Submits like the kiosk page: multipart with the token header. */
function kioskReport(token: string, report: Record<string, unknown>, files: File[] = []) {
const form = new FormData()
form.append('report', JSON.stringify(report))
for (const file of files) form.append('files', file)
return ctx.app.request('/api/kiosk/reports', { method: 'POST', headers: { 'x-kiosk-token': token, origin: 'http://safety.test' }, body: form })
}
const report = (overrides: Record<string, unknown> = {}) => ({ title: 'Leaking valve', type: 'hazard', severity: 'medium', occurredAt: hoursAgo(1), siteId: north.id, ...overrides })
beforeAll(async () => {
ctx = await createTestContext()
north = await ctx.createSite('North')
south = await ctx.createSite('South')
})
afterAll(() => ctx.close())
describe('reporting links', () => {
it('are managed by safety managers', async () => {
const supervisor = await ctx.createUser('supervisor')
expect((await supervisor.post('/api/kiosks', { name: 'Canteen tablet' })).status).toBe(403)
const res = await ctx.admin.post('/api/kiosks', { name: 'Canteen tablet' })
expect(res.status).toBe(201)
expect(res.body.kiosk.token).toMatch(/^[\w-]{32}$/)
expect((await ctx.admin.get('/api/kiosks')).body.kiosks.map((k: { name: string }) => k.name)).toContain('Canteen tablet')
})
it('let anyone with the token file a report, with photos, and nothing more', async () => {
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Gate 2' })).body
const config = await ctx.app.request('/api/kiosk/config', { headers: { 'x-kiosk-token': kiosk.token } })
expect(await json(config)).toMatchObject({ organization: { name: 'Northstar Co.' }, kiosk: { name: 'Gate 2' } })
const res = await kioskReport(kiosk.token, report({ reporterName: 'Luis' }), [new File([PNG], 'valve.png', { type: 'image/png' })])
expect(res.status).toBe(201)
const { reference, failedFiles } = await json(res)
expect(reference).toMatch(/^INC-\d{4}$/)
expect(failedFiles).toEqual([])
const number = Number(reference.slice(4))
const incident = (await ctx.admin.get(`/api/incidents/${number}`)).body.incident
expect(incident).toMatchObject({ channel: 'kiosk', kiosk: { name: 'Gate 2' }, reporterName: 'Luis', reporter: null, anonymous: false })
expect(incident.attachments).toHaveLength(1)
// The token is not a session
expect((await ctx.app.request('/api/incidents', { headers: { 'x-kiosk-token': kiosk.token } })).status).toBe(401)
})
it('treat reports without a name as anonymous', async () => {
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Anon' })).body
const { reference } = await json(await kioskReport(kiosk.token, report()))
const incident = (await ctx.admin.get(`/api/incidents/${Number(reference.slice(4))}`)).body.incident
expect(incident).toMatchObject({ anonymous: true, reporterName: null })
})
it('stop working when revoked', async () => {
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Old tablet' })).body
await ctx.admin.patch(`/api/kiosks/${kiosk.id}`, { revoked: true })
expect((await kioskReport(kiosk.token, report())).status).toBe(401)
expect((await kioskReport('made-up-token', report())).status).toBe(401)
})
it('can be limited to one site', async () => {
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'North only', siteId: north.id })).body
const config = await json(await ctx.app.request('/api/kiosk/config', { headers: { 'x-kiosk-token': kiosk.token } }))
expect(config.sites.map((s: { id: string }) => s.id)).toEqual([north.id])
expect((await kioskReport(kiosk.token, report({ siteId: south.id }))).status).toBe(400)
})
it('validate reports', async () => {
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Validation' })).body
const res = await kioskReport(kiosk.token, report({ type: 'explosion' }))
expect(res.status).toBe(400)
expect((await json(res)).issues[0].path).toBe('type')
})
it('are rate limited', async () => {
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Busy' })).body
for (let i = 0; i < 30; i++) expect((await kioskReport(kiosk.token, report({ severity: 'low' }))).status).toBe(201)
expect((await kioskReport(kiosk.token, report({ severity: 'low' }))).status).toBe(429)
})
})
describe('resubmitting the same report (offline retries)', () => {
it('returns the original instead of creating a duplicate', async () => {
const reporter = await ctx.createUser('reporter')
const clientId = crypto.randomUUID()
const first = await reporter.post('/api/incidents', { ...report(), clientId })
const again = await reporter.post('/api/incidents', { ...report(), clientId })
expect(first.status).toBe(201)
expect(again.status).toBe(200)
expect(again.body.incident.number).toBe(first.body.incident.number)
})
it('holds under simultaneous retries', async () => {
const reporter = await ctx.createUser('reporter', 'racer@example.com')
const clientId = crypto.randomUUID()
const results = await Promise.all([1, 2, 3].map(() => reporter.post('/api/incidents', { ...report(), clientId })))
expect(new Set(results.map((r) => r.body.incident.number)).size).toBe(1)
})
it('does not attach photos twice from a kiosk retry', async () => {
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Retry' })).body
const clientId = crypto.randomUUID()
const file = () => new File([PNG], 'p.png', { type: 'image/png' })
const a = await json(await kioskReport(kiosk.token, report({ clientId }), [file()]))
const b = await kioskReport(kiosk.token, report({ clientId }), [file()])
expect(b.status).toBe(200)
expect((await json(b)).reference).toBe(a.reference)
const incident = (await ctx.admin.get(`/api/incidents/${Number(a.reference.slice(4))}`)).body.incident
expect(incident.attachments).toHaveLength(1)
})
})
+177
View File
@@ -0,0 +1,177 @@
import { and, asc, eq, inArray, isNull } from 'drizzle-orm'
import { Hono, type Context } from 'hono'
import { bodyLimit } from 'hono/body-limit'
import { randomBytes } from 'node:crypto'
import { z } from 'zod'
import type { Kiosk, KioskConfig } from '../../shared/api-types.ts'
import { incidentReference } from '../../shared/format.ts'
import { createKioskSchema, kioskReportSchema, updateKioskSchema } from '../../shared/schemas.ts'
import { requireRole } from '../auth/middleware.ts'
import { RateLimiter } from '../auth/rate-limit.ts'
import type { Db } from '../db/client.ts'
import { areas, kiosks, organizations, sites } from '../db/schema.ts'
import { saveAttachment } from '../lib/attachments.ts'
import { audit, diff } from '../lib/audit.ts'
import { iso } from '../lib/dates.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
import { auditIncident } from '../lib/incidents.ts'
import { fileReport } from '../lib/reporting.ts'
export const KIOSK_HEADER = 'x-kiosk-token'
const MAX_FILES_PER_REPORT = 5
const idParam = z.object({ id: z.uuid() })
async function toKiosk(db: Db, row: typeof kiosks.$inferSelect): Promise<Kiosk> {
const [site] = row.siteId ? await db.select({ id: sites.id, name: sites.name }).from(sites).where(eq(sites.id, row.siteId)) : []
return { id: row.id, name: row.name, token: row.token, site: site ?? null, lastUsedAt: iso(row.lastUsedAt), revokedAt: iso(row.revokedAt), createdAt: iso(row.createdAt) }
}
async function assertSite(db: Db, organizationId: string, siteId: string | null | undefined) {
if (!siteId) return
const [site] = await db.select({ id: sites.id }).from(sites).where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId)))
if (!site) throw fail(400, 'Unknown site')
}
/** Managing reporting links (signed-in safety managers). */
export const kioskAdminRoutes = new Hono<AppEnv>()
.get('/', requireRole('safety_manager'), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const rows = await db.select().from(kiosks).where(eq(kiosks.organizationId, me.organizationId)).orderBy(asc(kiosks.name))
return c.json({ kiosks: await Promise.all(rows.map((row) => toKiosk(db, row))) })
})
.post('/', requireRole('safety_manager'), validate('json', createKioskSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const input = c.req.valid('json')
await assertSite(db, me.organizationId, input.siteId)
const [row] = await db
.insert(kiosks)
.values({ ...input, organizationId: me.organizationId, token: randomBytes(24).toString('base64url'), createdById: me.id })
.returning()
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'kiosk', entityId: row.id, action: 'create', changes: { name: row.name } })
return c.json({ kiosk: await toKiosk(db, row) }, 201)
})
.patch('/:id', requireRole('safety_manager'), validate('param', idParam), validate('json', updateKioskSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const [current] = await db
.select()
.from(kiosks)
.where(and(eq(kiosks.id, c.req.valid('param').id), eq(kiosks.organizationId, me.organizationId)))
if (!current) throw fail(404, 'Reporting link not found')
const { revoked, ...fields } = c.req.valid('json')
await assertSite(db, me.organizationId, fields.siteId)
const update = { ...fields, revokedAt: revoked === undefined ? undefined : revoked ? (current.revokedAt ?? new Date()) : null }
const changes = diff(current, update)
if (Object.keys(changes).length === 0) return c.json({ kiosk: await toKiosk(db, current) })
const [row] = await db.update(kiosks).set(update).where(eq(kiosks.id, current.id)).returning()
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'kiosk', entityId: row.id, action: 'update', changes })
return c.json({ kiosk: await toKiosk(db, row) })
})
/**
* Endpoints used with a reporting link's token instead of a session. The token travels in a custom
* header, which browsers never send cross-site without a CORS preflight, so these can't be forged
* from another site.
*/
export function kioskPublicRoutes() {
// Each link can file 30 reports per 10 minutes: plenty for a busy shop floor, a brake on abuse.
const limiter = new RateLimiter(30, 10 * 60 * 1000)
async function kioskFrom(c: Context<AppEnv>) {
const token = c.req.header(KIOSK_HEADER)
if (!token) throw fail(401, 'Missing reporting link token')
const [row] = await c
.get('db')
.select()
.from(kiosks)
.where(and(eq(kiosks.token, token), isNull(kiosks.revokedAt)))
if (!row) throw fail(401, 'This reporting link is no longer active. Ask your safety team for a new one.')
return row
}
return new Hono<AppEnv>()
.get('/config', async (c) => {
const kiosk = await kioskFrom(c)
const db = c.get('db')
const [organization] = await db.select({ name: organizations.name }).from(organizations).where(eq(organizations.id, kiosk.organizationId))
const siteRows = await db
.select()
.from(sites)
.where(and(eq(sites.organizationId, kiosk.organizationId), isNull(sites.archivedAt), kiosk.siteId ? eq(sites.id, kiosk.siteId) : undefined))
.orderBy(asc(sites.name))
const areaRows = siteRows.length
? await db
.select()
.from(areas)
.where(and(inArray(areas.siteId, siteRows.map((s) => s.id)), isNull(areas.archivedAt)))
.orderBy(asc(areas.name))
: []
const config: KioskConfig = {
organization,
kiosk: { name: kiosk.name },
sites: siteRows.map((site) => ({
id: site.id,
name: site.name,
address: null,
timezone: site.timezone,
archivedAt: null,
areas: areaRows
.filter((area) => area.siteId === site.id)
.map((area) => ({ id: area.id, siteId: area.siteId, parentId: area.parentId, name: area.name, kind: area.kind, archivedAt: null })),
})),
}
return c.json(config)
})
// Multipart: a `report` field holding the JSON report, plus up to 5 `files`.
.post('/reports', async (c, next) => bodyLimit({ maxSize: c.get('config').maxUploadBytes * MAX_FILES_PER_REPORT + 256 * 1024, onError: (c) => c.json({ error: 'Files are too large' }, 413) })(c, next), async (c) => {
const kiosk = await kioskFrom(c)
if (!limiter.attempt(kiosk.id)) throw fail(429, 'Too many reports from this link right now. Please try again in a few minutes.')
const db = c.get('db')
const body = await c.req.parseBody({ all: true })
let json: unknown
try {
json = JSON.parse(String(body.report ?? ''))
} catch {
throw fail(400, 'Missing report')
}
const parsed = kioskReportSchema.safeParse(json)
if (!parsed.success) {
return c.json({ error: 'Invalid request', issues: parsed.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })) }, 400)
}
if (kiosk.siteId && parsed.data.siteId !== kiosk.siteId) throw fail(400, 'This reporting link is for a different site')
const files = ([] as unknown[]).concat(body.files ?? []).filter((file) => file instanceof File)
if (files.length > MAX_FILES_PER_REPORT) throw fail(400, `Attach at most ${MAX_FILES_PER_REPORT} photos`)
const { reporterName, ...report } = parsed.data
const { id, number, created } = await fileReport(db, c.get('notifier'), {
...report,
organizationId: kiosk.organizationId,
reporter: null,
reporterName,
anonymous: !reporterName,
kioskId: kiosk.id,
})
await db.update(kiosks).set({ lastUsedAt: new Date() }).where(eq(kiosks.id, kiosk.id))
// Files are only taken with the first copy of a submission, so retries don't duplicate photos.
const failed: string[] = []
if (created) {
for (const file of files) {
try {
const row = await saveAttachment({ db, storage: c.get('storage'), config: c.get('config') }, id, file, null)
await auditIncident(db, null, kiosk.organizationId, id, 'attachment.add', { attachmentId: row.id, fileName: row.fileName })
} catch (error) {
failed.push(`${(file as File).name}: ${error instanceof Error ? error.message : 'upload failed'}`)
}
}
}
// Only the reference goes back: people using a link can't read reports.
return c.json({ reference: incidentReference(number), failedFiles: failed }, created ? 201 : 200)
})
}