import { and, asc, count, desc, eq, ilike, isNull, or, sql, type SQL } from 'drizzle-orm' import { Hono } from 'hono' import { bodyLimit } from 'hono/body-limit' import { z } from 'zod' import type { Comment, TimelineEntry } from '../../shared/api-types.ts' import { hasRole } from '../../shared/enums.ts' import { commentSchema, createActionSchema, createIncidentSchema, incidentListQuery, investigationSchema, rootCauseSchema, transitionSchema, updateIncidentSchema, type IncidentListQuery, } from '../../shared/schemas.ts' 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 { diff } from '../lib/audit.ts' import { toCsv } from '../lib/csv.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' 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) { let search: SQL | undefined if (q) { const pattern = `%${escapeLike(q)}%` const number = /^(?:inc-?)?(\d+)$/i.exec(q)?.[1] search = or( ilike(incidents.title, pattern), ilike(incidents.description, pattern), ilike(incidents.locationDetail, pattern), number ? eq(incidents.number, Number(number)) : undefined, ) } return and( visibleTo(db, user), search, type ? eq(incidents.type, type) : undefined, severity ? eq(incidents.severity, severity) : undefined, status ? eq(incidents.status, status) : undefined, siteId ? eq(incidents.siteId, siteId) : undefined, ) } /** Creates the incident's investigation if it doesn't exist yet and returns its id. */ async function ensureInvestigation(db: Db, incidentId: string): Promise { await db.insert(investigations).values({ incidentId }).onConflictDoNothing({ target: investigations.incidentId }) const [row] = await db.select({ id: investigations.id }).from(investigations).where(eq(investigations.incidentId, incidentId)) return row.id } export const incidentRoutes = new Hono() .get('/', requireAuth, validate('query', incidentListQuery), async (c) => { const me = currentUser(c) const { page, pageSize, ...filters } = c.req.valid('query') const db = c.get('db') const where = listFilter(db, me, filters) const [{ total }] = await db.select({ total: count() }).from(incidents).where(where) const rows = await selectIncidents(db) .where(where) .orderBy(desc(incidents.occurredAt), desc(incidents.number)) .limit(pageSize) .offset((page - 1) * pageSize) return c.json({ items: rows.map(toIncident), page, pageSize, total }) }) .get('/export', requireAuth, validate('query', incidentListQuery.omit({ page: true, pageSize: true })), async (c) => { const me = currentUser(c) const db = c.get('db') const rows = await selectIncidents(db) .where(listFilter(db, me, c.req.valid('query'))) .orderBy(desc(incidents.occurredAt), desc(incidents.number)) .limit(EXPORT_LIMIT) const csv = toCsv( ['Reference', 'Title', 'Type', 'Severity', 'Status', 'Occurred at', 'Reported at', 'Closed at', 'Site', 'Area', 'Location detail', 'Reporter', 'Description', 'Immediate action'], rows.map(toIncident).map((i) => [ i.reference, i.title, i.type, i.severity, i.status, i.occurredAt, i.reportedAt, i.closedAt, i.site.name, i.area?.name, i.locationDetail, i.anonymous ? 'Anonymous' : i.reporter?.name, i.description, i.immediateAction, ]), ) const date = new Date().toISOString().slice(0, 10) return c.body(csv, 200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="incidents-${date}.csv"` }) }) .post('/', requireAuth, validate('json', createIncidentSchema), async (c) => { const me = currentUser(c) const { anonymous, occurredAt, ...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) }) .get('/:number', requireAuth, validate('param', numberParam), async (c) => { return c.json({ incident: await loadDetail(c.get('db'), currentUser(c), c.req.valid('param').number) }) }) .patch('/:number', requireRole('supervisor'), validate('param', numberParam), validate('json', updateIncidentSchema), async (c) => { const me = currentUser(c) const db = c.get('db') const { incident } = await findIncident(db, me, c.req.valid('param').number) const { occurredAt, ...input } = c.req.valid('json') const siteId = input.siteId ?? incident.siteId let areaId = input.areaId !== undefined ? input.areaId : incident.areaId if (input.siteId && input.siteId !== incident.siteId && input.areaId === undefined) areaId = null // moving site clears the old area if (input.siteId !== undefined || input.areaId !== undefined) await assertLocation(db, me.organizationId, siteId, areaId) const update = { ...input, areaId, occurredAt: occurredAt ? parseOccurredAt(occurredAt) : undefined } const changes = diff(incident, update) if (Object.keys(changes).length > 0) { await db.update(incidents).set(update).where(eq(incidents.id, incident.id)) await auditIncident(db, me, me.organizationId, incident.id, 'update', changes) } return c.json({ incident: await loadDetail(db, me, incident.number) }) }) .post('/:number/transitions', requireAuth, validate('param', numberParam), validate('json', transitionSchema), async (c) => { const me = currentUser(c) const db = c.get('db') const { to, note } = c.req.valid('json') const detail = await loadDetail(db, me, c.req.valid('param').number) const rule = transitions.find((t) => t.from === detail.status && t.to === to) if (!rule) throw fail(409, `An incident in "${detail.status}" can't move to "${to}"`) if (!hasRole(me.role, rule.minRole)) throw fail(403, 'You do not have permission to do that') const blocker = transitionBlocker(rule, { severity: detail.severity, rootCauseCount: detail.rootCauses.length, actionStatuses: detail.actions.map((action) => action.status), }) if (blocker) throw fail(409, blocker) // Conditional update guards against two people moving the same incident at once. const moved = await db .update(incidents) .set({ status: to, closedAt: to === 'closed' ? new Date() : null }) .where(and(eq(incidents.id, detail.id), eq(incidents.status, detail.status))) .returning({ id: incidents.id }) if (moved.length === 0) throw fail(409, 'This incident was just changed by someone else. Reload and try again.') await auditIncident(db, me, me.organizationId, detail.id, 'status', { status: [detail.status, to], ...(note ? { note } : {}) }) if (note) await db.insert(comments).values({ incidentId: detail.id, authorId: me.id, body: note }) return c.json({ incident: await loadDetail(db, me, detail.number) }) }) .get('/:number/comments', requireAuth, validate('param', numberParam), async (c) => { const db = c.get('db') const { incident } = await findIncident(db, currentUser(c), c.req.valid('param').number) const rows = await db .select({ comment: comments, authorName: users.name }) .from(comments) .leftJoin(users, eq(users.id, comments.authorId)) .where(eq(comments.incidentId, incident.id)) .orderBy(asc(comments.createdAt)) const result: Comment[] = rows.map(({ comment, authorName }) => ({ id: comment.id, author: comment.authorId ? { id: comment.authorId, name: authorName ?? '' } : null, body: comment.body, createdAt: iso(comment.createdAt), })) return c.json({ comments: result }) }) .post('/:number/comments', requireAuth, validate('param', numberParam), validate('json', commentSchema), async (c) => { const me = currentUser(c) const db = c.get('db') const { incident } = await findIncident(db, me, c.req.valid('param').number) const [comment] = await db.insert(comments).values({ incidentId: incident.id, authorId: me.id, body: c.req.valid('json').body }).returning() const result: Comment = { id: comment.id, author: { id: me.id, name: me.name }, body: comment.body, createdAt: iso(comment.createdAt) } return c.json({ comment: result }, 201) }) .get('/:number/timeline', requireAuth, validate('param', numberParam), async (c) => { const me = currentUser(c) const db = c.get('db') const { incident } = await findIncident(db, me, c.req.valid('param').number) const rows = await db .select({ entry: auditLog, actorName: users.name }) .from(auditLog) .leftJoin(users, eq(users.id, auditLog.actorId)) .where(and(eq(auditLog.organizationId, me.organizationId), eq(auditLog.entityType, 'incident'), eq(auditLog.entityId, incident.id))) .orderBy(asc(auditLog.id)) const timeline: TimelineEntry[] = rows.map(({ entry, actorName }) => ({ id: entry.id, at: iso(entry.createdAt), actor: entry.actorId ? { id: entry.actorId, name: actorName ?? '' } : null, action: entry.action, changes: (entry.changes as Record | null) ?? null, })) return c.json({ timeline }) }) .put('/:number/investigation', requireRole('supervisor'), validate('param', numberParam), validate('json', investigationSchema), async (c) => { const me = currentUser(c) const db = c.get('db') const { incident } = await findIncident(db, me, c.req.valid('param').number) const { completed, ...input } = c.req.valid('json') if (input.leadId) await assertActiveUser(db, me.organizationId, input.leadId) const investigationId = await ensureInvestigation(db, incident.id) const [current] = await db.select().from(investigations).where(eq(investigations.id, investigationId)) const update = { ...input, completedAt: completed === undefined ? undefined : completed ? (current.completedAt ?? new Date()) : null } const changes = diff(current, update) if (Object.keys(changes).length > 0) { await db.update(investigations).set(update).where(eq(investigations.id, investigationId)) await auditIncident(db, me, me.organizationId, incident.id, 'investigation.update', changes) } return c.json({ incident: await loadDetail(db, me, incident.number) }) }) .post('/:number/root-causes', requireRole('supervisor'), validate('param', numberParam), validate('json', rootCauseSchema), async (c) => { const me = currentUser(c) const db = c.get('db') const { incident } = await findIncident(db, me, c.req.valid('param').number) const input = c.req.valid('json') const investigationId = await ensureInvestigation(db, incident.id) await db.insert(rootCauses).values({ ...input, investigationId }) await auditIncident(db, me, me.organizationId, incident.id, 'root_cause.add', { category: input.category, description: input.description }) return c.json({ incident: await loadDetail(db, me, incident.number) }, 201) }) .post('/:number/actions', requireRole('supervisor'), validate('param', numberParam), validate('json', createActionSchema), async (c) => { const me = currentUser(c) const db = c.get('db') const { incident } = await findIncident(db, me, c.req.valid('param').number) if (incident.status === 'closed') throw fail(409, 'Reopen the incident before adding actions') const input = c.req.valid('json') if (input.assigneeId) await assertActiveUser(db, me.organizationId, input.assigneeId) if (input.rootCauseId) { const [cause] = await db .select({ id: rootCauses.id }) .from(rootCauses) .innerJoin(investigations, eq(investigations.id, rootCauses.investigationId)) .where(and(eq(rootCauses.id, input.rootCauseId), eq(investigations.incidentId, incident.id))) if (!cause) throw fail(400, 'Root cause must belong to this incident') } const [action] = await db .insert(correctiveActions) .values({ ...input, incidentId: incident.id }) .returning() await auditIncident(db, me, me.organizationId, incident.id, 'action.add', { actionId: action.id, title: action.title, assigneeId: action.assigneeId, dueDate: action.dueDate }) if (action.assigneeId && action.assigneeId !== me.id) c.get('notifier').actionAssigned(action.id, me.name) return c.json({ incident: await loadDetail(db, me, incident.number) }, 201) }) .post( '/:number/attachments', requireAuth, validate('param', numberParam), // Reject oversized bodies before reading them into memory async (c, next) => bodyLimit({ maxSize: c.get('config').maxUploadBytes + 64 * 1024, onError: (c) => c.json({ error: 'File is too large' }, 413) })(c, next), async (c) => { const me = currentUser(c) const db = c.get('db') const config = c.get('config') 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 } }, )