feat: implement incident reporting and management system
- Add incident reporting routes and logic to handle creation, updating, and retrieval of incidents. - Implement visibility rules for users based on roles (reporters, supervisors). - Create tests for incident reporting functionality to ensure correctness and reliability. - Introduce site and area management routes, including validation for area hierarchy. - Establish user management routes with role-based access control for creating and updating users. - Add setup routes for initializing the application with an organization and admin user. - Implement shared enums and schemas for consistent validation and data handling across the application. - Create a test helper for setting up an in-memory database and API client for testing. - Add TypeScript configuration for server-side code with strict type checking and module resolution.
This commit is contained in:
1 parent
a382fa904a
commit
c69eccb5c1
47 files changed
+6297
-45
No files matched your search
@@ -0,0 +1,191 @@
|
||||
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<AppEnv>()
|
||||
.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) })
|
||||
})
|
||||
Reference in new issue
Block a user