feat: implement incident reporting and management system
- 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.
This commit is contained in:
1 parent
a382fa904a
commit
c69eccb5c1
47 files changed
+6297
-45
No files matched your search
@@ -0,0 +1,286 @@
|
||||
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)],
|
||||
)
|
||||
Reference in new issue
Block a user