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) { 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() .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"` }) })