- 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.
290 lines
14 KiB
TypeScript
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"` })
|
|
})
|