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
+12
-79
@@ -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)
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user