- 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.
188 lines
8.0 KiB
TypeScript
188 lines
8.0 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, 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<ReturnType<ReturnType<typeof selectIncidents>['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<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')
|
|
}
|
|
|