Add OSHA Form 301 and Osha page components
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s

- 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.
This commit is contained in:
jayjennings committed 2026-10-06 13:09:57 -07:00
1 parent 3e964d1176
commit 61c876edec
59 files changed
+9180 -183

No files matched your search

+130
View File
@@ -0,0 +1,130 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { buckets } from '../lib/analytics.ts'
import { localDate } from '../lib/dates.ts'
import { createTestContext } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
let manager: Awaited<ReturnType<typeof ctx.createUser>>
let reporter: Awaited<ReturnType<typeof ctx.createUser>>
let north: { id: string }
let south: { id: string }
const daysAgo = (days: number, hour = 10) => {
const d = new Date()
d.setDate(d.getDate() - days)
d.setHours(hour, 0, 0, 0)
return d
}
const range = (fromDaysAgo: number, toDaysAgo = 0) => `from=${localDate(daysAgo(fromDaysAgo))}&to=${localDate(daysAgo(toDaysAgo))}`
async function report(type: string, when: Date, siteId = north.id, by = manager) {
const res = await by.post('/api/incidents', { title: `${type} report`, type, severity: 'low', occurredAt: when.toISOString(), siteId })
return res.body.incident.number as number
}
beforeAll(async () => {
ctx = await createTestContext()
north = await ctx.createSite('North')
south = await ctx.createSite('South')
manager = await ctx.createUser('safety_manager')
reporter = await ctx.createUser('reporter')
// Current 30-day period: 3 near misses/hazards, 1 injury, 1 spill
await report('near_miss', daysAgo(2, 14))
await report('near_miss', daysAgo(3, 14))
await report('hazard', daysAgo(5, 9), south.id)
const injury = await report('injury', daysAgo(6, 22))
await report('environmental', daysAgo(10))
// Previous period: 1 near miss, 2 injuries
await report('near_miss', daysAgo(40))
await report('injury', daysAgo(45))
await report('injury', daysAgo(50))
// Triage one report straight away
await manager.post(`/api/incidents/${injury}/transitions`, { to: 'triage' })
// A recordable case with hours worked
await manager.post(`/api/osha/incidents/${injury}/cases`, { name: 'Case', workRelated: true, newCase: true, daysAwayFromWork: true, daysAway: 2 })
const now = new Date()
await manager.put(`/api/osha/sites/${north.id}/hours`, { year: now.getFullYear(), months: [{ month: now.getMonth() + 1, hours: 10_000, averageEmployees: 60 }] })
})
afterAll(() => ctx.close())
describe('analytics', () => {
it('compares the period with the one before', async () => {
const stats = (await manager.get(`/api/stats/analytics?${range(29)}`)).body
expect(stats.kpis.reports).toEqual({ value: 5, previous: 3 })
expect(stats.kpis.leadingReports).toEqual({ value: 3, previous: 1 })
expect(stats.kpis.injuries).toEqual({ value: 1, previous: 2 })
expect(stats.kpis.nearMissRatio).toEqual({ value: 3, previous: 0.5 })
expect(stats.kpis.triagedWithin24h.value).toBeCloseTo(0.2)
expect(stats.kpis.recordables).toEqual({ value: 1, previous: 0 })
})
it('groups the trend, locations and the time-of-day heatmap', async () => {
const stats = (await manager.get(`/api/stats/analytics?${range(29)}`)).body
expect(stats.bucket).toBe('week')
const totals = stats.trend.reduce((sum: Record<string, number>, b: Record<string, number>) => ({ hazards: sum.hazards + b.hazards, injuries: sum.injuries + b.injuries, damage: sum.damage + b.damage }), { hazards: 0, injuries: 0, damage: 0 })
expect(totals).toEqual({ hazards: 3, injuries: 1, damage: 1 })
expect(stats.byLocation).toEqual([
{ id: north.id, name: 'North', count: 4 },
{ id: south.id, name: 'South', count: 1 },
])
expect(stats.heatmap).toHaveLength(7)
expect(stats.heatmap.flat().reduce((a: number, b: number) => a + b, 0)).toBe(5)
expect(stats.heatmap[(daysAgo(6).getDay() + 6) % 7][22]).toBeGreaterThanOrEqual(1)
})
it('narrows to a site (showing its areas) and a type', async () => {
const stats = (await manager.get(`/api/stats/analytics?${range(29)}&siteId=${north.id}&type=near_miss`)).body
expect(stats.kpis.reports.value).toBe(2)
expect(stats.byLocation).toEqual([{ id: null, name: 'Area not specified', count: 2 }])
})
it('computes rolling 12-month OSHA rates from hours worked', async () => {
const stats = (await manager.get(`/api/stats/analytics?${range(29)}`)).body
expect(stats.rates).toHaveLength(12)
expect(stats.rates.at(-1)).toEqual({ month: localDate(new Date()).slice(0, 7), trir: 20, dart: 20 })
// Summed over the years, so the test holds even when the injury falls in last year (early January)
expect(stats.yearly.reduce((sum: number, y: { recordables: number }) => sum + y.recordables, 0)).toBe(1)
expect(stats.yearly.at(-1)).toMatchObject({ year: new Date().getFullYear(), hours: 10_000 })
})
it('scopes reporters to their own reports and hides rates', async () => {
await report('hazard', daysAgo(1), north.id, reporter)
const stats = (await reporter.get(`/api/stats/analytics?${range(29)}`)).body
expect(stats.kpis.reports.value).toBe(1)
expect(stats.rates).toBeNull()
expect(stats.kpis.recordables).toBeNull()
})
it('validates the range', async () => {
expect((await manager.get(`/api/stats/analytics?from=2026-02-01&to=2026-01-01`)).status).toBe(400)
expect((await manager.get(`/api/stats/analytics?from=2015-01-01&to=2026-01-01`)).status).toBe(400)
})
it('uses weeks for short ranges and months for long ones', () => {
expect(buckets('2026-01-01', '2026-03-31').bucket).toBe('week')
const long = buckets('2025-01-15', '2025-12-31')
expect(long.bucket).toBe('month')
expect(long.starts).toHaveLength(12)
})
})
describe('saved views', () => {
it('keeps private views private and lets supervisors share', async () => {
const supervisor = await ctx.createUser('supervisor')
const mine = await reporter.post('/api/views', { page: 'analytics', name: 'My hazards', filters: { type: 'hazard', range: '90' } })
expect(mine.status).toBe(201)
expect((await reporter.post('/api/views', { page: 'analytics', name: 'Shared?', filters: {}, shared: true })).status).toBe(403)
await supervisor.post('/api/views', { page: 'analytics', name: 'Plant review', filters: { range: '365' }, shared: true })
const reporterViews = (await reporter.get('/api/views?page=analytics')).body.views.map((v: { name: string; mine: boolean }) => [v.name, v.mine])
expect(reporterViews).toEqual([
['My hazards', true],
['Plant review', false],
])
expect((await supervisor.get('/api/views?page=analytics')).body.views.map((v: { name: string }) => v.name)).toEqual(['Plant review'])
expect((await reporter.delete(`/api/views/${(await supervisor.get('/api/views?page=analytics')).body.views[0].id}`)).status).toBe(403)
expect((await reporter.delete(`/api/views/${mine.body.view.id}`)).status).toBe(204)
})
})
+176
View File
@@ -0,0 +1,176 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createTestContext } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
let manager: Awaited<ReturnType<typeof ctx.createUser>>
let supervisor: Awaited<ReturnType<typeof ctx.createUser>>
let site: { id: string }
const year = new Date().getFullYear()
/** An incident earlier this year (Jan 15, local time) so it lands on this year's log. */
async function incident(title = 'Hand caught in press') {
const occurredAt = new Date(year, 0, 15, 10, 30).toISOString()
const res = await supervisor.post('/api/incidents', { title, type: 'injury', severity: 'high', occurredAt, siteId: site.id, locationDetail: 'Line 3' })
return res.body.incident.number as number
}
const recordableAnswers = {
workRelated: true,
newCase: true,
jobTitle: 'Press operator',
natureOfInjury: 'Laceration',
bodyPart: 'left hand',
objectSubstance: 'Press die',
dateOfBirth: '1990-04-02',
dateOfHire: '2019-06-10',
activityBefore: 'Clearing a jam',
whatHappened: 'Hand slipped into the die area',
sex: 'M',
timeStartedWork: '07:00',
}
beforeAll(async () => {
ctx = await createTestContext()
site = await ctx.createSite('Main Plant')
manager = await ctx.createUser('safety_manager')
supervisor = await ctx.createUser('supervisor')
})
afterAll(() => ctx.close())
describe('injured people (OSHA cases)', () => {
it('keeps medical details to safety managers', async () => {
const number = await incident()
await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Pat Doe' })
expect((await supervisor.get(`/api/osha/incidents/${number}/cases`)).status).toBe(403)
expect((await supervisor.post(`/api/osha/incidents/${number}/cases`, { name: 'X' })).status).toBe(403)
const detail = (await supervisor.get(`/api/incidents/${number}`)).body.incident
expect(detail).toMatchObject({ injuredCount: 1, can: { manageOsha: false } })
// The timeline says a person was added, without naming them
const timeline = (await supervisor.get(`/api/incidents/${number}/timeline`)).body.timeline
expect(JSON.stringify(timeline)).not.toContain('Pat Doe')
})
it('works out recordability from the answers, and re-checks on every change', async () => {
const number = await incident()
let res = await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Sam Lee' })
expect(res.body.case).toMatchObject({ recordable: false, outcome: null, caseNumber: `INC-${String(number).padStart(4, '0')}` })
const id = res.body.case.id
res = await manager.patch(`/api/osha/cases/${id}`, { ...recordableAnswers })
expect(res.body.case).toMatchObject({ recordable: false }) // first aid only so far
res = await manager.patch(`/api/osha/cases/${id}`, { medicalTreatment: true })
expect(res.body.case).toMatchObject({ recordable: true, outcome: 'other' })
res = await manager.patch(`/api/osha/cases/${id}`, { daysAwayFromWork: true, daysAway: 4, restrictedOrTransfer: true, daysRestricted: 10 })
expect(res.body.case).toMatchObject({ outcome: 'days_away', daysAway: 4, daysRestricted: 10, missingFields: [] })
res = await manager.patch(`/api/osha/cases/${id}`, { workRelated: false })
expect(res.body.case.recordable).toBe(false)
await manager.patch(`/api/osha/cases/${id}`, { workRelated: true })
})
it('numbers each person on an incident and enforces the 180-day cap', async () => {
const number = await incident()
await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'First' })
const second = await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Second' })
expect(second.body.case.caseNumber).toBe(`INC-${String(number).padStart(4, '0')}-2`)
const res = await manager.patch(`/api/osha/cases/${second.body.case.id}`, { daysAwayFromWork: true, daysAway: 150, restrictedOrTransfer: true, daysRestricted: 40 })
expect(res.status).toBe(400)
expect(res.body.error).toMatch(/180/)
})
it('ignores day counts when the outcome does not apply', async () => {
const number = await incident()
const created = await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Counts', daysAway: 12 })
expect(created.body.case.daysAway).toBe(0)
})
it('alerts safety managers when a case must be reported to OSHA, once', async () => {
const number = await incident('Finger amputation at shear')
const created = await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Alex Kim', ...recordableAnswers, amputation: true, medicalTreatment: true })
expect(created.body.case.severeReport).toMatchObject({ withinHours: 24, reasons: ['amputation'] })
await ctx.notifier.flush()
const alert = ctx.mailer.last(manager.user.email)
expect(alert?.subject).toMatch(/^Report to OSHA within 24 hours: INC-\d{4}$/)
expect(alert?.text).toContain('1-800-321-OSHA')
const before = ctx.mailer.sent.length
await manager.patch(`/api/osha/cases/${created.body.case.id}`, { reportedToOshaAt: new Date().toISOString(), lossOfEye: true })
await ctx.notifier.flush()
expect(ctx.mailer.sent.length).toBe(before)
})
})
describe('OSHA log, summary and rates', () => {
let other: { id: string }
beforeAll(async () => {
other = await ctx.createSite('Second Plant')
const occurredAt = new Date(year, 1, 3, 14, 5).toISOString()
const res = await supervisor.post('/api/incidents', { title: 'Needlestick', type: 'injury', severity: 'medium', occurredAt, siteId: other.id })
const number = res.body.incident.number
const created = await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Jordan Private', ...recordableAnswers, specialCase: 'needlestick', privacyCase: true, restrictedOrTransfer: true, daysRestricted: 3, caseType: 'other_illness' })
if (!created.body.case.recordable) throw new Error('setup: needlestick case should be recordable')
// Not recordable: should stay off the log
await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'First Aid Only', ...recordableAnswers })
await manager.put(`/api/osha/sites/${other.id}/hours`, {
year,
months: Array.from({ length: 12 }, (_, i) => ({ month: i + 1, hours: 10_000, averageEmployees: i < 6 ? 50 : 70 })),
})
})
it('builds the 300 Log with privacy cases hidden', async () => {
const log = (await manager.get(`/api/osha/log?siteId=${other.id}&year=${year}`)).body
expect(log.rows).toHaveLength(1)
expect(log.rows[0]).toMatchObject({ employeeName: 'Privacy case', jobTitle: 'Press operator', outcome: 'restricted', daysRestricted: 3, caseType: 'other_illness' })
expect(log.rows[0].description).toBe('Laceration (left hand); Press die')
})
it('totals the 300A, computes rates and runs the ITA checks', async () => {
const log = (await manager.get(`/api/osha/log?siteId=${other.id}&year=${year}`)).body
expect(log.totals).toMatchObject({ deaths: 0, daysAwayCases: 0, restrictedCases: 1, otherCases: 0, daysRestricted: 3 })
expect(log).toMatchObject({ totalHoursWorked: 120_000, annualAverageEmployees: 60, maxEmployees: 70 })
expect(log.rates).toEqual({ trir: 1.67, dart: 1.67, ltir: 0 })
expect(log.checks.errors).toEqual([])
expect(log.missingEstablishmentFields).toEqual(['street address', 'city', 'state', 'ZIP code', 'NAICS code', 'EIN'])
const empty = (await manager.get(`/api/osha/log?siteId=${site.id}&year=${year}`)).body
expect(empty.checks.errors).toContain('Enter hours worked for each month.')
})
it('validates and normalises establishment details', async () => {
expect((await manager.patch(`/api/sites/${other.id}`, { naicsCode: '33211' })).status).toBe(400)
const res = await manager.patch(`/api/sites/${other.id}`, { streetAddress: '1 Mill Rd', city: 'Dayton', state: 'oh', zip: '45402-1234', naicsCode: '332119', ein: '12-3456789' })
expect(res.body.site).toMatchObject({ state: 'OH', zip: '454021234', ein: '123456789' })
})
it('exports the ITA 300A file in OSHA’s format', async () => {
const res = await manager.get(`/api/osha/ita/summary.csv?year=${year}`)
const lines = res.body.toString('utf8').trimEnd().split('\r\n')
expect(lines[0].charCodeAt(0)).not.toBe(0xfeff)
expect(lines[0]).toBe(
'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',
)
const second = lines.find((line: string) => line.startsWith('Second Plant'))
expect(second).toBe(`Second Plant,123456789,Northstar Co.,1 Mill Rd,Dayton,OH,454021234,332119,,21,1,${year},60,120000,1,0,0,1,0,0,3,0,0,0,0,0,1,`)
})
it('exports ITA case data without names', async () => {
const res = await manager.get(`/api/osha/ita/cases.csv?year=${year}`)
const csv = res.body.toString('utf8')
expect(csv).not.toContain('Jordan Private')
expect(csv).not.toContain('First Aid Only')
const [header, ...rows] = csv.trimEnd().split('\r\n')
expect(header.split(',')).toHaveLength(24)
const row = rows.find((line: string) => line.startsWith('Second Plant'))!
expect(row).toContain(`02-03-${year}`) // mm-dd-yyyy
expect(row).toContain(',3,0,3,6,04-02-1990,06-10-2019,M,0,0,07:00,14:05,,') // outcome, days, type, dates, sex, treatment, times
})
it('produces a Form 301 for a case', async () => {
const log = (await manager.get(`/api/osha/log?siteId=${other.id}&year=${year}`)).body
const form = (await manager.get(`/api/osha/cases/${log.rows[0].caseId}/301`)).body
expect(form).toMatchObject({ establishment: { name: 'Second Plant', city: 'Dayton' }, case: { name: 'Jordan Private', privacyCase: true }, incident: { title: 'Needlestick' } })
})
})
+289
View File
@@ -0,0 +1,289 @@
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"` })
})
+5 -3
View File
@@ -35,9 +35,11 @@ describe('stats', () => {
{ siteId: expect.any(String), name: 'South', count: 0 },
])
const analytics = (await supervisor.get('/api/stats/analytics?days=30')).body
expect(analytics).toMatchObject({ periodDays: 30, total: 3, topRootCause: { category: 'equipment', count: 1, share: 1 } })
expect(analytics.byType).toEqual(expect.arrayContaining([{ type: 'hazard', count: 2 }, { type: 'injury', count: 1 }]))
const to = new Date().toISOString().slice(0, 10)
const from = new Date(Date.now() - 29 * 86_400_000).toISOString().slice(0, 10)
const analytics = (await supervisor.get(`/api/stats/analytics?from=${from}&to=${to}`)).body
expect(analytics.kpis.reports.value).toBe(3)
expect(analytics.byRootCause).toEqual([{ category: 'equipment', count: 1 }])
})
it("scopes a reporter's numbers to what they can see", async () => {
+11 -49
View File
@@ -1,12 +1,13 @@
import { and, asc, count, desc, eq, gte, inArray, isNotNull, isNull, lt, ne, sql } from 'drizzle-orm'
import { Hono } from 'hono'
import { z } from 'zod'
import type { AnalyticsStats, OverviewStats } from '../../shared/api-types.ts'
import type { OverviewStats } from '../../shared/api-types.ts'
import { incidentStatuses, type IncidentStatus } from '../../shared/enums.ts'
import { requireAuth } from '../auth/middleware.ts'
import { correctiveActions, incidents, investigations, rootCauses, sites } from '../db/schema.ts'
import { correctiveActions, incidents, sites } from '../db/schema.ts'
import { addDays, localDate } from '../lib/dates.ts'
import { currentUser, validate, type AppEnv } from '../lib/http.ts'
import { computeAnalytics } from '../lib/analytics.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
import { analyticsQuery } from '../../shared/schemas.ts'
import { visibleTo } from '../lib/incidents.ts'
const DAY_MS = 86_400_000
@@ -97,49 +98,10 @@ export const statsRoutes = new Hono<AppEnv>()
return c.json(stats)
})
.get('/analytics', requireAuth, validate('query', z.object({ days: z.coerce.number().int().min(7).max(730).default(90) })), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { days } = c.req.valid('query')
const since = new Date(Date.now() - days * DAY_MS)
const inPeriod = and(visibleTo(db, me), gte(incidents.occurredAt, since))
const byType = await db.select({ type: incidents.type, count: count() }).from(incidents).where(inPeriod).groupBy(incidents.type).orderBy(desc(count()))
const bySeverity = await db.select({ severity: incidents.severity, count: count() }).from(incidents).where(inPeriod).groupBy(incidents.severity)
const causes = await db
.select({ category: rootCauses.category, count: count() })
.from(rootCauses)
.innerJoin(investigations, eq(investigations.id, rootCauses.investigationId))
.innerJoin(incidents, eq(incidents.id, investigations.incidentId))
.where(inPeriod)
.groupBy(rootCauses.category)
.orderBy(desc(count()))
const totalCauses = causes.reduce((sum, row) => sum + row.count, 0)
const monthExpr = sql<string>`to_char(${correctiveActions.completedAt}, 'YYYY-MM')`
const onTimeExpr = sql<boolean>`(${correctiveActions.completedAt})::date <= ${correctiveActions.dueDate}`
const completed = await db
.select({ month: monthExpr, onTime: onTimeExpr, count: count() })
.from(correctiveActions)
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
.where(and(visibleTo(db, me), isNotNull(correctiveActions.completedAt), isNotNull(correctiveActions.dueDate), gte(correctiveActions.completedAt, since)))
.groupBy(monthExpr, onTimeExpr)
const onTime = completed.filter((row) => row.onTime).reduce((sum, row) => sum + row.count, 0)
const allCompleted = completed.reduce((sum, row) => sum + row.count, 0)
const months = [...new Set(completed.map((row) => row.month))].sort()
const stats: AnalyticsStats = {
periodDays: days,
total: byType.reduce((sum, row) => sum + row.count, 0),
byType,
bySeverity,
topRootCause: causes[0] ? { category: causes[0].category, count: causes[0].count, share: causes[0].count / totalCauses } : null,
onTimeActionRate: allCompleted ? onTime / allCompleted : null,
monthlyCompletedActions: months.map((month) => ({
month,
onTime: completed.filter((row) => row.month === month && row.onTime).reduce((sum, row) => sum + row.count, 0),
late: completed.filter((row) => row.month === month && !row.onTime).reduce((sum, row) => sum + row.count, 0),
})),
}
return c.json(stats)
.get('/analytics', requireAuth, validate('query', analyticsQuery), async (c) => {
const filters = c.req.valid('query')
if (filters.from > filters.to) throw fail(400, 'The start date must be before the end date')
const days = (Date.parse(filters.to) - Date.parse(filters.from)) / 86_400_000
if (days > 3 * 366) throw fail(400, 'Choose a range of up to three years')
return c.json(await computeAnalytics(c.get('db'), currentUser(c), filters))
})
+56
View File
@@ -0,0 +1,56 @@
import { and, asc, eq, or } from 'drizzle-orm'
import { Hono } from 'hono'
import { z } from 'zod'
import type { SavedView } from '../../shared/api-types.ts'
import { hasRole } from '../../shared/enums.ts'
import { savedViewSchema } from '../../shared/schemas.ts'
import { requireAuth } from '../auth/middleware.ts'
import { savedViews } from '../db/schema.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
const toView = (row: typeof savedViews.$inferSelect, userId: string): SavedView => ({
id: row.id,
page: row.page as SavedView['page'],
name: row.name,
filters: row.filters,
shared: row.shared,
mine: row.userId === userId,
})
/** Saved filter sets. Supervisors and above can share views with the whole organization. */
export const viewRoutes = new Hono<AppEnv>()
.get('/', requireAuth, validate('query', z.object({ page: z.enum(['analytics', 'incidents']) })), async (c) => {
const me = currentUser(c)
const rows = await c
.get('db')
.select()
.from(savedViews)
.where(and(eq(savedViews.organizationId, me.organizationId), eq(savedViews.page, c.req.valid('query').page), or(eq(savedViews.userId, me.id), eq(savedViews.shared, true))))
.orderBy(asc(savedViews.name))
return c.json({ views: rows.map((row) => toView(row, me.id)) })
})
.post('/', requireAuth, validate('json', savedViewSchema), async (c) => {
const me = currentUser(c)
const input = c.req.valid('json')
if (input.shared && !hasRole(me.role, 'supervisor')) throw fail(403, 'Only supervisors and above can share views')
const [row] = await c
.get('db')
.insert(savedViews)
.values({ ...input, organizationId: me.organizationId, userId: me.id })
.returning()
return c.json({ view: toView(row, me.id) }, 201)
})
.delete('/:id', requireAuth, validate('param', z.object({ id: z.uuid() })), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const [row] = await db
.select()
.from(savedViews)
.where(and(eq(savedViews.id, c.req.valid('param').id), eq(savedViews.organizationId, me.organizationId)))
if (!row || (!row.shared && row.userId !== me.id)) throw fail(404, 'View not found')
if (row.userId !== me.id && me.role !== 'admin') throw fail(403, 'Only the owner or an admin can delete this view')
await db.delete(savedViews).where(eq(savedViews.id, row.id))
return c.body(null, 204)
})