Files
open-safetylog-ehs/server/db/schema.ts
T
jayjennings 61c876edec
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
Add OSHA Form 301 and Osha page components
- Implemented Form301 component for displaying OSHA Form 301 equivalent, including employee and case information.
- Created Osha component to manage OSHA recordkeeping, including tabs for 300 Log, 300A Summary, Hours & rates, and Submit to OSHA.
- Added functionality for printing and downloading CSV files for OSHA submissions.
- Integrated loading and error handling states for data fetching.
2026-10-06 13:09:57 -07:00

409 lines
14 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)
export const authTokenPurposeEnum = pgEnum('auth_token_purpose', ['magic_link', 'password_reset'])
export const reportChannelEnum = pgEnum('report_channel', ['web', 'kiosk'])
export const oshaCaseTypeEnum = pgEnum('osha_case_type', vocab.oshaCaseTypes)
export const oshaOutcomeEnum = pgEnum('osha_outcome', vocab.oshaOutcomes)
export const oshaSpecialCaseEnum = pgEnum('osha_special_case', vocab.oshaSpecialCases)
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)],
)
/** Single-use links sent by email (magic sign-in, password reset/invite). */
export const authTokens = pgTable(
'auth_tokens',
{
/** SHA-256 of the token sent in the link. */
id: text().primaryKey(),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
purpose: authTokenPurposeEnum().notNull(),
expiresAt: timestamptz().notNull(),
usedAt: timestamptz(),
createdAt: createdAt(),
},
(t) => [index('auth_tokens_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'),
// OSHA establishment details (Form 300A and the ITA upload)
streetAddress: text(),
city: text(),
state: text(),
zip: text(),
naicsCode: text(),
industryDescription: text(),
ein: text(),
/** ITA establishment_type: 1 private, 2 state government, 3 local government. */
establishmentType: integer().notNull().default(1),
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)],
)
/**
* Reporting links let people without accounts file reports: on a shared kiosk tablet, or by scanning
* a QR code. The token only allows creating reports, so it is stored as-is to let admins reprint QR codes.
*/
export const kiosks = pgTable(
'kiosks',
{
id: id(),
organizationId: uuid()
.notNull()
.references(() => organizations.id),
name: text().notNull(),
token: text().notNull(),
/** Limits the link to one site; null allows any. */
siteId: uuid().references(() => sites.id, { onDelete: 'set null' }),
createdById: uuid().references(() => users.id, { onDelete: 'set null' }),
lastUsedAt: timestamptz(),
revokedAt: timestamptz(),
createdAt: createdAt(),
},
(t) => [uniqueIndex('kiosks_token_unique').on(t.token), index('kiosks_organization_idx').on(t.organizationId)],
)
/** 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),
/** Name typed by someone reporting without an account (kiosk/QR). */
reporterName: text(),
channel: reportChannelEnum().notNull().default('web'),
kioskId: uuid().references(() => kiosks.id, { onDelete: 'set null' }),
/** Generated on the reporting device so retries after a lost response don't create duplicates. */
clientId: uuid(),
closedAt: timestamptz(),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [
uniqueIndex('incidents_organization_number_unique').on(t.organizationId, t.number),
uniqueIndex('incidents_organization_client_id_unique').on(t.organizationId, t.clientId),
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),
// OSHA recordability (29 CFR 1904). Null answers mean "not assessed yet".
caseNumber: text(),
workRelated: boolean(),
newCase: boolean(),
died: boolean().notNull().default(false),
dateOfDeath: date({ mode: 'string' }),
daysAwayFromWork: boolean().notNull().default(false),
restrictedOrTransfer: boolean().notNull().default(false),
medicalTreatment: boolean().notNull().default(false),
lossOfConsciousness: boolean().notNull().default(false),
significantDiagnosis: boolean().notNull().default(false),
specialCase: oshaSpecialCaseEnum().notNull().default('none'),
caseType: oshaCaseTypeEnum().notNull().default('injury'),
/** Derived from the answers above with shared/osha.ts, stored for reporting. */
recordable: boolean().notNull().default(false),
outcome: oshaOutcomeEnum(),
// Severe injury reporting (1904.39)
inpatientHospitalization: boolean().notNull().default(false),
amputation: boolean().notNull().default(false),
lossOfEye: boolean().notNull().default(false),
reportedToOshaAt: timestamptz(),
// Form 301 details
employeeAddress: text(),
dateOfBirth: date({ mode: 'string' }),
dateOfHire: date({ mode: 'string' }),
sex: text(),
physicianName: text(),
treatmentFacility: text(),
treatedInEmergencyRoom: boolean().notNull().default(false),
timeStartedWork: text(),
activityBefore: text(),
whatHappened: text(),
objectSubstance: text(),
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(),
/** Date the last due/overdue reminder was emailed, so it goes out at most once a day. */
reminderSentOn: date({ mode: 'string' }),
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(),
/** Average number of employees on the payroll that month (for the annual average). */
averageEmployees: integer(),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [uniqueIndex('hours_worked_site_month_unique').on(t.siteId, t.month)],
)
/** Named filter sets for the Analytics and Incidents pages; private, or shared with the organization. */
export const savedViews = pgTable(
'saved_views',
{
id: id(),
organizationId: uuid()
.notNull()
.references(() => organizations.id),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
page: text().notNull(),
name: text().notNull(),
filters: jsonb().$type<Record<string, string>>().notNull(),
shared: boolean().notNull().default(false),
createdAt: createdAt(),
},
(t) => [index('saved_views_organization_page_idx').on(t.organizationId, t.page)],
)