- 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.
287 lines
9.4 KiB
TypeScript
287 lines
9.4 KiB
TypeScript
import { bigserial, boolean, date, index, integer, jsonb, numeric, pgEnum, pgTable, text, timestamp, uniqueIndex, uuid, type AnyPgColumn } from 'drizzle-orm/pg-core'
|
|
import * as vocab from '../../shared/enums.ts'
|
|
|
|
export const roleEnum = pgEnum('role', vocab.roles)
|
|
export const incidentTypeEnum = pgEnum('incident_type', vocab.incidentTypes)
|
|
export const severityEnum = pgEnum('severity', vocab.severities)
|
|
export const incidentStatusEnum = pgEnum('incident_status', vocab.incidentStatuses)
|
|
export const areaKindEnum = pgEnum('area_kind', vocab.areaKinds)
|
|
export const employmentTypeEnum = pgEnum('employment_type', vocab.employmentTypes)
|
|
export const treatmentLevelEnum = pgEnum('treatment_level', vocab.treatmentLevels)
|
|
export const rcaMethodEnum = pgEnum('rca_method', vocab.rcaMethods)
|
|
export const rootCauseCategoryEnum = pgEnum('root_cause_category', vocab.rootCauseCategories)
|
|
export const actionKindEnum = pgEnum('action_kind', vocab.actionKinds)
|
|
export const actionStatusEnum = pgEnum('action_status', vocab.actionStatuses)
|
|
|
|
const id = () => uuid().primaryKey().defaultRandom()
|
|
const timestamptz = () => timestamp({ withTimezone: true })
|
|
const createdAt = () => timestamptz().notNull().defaultNow()
|
|
const updatedAt = () =>
|
|
timestamptz()
|
|
.notNull()
|
|
.defaultNow()
|
|
.$onUpdate(() => new Date())
|
|
|
|
export const organizations = pgTable('organizations', {
|
|
id: id(),
|
|
name: text().notNull(),
|
|
createdAt: createdAt(),
|
|
})
|
|
|
|
export const users = pgTable(
|
|
'users',
|
|
{
|
|
id: id(),
|
|
organizationId: uuid()
|
|
.notNull()
|
|
.references(() => organizations.id),
|
|
/** Stored lower-cased. */
|
|
email: text().notNull(),
|
|
name: text().notNull(),
|
|
role: roleEnum().notNull().default('reporter'),
|
|
passwordHash: text(),
|
|
active: boolean().notNull().default(true),
|
|
lastLoginAt: timestamptz(),
|
|
createdAt: createdAt(),
|
|
updatedAt: updatedAt(),
|
|
},
|
|
(t) => [uniqueIndex('users_email_unique').on(t.email), index('users_organization_idx').on(t.organizationId)],
|
|
)
|
|
|
|
export const sessions = pgTable(
|
|
'sessions',
|
|
{
|
|
/** SHA-256 of the session token; the token itself is never stored. */
|
|
id: text().primaryKey(),
|
|
userId: uuid()
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: 'cascade' }),
|
|
expiresAt: timestamptz().notNull(),
|
|
createdAt: createdAt(),
|
|
},
|
|
(t) => [index('sessions_user_idx').on(t.userId)],
|
|
)
|
|
|
|
export const sites = pgTable(
|
|
'sites',
|
|
{
|
|
id: id(),
|
|
organizationId: uuid()
|
|
.notNull()
|
|
.references(() => organizations.id),
|
|
name: text().notNull(),
|
|
address: text(),
|
|
timezone: text().notNull().default('UTC'),
|
|
archivedAt: timestamptz(),
|
|
createdAt: createdAt(),
|
|
updatedAt: updatedAt(),
|
|
},
|
|
(t) => [uniqueIndex('sites_organization_name_unique').on(t.organizationId, t.name)],
|
|
)
|
|
|
|
/** Location tree within a site: building → area → line. */
|
|
export const areas = pgTable(
|
|
'areas',
|
|
{
|
|
id: id(),
|
|
siteId: uuid()
|
|
.notNull()
|
|
.references(() => sites.id, { onDelete: 'cascade' }),
|
|
parentId: uuid().references((): AnyPgColumn => areas.id, { onDelete: 'cascade' }),
|
|
name: text().notNull(),
|
|
kind: areaKindEnum().notNull().default('area'),
|
|
archivedAt: timestamptz(),
|
|
createdAt: createdAt(),
|
|
updatedAt: updatedAt(),
|
|
},
|
|
(t) => [index('areas_site_idx').on(t.siteId), index('areas_parent_idx').on(t.parentId)],
|
|
)
|
|
|
|
/** Per-organization counter for gap-free, human-friendly incident numbers. */
|
|
export const incidentCounters = pgTable('incident_counters', {
|
|
organizationId: uuid()
|
|
.primaryKey()
|
|
.references(() => organizations.id),
|
|
lastNumber: integer().notNull().default(0),
|
|
})
|
|
|
|
export const incidents = pgTable(
|
|
'incidents',
|
|
{
|
|
id: id(),
|
|
organizationId: uuid()
|
|
.notNull()
|
|
.references(() => organizations.id),
|
|
number: integer().notNull(),
|
|
title: text().notNull(),
|
|
type: incidentTypeEnum().notNull(),
|
|
severity: severityEnum().notNull(),
|
|
status: incidentStatusEnum().notNull().default('reported'),
|
|
occurredAt: timestamptz().notNull(),
|
|
siteId: uuid()
|
|
.notNull()
|
|
.references(() => sites.id),
|
|
areaId: uuid().references(() => areas.id, { onDelete: 'set null' }),
|
|
locationDetail: text(),
|
|
description: text(),
|
|
immediateAction: text(),
|
|
/** Null when reported anonymously. */
|
|
reporterId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
|
anonymous: boolean().notNull().default(false),
|
|
closedAt: timestamptz(),
|
|
createdAt: createdAt(),
|
|
updatedAt: updatedAt(),
|
|
},
|
|
(t) => [
|
|
uniqueIndex('incidents_organization_number_unique').on(t.organizationId, t.number),
|
|
index('incidents_organization_occurred_idx').on(t.organizationId, t.occurredAt),
|
|
index('incidents_organization_status_idx').on(t.organizationId, t.status),
|
|
index('incidents_reporter_idx').on(t.reporterId),
|
|
],
|
|
)
|
|
|
|
export const injuredPersons = pgTable(
|
|
'injured_persons',
|
|
{
|
|
id: id(),
|
|
incidentId: uuid()
|
|
.notNull()
|
|
.references(() => incidents.id, { onDelete: 'cascade' }),
|
|
/** Set when the person has a user account. */
|
|
userId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
|
name: text().notNull(),
|
|
jobTitle: text(),
|
|
employmentType: employmentTypeEnum().notNull().default('employee'),
|
|
bodyPart: text(),
|
|
natureOfInjury: text(),
|
|
treatment: treatmentLevelEnum().notNull().default('none'),
|
|
daysAway: integer().notNull().default(0),
|
|
daysRestricted: integer().notNull().default(0),
|
|
/** OSHA privacy case: name withheld from the 300 log. */
|
|
privacyCase: boolean().notNull().default(false),
|
|
createdAt: createdAt(),
|
|
updatedAt: updatedAt(),
|
|
},
|
|
(t) => [index('injured_persons_incident_idx').on(t.incidentId)],
|
|
)
|
|
|
|
export const attachments = pgTable(
|
|
'attachments',
|
|
{
|
|
id: id(),
|
|
incidentId: uuid()
|
|
.notNull()
|
|
.references(() => incidents.id, { onDelete: 'cascade' }),
|
|
fileName: text().notNull(),
|
|
contentType: text().notNull(),
|
|
sizeBytes: integer().notNull(),
|
|
storageKey: text().notNull().unique(),
|
|
uploadedById: uuid().references(() => users.id, { onDelete: 'set null' }),
|
|
createdAt: createdAt(),
|
|
},
|
|
(t) => [index('attachments_incident_idx').on(t.incidentId)],
|
|
)
|
|
|
|
export const investigations = pgTable('investigations', {
|
|
id: id(),
|
|
incidentId: uuid()
|
|
.notNull()
|
|
.unique()
|
|
.references(() => incidents.id, { onDelete: 'cascade' }),
|
|
leadId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
|
method: rcaMethodEnum().notNull().default('five_whys'),
|
|
summary: text(),
|
|
startedAt: timestamptz().notNull().defaultNow(),
|
|
completedAt: timestamptz(),
|
|
createdAt: createdAt(),
|
|
updatedAt: updatedAt(),
|
|
})
|
|
|
|
export const rootCauses = pgTable(
|
|
'root_causes',
|
|
{
|
|
id: id(),
|
|
investigationId: uuid()
|
|
.notNull()
|
|
.references(() => investigations.id, { onDelete: 'cascade' }),
|
|
category: rootCauseCategoryEnum().notNull(),
|
|
description: text().notNull(),
|
|
/** 5 Whys chain, first "why" first. */
|
|
whys: jsonb().$type<string[]>().notNull().default([]),
|
|
createdAt: createdAt(),
|
|
},
|
|
(t) => [index('root_causes_investigation_idx').on(t.investigationId)],
|
|
)
|
|
|
|
export const correctiveActions = pgTable(
|
|
'corrective_actions',
|
|
{
|
|
id: id(),
|
|
incidentId: uuid()
|
|
.notNull()
|
|
.references(() => incidents.id, { onDelete: 'cascade' }),
|
|
rootCauseId: uuid().references(() => rootCauses.id, { onDelete: 'set null' }),
|
|
kind: actionKindEnum().notNull().default('corrective'),
|
|
title: text().notNull(),
|
|
description: text(),
|
|
assigneeId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
|
dueDate: date({ mode: 'string' }),
|
|
status: actionStatusEnum().notNull().default('open'),
|
|
completedAt: timestamptz(),
|
|
verifiedById: uuid().references(() => users.id, { onDelete: 'set null' }),
|
|
verifiedAt: timestamptz(),
|
|
createdAt: createdAt(),
|
|
updatedAt: updatedAt(),
|
|
},
|
|
(t) => [index('corrective_actions_incident_idx').on(t.incidentId), index('corrective_actions_assignee_status_idx').on(t.assigneeId, t.status)],
|
|
)
|
|
|
|
export const comments = pgTable(
|
|
'comments',
|
|
{
|
|
id: id(),
|
|
incidentId: uuid()
|
|
.notNull()
|
|
.references(() => incidents.id, { onDelete: 'cascade' }),
|
|
authorId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
|
body: text().notNull(),
|
|
createdAt: createdAt(),
|
|
},
|
|
(t) => [index('comments_incident_idx').on(t.incidentId)],
|
|
)
|
|
|
|
export const auditLog = pgTable(
|
|
'audit_log',
|
|
{
|
|
id: bigserial({ mode: 'number' }).primaryKey(),
|
|
organizationId: uuid()
|
|
.notNull()
|
|
.references(() => organizations.id),
|
|
actorId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
|
entityType: text().notNull(),
|
|
entityId: text().notNull(),
|
|
action: text().notNull(),
|
|
/** `{ field: [before, after] }` for updates, the created record for creates. */
|
|
changes: jsonb(),
|
|
createdAt: createdAt(),
|
|
},
|
|
(t) => [index('audit_log_entity_idx').on(t.organizationId, t.entityType, t.entityId)],
|
|
)
|
|
|
|
/** Monthly hours worked per site, for TRIR / DART rate calculations. */
|
|
export const hoursWorked = pgTable(
|
|
'hours_worked',
|
|
{
|
|
id: id(),
|
|
siteId: uuid()
|
|
.notNull()
|
|
.references(() => sites.id, { onDelete: 'cascade' }),
|
|
/** First day of the month, e.g. `2026-10-01`. */
|
|
month: date({ mode: 'string' }).notNull(),
|
|
hours: numeric({ precision: 12, scale: 2 }).notNull(),
|
|
createdAt: createdAt(),
|
|
updatedAt: updatedAt(),
|
|
},
|
|
(t) => [uniqueIndex('hours_worked_site_month_unique').on(t.siteId, t.month)],
|
|
)
|