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

+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)
})
}