import { and, asc, eq, exists, or, type SQL } from 'drizzle-orm' import { alias } from 'drizzle-orm/pg-core' import type { Attachment, CorrectiveAction, Incident, IncidentDetail, Investigation, RootCause } from '../../shared/api-types.ts' import { hasRole } from '../../shared/enums.ts' import { incidentReference } from '../../shared/format.ts' import { availableTransitions } from '../../shared/workflow.ts' import type { SessionUser } from '../auth/sessions.ts' import type { Db } from '../db/client.ts' import { areas, attachments, correctiveActions, incidents, investigations, rootCauses, sites, users } from '../db/schema.ts' import { audit } from './audit.ts' import { iso, localDate } from './dates.ts' import { fail } from './http.ts' const reporters = alias(users, 'reporter') export function selectIncidents(db: Db) { return db .select({ incident: incidents, siteName: sites.name, areaName: areas.name, reporterName: reporters.name }) .from(incidents) .innerJoin(sites, eq(sites.id, incidents.siteId)) .leftJoin(areas, eq(areas.id, incidents.areaId)) .leftJoin(reporters, eq(reporters.id, incidents.reporterId)) } type IncidentRow = Awaited['execute']>>[number] export function toIncident({ incident, siteName, areaName, reporterName }: IncidentRow): Incident { return { id: incident.id, number: incident.number, reference: incidentReference(incident.number), title: incident.title, type: incident.type, severity: incident.severity, status: incident.status, occurredAt: iso(incident.occurredAt), reportedAt: iso(incident.createdAt), updatedAt: iso(incident.updatedAt), closedAt: iso(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, } } /** * Supervisors and above see every incident in their organization. Reporters see incidents they * reported, plus any where they are assigned a corrective action. */ export function visibleTo(db: Db, user: SessionUser): SQL | undefined { const inOrg = eq(incidents.organizationId, user.organizationId) if (hasRole(user.role, 'supervisor')) return inOrg const assigned = db .select({ id: correctiveActions.id }) .from(correctiveActions) .where(and(eq(correctiveActions.incidentId, incidents.id), eq(correctiveActions.assigneeId, user.id))) return and(inOrg, or(eq(incidents.reporterId, user.id), exists(assigned))) } export async function findIncident(db: Db, user: SessionUser, number: number) { const [row] = await selectIncidents(db).where(and(visibleTo(db, user), eq(incidents.number, number))) if (!row) throw fail(404, 'Incident not found') return row } const assignees = alias(users, 'assignee') const verifiers = alias(users, 'verifier') export function selectActions(db: Db) { return db .select({ action: correctiveActions, incidentNumber: incidents.number, incidentTitle: incidents.title, assigneeName: assignees.name, verifierName: verifiers.name, }) .from(correctiveActions) .innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId)) .leftJoin(assignees, eq(assignees.id, correctiveActions.assigneeId)) .leftJoin(verifiers, eq(verifiers.id, correctiveActions.verifiedById)) } type ActionRow = Awaited['execute']>>[number] export const isOpenAction = (status: string) => status === 'open' || status === 'in_progress' export function toAction({ action, incidentNumber, incidentTitle, assigneeName, verifierName }: ActionRow, today = localDate()): CorrectiveAction { return { id: action.id, incident: { number: incidentNumber, reference: incidentReference(incidentNumber), title: incidentTitle }, rootCauseId: action.rootCauseId, kind: action.kind, title: action.title, description: action.description, assignee: action.assigneeId ? { id: action.assigneeId, name: assigneeName ?? '' } : null, dueDate: action.dueDate, status: action.status, overdue: isOpenAction(action.status) && !!action.dueDate && action.dueDate < today, completedAt: iso(action.completedAt), verifiedBy: action.verifiedById ? { id: action.verifiedById, name: verifierName ?? '' } : null, verifiedAt: iso(action.verifiedAt), createdAt: iso(action.createdAt), } } export function toAttachment(row: typeof attachments.$inferSelect, uploaderName: string | null): Attachment { return { id: row.id, fileName: row.fileName, contentType: row.contentType, sizeBytes: row.sizeBytes, uploadedBy: row.uploadedById ? { id: row.uploadedById, name: uploaderName ?? '' } : null, createdAt: iso(row.createdAt), url: `/api/attachments/${row.id}`, } } export async function loadDetail(db: Db, user: SessionUser, number: number): Promise { const row = await findIncident(db, user, number) const incidentId = row.incident.id const leads = alias(users, 'lead') const [investigationRow] = await db .select({ investigation: investigations, leadName: leads.name }) .from(investigations) .leftJoin(leads, eq(leads.id, investigations.leadId)) .where(eq(investigations.incidentId, incidentId)) const causes = investigationRow ? await db.select().from(rootCauses).where(eq(rootCauses.investigationId, investigationRow.investigation.id)).orderBy(asc(rootCauses.createdAt)) : [] const actionRows = await selectActions(db).where(eq(correctiveActions.incidentId, incidentId)).orderBy(asc(correctiveActions.createdAt)) const uploaders = alias(users, 'uploader') const files = await db .select({ file: attachments, uploaderName: uploaders.name }) .from(attachments) .leftJoin(uploaders, eq(uploaders.id, attachments.uploadedById)) .where(eq(attachments.incidentId, incidentId)) .orderBy(asc(attachments.createdAt)) const investigation: Investigation | null = investigationRow ? { id: investigationRow.investigation.id, lead: investigationRow.investigation.leadId ? { id: investigationRow.investigation.leadId, name: investigationRow.leadName ?? '' } : null, method: investigationRow.investigation.method, summary: investigationRow.investigation.summary, startedAt: iso(investigationRow.investigation.startedAt), completedAt: iso(investigationRow.investigation.completedAt), } : null const actions = actionRows.map((action) => toAction(action)) const manage = hasRole(user.role, 'supervisor') return { ...toIncident(row), investigation, rootCauses: causes.map((cause): RootCause => ({ id: cause.id, category: cause.category, description: cause.description, whys: cause.whys })), actions, attachments: files.map(({ file, uploaderName }) => toAttachment(file, uploaderName)), transitions: availableTransitions(row.incident.status, user.role, { severity: row.incident.severity, rootCauseCount: causes.length, actionStatuses: actions.map((action) => action.status), }), can: { edit: manage, manageInvestigation: manage, manageActions: manage }, } } /** Records an event on an incident's timeline. */ export function auditIncident(db: Db, user: SessionUser | null, organizationId: string, incidentId: string, action: string, changes?: Record) { return audit(db, { organizationId, actorId: user?.id ?? null, entityType: 'incident', entityId: incidentId, action, changes }) } /** Throws 400 unless the user is active in the organization. */ export async function assertActiveUser(db: Db, organizationId: string, userId: string) { const [user] = await db .select({ id: users.id }) .from(users) .where(and(eq(users.id, userId), eq(users.organizationId, organizationId), eq(users.active, true))) if (!user) throw fail(400, 'Unknown or inactive user') }