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
178 lines
8.5 KiB
TypeScript
178 lines
8.5 KiB
TypeScript
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)
|
|
})
|
|
}
|