Files
open-safetylog-ehs/server/lib/incidents.ts
T
jayjennings 3e964d1176
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
feat(i18n): add internationalization support with English and Spanish translations
feat(kiosk): implement kiosk report submission functionality with offline support

test(outbox): add tests for outbox functionality including report submission and error handling

feat(outbox): create outbox management for queued reports with IndexedDB persistence

feat(uuid): add UUID generation utility for client IDs

feat(qr): implement QR code generation for reporting links by site and area

feat(kiosk): create Kiosk page for reporting incidents without an account
2026-10-06 11:38:40 -07:00

192 lines
8.3 KiB
TypeScript

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, kiosks, 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, kioskName: kiosks.name })
.from(incidents)
.innerJoin(sites, eq(sites.id, incidents.siteId))
.leftJoin(areas, eq(areas.id, incidents.areaId))
.leftJoin(reporters, eq(reporters.id, incidents.reporterId))
.leftJoin(kiosks, eq(kiosks.id, incidents.kioskId))
}
type IncidentRow = Awaited<ReturnType<ReturnType<typeof selectIncidents>['execute']>>[number]
export function toIncident({ incident, siteName, areaName, reporterName, kioskName }: 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,
reporterName: incident.reporterName,
channel: incident.channel,
kiosk: incident.kioskId ? { id: incident.kioskId, name: kioskName ?? '' } : 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<ReturnType<ReturnType<typeof selectActions>['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<IncidentDetail> {
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<string, unknown>) {
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')
}