import { and, count, desc, eq, ilike, isNull, or, sql, type SQL } from 'drizzle-orm' import { Hono } from 'hono' import { z } from 'zod' import { hasRole } from '../../shared/enums.ts' import { incidentReference } from '../../shared/format.ts' import { createIncidentSchema, incidentListQuery, updateIncidentSchema } from '../../shared/schemas.ts' import type { SessionUser } from '../auth/sessions.ts' import { requireAuth, requireRole } from '../auth/middleware.ts' import type { Db } from '../db/client.ts' import { areas, incidentCounters, incidents, sites, users } from '../db/schema.ts' import { audit, diff } from '../lib/audit.ts' import { currentUser, fail, validate, type AppEnv } from '../lib/http.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 columns = { incident: incidents, siteName: sites.name, areaName: areas.name, reporterName: users.name, } type IncidentRow = { incident: typeof incidents.$inferSelect; siteName: string; areaName: string | null; reporterName: string | null } function toDto({ incident, siteName, areaName, reporterName }: IncidentRow) { return { id: incident.id, number: incident.number, reference: incidentReference(incident.number), title: incident.title, type: incident.type, severity: incident.severity, status: incident.status, occurredAt: incident.occurredAt, reportedAt: incident.createdAt, updatedAt: incident.updatedAt, closedAt: incident.closedAt, site: { id: incident.siteId, name: siteName }, area: incident.areaId ? { id: incident.areaId, name: areaName } : null, locationDetail: incident.locationDetail, description: incident.description, immediateAction: incident.immediateAction, anonymous: incident.anonymous, reporter: incident.reporterId ? { id: incident.reporterId, name: reporterName } : null, } } function selectIncidents(db: Db) { return db .select(columns) .from(incidents) .innerJoin(sites, eq(sites.id, incidents.siteId)) .leftJoin(areas, eq(areas.id, incidents.areaId)) .leftJoin(users, eq(users.id, incidents.reporterId)) } /** Reporters only see what they reported; supervisors and above see everything in the organization. */ function visibleTo(user: SessionUser): SQL | undefined { return and(eq(incidents.organizationId, user.organizationId), hasRole(user.role, 'supervisor') ? undefined : eq(incidents.reporterId, user.id)) } async function findIncident(db: Db, user: SessionUser, number: number) { const [row] = await selectIncidents(db).where(and(visibleTo(user), eq(incidents.number, number))) if (!row) throw fail(404, 'Incident not found') return row } 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, '\\$&') export const incidentRoutes = new Hono() .get('/', requireAuth, validate('query', incidentListQuery), async (c) => { const me = currentUser(c) const { page, pageSize, q, type, severity, status, siteId } = c.req.valid('query') const db = c.get('db') 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, ) } const where = and( visibleTo(me), 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, ) 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(toDto), page, pageSize, total }) }) .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 number = 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 }) await audit(tx, { organizationId: me.organizationId, actorId: anonymous ? null : me.id, entityType: 'incident', entityId: incident.id, action: 'create', changes: { number: lastNumber, title: input.title, type: input.type, severity: input.severity }, }) return lastNumber }) const [row] = await selectIncidents(db).where(and(eq(incidents.organizationId, me.organizationId), eq(incidents.number, number))) return c.json({ incident: toDto(row) }, 201) }) .get('/:number', requireAuth, validate('param', numberParam), async (c) => { const row = await findIncident(c.get('db'), currentUser(c), c.req.valid('param').number) return c.json({ incident: toDto(row) }) }) .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, closedAt: input.status === undefined ? undefined : input.status === 'closed' ? (incident.closedAt ?? new Date()) : null, } const changes = diff(incident, update) if (Object.keys(changes).length > 0) { await db.update(incidents).set(update).where(eq(incidents.id, incident.id)) await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'incident', entityId: incident.id, action: 'update', changes }) } const row = await findIncident(db, me, incident.number) return c.json({ incident: toDto(row) }) })