Files
open-safetylog-ehs/server/routes/osha.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

290 lines
14 KiB
TypeScript

import { and, asc, count, eq } from 'drizzle-orm'
import { Hono } from 'hono'
import { z } from 'zod'
import type { Form301 } from '../../shared/api-types.ts'
import { incidentReference } from '../../shared/format.ts'
import { assessRecordability, caseTypeCode, outcomeCode, severeInjuryReport, itaSizeCode } from '../../shared/osha.ts'
import { createOshaCaseSchema, hoursWorkedSchema, updateOshaCaseSchema } from '../../shared/schemas.ts'
import { requireRole } from '../auth/middleware.ts'
import type { SessionUser } from '../auth/sessions.ts'
import type { Db } from '../db/client.ts'
import { areas, hoursWorked, incidents, injuredPersons, sites } from '../db/schema.ts'
import { toCsv } from '../lib/csv.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
import { auditIncident, findIncident } from '../lib/incidents.ts'
import { activeSites, buildOshaYear, describeCase, establishmentOf, toOshaCase, yearRange } from '../lib/osha.ts'
const numberParam = z.object({ number: z.coerce.number().int().positive() })
const idParam = z.object({ id: z.uuid() })
const yearQuery = z.object({ year: z.coerce.number().int().min(2000).max(2100) })
type Person = typeof injuredPersons.$inferSelect
/** Days only count when the matching outcome applies; recordability is always derived, never sent. */
function derive(person: Omit<Person, 'recordable' | 'outcome'>) {
if (person.daysAway + person.daysRestricted > 180) throw fail(400, 'Stop counting once days away and restricted days reach 180 in total (1904.7(b)(3)(vii)).')
const assessment = assessRecordability(person)
return {
daysAway: person.daysAwayFromWork ? person.daysAway : 0,
daysRestricted: person.restrictedOrTransfer ? person.daysRestricted : 0,
recordable: assessment.status === 'recordable',
outcome: assessment.outcome,
}
}
async function findCase(db: Db, user: SessionUser, id: string) {
const [row] = await db
.select({ person: injuredPersons, incident: incidents })
.from(injuredPersons)
.innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId))
.where(and(eq(injuredPersons.id, id), eq(incidents.organizationId, user.organizationId)))
if (!row) throw fail(404, 'Case not found')
return row
}
const dateMDY = (isoDate: string | null) => (isoDate ? `${isoDate.slice(5, 7)}-${isoDate.slice(8, 10)}-${isoDate.slice(0, 4)}` : '')
const localTime = (date: Date) => `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
const localDateMDY = (date: Date) => `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}-${date.getFullYear()}`
const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
/**
* OSHA recordkeeping. Injury and medical details are limited to safety managers and admins
* (they hold the confidential records); everyone else only sees that a person was involved.
*/
export const oshaRoutes = new Hono<AppEnv>()
.get('/incidents/:number/cases', requireRole('safety_manager'), validate('param', numberParam), async (c) => {
const db = c.get('db')
const { incident } = await findIncident(db, currentUser(c), c.req.valid('param').number)
const people = await db.select().from(injuredPersons).where(eq(injuredPersons.incidentId, incident.id)).orderBy(asc(injuredPersons.createdAt))
return c.json({ cases: people.map((person) => toOshaCase(person, incident)) })
})
.post('/incidents/:number/cases', requireRole('safety_manager'), validate('param', numberParam), validate('json', createOshaCaseSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { incident } = await findIncident(db, me, c.req.valid('param').number)
const input = c.req.valid('json')
const [{ existing }] = await db.select({ existing: count() }).from(injuredPersons).where(eq(injuredPersons.incidentId, incident.id))
const reference = incidentReference(incident.number)
const draft = {
employmentType: 'employee' as const,
caseType: 'injury' as const,
workRelated: null,
newCase: null,
died: false,
daysAwayFromWork: false,
daysAway: 0,
restrictedOrTransfer: false,
daysRestricted: 0,
medicalTreatment: false,
lossOfConsciousness: false,
significantDiagnosis: false,
specialCase: 'none' as const,
...input,
reportedToOshaAt: input.reportedToOshaAt ? new Date(input.reportedToOshaAt) : null,
}
const [person] = await db
.insert(injuredPersons)
.values({ ...draft, ...derive(draft as Person), incidentId: incident.id, caseNumber: existing ? `${reference}-${existing + 1}` : reference })
.returning()
// The timeline is visible beyond the safety team, so it carries no names or medical details.
await auditIncident(db, me, me.organizationId, incident.id, 'osha_case.add', { caseNumber: person.caseNumber, recordable: person.recordable })
if (severeInjuryReport(person) && !person.reportedToOshaAt) c.get('notifier').severeInjury(person.id)
return c.json({ case: toOshaCase(person, incident) }, 201)
})
.patch('/cases/:id', requireRole('safety_manager'), validate('param', idParam), validate('json', updateOshaCaseSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { person, incident } = await findCase(db, me, c.req.valid('param').id)
const input = c.req.valid('json')
const merged = { ...person, ...input, reportedToOshaAt: input.reportedToOshaAt === undefined ? person.reportedToOshaAt : input.reportedToOshaAt ? new Date(input.reportedToOshaAt) : null }
const update = { ...merged, ...derive(merged) }
const [saved] = await db
.update(injuredPersons)
.set({ ...update, id: undefined, incidentId: undefined, createdAt: undefined, updatedAt: undefined })
.where(eq(injuredPersons.id, person.id))
.returning()
await auditIncident(db, me, me.organizationId, incident.id, 'osha_case.update', {
caseNumber: saved.caseNumber,
...(saved.recordable !== person.recordable ? { recordable: [person.recordable, saved.recordable] } : {}),
})
const becameSevere = severeInjuryReport(saved) && !severeInjuryReport(person)
if (becameSevere && !saved.reportedToOshaAt) c.get('notifier').severeInjury(saved.id)
return c.json({ case: toOshaCase(saved, incident) })
})
.delete('/cases/:id', requireRole('safety_manager'), validate('param', idParam), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { person, incident } = await findCase(db, me, c.req.valid('param').id)
await db.delete(injuredPersons).where(eq(injuredPersons.id, person.id))
await auditIncident(db, me, me.organizationId, incident.id, 'osha_case.remove', { caseNumber: person.caseNumber })
return c.body(null, 204)
})
.get('/cases/:id/301', requireRole('safety_manager'), validate('param', idParam), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { person, incident } = await findCase(db, me, c.req.valid('param').id)
const [site] = await db.select().from(sites).where(eq(sites.id, incident.siteId))
const [area] = incident.areaId ? await db.select({ name: areas.name }).from(areas).where(eq(areas.id, incident.areaId)) : []
const year = await buildOshaYear(db, me.organizationId, site.id, incident.occurredAt.getFullYear())
const form: Form301 = {
case: toOshaCase(person, incident),
establishment: { name: site.name, companyName: year.companyName, ...establishmentOf(site) },
incident: {
reference: incidentReference(incident.number),
occurredAt: incident.occurredAt.toISOString(),
title: incident.title,
description: incident.description,
site: site.name,
area: area?.name ?? null,
locationDetail: incident.locationDetail,
},
}
return c.json(form)
})
.get('/log', requireRole('safety_manager'), validate('query', yearQuery.extend({ siteId: z.uuid() })), async (c) => {
const { siteId, year } = c.req.valid('query')
return c.json(await buildOshaYear(c.get('db'), currentUser(c).organizationId, siteId, year))
})
.get('/sites/:id/hours', requireRole('safety_manager'), validate('param', idParam), validate('query', yearQuery), async (c) => {
const year = await buildOshaYear(c.get('db'), currentUser(c).organizationId, c.req.valid('param').id, c.req.valid('query').year)
return c.json({ months: year.months })
})
.put('/sites/:id/hours', requireRole('safety_manager'), validate('param', idParam), validate('json', hoursWorkedSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const siteId = c.req.valid('param').id
const { year, months } = c.req.valid('json')
await buildOshaYear(db, me.organizationId, siteId, year) // 404 unless the site is ours
for (const { month, hours, averageEmployees } of months) {
const monthDate = `${year}-${String(month).padStart(2, '0')}-01`
if (hours === null && averageEmployees === null) {
await db.delete(hoursWorked).where(and(eq(hoursWorked.siteId, siteId), eq(hoursWorked.month, monthDate)))
continue
}
await db
.insert(hoursWorked)
.values({ siteId, month: monthDate, hours: String(hours ?? 0), averageEmployees })
.onConflictDoUpdate({ target: [hoursWorked.siteId, hoursWorked.month], set: { hours: String(hours ?? 0), averageEmployees } })
}
const updated = await buildOshaYear(db, me.organizationId, siteId, year)
return c.json({ months: updated.months })
})
// OSHA Injury Tracking Application uploads. Formats follow OSHA's published CSV specifications.
.get('/ita/summary.csv', requireRole('safety_manager'), validate('query', yearQuery), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { year } = c.req.valid('query')
const rows = []
for (const site of await activeSites(db, me.organizationId)) {
const y = await buildOshaYear(db, me.organizationId, site.id, year)
const t = y.totals
rows.push([
site.name,
site.ein,
y.companyName,
site.streetAddress,
site.city,
site.state,
site.zip,
site.naicsCode,
site.industryDescription,
itaSizeCode(y.maxEmployees),
site.establishmentType,
year,
y.annualAverageEmployees,
y.totalHoursWorked,
y.rows.length ? 1 : 2,
t.deaths,
t.daysAwayCases,
t.restrictedCases,
t.otherCases,
t.daysAway,
t.daysRestricted,
t.byType.injury,
t.byType.skin_disorder,
t.byType.respiratory,
t.byType.poisoning,
t.byType.hearing_loss,
t.byType.other_illness,
'',
])
}
const csv = toCsv(
[
'establishment_name', 'ein_number', 'company_name', 'street_address', 'city', 'state', 'zip', 'naics_code', 'industry_description', 'size',
'establishment_type', 'year_filing_for', 'annual_average_employees', 'total_hours_worked', 'no_injuries_illnesses', 'total_deaths',
'total_dafw_cases', 'total_djtr_cases', 'total_other_cases', 'total_dafw_days', 'total_djtr_days', 'total_injuries', 'total_skin_disorders',
'total_respiratory_conditions', 'total_poisonings', 'total_hearing_loss', 'total_other_illnesses', 'change_reason',
],
rows,
{ bom: false },
)
return c.body(csv, 200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="osha-ita-300a-${year}.csv"` })
})
.get('/ita/cases.csv', requireRole('safety_manager'), validate('query', yearQuery), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { year } = c.req.valid('query')
const [from, to] = yearRange(year)
const rows = []
for (const site of await activeSites(db, me.organizationId)) {
const y = await buildOshaYear(db, me.organizationId, site.id, year)
for (const row of y.rows) {
const [{ person, incident }] = await db
.select({ person: injuredPersons, incident: incidents })
.from(injuredPersons)
.innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId))
.where(eq(injuredPersons.id, row.caseId))
if (incident.occurredAt < from || incident.occurredAt >= to) continue
// No names or other personal identifiers: OSHA publishes this data.
rows.push([
site.name,
year,
row.caseNumber,
clip(row.jobTitle, 255),
localDateMDY(incident.occurredAt),
clip(row.location, 255),
clip(describeCase(person), 255),
outcomeCode[row.outcome],
row.daysAway,
row.daysRestricted,
caseTypeCode[row.caseType],
dateMDY(person.dateOfBirth),
dateMDY(person.dateOfHire),
person.sex ?? '',
person.treatedInEmergencyRoom ? 1 : 0,
person.inpatientHospitalization ? 1 : 0,
person.timeStartedWork ?? '',
localTime(incident.occurredAt),
'',
person.activityBefore ?? '',
person.whatHappened ?? incident.description ?? '',
person.natureOfInjury ?? '',
person.objectSubstance ?? '',
dateMDY(person.dateOfDeath),
])
}
}
const csv = toCsv(
[
'establishment_name', 'year_of_filing', 'case_number', 'job_title', 'date_of_incident', 'incident_location', 'incident_description',
'incident_outcome', 'dafw_num_away', 'djtr_num_tr', 'type_of_incident', 'date_of_birth', 'date_of_hire', 'sex', 'treatment_facility_type',
'treatment_in_patient', 'time_started_work', 'time_of_incident', 'time_unknown', 'nar_before_incident', 'nar_what_happened',
'nar_injury_illness', 'nar_object_substance', 'date_of_death',
],
rows,
{ bom: false },
)
return c.body(csv, 200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="osha-ita-cases-${year}.csv"` })
})