feat: implement permissions and actions management
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s

- Add permissions logic for status options in corrective actions.
- Create tests for permissions functionality.
- Implement queries for user authentication and actions management.
- Develop Actions page to display and manage corrective actions.
- Create authentication pages for login, setup, and password reset.
- Add IncidentDetail page to view incident details and associated actions.
- Implement Settings page for user management and password changes.
This commit is contained in:
jayjennings committed 2026-10-06 11:20:44 -07:00
1 parent 708da6b9f7
commit 3f6dff5f24
94 files changed
+7213 -643

No files matched your search

+271 -104
View File
@@ -1,72 +1,38 @@
import { and, count, desc, eq, ilike, isNull, or, sql, type SQL } from 'drizzle-orm'
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 { incidentReference } from '../../shared/format.ts'
import { createIncidentSchema, incidentListQuery, updateIncidentSchema } from '../../shared/schemas.ts'
import type { SessionUser } from '../auth/sessions.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, incidentCounters, incidents, sites, users } from '../db/schema.ts'
import { audit, diff } from '../lib/audit.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 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
}
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
@@ -91,39 +57,78 @@ function parseOccurredAt(value: string): Date {
const escapeLike = (value: string) => value.replace(/[\\%_]/g, '\\$&')
function listFilter(db: Db, user: SessionUser, { q, type, severity, status, siteId }: Omit<IncidentListQuery, 'page' | 'pageSize'>) {
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<string> {
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<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 { page, pageSize, ...filters } = 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 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(toDto), page, pageSize, total })
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) => {
@@ -133,7 +138,7 @@ export const incidentRoutes = new Hono<AppEnv>()
await assertLocation(db, me.organizationId, input.siteId, input.areaId)
const occurred = parseOccurredAt(occurredAt)
const number = await db.transaction(async (tx) => {
const created = await db.transaction(async (tx) => {
// Atomic increment: concurrent reports always get distinct, gap-free numbers.
const [{ lastNumber }] = await tx
.insert(incidentCounters)
@@ -143,25 +148,23 @@ export const incidentRoutes = new Hono<AppEnv>()
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 },
.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 lastNumber
return incident
})
const [row] = await selectIncidents(db).where(and(eq(incidents.organizationId, me.organizationId), eq(incidents.number, number)))
return c.json({ incident: toDto(row) }, 201)
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) => {
const row = await findIncident(c.get('db'), currentUser(c), c.req.valid('param').number)
return c.json({ incident: toDto(row) })
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) => {
@@ -175,17 +178,181 @@ export const incidentRoutes = new Hono<AppEnv>()
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 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 audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'incident', entityId: incident.id, action: 'update', changes })
await auditIncident(db, me, me.organizationId, incident.id, 'update', changes)
}
const row = await findIncident(db, me, incident.number)
return c.json({ incident: toDto(row) })
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<string, unknown> | 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
}
},
)