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