From 61c876edec00defebee7ca3421e93e5732cb7757 Mon Sep 17 00:00:00 2001 From: jayjennings Date: Tue, 6 Oct 2026 13:09:57 -0700 Subject: [PATCH] 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. --- README.md | 10 +- docs/API.md | 27 +- docs/ROADMAP.md | 24 +- docs/TODO.md | 19 + e2e/analytics.spec.ts | 38 + e2e/fixtures.ts | 1 + e2e/global-setup.ts | 2 +- e2e/osha.spec.ts | 64 + server/app.ts | 4 + .../db/migrations/0003_osha_recordkeeping.sql | 41 + server/db/migrations/0004_saved_views.sql | 14 + server/db/migrations/meta/0003_snapshot.json | 2409 ++++++++++++++++ server/db/migrations/meta/0004_snapshot.json | 2522 +++++++++++++++++ server/db/migrations/meta/_journal.json | 14 + server/db/schema.ts | 71 + server/db/seed.ts | 146 +- server/lib/analytics.ts | 252 ++ server/lib/csv.test.ts | 4 +- server/lib/csv.ts | 9 +- server/lib/incidents.ts | 8 +- server/lib/osha.ts | 200 ++ server/notifications.ts | 28 +- server/routes/analytics.test.ts | 130 + server/routes/osha.test.ts | 176 ++ server/routes/osha.ts | 289 ++ server/routes/stats.test.ts | 8 +- server/routes/stats.ts | 60 +- server/routes/views.ts | 56 + shared/api-types.ts | 167 +- shared/enums.ts | 12 + shared/osha.test.ts | 122 + shared/osha.ts | 173 ++ shared/schemas.ts | 107 +- src/App.tsx | 4 + src/components/SavedViews.tsx | 92 + src/components/Sidebar.tsx | 5 +- src/components/charts/BarList.tsx | 21 + src/components/charts/ChartFrame.tsx | 105 + src/components/charts/Heatmap.tsx | 65 + src/components/charts/LineChart.tsx | 119 + src/components/charts/StackedColumns.tsx | 101 + src/components/charts/StatTile.tsx | 27 + src/components/charts/scale.test.ts | 11 + src/components/charts/scale.ts | 12 + src/components/charts/useWidth.ts | 15 + src/components/incident/OshaSection.tsx | 442 +++ src/i18n/en.ts | 1 + src/i18n/es.ts | 1 + src/index.css | 19 + src/lib/labels.ts | 40 +- src/lib/navigation.ts | 8 +- src/lib/queries.ts | 67 +- src/pages/Analytics.tsx | 367 ++- src/pages/Form301.tsx | 122 + src/pages/IncidentDetail.tsx | 2 + src/pages/Incidents.tsx | 6 +- src/pages/Locations.tsx | 67 +- src/pages/Osha.tsx | 433 +++ tsconfig.app.json | 4 +- 59 files changed, 9180 insertions(+), 183 deletions(-) create mode 100644 e2e/analytics.spec.ts create mode 100644 e2e/osha.spec.ts create mode 100644 server/db/migrations/0003_osha_recordkeeping.sql create mode 100644 server/db/migrations/0004_saved_views.sql create mode 100644 server/db/migrations/meta/0003_snapshot.json create mode 100644 server/db/migrations/meta/0004_snapshot.json create mode 100644 server/lib/analytics.ts create mode 100644 server/lib/osha.ts create mode 100644 server/routes/analytics.test.ts create mode 100644 server/routes/osha.test.ts create mode 100644 server/routes/osha.ts create mode 100644 server/routes/views.ts create mode 100644 shared/osha.test.ts create mode 100644 shared/osha.ts create mode 100644 src/components/SavedViews.tsx create mode 100644 src/components/charts/BarList.tsx create mode 100644 src/components/charts/ChartFrame.tsx create mode 100644 src/components/charts/Heatmap.tsx create mode 100644 src/components/charts/LineChart.tsx create mode 100644 src/components/charts/StackedColumns.tsx create mode 100644 src/components/charts/StatTile.tsx create mode 100644 src/components/charts/scale.test.ts create mode 100644 src/components/charts/scale.ts create mode 100644 src/components/charts/useWidth.ts create mode 100644 src/components/incident/OshaSection.tsx create mode 100644 src/pages/Form301.tsx create mode 100644 src/pages/Osha.tsx diff --git a/README.md b/README.md index c446179..0f5e61b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Free, open-source accident and safety tracking for manufacturing: incident and near-miss reporting, investigations, corrective actions and (planned) OSHA record keeping. -> **Status: early development (Phases 0–3 done).** Reporting (including offline, kiosk tablets, QR codes and Spanish), investigation, corrective actions, email notifications, dashboards and CSV export work end to end. OSHA record keeping is next. See [docs/ROADMAP.md](docs/ROADMAP.md) for the plan and [docs/TODO.md](docs/TODO.md) for open items. +> **Status: early development (Phases 0–5 done).** Reporting (including offline, kiosk tablets, QR codes and Spanish), investigation, corrective actions, OSHA recordkeeping (Forms 300, 300A, 301, rates and electronic submission files), leading/lagging-indicator analytics with saved views, and email notifications work end to end. See [docs/ROADMAP.md](docs/ROADMAP.md) for the plan and [docs/TODO.md](docs/TODO.md) for open items. ## Getting started @@ -14,7 +14,7 @@ npm run db:seed # optional: demo organization, sites, users and incidents npm run dev # web app on http://localhost:5173, API on http://localhost:3000 ``` -Demo users (after seeding): `admin@example.com`, `manager@example.com`, `supervisor@example.com` and `reporter@example.com`, all with password `safetylog-demo`. Without seeding, opening the app takes you to first-run setup. +`npm run db:seed` also loads a year of realistic history so the dashboards and OSHA log have data. Demo users: `admin@example.com`, `manager@example.com`, `supervisor@example.com` and `reporter@example.com`, all with password `safetylog-demo`. Without seeding, opening the app takes you to first-run setup. In development, emails (sign-in links, invitations, notifications) are printed in the terminal running the server. @@ -35,6 +35,12 @@ This runs the app and PostgreSQL. The app applies database migrations on startup - Back up both volumes: `db-data` (PostgreSQL) and `app-data` (uploads). - Installing the app on phones and opening it offline needs HTTPS (browsers require it for service workers). Over plain HTTP everything else works, and reports made while offline are still queued once the page is open. +## OSHA recordkeeping + +On an incident, safety managers add each injured or ill person and answer a short set of questions; the app decides whether the case is recordable under 29 CFR 1904 and how it's classified, flags injuries that must be reported to OSHA within 8 or 24 hours, and builds the Form 300 Log, 300A Summary and 301 reports per site. Enter monthly hours and headcount for incidence rates (TRIR, DART), add establishment details on the Locations page, and download the CSV files for OSHA's Injury Tracking Application from **OSHA log → Submit to OSHA**. + +The app helps apply OSHA's rules consistently, but it is not legal advice: the employer remains responsible for its records. + ## Shop-floor reporting - **Kiosk tablets and QR codes:** in **Settings → Reporting links**, create a link. Open it on a shared tablet for a kiosk, or print QR codes for it from **Locations → QR codes**. Anyone can then report without an account; the link can't read anything and can be revoked at any time. diff --git a/docs/API.md b/docs/API.md index 110957f..40fe3a9 100644 --- a/docs/API.md +++ b/docs/API.md @@ -138,12 +138,37 @@ The token goes in the `X-Kiosk-Token` header. Browsers can't send custom headers The web app opens links at `/k/`, optionally with `?site=&area=` to pre-fill the location (as QR codes do). +## OSHA recordkeeping + +All endpoints need **safety_manager**: they hold confidential medical information. Other roles only see `injuredCount` on an incident. Rules are in [`shared/osha.ts`](../shared/osha.ts); section numbers refer to 29 CFR 1904. + +| Method | Path | Notes | +| --- | --- | --- | +| GET | `/api/osha/incidents/:number/cases` | Injured or ill people on an incident | +| POST | `/api/osha/incidents/:number/cases` | `{ name, ...answers }`. Case number is the incident reference, with `-2`, `-3`… for further people | +| PATCH | `/api/osha/cases/:id` | Any case field. `recordable` and `outcome` are always recalculated from the answers; days away + restricted may not exceed 180 | +| DELETE | `/api/osha/cases/:id` | | +| GET | `/api/osha/cases/:id/301` | Data for Form 301 | +| GET | `/api/osha/log?siteId=&year=` | 300 Log rows, 300A totals, monthly hours, annual average employees, TRIR/DART/LTIR, ITA checks, missing establishment fields | +| GET/PUT | `/api/osha/sites/:id/hours?year=` | Monthly `{ month, hours, averageEmployees }`; `null` for both clears a month | +| GET | `/api/osha/ita/summary.csv?year=` | ITA Establishment and 300A file, one row per active site | +| GET | `/api/osha/ita/cases.csv?year=` | ITA case data (Forms 300/301) file, without names | + +**Case answers:** `employmentType`, `workRelated`, `newCase`, `died`, `dateOfDeath`, `daysAwayFromWork`, `daysAway`, `restrictedOrTransfer`, `daysRestricted`, `medicalTreatment`, `lossOfConsciousness`, `significantDiagnosis`, `specialCase` (`none`, `needlestick`, `medical_removal`, `hearing_loss`, `tuberculosis`), `caseType` (300 column M), `privacyCase`, severe-injury flags (`inpatientHospitalization`, `amputation`, `lossOfEye`, `reportedToOshaAt`) and the Form 301 fields (`jobTitle`, `natureOfInjury`, `bodyPart`, `objectSubstance`, `activityBefore`, `whatHappened`, `dateOfBirth`, `dateOfHire`, `sex`, `employeeAddress`, `physicianName`, `treatmentFacility`, `treatedInEmergencyRoom`, `timeStartedWork`). + +Responses include `severeReport` (`{ withinHours, reasons, dueBy }`) when 1904.39 requires a direct report, and `missingFields` listing what Form 301 and the ITA still need. A case newly needing a direct report emails safety managers and admins. + +Site establishment fields (`streetAddress`, `city`, `state`, `zip`, `naicsCode`, `industryDescription`, `ein`, `establishmentType`) are set with `PATCH /api/sites/:id`. + ## Stats | Method | Path | Who | Notes | | --- | --- | --- | --- | | GET | `/api/stats/overview` | signed in | Open incidents, actions due/overdue, average close time, status counts, weekly trend (12 weeks), incidents by site this year | -| GET | `/api/stats/analytics` | signed in | `?days=` (7–730, default 90): counts by type and severity, top root-cause category, on-time action rate, monthly completions | +| GET | `/api/stats/analytics` | signed in | `?from=YYYY-MM-DD&to=YYYY-MM-DD` (up to 3 years), optional `siteId`, `type`. Leading/lagging KPIs with the previous period, trend (weekly up to ~3 months, else monthly), locations, root causes, weekday × hour heatmap, actions on time vs late, and for supervisors+ rolling 12-month TRIR/DART and yearly rates | +| GET | `/api/views?page=analytics\|incidents` | signed in | Your saved views plus shared ones | +| POST | `/api/views` | signed in | `{ page, name, filters, shared }`. Sharing needs supervisor | +| DELETE | `/api/views/:id` | owner or admin | | Stats only count what the signed-in user can see. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9b90fab..09af5b9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -20,6 +20,9 @@ This is a living document. Update it as decisions are made or scope changes. | Reporting without accounts | "Reporting links": revocable, rate-limited tokens that can only file reports; used for kiosk tablets and QR codes | Decided | | Languages | English + Spanish with typed message files; worker-facing screens first, manager screens later (see TODO) | Decided | | Offline | Installable PWA; reports queued in IndexedDB and retried with a client-generated id so retries never duplicate | Decided | +| OSHA rules | One shared rules module (`shared/osha.ts`) used by API and UI; printable forms are OSHA-permitted equivalents (1904.29(b)(4)); ITA exports follow OSHA's published CSV specs | Decided | +| Medical privacy | Injured-person and medical details visible to safety managers and admins only; timeline and case CSV carry no names | Decided | +| Charts | Hand-built SVG components (no chart library) following the dataviz method: validated color slots, one axis, hairline grid, hover tooltips, a table view on every chart | Decided | | CI | Gitea Actions (`.gitea/workflows`): lint, typecheck, unit tests, build, e2e | Decided | | Product name | Open SafetyLog EHS (replaced placeholder "IA Tracker" and the prototype's "Safesite" branding) | Decided | @@ -74,15 +77,24 @@ This is a living document. Update it as decisions are made or scope changes. ## Phase 4: Compliance (US OSHA) -- Recordability wizard; days away / restricted / transferred tracking -- OSHA 300, 300A and 301 form generation; privacy-case handling -- ITA (Injury Tracking Application) CSV export -- Hours worked per site per month; TRIR, DART and lost-time rates +- [x] Injured/ill people per incident with a live recordability check (1904.4–1904.11): covered worker, work-related, new case, general criteria, special cases; most-serious-outcome classification; 14-item first-aid list and privacy-case rules shown in context +- [x] Day counts with the 180-day cap; days only counted when the matching outcome applies +- [x] Severe injury reporting (1904.39): 8-hour/24-hour deadline banner, email alert to safety managers, "reported to OSHA at" record +- [x] Form 300 Log (landscape print), Form 300A Summary with establishment details and certification block, printable Form 301 per case +- [x] Establishment details per site (address, NAICS, EIN, type) with validation +- [x] Monthly hours worked and headcount; TRIR, DART and LTIR +- [x] ITA CSV exports for the 300A summary and 300/301 case data (no names), plus OSHA's own upload checks run in the app +- [x] Tests: rules module unit tests, API tests incl. exact CSV output, browser test of the full recordkeeping flow ## Phase 5: Analytics -- Trends, location breakdown, top root causes, corrective-action closure time -- Leading vs lagging indicators; filters and saved dashboards +- [x] One filter row (date range presets or custom, site, incident type) scoping every number and chart on the page +- [x] Leading indicators: hazards & near misses reported, near misses per injury, triaged within 24 hours, actions completed on time; each compared with the previous period +- [x] Lagging indicators: injuries, OSHA-recordable cases, rolling 12-month TRIR/DART, average days to close; OSHA rates by calendar year +- [x] Charts: reports per week/month by kind, rolling OSHA rates, locations (sites, or areas within a site), root causes, weekday × hour heatmap, actions completed on time vs late +- [x] Saved views (private, or shared by supervisors and above) on Analytics and the Incidents list +- [x] Demo seed with a year of realistic history (deterministic) +- [x] Tests: analytics computations (periods, groupings, rates, scoping), saved views, chart scale helper, browser tests ## Phase 6: Release v1.0 diff --git a/docs/TODO.md b/docs/TODO.md index 6f553b9..301a346 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -31,6 +31,25 @@ Open items that aren't tied to a single roadmap phase. Tick them off (or delete - [ ] **Have Spanish reviewed by a native speaker** familiar with plant safety terms (e.g. "casi accidente" for near miss, "planta" for site). - [ ] **Offline incident list.** Only reporting works offline; viewing incidents and actions needs a connection. +## Phase 4 follow-ups + +- [ ] **Expert review of the OSHA rules.** `shared/osha.ts` encodes 29 CFR 1904 as plainly as possible with section references, but it should be reviewed by a recordkeeping professional before anyone relies on it. The app should say clearly that it assists, and the employer remains responsible. +- [ ] **Deadline reminders.** Email safety managers in January to certify and post the 300A (Feb 1–Apr 30) and before the ITA submission deadline (March 2). +- [ ] **Direct ITA submission.** OSHA offers an API for the Injury Tracking Application; submitting directly would remove the CSV download/upload step. +- [ ] **Day counting from dates.** Calculate days away/restricted from start and return-to-work dates instead of typed counts. +- [ ] **Employee access (1904.35).** Employees and their representatives may request the 300 Log (and their own 301). Add a way to produce those copies with privacy cases removed. +- [ ] **OSHA rates on the dashboard.** Show TRIR/DART on Overview and Analytics, and trends over years. +- [ ] **Translate the OSHA pages** along with the other manager screens. +- [ ] **Other regions.** The data model keeps outcomes and case types separate from US-specific forms; UK RIDDOR or Canadian provincial rules could be added as further rule sets. + +## Phase 5 follow-ups + +- [ ] **Industry benchmarks.** Show the BLS average TRIR/DART for the site's NAICS code next to its own rates. +- [ ] **Scheduled reports.** A weekly or monthly email digest of the indicators for safety managers. +- [ ] **Chart export.** Download a chart as an image, or print the whole Analytics page as a report (print styles are in place for most of it). +- [ ] **Dark mode.** The app is light-only; if a dark theme is added, the chart colors need their dark-mode steps validated against the dark surface. +- [ ] **Translate Analytics** with the other manager screens. + ## Done - [x] Docker verified 2026-10-06 (Docker 29.8, Compose v5.5): image builds (~315 MB), `docker compose up` with PostgreSQL 18 passes setup → sign-in → report smoke test; data and sessions survive `down`/`up`; clean shutdown; healthcheck healthy; runs as non-root; standalone PGlite container works; `Secure` cookie with an `https://` PUBLIC_URL; web app loads with no errors. diff --git a/e2e/analytics.spec.ts b/e2e/analytics.spec.ts new file mode 100644 index 0000000..33bf19f --- /dev/null +++ b/e2e/analytics.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test' +import { signIn, unique } from './fixtures' + +test('shows leading and lagging indicators with charts that have table views', async ({ page }) => { + await signIn(page, 'safety_manager') + await page.goto('/analytics?range=365') + await expect(page.getByRole('heading', { name: 'Leading indicators' })).toBeVisible() + await expect(page.getByText('Hazards & near misses reported')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Lagging indicators' })).toBeVisible() + + // Every chart has a table view + const trend = page.locator('figure').filter({ hasText: 'Reports per' }) + await trend.getByRole('button', { name: 'Table' }).click() + await expect(trend.getByRole('columnheader', { name: 'Hazards & near misses' })).toBeVisible() +}) + +test('filters and saved views', async ({ page, isMobile }) => { + test.skip(isMobile, 'Covered on desktop') + await signIn(page, 'supervisor') + await page.goto('/analytics') + await page.getByLabel('Date range').selectOption('30') + await page.getByLabel('Incident type').selectOption('hazard') + await expect(page).toHaveURL(/range=30/) + await expect(page).toHaveURL(/type=hazard/) + + const name = unique('Hazards this month') + await page.getByRole('button', { name: 'Save view' }).click() + await page.getByLabel('View name').fill(name) + await page.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.getByLabel('Saved views')).toHaveValue(/.+/) + + // Clearing the filters and picking the view brings them back + await page.goto('/analytics') + await expect(page.getByLabel('Incident type')).toHaveValue('') + await page.getByLabel('Saved views').selectOption({ label: name }) + await expect(page.getByLabel('Incident type')).toHaveValue('hazard') + await expect(page.getByLabel('Date range')).toHaveValue('30') +}) diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index b198065..8e30f9a 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -5,6 +5,7 @@ export const users = { admin: { name: 'Avery Admin', email: 'admin@e2e.test' }, supervisor: { name: 'Sam Supervisor', email: 'supervisor@e2e.test' }, reporter: { name: 'Riley Reporter', email: 'reporter@e2e.test' }, + safety_manager: { name: 'Morgan Manager', email: 'manager@e2e.test' }, } as const export async function signIn(page: Page, who: keyof typeof users) { diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index 1c8b623..c6f79e1 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -18,7 +18,7 @@ export default async function globalSetup() { process.env.E2E_KIOSK_TOKEN = kiosk.token process.env.E2E_SITE_ID = site.id process.env.E2E_AREA_ID = area.id - for (const role of ['supervisor', 'reporter'] as const) { + for (const role of ['supervisor', 'reporter', 'safety_manager'] as const) { await ok(await api.post('/api/users', { data: { ...users[role], role, password: PASSWORD } })) } await api.dispose() diff --git a/e2e/osha.spec.ts b/e2e/osha.spec.ts new file mode 100644 index 0000000..c464638 --- /dev/null +++ b/e2e/osha.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from '@playwright/test' +import { signIn, unique } from './fixtures' + +test('records an injury on the OSHA log and exports it', async ({ page, browser, isMobile }) => { + test.skip(isMobile, 'Recordkeeping is a desktop task') + test.setTimeout(60_000) + const title = unique('Burn from hot press platen') + + // A supervisor reports it; they can't see medical details + const supervisor = await (await browser.newContext()).newPage() + await signIn(supervisor, 'supervisor') + await supervisor.goto('/report') + await supervisor.getByLabel('Incident title').fill(title) + await supervisor.getByLabel('Incident type').selectOption('injury') + await supervisor.getByRole('button', { name: 'Submit report' }).click() + await expect(supervisor.getByRole('heading', { level: 1 })).toHaveText(title) + const url = supervisor.url() + + // The safety manager decides recordability + await signIn(page, 'safety_manager') + await page.goto(url) + await page.getByRole('button', { name: 'Add person' }).click() + await page.getByLabel('Name', { exact: true }).fill('Casey Worker') + await page.getByPlaceholder('e.g. Press operator').fill('Press operator') + const verdict = page.locator('[aria-live="polite"]') + await expect(verdict).toContainText('Answer the questions above') + await page.getByText('Is it work-related?').locator('..').getByLabel('Yes').check() + await page.getByText('Is it a new case?').locator('..').getByLabel('Yes').check() + await expect(verdict).toContainText('Not recordable') // first aid only + await page.getByText('Days away from work (count').click() + await page.getByLabel('Days away so far').fill('3') + await expect(verdict).toContainText('Recordable: Days away from work') + await page.getByText('Hospitalized as an in-patient').click() + await expect(page.getByText('Must be reported to OSHA within 24 hours.')).toBeVisible() + await page.getByPlaceholder('e.g. Laceration, sprain, burn').fill('Second-degree burn') + await page.getByRole('button', { name: 'Add person' }).click() + + await expect(page.getByText('OSHA recordable · Days away from work')).toBeVisible() + await expect(page.getByRole('alert').filter({ hasText: 'Report to OSHA within 24 hours' })).toBeVisible() + + // Supervisors see that someone was hurt, nothing more + await supervisor.reload() + await expect(supervisor.getByText('1 person recorded. Injury and medical details are confidential')).toBeVisible() + await expect(supervisor.getByText('Casey Worker')).toHaveCount(0) + + // It's on the 300 Log, and the 301 is one click away + await page.getByRole('link', { name: 'OSHA log' }).click() + const row = page.getByRole('row').filter({ hasText: 'Casey Worker' }) + await expect(row).toContainText('Second-degree burn') + await expect(row).toContainText('Missing:') + + await page.getByRole('tab', { name: 'Submit to OSHA' }).click() + await expect(page.getByText('Enter hours worked for each month.')).toBeVisible() + const download = page.waitForEvent('download') + await page.getByRole('link', { name: /300A summary CSV/ }).click() + expect((await download).suggestedFilename()).toMatch(/^osha-ita-300a-\d{4}\.csv$/) +}) + +test("OSHA pages aren't offered to supervisors", async ({ page, isMobile }) => { + await signIn(page, 'supervisor') + if (isMobile) await page.getByRole('button', { name: 'Open menu' }).click() + await expect(page.getByRole('link', { name: 'Incidents' }).first()).toBeVisible() + await expect(page.getByRole('link', { name: 'OSHA log' })).toHaveCount(0) +}) diff --git a/server/app.ts b/server/app.ts index 2ee778e..38b5212 100644 --- a/server/app.ts +++ b/server/app.ts @@ -12,11 +12,13 @@ import { attachmentRoutes } from './routes/attachments.ts' import { authRoutes } from './routes/auth.ts' import { incidentRoutes } from './routes/incidents.ts' import { kioskAdminRoutes, kioskPublicRoutes } from './routes/kiosks.ts' +import { oshaRoutes } from './routes/osha.ts' import { rootCauseRoutes } from './routes/root-causes.ts' import { setupRoutes } from './routes/setup.ts' import { areaRoutes, siteRoutes } from './routes/sites.ts' import { statsRoutes } from './routes/stats.ts' import { userRoutes } from './routes/users.ts' +import { viewRoutes } from './routes/views.ts' import type { FileStorage } from './storage.ts' export interface AppDeps { @@ -57,6 +59,8 @@ export function createApp({ db, config, notifier, storage }: AppDeps) { app.route('/api/stats', statsRoutes) app.route('/api/kiosks', kioskAdminRoutes) app.route('/api/kiosk', kioskPublicRoutes()) + app.route('/api/osha', oshaRoutes) + app.route('/api/views', viewRoutes) app.all('/api/*', (c) => c.json({ error: 'Not found' }, 404)) app.onError((error, c) => { diff --git a/server/db/migrations/0003_osha_recordkeeping.sql b/server/db/migrations/0003_osha_recordkeeping.sql new file mode 100644 index 0000000..99b9b83 --- /dev/null +++ b/server/db/migrations/0003_osha_recordkeeping.sql @@ -0,0 +1,41 @@ +CREATE TYPE "public"."osha_case_type" AS ENUM('injury', 'skin_disorder', 'respiratory', 'poisoning', 'hearing_loss', 'other_illness');--> statement-breakpoint +CREATE TYPE "public"."osha_outcome" AS ENUM('death', 'days_away', 'restricted', 'other');--> statement-breakpoint +CREATE TYPE "public"."osha_special_case" AS ENUM('none', 'needlestick', 'medical_removal', 'hearing_loss', 'tuberculosis');--> statement-breakpoint +ALTER TABLE "hours_worked" ADD COLUMN "average_employees" integer;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "case_number" text;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "work_related" boolean;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "new_case" boolean;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "died" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "date_of_death" date;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "days_away_from_work" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "restricted_or_transfer" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "medical_treatment" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "loss_of_consciousness" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "significant_diagnosis" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "special_case" "osha_special_case" DEFAULT 'none' NOT NULL;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "case_type" "osha_case_type" DEFAULT 'injury' NOT NULL;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "recordable" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "outcome" "osha_outcome";--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "inpatient_hospitalization" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "amputation" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "loss_of_eye" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "reported_to_osha_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "employee_address" text;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "date_of_birth" date;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "date_of_hire" date;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "sex" text;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "physician_name" text;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "treatment_facility" text;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "treated_in_emergency_room" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "time_started_work" text;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "activity_before" text;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "what_happened" text;--> statement-breakpoint +ALTER TABLE "injured_persons" ADD COLUMN "object_substance" text;--> statement-breakpoint +ALTER TABLE "sites" ADD COLUMN "street_address" text;--> statement-breakpoint +ALTER TABLE "sites" ADD COLUMN "city" text;--> statement-breakpoint +ALTER TABLE "sites" ADD COLUMN "state" text;--> statement-breakpoint +ALTER TABLE "sites" ADD COLUMN "zip" text;--> statement-breakpoint +ALTER TABLE "sites" ADD COLUMN "naics_code" text;--> statement-breakpoint +ALTER TABLE "sites" ADD COLUMN "industry_description" text;--> statement-breakpoint +ALTER TABLE "sites" ADD COLUMN "ein" text;--> statement-breakpoint +ALTER TABLE "sites" ADD COLUMN "establishment_type" integer DEFAULT 1 NOT NULL; \ No newline at end of file diff --git a/server/db/migrations/0004_saved_views.sql b/server/db/migrations/0004_saved_views.sql new file mode 100644 index 0000000..d1a5c58 --- /dev/null +++ b/server/db/migrations/0004_saved_views.sql @@ -0,0 +1,14 @@ +CREATE TABLE "saved_views" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "page" text NOT NULL, + "name" text NOT NULL, + "filters" jsonb NOT NULL, + "shared" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "saved_views" ADD CONSTRAINT "saved_views_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "saved_views" ADD CONSTRAINT "saved_views_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "saved_views_organization_page_idx" ON "saved_views" USING btree ("organization_id","page"); \ No newline at end of file diff --git a/server/db/migrations/meta/0003_snapshot.json b/server/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..f088719 --- /dev/null +++ b/server/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,2409 @@ +{ + "id": "bc8cffa0-a2ba-49cd-9c96-2b7ddc70329f", + "prevId": "9f956116-7800-4428-8fdd-c21f012e29b7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.areas": { + "name": "areas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "area_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'area'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "areas_site_idx": { + "name": "areas_site_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "areas_parent_idx": { + "name": "areas_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "areas_site_id_sites_id_fk": { + "name": "areas_site_id_sites_id_fk", + "tableFrom": "areas", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "areas_parent_id_areas_id_fk": { + "name": "areas_parent_id_areas_id_fk", + "tableFrom": "areas", + "tableTo": "areas", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "incident_id": { + "name": "incident_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "attachments_incident_idx": { + "name": "attachments_incident_idx", + "columns": [ + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_incident_id_incidents_id_fk": { + "name": "attachments_incident_id_incidents_id_fk", + "tableFrom": "attachments", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_id_users_id_fk": { + "name": "attachments_uploaded_by_id_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "attachments_storageKey_unique": { + "name": "attachments_storageKey_unique", + "nullsNotDistinct": false, + "columns": [ + "storage_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_entity_idx": { + "name": "audit_log_entity_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_organization_id_organizations_id_fk": { + "name": "audit_log_organization_id_organizations_id_fk", + "tableFrom": "audit_log", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_tokens": { + "name": "auth_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "auth_token_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_tokens_user_idx": { + "name": "auth_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_tokens_user_id_users_id_fk": { + "name": "auth_tokens_user_id_users_id_fk", + "tableFrom": "auth_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "incident_id": { + "name": "incident_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_incident_idx": { + "name": "comments_incident_idx", + "columns": [ + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_incident_id_incidents_id_fk": { + "name": "comments_incident_id_incidents_id_fk", + "tableFrom": "comments", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_author_id_users_id_fk": { + "name": "comments_author_id_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.corrective_actions": { + "name": "corrective_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "incident_id": { + "name": "incident_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "root_cause_id": { + "name": "root_cause_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "action_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'corrective'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "action_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "verified_by_id": { + "name": "verified_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reminder_sent_on": { + "name": "reminder_sent_on", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "corrective_actions_incident_idx": { + "name": "corrective_actions_incident_idx", + "columns": [ + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "corrective_actions_assignee_status_idx": { + "name": "corrective_actions_assignee_status_idx", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "corrective_actions_incident_id_incidents_id_fk": { + "name": "corrective_actions_incident_id_incidents_id_fk", + "tableFrom": "corrective_actions", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "corrective_actions_root_cause_id_root_causes_id_fk": { + "name": "corrective_actions_root_cause_id_root_causes_id_fk", + "tableFrom": "corrective_actions", + "tableTo": "root_causes", + "columnsFrom": [ + "root_cause_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "corrective_actions_assignee_id_users_id_fk": { + "name": "corrective_actions_assignee_id_users_id_fk", + "tableFrom": "corrective_actions", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "corrective_actions_verified_by_id_users_id_fk": { + "name": "corrective_actions_verified_by_id_users_id_fk", + "tableFrom": "corrective_actions", + "tableTo": "users", + "columnsFrom": [ + "verified_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hours_worked": { + "name": "hours_worked", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "hours": { + "name": "hours", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "average_employees": { + "name": "average_employees", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hours_worked_site_month_unique": { + "name": "hours_worked_site_month_unique", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hours_worked_site_id_sites_id_fk": { + "name": "hours_worked_site_id_sites_id_fk", + "tableFrom": "hours_worked", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.incident_counters": { + "name": "incident_counters", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "last_number": { + "name": "last_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "incident_counters_organization_id_organizations_id_fk": { + "name": "incident_counters_organization_id_organizations_id_fk", + "tableFrom": "incident_counters", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.incidents": { + "name": "incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "incident_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "incident_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'reported'" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "area_id": { + "name": "area_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "location_detail": { + "name": "location_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "immediate_action": { + "name": "immediate_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "anonymous": { + "name": "anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reporter_name": { + "name": "reporter_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel": { + "name": "channel", + "type": "report_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'web'" + }, + "kiosk_id": { + "name": "kiosk_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "incidents_organization_number_unique": { + "name": "incidents_organization_number_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "incidents_organization_client_id_unique": { + "name": "incidents_organization_client_id_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "incidents_organization_occurred_idx": { + "name": "incidents_organization_occurred_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "incidents_organization_status_idx": { + "name": "incidents_organization_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "incidents_reporter_idx": { + "name": "incidents_reporter_idx", + "columns": [ + { + "expression": "reporter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "incidents_organization_id_organizations_id_fk": { + "name": "incidents_organization_id_organizations_id_fk", + "tableFrom": "incidents", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incidents_site_id_sites_id_fk": { + "name": "incidents_site_id_sites_id_fk", + "tableFrom": "incidents", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incidents_area_id_areas_id_fk": { + "name": "incidents_area_id_areas_id_fk", + "tableFrom": "incidents", + "tableTo": "areas", + "columnsFrom": [ + "area_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "incidents_reporter_id_users_id_fk": { + "name": "incidents_reporter_id_users_id_fk", + "tableFrom": "incidents", + "tableTo": "users", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "incidents_kiosk_id_kiosks_id_fk": { + "name": "incidents_kiosk_id_kiosks_id_fk", + "tableFrom": "incidents", + "tableTo": "kiosks", + "columnsFrom": [ + "kiosk_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.injured_persons": { + "name": "injured_persons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "incident_id": { + "name": "incident_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "employment_type": { + "name": "employment_type", + "type": "employment_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'employee'" + }, + "body_part": { + "name": "body_part", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nature_of_injury": { + "name": "nature_of_injury", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "treatment": { + "name": "treatment", + "type": "treatment_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "days_away": { + "name": "days_away", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "days_restricted": { + "name": "days_restricted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "privacy_case": { + "name": "privacy_case", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "case_number": { + "name": "case_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "work_related": { + "name": "work_related", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "new_case": { + "name": "new_case", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "died": { + "name": "died", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "date_of_death": { + "name": "date_of_death", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "days_away_from_work": { + "name": "days_away_from_work", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "restricted_or_transfer": { + "name": "restricted_or_transfer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "medical_treatment": { + "name": "medical_treatment", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "loss_of_consciousness": { + "name": "loss_of_consciousness", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "significant_diagnosis": { + "name": "significant_diagnosis", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "special_case": { + "name": "special_case", + "type": "osha_special_case", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "case_type": { + "name": "case_type", + "type": "osha_case_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'injury'" + }, + "recordable": { + "name": "recordable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "outcome": { + "name": "outcome", + "type": "osha_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "inpatient_hospitalization": { + "name": "inpatient_hospitalization", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "amputation": { + "name": "amputation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "loss_of_eye": { + "name": "loss_of_eye", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reported_to_osha_at": { + "name": "reported_to_osha_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "employee_address": { + "name": "employee_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date_of_birth": { + "name": "date_of_birth", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "date_of_hire": { + "name": "date_of_hire", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "sex": { + "name": "sex", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "physician_name": { + "name": "physician_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "treatment_facility": { + "name": "treatment_facility", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "treated_in_emergency_room": { + "name": "treated_in_emergency_room", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "time_started_work": { + "name": "time_started_work", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "activity_before": { + "name": "activity_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "what_happened": { + "name": "what_happened", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "object_substance": { + "name": "object_substance", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "injured_persons_incident_idx": { + "name": "injured_persons_incident_idx", + "columns": [ + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "injured_persons_incident_id_incidents_id_fk": { + "name": "injured_persons_incident_id_incidents_id_fk", + "tableFrom": "injured_persons", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "injured_persons_user_id_users_id_fk": { + "name": "injured_persons_user_id_users_id_fk", + "tableFrom": "injured_persons", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.investigations": { + "name": "investigations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "incident_id": { + "name": "incident_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "rca_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'five_whys'" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "investigations_incident_id_incidents_id_fk": { + "name": "investigations_incident_id_incidents_id_fk", + "tableFrom": "investigations", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "investigations_lead_id_users_id_fk": { + "name": "investigations_lead_id_users_id_fk", + "tableFrom": "investigations", + "tableTo": "users", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "investigations_incidentId_unique": { + "name": "investigations_incidentId_unique", + "nullsNotDistinct": false, + "columns": [ + "incident_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiosks": { + "name": "kiosks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kiosks_token_unique": { + "name": "kiosks_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kiosks_organization_idx": { + "name": "kiosks_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiosks_organization_id_organizations_id_fk": { + "name": "kiosks_organization_id_organizations_id_fk", + "tableFrom": "kiosks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiosks_site_id_sites_id_fk": { + "name": "kiosks_site_id_sites_id_fk", + "tableFrom": "kiosks", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "kiosks_created_by_id_users_id_fk": { + "name": "kiosks_created_by_id_users_id_fk", + "tableFrom": "kiosks", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.root_causes": { + "name": "root_causes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "investigation_id": { + "name": "investigation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "root_cause_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "whys": { + "name": "whys", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "root_causes_investigation_idx": { + "name": "root_causes_investigation_idx", + "columns": [ + { + "expression": "investigation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "root_causes_investigation_id_investigations_id_fk": { + "name": "root_causes_investigation_id_investigations_id_fk", + "tableFrom": "root_causes", + "tableTo": "investigations", + "columnsFrom": [ + "investigation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sites": { + "name": "sites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "street_address": { + "name": "street_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "zip": { + "name": "zip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "naics_code": { + "name": "naics_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "industry_description": { + "name": "industry_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ein": { + "name": "ein", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "establishment_type": { + "name": "establishment_type", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sites_organization_name_unique": { + "name": "sites_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sites_organization_id_organizations_id_fk": { + "name": "sites_organization_id_organizations_id_fk", + "tableFrom": "sites", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'reporter'" + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_organization_idx": { + "name": "users_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_organization_id_organizations_id_fk": { + "name": "users_organization_id_organizations_id_fk", + "tableFrom": "users", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.action_kind": { + "name": "action_kind", + "schema": "public", + "values": [ + "corrective", + "preventive" + ] + }, + "public.action_status": { + "name": "action_status", + "schema": "public", + "values": [ + "open", + "in_progress", + "done", + "verified", + "cancelled" + ] + }, + "public.area_kind": { + "name": "area_kind", + "schema": "public", + "values": [ + "building", + "area", + "line", + "other" + ] + }, + "public.auth_token_purpose": { + "name": "auth_token_purpose", + "schema": "public", + "values": [ + "magic_link", + "password_reset" + ] + }, + "public.employment_type": { + "name": "employment_type", + "schema": "public", + "values": [ + "employee", + "temporary", + "contractor", + "visitor" + ] + }, + "public.incident_status": { + "name": "incident_status", + "schema": "public", + "values": [ + "reported", + "triage", + "investigation", + "actions", + "verification", + "closed" + ] + }, + "public.incident_type": { + "name": "incident_type", + "schema": "public", + "values": [ + "near_miss", + "hazard", + "injury", + "illness", + "property_damage", + "environmental" + ] + }, + "public.osha_case_type": { + "name": "osha_case_type", + "schema": "public", + "values": [ + "injury", + "skin_disorder", + "respiratory", + "poisoning", + "hearing_loss", + "other_illness" + ] + }, + "public.osha_outcome": { + "name": "osha_outcome", + "schema": "public", + "values": [ + "death", + "days_away", + "restricted", + "other" + ] + }, + "public.osha_special_case": { + "name": "osha_special_case", + "schema": "public", + "values": [ + "none", + "needlestick", + "medical_removal", + "hearing_loss", + "tuberculosis" + ] + }, + "public.rca_method": { + "name": "rca_method", + "schema": "public", + "values": [ + "five_whys", + "fishbone", + "other" + ] + }, + "public.report_channel": { + "name": "report_channel", + "schema": "public", + "values": [ + "web", + "kiosk" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "reporter", + "supervisor", + "safety_manager", + "admin" + ] + }, + "public.root_cause_category": { + "name": "root_cause_category", + "schema": "public", + "values": [ + "people", + "process", + "equipment", + "materials", + "environment", + "management" + ] + }, + "public.severity": { + "name": "severity", + "schema": "public", + "values": [ + "low", + "medium", + "high", + "critical" + ] + }, + "public.treatment_level": { + "name": "treatment_level", + "schema": "public", + "values": [ + "none", + "first_aid", + "medical_treatment", + "hospitalized", + "fatality" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/db/migrations/meta/0004_snapshot.json b/server/db/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..8f65bc3 --- /dev/null +++ b/server/db/migrations/meta/0004_snapshot.json @@ -0,0 +1,2522 @@ +{ + "id": "bf9616ec-7057-4521-b98a-bf515d3fd11a", + "prevId": "bc8cffa0-a2ba-49cd-9c96-2b7ddc70329f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.areas": { + "name": "areas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "area_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'area'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "areas_site_idx": { + "name": "areas_site_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "areas_parent_idx": { + "name": "areas_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "areas_site_id_sites_id_fk": { + "name": "areas_site_id_sites_id_fk", + "tableFrom": "areas", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "areas_parent_id_areas_id_fk": { + "name": "areas_parent_id_areas_id_fk", + "tableFrom": "areas", + "tableTo": "areas", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "incident_id": { + "name": "incident_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "attachments_incident_idx": { + "name": "attachments_incident_idx", + "columns": [ + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_incident_id_incidents_id_fk": { + "name": "attachments_incident_id_incidents_id_fk", + "tableFrom": "attachments", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_id_users_id_fk": { + "name": "attachments_uploaded_by_id_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "attachments_storageKey_unique": { + "name": "attachments_storageKey_unique", + "nullsNotDistinct": false, + "columns": [ + "storage_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_entity_idx": { + "name": "audit_log_entity_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_organization_id_organizations_id_fk": { + "name": "audit_log_organization_id_organizations_id_fk", + "tableFrom": "audit_log", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_tokens": { + "name": "auth_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "auth_token_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_tokens_user_idx": { + "name": "auth_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_tokens_user_id_users_id_fk": { + "name": "auth_tokens_user_id_users_id_fk", + "tableFrom": "auth_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "incident_id": { + "name": "incident_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_incident_idx": { + "name": "comments_incident_idx", + "columns": [ + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_incident_id_incidents_id_fk": { + "name": "comments_incident_id_incidents_id_fk", + "tableFrom": "comments", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_author_id_users_id_fk": { + "name": "comments_author_id_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.corrective_actions": { + "name": "corrective_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "incident_id": { + "name": "incident_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "root_cause_id": { + "name": "root_cause_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "action_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'corrective'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "action_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "verified_by_id": { + "name": "verified_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reminder_sent_on": { + "name": "reminder_sent_on", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "corrective_actions_incident_idx": { + "name": "corrective_actions_incident_idx", + "columns": [ + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "corrective_actions_assignee_status_idx": { + "name": "corrective_actions_assignee_status_idx", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "corrective_actions_incident_id_incidents_id_fk": { + "name": "corrective_actions_incident_id_incidents_id_fk", + "tableFrom": "corrective_actions", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "corrective_actions_root_cause_id_root_causes_id_fk": { + "name": "corrective_actions_root_cause_id_root_causes_id_fk", + "tableFrom": "corrective_actions", + "tableTo": "root_causes", + "columnsFrom": [ + "root_cause_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "corrective_actions_assignee_id_users_id_fk": { + "name": "corrective_actions_assignee_id_users_id_fk", + "tableFrom": "corrective_actions", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "corrective_actions_verified_by_id_users_id_fk": { + "name": "corrective_actions_verified_by_id_users_id_fk", + "tableFrom": "corrective_actions", + "tableTo": "users", + "columnsFrom": [ + "verified_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hours_worked": { + "name": "hours_worked", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "hours": { + "name": "hours", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "average_employees": { + "name": "average_employees", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hours_worked_site_month_unique": { + "name": "hours_worked_site_month_unique", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hours_worked_site_id_sites_id_fk": { + "name": "hours_worked_site_id_sites_id_fk", + "tableFrom": "hours_worked", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.incident_counters": { + "name": "incident_counters", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "last_number": { + "name": "last_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "incident_counters_organization_id_organizations_id_fk": { + "name": "incident_counters_organization_id_organizations_id_fk", + "tableFrom": "incident_counters", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.incidents": { + "name": "incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "incident_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "incident_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'reported'" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "area_id": { + "name": "area_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "location_detail": { + "name": "location_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "immediate_action": { + "name": "immediate_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "anonymous": { + "name": "anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reporter_name": { + "name": "reporter_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel": { + "name": "channel", + "type": "report_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'web'" + }, + "kiosk_id": { + "name": "kiosk_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "incidents_organization_number_unique": { + "name": "incidents_organization_number_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "incidents_organization_client_id_unique": { + "name": "incidents_organization_client_id_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "incidents_organization_occurred_idx": { + "name": "incidents_organization_occurred_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "incidents_organization_status_idx": { + "name": "incidents_organization_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "incidents_reporter_idx": { + "name": "incidents_reporter_idx", + "columns": [ + { + "expression": "reporter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "incidents_organization_id_organizations_id_fk": { + "name": "incidents_organization_id_organizations_id_fk", + "tableFrom": "incidents", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incidents_site_id_sites_id_fk": { + "name": "incidents_site_id_sites_id_fk", + "tableFrom": "incidents", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incidents_area_id_areas_id_fk": { + "name": "incidents_area_id_areas_id_fk", + "tableFrom": "incidents", + "tableTo": "areas", + "columnsFrom": [ + "area_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "incidents_reporter_id_users_id_fk": { + "name": "incidents_reporter_id_users_id_fk", + "tableFrom": "incidents", + "tableTo": "users", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "incidents_kiosk_id_kiosks_id_fk": { + "name": "incidents_kiosk_id_kiosks_id_fk", + "tableFrom": "incidents", + "tableTo": "kiosks", + "columnsFrom": [ + "kiosk_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.injured_persons": { + "name": "injured_persons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "incident_id": { + "name": "incident_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "employment_type": { + "name": "employment_type", + "type": "employment_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'employee'" + }, + "body_part": { + "name": "body_part", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nature_of_injury": { + "name": "nature_of_injury", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "treatment": { + "name": "treatment", + "type": "treatment_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "days_away": { + "name": "days_away", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "days_restricted": { + "name": "days_restricted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "privacy_case": { + "name": "privacy_case", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "case_number": { + "name": "case_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "work_related": { + "name": "work_related", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "new_case": { + "name": "new_case", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "died": { + "name": "died", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "date_of_death": { + "name": "date_of_death", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "days_away_from_work": { + "name": "days_away_from_work", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "restricted_or_transfer": { + "name": "restricted_or_transfer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "medical_treatment": { + "name": "medical_treatment", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "loss_of_consciousness": { + "name": "loss_of_consciousness", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "significant_diagnosis": { + "name": "significant_diagnosis", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "special_case": { + "name": "special_case", + "type": "osha_special_case", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "case_type": { + "name": "case_type", + "type": "osha_case_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'injury'" + }, + "recordable": { + "name": "recordable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "outcome": { + "name": "outcome", + "type": "osha_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "inpatient_hospitalization": { + "name": "inpatient_hospitalization", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "amputation": { + "name": "amputation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "loss_of_eye": { + "name": "loss_of_eye", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reported_to_osha_at": { + "name": "reported_to_osha_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "employee_address": { + "name": "employee_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date_of_birth": { + "name": "date_of_birth", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "date_of_hire": { + "name": "date_of_hire", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "sex": { + "name": "sex", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "physician_name": { + "name": "physician_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "treatment_facility": { + "name": "treatment_facility", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "treated_in_emergency_room": { + "name": "treated_in_emergency_room", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "time_started_work": { + "name": "time_started_work", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "activity_before": { + "name": "activity_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "what_happened": { + "name": "what_happened", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "object_substance": { + "name": "object_substance", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "injured_persons_incident_idx": { + "name": "injured_persons_incident_idx", + "columns": [ + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "injured_persons_incident_id_incidents_id_fk": { + "name": "injured_persons_incident_id_incidents_id_fk", + "tableFrom": "injured_persons", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "injured_persons_user_id_users_id_fk": { + "name": "injured_persons_user_id_users_id_fk", + "tableFrom": "injured_persons", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.investigations": { + "name": "investigations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "incident_id": { + "name": "incident_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "rca_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'five_whys'" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "investigations_incident_id_incidents_id_fk": { + "name": "investigations_incident_id_incidents_id_fk", + "tableFrom": "investigations", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "investigations_lead_id_users_id_fk": { + "name": "investigations_lead_id_users_id_fk", + "tableFrom": "investigations", + "tableTo": "users", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "investigations_incidentId_unique": { + "name": "investigations_incidentId_unique", + "nullsNotDistinct": false, + "columns": [ + "incident_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiosks": { + "name": "kiosks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kiosks_token_unique": { + "name": "kiosks_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kiosks_organization_idx": { + "name": "kiosks_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiosks_organization_id_organizations_id_fk": { + "name": "kiosks_organization_id_organizations_id_fk", + "tableFrom": "kiosks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiosks_site_id_sites_id_fk": { + "name": "kiosks_site_id_sites_id_fk", + "tableFrom": "kiosks", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "kiosks_created_by_id_users_id_fk": { + "name": "kiosks_created_by_id_users_id_fk", + "tableFrom": "kiosks", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.root_causes": { + "name": "root_causes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "investigation_id": { + "name": "investigation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "root_cause_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "whys": { + "name": "whys", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "root_causes_investigation_idx": { + "name": "root_causes_investigation_idx", + "columns": [ + { + "expression": "investigation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "root_causes_investigation_id_investigations_id_fk": { + "name": "root_causes_investigation_id_investigations_id_fk", + "tableFrom": "root_causes", + "tableTo": "investigations", + "columnsFrom": [ + "investigation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_views": { + "name": "saved_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filters": { + "name": "filters", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "saved_views_organization_page_idx": { + "name": "saved_views_organization_page_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "page", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_views_organization_id_organizations_id_fk": { + "name": "saved_views_organization_id_organizations_id_fk", + "tableFrom": "saved_views", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "saved_views_user_id_users_id_fk": { + "name": "saved_views_user_id_users_id_fk", + "tableFrom": "saved_views", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sites": { + "name": "sites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "street_address": { + "name": "street_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "zip": { + "name": "zip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "naics_code": { + "name": "naics_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "industry_description": { + "name": "industry_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ein": { + "name": "ein", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "establishment_type": { + "name": "establishment_type", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sites_organization_name_unique": { + "name": "sites_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sites_organization_id_organizations_id_fk": { + "name": "sites_organization_id_organizations_id_fk", + "tableFrom": "sites", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'reporter'" + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_organization_idx": { + "name": "users_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_organization_id_organizations_id_fk": { + "name": "users_organization_id_organizations_id_fk", + "tableFrom": "users", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.action_kind": { + "name": "action_kind", + "schema": "public", + "values": [ + "corrective", + "preventive" + ] + }, + "public.action_status": { + "name": "action_status", + "schema": "public", + "values": [ + "open", + "in_progress", + "done", + "verified", + "cancelled" + ] + }, + "public.area_kind": { + "name": "area_kind", + "schema": "public", + "values": [ + "building", + "area", + "line", + "other" + ] + }, + "public.auth_token_purpose": { + "name": "auth_token_purpose", + "schema": "public", + "values": [ + "magic_link", + "password_reset" + ] + }, + "public.employment_type": { + "name": "employment_type", + "schema": "public", + "values": [ + "employee", + "temporary", + "contractor", + "visitor" + ] + }, + "public.incident_status": { + "name": "incident_status", + "schema": "public", + "values": [ + "reported", + "triage", + "investigation", + "actions", + "verification", + "closed" + ] + }, + "public.incident_type": { + "name": "incident_type", + "schema": "public", + "values": [ + "near_miss", + "hazard", + "injury", + "illness", + "property_damage", + "environmental" + ] + }, + "public.osha_case_type": { + "name": "osha_case_type", + "schema": "public", + "values": [ + "injury", + "skin_disorder", + "respiratory", + "poisoning", + "hearing_loss", + "other_illness" + ] + }, + "public.osha_outcome": { + "name": "osha_outcome", + "schema": "public", + "values": [ + "death", + "days_away", + "restricted", + "other" + ] + }, + "public.osha_special_case": { + "name": "osha_special_case", + "schema": "public", + "values": [ + "none", + "needlestick", + "medical_removal", + "hearing_loss", + "tuberculosis" + ] + }, + "public.rca_method": { + "name": "rca_method", + "schema": "public", + "values": [ + "five_whys", + "fishbone", + "other" + ] + }, + "public.report_channel": { + "name": "report_channel", + "schema": "public", + "values": [ + "web", + "kiosk" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "reporter", + "supervisor", + "safety_manager", + "admin" + ] + }, + "public.root_cause_category": { + "name": "root_cause_category", + "schema": "public", + "values": [ + "people", + "process", + "equipment", + "materials", + "environment", + "management" + ] + }, + "public.severity": { + "name": "severity", + "schema": "public", + "values": [ + "low", + "medium", + "high", + "critical" + ] + }, + "public.treatment_level": { + "name": "treatment_level", + "schema": "public", + "values": [ + "none", + "first_aid", + "medical_treatment", + "hospitalized", + "fatality" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/db/migrations/meta/_journal.json b/server/db/migrations/meta/_journal.json index ddc781c..6cc85d9 100644 --- a/server/db/migrations/meta/_journal.json +++ b/server/db/migrations/meta/_journal.json @@ -22,6 +22,20 @@ "when": 1791310953907, "tag": "0002_kiosks_and_offline", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1791312050891, + "tag": "0003_osha_recordkeeping", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1791315152348, + "tag": "0004_saved_views", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/db/schema.ts b/server/db/schema.ts index c75e625..ea963dc 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -14,6 +14,9 @@ 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 }) @@ -91,6 +94,16 @@ export const sites = pgTable( 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(), @@ -208,6 +221,42 @@ export const injuredPersons = pgTable( 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(), }, @@ -330,8 +379,30 @@ export const hoursWorked = pgTable( /** 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>().notNull(), + shared: boolean().notNull().default(false), + createdAt: createdAt(), + }, + (t) => [index('saved_views_organization_page_idx').on(t.organizationId, t.page)], +) diff --git a/server/db/seed.ts b/server/db/seed.ts index 336785b..5420a19 100644 --- a/server/db/seed.ts +++ b/server/db/seed.ts @@ -2,7 +2,8 @@ import { hashPassword } from '../auth/password.ts' import { loadConfig } from '../config.ts' import { openDatabase } from './client.ts' -import { auditLog, areas, incidentCounters, incidents, organizations, sites, users } from './schema.ts' +import { assessRecordability } from '../../shared/osha.ts' +import { auditLog, areas, correctiveActions, hoursWorked, incidentCounters, incidents, injuredPersons, investigations, organizations, rootCauses, sites, users } from './schema.ts' const DEMO_PASSWORD = 'safetylog-demo' @@ -79,10 +80,151 @@ try { createdAt: incident.createdAt, })), ) - await tx.insert(incidentCounters).values({ organizationId: org.id, lastNumber: demo.length }) + // A year of history so dashboards and the OSHA log have something to show + const lastNumber = await seedHistory(tx as unknown as typeof db, org.id, demo.length, areaIds, people.map((p) => p.id)) + await tx.insert(incidentCounters).values({ organizationId: org.id, lastNumber }) }) console.log(`Seeded demo data. Sign in as admin@example.com, manager@example.com, supervisor@example.com or reporter@example.com with password "${DEMO_PASSWORD}".`) } } finally { await close() } + +/** Deterministic pseudo-random numbers, so every seed produces the same demo. */ +function random(seed: number) { + let state = seed + return () => { + state = (state * 1_664_525 + 1_013_904_223) % 2 ** 32 + return state / 2 ** 32 + } +} + +async function seedHistory( + database: typeof db, + organizationId: string, + startNumber: number, + areaIds: Record, + userIds: string[], +): Promise { + const rand = random(42) + const pick = (items: readonly T[]) => items[Math.floor(rand() * items.length)] + const day = 86_400_000 + const now = Date.now() + const locations = Object.values(areaIds) + // Near misses and hazards outnumber injuries, as in a healthy reporting culture + const types = ['near_miss', 'near_miss', 'near_miss', 'hazard', 'hazard', 'hazard', 'injury', 'injury', 'property_damage', 'environmental', 'illness'] as const + // Busier around shift changes (06–08, 14–16) and mid-shift + const hours = [6, 7, 7, 9, 10, 11, 13, 14, 14, 15, 15, 16, 19, 22, 2] + const titles = { + near_miss: ['Forklift nearly struck pedestrian', 'Load slipped from pallet jack', 'Unsecured ladder almost fell', 'Dropped tool from mezzanine'], + hazard: ['Oil leak under press', 'Blocked emergency exit', 'Damaged guard on conveyor', 'Frayed extension cord', 'Missing machine guard'], + injury: ['Hand laceration from sheet metal', 'Back strain lifting boxes', 'Slip on wet floor', 'Pinched finger in die'], + illness: ['Dermatitis from cutting fluid'], + property_damage: ['Forklift hit racking', 'Damaged dock door'], + environmental: ['Hydraulic fluid spill', 'Coolant leak to drain'], + } as const + const categories = ['people', 'process', 'equipment', 'materials', 'environment', 'management'] as const + + let number = startNumber + for (let i = 0; i < 150; i++) { + const type = pick(types) + const occurred = new Date(now - Math.floor(rand() * 360 + 3) * day) + occurred.setHours(pick(hours), Math.floor(rand() * 60), 0, 0) + const created = new Date(occurred.getTime() + (1 + rand() * 6) * 3_600_000) + const age = (now - occurred.getTime()) / day + const status = age > 60 ? (rand() < 0.85 ? 'closed' : 'actions') : age > 20 ? pick(['investigation', 'actions', 'verification', 'closed'] as const) : pick(['reported', 'triage', 'investigation'] as const) + const closedAt = status === 'closed' ? new Date(created.getTime() + (5 + rand() * 35) * day) : null + number++ + const [incident] = await database + .insert(incidents) + .values({ + organizationId, + number, + title: pick(titles[type]), + type, + severity: type === 'injury' ? pick(['medium', 'high', 'high'] as const) : pick(['low', 'medium', 'medium', 'high'] as const), + status, + occurredAt: occurred, + createdAt: created, + closedAt: closedAt && closedAt.getTime() < now ? closedAt : null, + ...pick(locations), + reporterId: pick(userIds), + }) + .returning() + const auditRows: (typeof auditLog.$inferInsert)[] = [ + { organizationId, actorId: incident.reporterId, entityType: 'incident', entityId: incident.id, action: 'create', changes: { number, title: incident.title }, createdAt: created }, + ] + if (status !== 'reported') { + // Most reports are triaged within a day; the team got quicker over the year + const delayHours = rand() < 0.55 + (1 - age / 365) * 0.35 ? 2 + rand() * 18 : 26 + rand() * 70 + auditRows.push({ organizationId, actorId: pick(userIds), entityType: 'incident', entityId: incident.id, action: 'status', changes: { status: ['reported', 'triage'] }, createdAt: new Date(created.getTime() + delayHours * 3_600_000) }) + } + await database.insert(auditLog).values(auditRows) + + if (['investigation', 'actions', 'verification', 'closed'].includes(status) && type !== 'near_miss') { + const [investigation] = await database.insert(investigations).values({ incidentId: incident.id, method: 'five_whys', startedAt: created }).returning() + const causes = await database + .insert(rootCauses) + .values(Array.from({ length: 1 + Math.floor(rand() * 2) }, () => ({ investigationId: investigation.id, category: pick(categories), description: 'Identified during investigation' }))) + .returning() + const due = new Date(created.getTime() + 21 * day) + const done = status === 'closed' || status === 'verification' || rand() < 0.4 + const completed = new Date(created.getTime() + (8 + rand() * 30) * day) + await database.insert(correctiveActions).values({ + incidentId: incident.id, + rootCauseId: causes[0].id, + title: 'Fix the cause and brief the team', + assigneeId: pick(userIds), + dueDate: due.toISOString().slice(0, 10), + status: done && completed.getTime() < now ? (status === 'closed' ? 'verified' : 'done') : 'open', + completedAt: done && completed.getTime() < now ? completed : null, + }) + } + + if ((type === 'injury' || type === 'illness') && rand() < 0.6) { + const answers = { + employmentType: 'employee' as const, + workRelated: true, + newCase: true, + died: false, + daysAwayFromWork: rand() < 0.3, + restrictedOrTransfer: rand() < 0.4, + medicalTreatment: rand() < 0.7, + lossOfConsciousness: false, + significantDiagnosis: false, + specialCase: 'none' as const, + } + const verdict = assessRecordability(answers) + await database.insert(injuredPersons).values({ + incidentId: incident.id, + caseNumber: `INC-${String(number).padStart(4, '0')}`, + name: pick(['Alex Moreno', 'Jamie Patel', 'Chris Nguyen', 'Taylor Brooks', 'Morgan Diaz']), + jobTitle: pick(['Press operator', 'Material handler', 'Maintenance technician', 'Assembler']), + natureOfInjury: incident.title, + caseType: type === 'illness' ? 'skin_disorder' : 'injury', + ...answers, + daysAway: answers.daysAwayFromWork ? 1 + Math.floor(rand() * 10) : 0, + daysRestricted: answers.restrictedOrTransfer ? 2 + Math.floor(rand() * 14) : 0, + recordable: verdict.status === 'recordable', + outcome: verdict.outcome, + }) + } + } + + // Monthly hours worked and headcount for every site, for the last 13 months + const siteIds = [...new Set(locations.map((l) => l.siteId))] + const today = new Date() + for (const siteId of siteIds) { + for (let m = 12; m >= 0; m--) { + const month = new Date(today.getFullYear(), today.getMonth() - m, 1) + const employees = 70 + Math.floor(rand() * 30) + await database.insert(hoursWorked).values({ + siteId, + month: `${month.getFullYear()}-${String(month.getMonth() + 1).padStart(2, '0')}-01`, + hours: String(employees * 168), + averageEmployees: employees, + }) + } + } + return number +} diff --git a/server/lib/analytics.ts b/server/lib/analytics.ts new file mode 100644 index 0000000..a16db7d --- /dev/null +++ b/server/lib/analytics.ts @@ -0,0 +1,252 @@ +import { and, eq, gte, inArray, isNotNull, lt, min } from 'drizzle-orm' +import type { AnalyticsStats, Kpi, TypeGroup } from '../../shared/api-types.ts' +import { hasRole, type IncidentType } from '../../shared/enums.ts' +import { incidenceRate } from '../../shared/osha.ts' +import type { SessionUser } from '../auth/sessions.ts' +import type { Db } from '../db/client.ts' +import { areas, auditLog, correctiveActions, hoursWorked, incidents, injuredPersons, investigations, rootCauses, sites } from '../db/schema.ts' +import { addDays, localDate } from './dates.ts' +import { visibleTo } from './incidents.ts' + +const DAY_MS = 86_400_000 + +export const typeGroup: Record = { + near_miss: 'hazards', + hazard: 'hazards', + injury: 'injuries', + illness: 'injuries', + property_damage: 'damage', + environmental: 'damage', +} + +export interface AnalyticsFilters { + /** Inclusive start date, `YYYY-MM-DD` (server time zone). */ + from: string + /** Inclusive end date. */ + to: string + siteId?: string + type?: IncidentType +} + +const startOf = (isoDate: string) => new Date(`${isoDate}T00:00:00`) +const monthKey = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` +const ratio = (part: number, whole: number) => (whole > 0 ? part / whole : null) +const round1 = (value: number | null) => (value === null ? null : Math.round(value * 10) / 10) + +/** Monday of the week containing `date`, local time. */ +function weekStart(date: Date): Date { + const start = new Date(date.getFullYear(), date.getMonth(), date.getDate()) + start.setDate(start.getDate() - ((start.getDay() + 6) % 7)) + return start +} + +/** Buckets covering [from, to]: weeks for ranges up to ~3 months, otherwise months. */ +export function buckets(from: string, to: string): { bucket: 'week' | 'month'; starts: Date[] } { + const start = startOf(from) + const end = startOf(to) + const days = (end.getTime() - start.getTime()) / DAY_MS + 1 + const starts: Date[] = [] + if (days <= 92) { + for (let d = weekStart(start); d <= end; d = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 7)) starts.push(d) + return { bucket: 'week', starts } + } + for (let d = new Date(start.getFullYear(), start.getMonth(), 1); d <= end; d = new Date(d.getFullYear(), d.getMonth() + 1, 1)) starts.push(d) + return { bucket: 'month', starts } +} + +function bucketIndex(starts: Date[], date: Date): number { + for (let i = starts.length - 1; i >= 0; i--) if (date >= starts[i]) return i + return -1 +} + +export async function computeAnalytics(db: Db, user: SessionUser, filters: AnalyticsFilters): Promise { + const from = startOf(filters.from) + const to = startOf(addDays(filters.to, 1)) // exclusive + const length = to.getTime() - from.getTime() + const previousFrom = new Date(from.getTime() - length) + const visible = visibleTo(db, user) + const scope = and(visible, filters.siteId ? eq(incidents.siteId, filters.siteId) : undefined, filters.type ? eq(incidents.type, filters.type) : undefined) + const occurredIn = (start: Date, end: Date) => and(scope, gte(incidents.occurredAt, start), lt(incidents.occurredAt, end)) + + const columns = { id: incidents.id, type: incidents.type, occurredAt: incidents.occurredAt, createdAt: incidents.createdAt, siteId: incidents.siteId, areaId: incidents.areaId } + const current = await db.select(columns).from(incidents).where(occurredIn(from, to)) + const previous = await db.select(columns).from(incidents).where(occurredIn(previousFrom, from)) + + const countGroups = (rows: typeof current) => { + const groups = { hazards: 0, injuries: 0, damage: 0 } + for (const row of rows) groups[typeGroup[row.type]]++ + return groups + } + const now = countGroups(current) + const before = countGroups(previous) + + // Responsiveness: time from report to the first status change + async function triagedShare(rows: typeof current): Promise { + if (rows.length === 0) return null + const firstMoves = await db + .select({ id: auditLog.entityId, at: min(auditLog.createdAt) }) + .from(auditLog) + .where(and(eq(auditLog.entityType, 'incident'), eq(auditLog.action, 'status'), inArray(auditLog.entityId, rows.map((r) => r.id)))) + .groupBy(auditLog.entityId) + const moved = new Map(firstMoves.map((m) => [m.id, m.at])) + const quick = rows.filter((r) => { + const at = moved.get(r.id) + return at && at.getTime() - r.createdAt.getTime() <= DAY_MS + }) + return quick.length / rows.length + } + + async function averageDaysToClose(start: Date, end: Date) { + const closed = await db + .select({ createdAt: incidents.createdAt, closedAt: incidents.closedAt }) + .from(incidents) + .where(and(scope, isNotNull(incidents.closedAt), gte(incidents.closedAt, start), lt(incidents.closedAt, end))) + if (closed.length === 0) return null + return round1(closed.reduce((sum, r) => sum + (r.closedAt!.getTime() - r.createdAt.getTime()), 0) / closed.length / DAY_MS) + } + + async function completedActions(start: Date, end: Date) { + return db + .select({ completedAt: correctiveActions.completedAt, dueDate: correctiveActions.dueDate }) + .from(correctiveActions) + .innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId)) + .where(and(scope, isNotNull(correctiveActions.completedAt), isNotNull(correctiveActions.dueDate), gte(correctiveActions.completedAt, start), lt(correctiveActions.completedAt, end))) + } + const onTime = (row: { completedAt: Date | null; dueDate: string | null }) => localDate(row.completedAt!) <= row.dueDate! + const actionsNow = await completedActions(from, to) + const actionsBefore = await completedActions(previousFrom, from) + + const today = localDate() + const overdueRows = await db + .select({ id: correctiveActions.id }) + .from(correctiveActions) + .innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId)) + .where(and(scope, inArray(correctiveActions.status, ['open', 'in_progress']), lt(correctiveActions.dueDate, today))) + + // Lagging: OSHA-recordable cases. Counts only, so supervisors may see them. + const canSeeRates = hasRole(user.role, 'supervisor') + async function recordableRows(start: Date, end: Date) { + return db + .select({ occurredAt: incidents.occurredAt, outcome: injuredPersons.outcome }) + .from(injuredPersons) + .innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId)) + .where(and(eq(incidents.organizationId, user.organizationId), filters.siteId ? eq(incidents.siteId, filters.siteId) : undefined, eq(injuredPersons.recordable, true), gte(incidents.occurredAt, start), lt(incidents.occurredAt, end))) + } + + const kpi = (value: number | null, previousValue: number | null): Kpi => ({ value, previous: previousValue }) + const ratioOf = (groups: typeof now) => (groups.injuries ? Math.round(((groups.hazards / groups.injuries) * 10)) / 10 : null) + + // Trend + const { bucket, starts } = buckets(filters.from, filters.to) + const trend = starts.map((start) => ({ start: localDate(start), hazards: 0, injuries: 0, damage: 0 })) + for (const row of current) { + const i = bucketIndex(starts, row.occurredAt) + if (i >= 0) trend[i][typeGroup[row.type]]++ + } + + // Heatmap: weekday × hour of occurrence + const heatmap = Array.from({ length: 7 }, () => Array(24).fill(0)) + for (const row of current) heatmap[(row.occurredAt.getDay() + 6) % 7][row.occurredAt.getHours()]++ + + // Location: sites, or areas within the chosen site + let byLocation: AnalyticsStats['byLocation'] + if (filters.siteId) { + const areaRows = await db.select({ id: areas.id, name: areas.name }).from(areas).where(eq(areas.siteId, filters.siteId)) + const names = new Map(areaRows.map((a) => [a.id, a.name])) + const counts = new Map() + for (const row of current) counts.set(row.areaId, (counts.get(row.areaId) ?? 0) + 1) + byLocation = [...counts].map(([id, count]) => ({ id, name: id ? (names.get(id) ?? 'Unknown area') : 'Area not specified', count })) + } else { + const siteRows = await db.select({ id: sites.id, name: sites.name }).from(sites).where(eq(sites.organizationId, user.organizationId)) + const names = new Map(siteRows.map((s) => [s.id, s.name])) + const counts = new Map() + for (const row of current) counts.set(row.siteId, (counts.get(row.siteId) ?? 0) + 1) + byLocation = [...counts].map(([id, count]) => ({ id, name: names.get(id) ?? 'Unknown site', count })) + } + byLocation.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)) + + const causeRows = current.length + ? await db + .select({ category: rootCauses.category }) + .from(rootCauses) + .innerJoin(investigations, eq(investigations.id, rootCauses.investigationId)) + .where(inArray(investigations.incidentId, current.map((r) => r.id))) + : [] + const causeCounts = new Map() + for (const row of causeRows) causeCounts.set(row.category, (causeCounts.get(row.category) ?? 0) + 1) + const byRootCause = [...causeCounts].map(([category, count]) => ({ category: category as AnalyticsStats['byRootCause'][number]['category'], count })).sort((a, b) => b.count - a.count) + + // Actions completed per month, on time vs late + const actionsByMonth: AnalyticsStats['actionsByMonth'] = [] + for (let d = new Date(from.getFullYear(), from.getMonth(), 1); d < to; d = new Date(d.getFullYear(), d.getMonth() + 1, 1)) actionsByMonth.push({ month: monthKey(d), onTime: 0, late: 0 }) + for (const row of actionsNow) { + const entry = actionsByMonth.find((m) => m.month === monthKey(row.completedAt!)) + if (entry) entry[onTime(row) ? 'onTime' : 'late']++ + } + + // Rolling 12-month OSHA rates at each of the last 12 month ends + let rates: AnalyticsStats['rates'] = null + let yearly: AnalyticsStats['yearly'] = null + let recordables: Kpi | null = null + if (canSeeRates) { + recordables = kpi((await recordableRows(from, to)).length, (await recordableRows(previousFrom, from)).length) + const endMonth = new Date(startOf(filters.to).getFullYear(), startOf(filters.to).getMonth(), 1) + const windowStart = new Date(endMonth.getFullYear() - 5, 0, 1) + const cases = await recordableRows(windowStart, new Date(endMonth.getFullYear(), endMonth.getMonth() + 1, 1)) + const hours = await db + .select({ month: hoursWorked.month, hours: hoursWorked.hours }) + .from(hoursWorked) + .innerJoin(sites, eq(sites.id, hoursWorked.siteId)) + .where(and(eq(sites.organizationId, user.organizationId), filters.siteId ? eq(hoursWorked.siteId, filters.siteId) : undefined, gte(hoursWorked.month, localDate(windowStart)))) + const hoursByMonth = new Map() + for (const h of hours) hoursByMonth.set(h.month.slice(0, 7), (hoursByMonth.get(h.month.slice(0, 7)) ?? 0) + Number(h.hours)) + const casesIn = (start: Date, end: Date) => cases.filter((c) => c.occurredAt >= start && c.occurredAt < end) + const hoursIn = (start: Date, end: Date) => { + let total = 0 + for (let d = new Date(start); d < end; d = new Date(d.getFullYear(), d.getMonth() + 1, 1)) total += hoursByMonth.get(monthKey(d)) ?? 0 + return total + } + const isDart = (c: { outcome: string | null }) => c.outcome === 'days_away' || c.outcome === 'restricted' + + rates = Array.from({ length: 12 }, (_, i) => { + const monthStart = new Date(endMonth.getFullYear(), endMonth.getMonth() - 11 + i, 1) + const windowEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1) + const windowBegin = new Date(windowEnd.getFullYear() - 1, windowEnd.getMonth(), 1) + const inWindow = casesIn(windowBegin, windowEnd) + const h = hoursIn(windowBegin, windowEnd) + return { month: monthKey(monthStart), trir: incidenceRate(inWindow.length, h), dart: incidenceRate(inWindow.filter(isDart).length, h) } + }) + yearly = Array.from({ length: 5 }, (_, i) => { + const year = endMonth.getFullYear() - 4 + i + const inYear = casesIn(new Date(year, 0, 1), new Date(year + 1, 0, 1)) + const h = Math.round(hoursIn(new Date(year, 0, 1), new Date(year + 1, 0, 1))) + const dartCases = inYear.filter(isDart).length + return { year, recordables: inYear.length, dartCases, hours: h, trir: incidenceRate(inYear.length, h), dart: incidenceRate(dartCases, h) } + }) + } + + return { + from: filters.from, + to: filters.to, + previousFrom: localDate(previousFrom), + bucket, + kpis: { + reports: kpi(current.length, previous.length), + leadingReports: kpi(now.hazards, before.hazards), + injuries: kpi(now.injuries, before.injuries), + nearMissRatio: kpi(ratioOf(now), ratioOf(before)), + triagedWithin24h: kpi(await triagedShare(current), await triagedShare(previous)), + actionsOnTime: kpi(ratio(actionsNow.filter(onTime).length, actionsNow.length), ratio(actionsBefore.filter(onTime).length, actionsBefore.length)), + averageDaysToClose: kpi(await averageDaysToClose(from, to), await averageDaysToClose(previousFrom, from)), + recordables, + overdueActions: overdueRows.length, + }, + trend, + byLocation, + byRootCause, + heatmap, + actionsByMonth, + rates, + yearly, + } +} diff --git a/server/lib/csv.test.ts b/server/lib/csv.test.ts index 046957c..47343cf 100644 --- a/server/lib/csv.test.ts +++ b/server/lib/csv.test.ts @@ -3,10 +3,10 @@ import { toCsv } from './csv.ts' describe('toCsv', () => { it('quotes commas, quotes and newlines', () => { - expect(toCsv(['a', 'b'], [['x,y', 'say "hi"\nok']])).toBe('a,b\r\n"x,y","say ""hi""\nok"\r\n') + expect(toCsv(['a', 'b'], [['x,y', 'say "hi"\nok']])).toBe('\uFEFFa,b\r\n"x,y","say ""hi""\nok"\r\n') }) it('neutralises formula prefixes and blanks nulls', () => { - expect(toCsv(['a'], [['=1+1'], ['-2'], [null], [3]])).toBe("a\r\n'=1+1\r\n'-2\r\n\r\n3\r\n") + expect(toCsv(['a'], [['=1+1'], ['-2'], [null], [3]])).toBe("\uFEFFa\r\n'=1+1\r\n'-2\r\n\r\n3\r\n") }) }) diff --git a/server/lib/csv.ts b/server/lib/csv.ts index b725730..1ca9518 100644 --- a/server/lib/csv.ts +++ b/server/lib/csv.ts @@ -1,14 +1,15 @@ type Cell = string | number | null | undefined /** - * RFC 4180 CSV with a BOM so Excel opens UTF-8 correctly. Cells that a spreadsheet would treat as a - * formula are prefixed with an apostrophe (CSV injection). + * RFC 4180 CSV. By default starts with a BOM so Excel opens UTF-8 correctly (`bom: false` for machine + * uploads such as OSHA's ITA). Cells a spreadsheet would treat as a formula are prefixed with an + * apostrophe (CSV injection). */ -export function toCsv(header: string[], rows: Cell[][]): string { +export function toCsv(header: string[], rows: Cell[][], { bom = true }: { bom?: boolean } = {}): string { const cell = (value: Cell) => { let text = value === null || value === undefined ? '' : String(value) if (/^[=+\-@\t\r]/.test(text)) text = `'${text}` return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text } - return '' + [header, ...rows].map((row) => row.map(cell).join(',')).join('\r\n') + '\r\n' + return (bom ? '\uFEFF' : '') + [header, ...rows].map((row) => row.map(cell).join(',')).join('\r\n') + '\r\n' } diff --git a/server/lib/incidents.ts b/server/lib/incidents.ts index f9c2ed4..7675b19 100644 --- a/server/lib/incidents.ts +++ b/server/lib/incidents.ts @@ -1,4 +1,4 @@ -import { and, asc, eq, exists, or, type SQL } from 'drizzle-orm' +import { and, asc, count, eq, exists, or, type SQL } from 'drizzle-orm' import { alias } from 'drizzle-orm/pg-core' import type { Attachment, CorrectiveAction, Incident, IncidentDetail, Investigation, RootCause } from '../../shared/api-types.ts' import { hasRole } from '../../shared/enums.ts' @@ -6,7 +6,7 @@ import { incidentReference } from '../../shared/format.ts' import { availableTransitions } from '../../shared/workflow.ts' import type { SessionUser } from '../auth/sessions.ts' import type { Db } from '../db/client.ts' -import { areas, attachments, correctiveActions, incidents, investigations, kiosks, rootCauses, sites, users } from '../db/schema.ts' +import { areas, attachments, correctiveActions, incidents, injuredPersons, investigations, kiosks, rootCauses, sites, users } from '../db/schema.ts' import { audit } from './audit.ts' import { iso, localDate } from './dates.ts' import { fail } from './http.ts' @@ -159,6 +159,7 @@ export async function loadDetail(db: Db, user: SessionUser, number: number): Pro : null const actions = actionRows.map((action) => toAction(action)) const manage = hasRole(user.role, 'supervisor') + const [{ injured }] = await db.select({ injured: count() }).from(injuredPersons).where(eq(injuredPersons.incidentId, incidentId)) return { ...toIncident(row), @@ -171,7 +172,8 @@ export async function loadDetail(db: Db, user: SessionUser, number: number): Pro rootCauseCount: causes.length, actionStatuses: actions.map((action) => action.status), }), - can: { edit: manage, manageInvestigation: manage, manageActions: manage }, + injuredCount: injured, + can: { edit: manage, manageInvestigation: manage, manageActions: manage, manageOsha: hasRole(user.role, 'safety_manager') }, } } diff --git a/server/lib/osha.ts b/server/lib/osha.ts new file mode 100644 index 0000000..3b26a7b --- /dev/null +++ b/server/lib/osha.ts @@ -0,0 +1,200 @@ +import { and, asc, eq, gte, isNull, lt } from 'drizzle-orm' +import type { Establishment, OshaCase, OshaLogRow, OshaYear } from '../../shared/api-types.ts' +import { oshaCaseTypes } from '../../shared/enums.ts' +import { incidentReference } from '../../shared/format.ts' +import { capDays, incidenceRate, severeInjuryReport, summaryChecks, type SummaryTotals } from '../../shared/osha.ts' +import type { Db } from '../db/client.ts' +import { areas, hoursWorked, incidents, injuredPersons, organizations, sites } from '../db/schema.ts' +import { iso } from './dates.ts' +import { fail } from './http.ts' + +type Person = typeof injuredPersons.$inferSelect +type IncidentInfo = { id: string; number: number; title: string; occurredAt: Date; createdAt: Date } + +/** Jan 1 to Jan 1 in the server's time zone (set TZ to the plant's zone). */ +export function yearRange(year: number): [Date, Date] { + return [new Date(year, 0, 1), new Date(year + 1, 0, 1)] +} + +/** Fields the ITA case upload and Form 301 require that are still empty. */ +export function missingFields(person: Person): string[] { + if (!person.recordable) return [] + const required: [keyof Person, string][] = [ + ['jobTitle', 'job title'], + ['dateOfBirth', 'date of birth'], + ['dateOfHire', 'date hired'], + ['activityBefore', 'what they were doing'], + ['whatHappened', 'what happened'], + ['natureOfInjury', 'the injury or illness'], + ['objectSubstance', 'object or substance'], + ] + const missing = required.filter(([key]) => !person[key]).map(([, label]) => label) + if (person.died && !person.dateOfDeath) missing.push('date of death') + return missing +} + +export function toOshaCase(person: Person, incident: IncidentInfo): OshaCase { + const severe = severeInjuryReport(person) + return { + id: person.id, + incident: { id: incident.id, number: incident.number, reference: incidentReference(incident.number), title: incident.title, occurredAt: iso(incident.occurredAt) }, + caseNumber: person.caseNumber ?? incidentReference(incident.number), + name: person.name, + jobTitle: person.jobTitle, + employmentType: person.employmentType, + bodyPart: person.bodyPart, + natureOfInjury: person.natureOfInjury, + caseType: person.caseType, + workRelated: person.workRelated, + newCase: person.newCase, + died: person.died, + dateOfDeath: person.dateOfDeath, + daysAwayFromWork: person.daysAwayFromWork, + daysAway: person.daysAway, + restrictedOrTransfer: person.restrictedOrTransfer, + daysRestricted: person.daysRestricted, + medicalTreatment: person.medicalTreatment, + lossOfConsciousness: person.lossOfConsciousness, + significantDiagnosis: person.significantDiagnosis, + specialCase: person.specialCase, + privacyCase: person.privacyCase, + recordable: person.recordable, + outcome: person.outcome, + inpatientHospitalization: person.inpatientHospitalization, + amputation: person.amputation, + lossOfEye: person.lossOfEye, + reportedToOshaAt: iso(person.reportedToOshaAt), + // The clock starts when the employer learns of it; the report date is the best record of that. + severeReport: severe && { ...severe, dueBy: new Date(incident.createdAt.getTime() + severe.withinHours * 3_600_000).toISOString() }, + employeeAddress: person.employeeAddress, + dateOfBirth: person.dateOfBirth, + dateOfHire: person.dateOfHire, + sex: person.sex === 'M' || person.sex === 'F' ? person.sex : null, + physicianName: person.physicianName, + treatmentFacility: person.treatmentFacility, + treatedInEmergencyRoom: person.treatedInEmergencyRoom, + timeStartedWork: person.timeStartedWork, + activityBefore: person.activityBefore, + whatHappened: person.whatHappened, + objectSubstance: person.objectSubstance, + missingFields: missingFields(person), + updatedAt: iso(person.updatedAt), + } +} + +/** Form 300 column F: injury, body part, and what caused it. */ +export function describeCase(person: Pick): string { + const injury = [person.natureOfInjury, person.bodyPart && `(${person.bodyPart})`].filter(Boolean).join(' ') + return [injury, person.objectSubstance].filter(Boolean).join('; ') +} + +export const establishmentOf = (site: typeof sites.$inferSelect): Establishment => ({ + streetAddress: site.streetAddress, + city: site.city, + state: site.state, + zip: site.zip, + naicsCode: site.naicsCode, + industryDescription: site.industryDescription, + ein: site.ein, + establishmentType: site.establishmentType, +}) + +export function missingEstablishmentFields(site: typeof sites.$inferSelect): string[] { + const required: [keyof typeof site, string][] = [ + ['streetAddress', 'street address'], + ['city', 'city'], + ['state', 'state'], + ['zip', 'ZIP code'], + ['naicsCode', 'NAICS code'], + ['ein', 'EIN'], + ] + return required.filter(([key]) => !site[key]).map(([, label]) => label) +} + +/** Everything for one establishment's year: the 300 Log, 300A totals, hours, rates and ITA checks. */ +export async function buildOshaYear(db: Db, organizationId: string, siteId: string, year: number): Promise { + const [site] = await db.select().from(sites).where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId))) + if (!site) throw fail(404, 'Site not found') + const [organization] = await db.select({ name: organizations.name }).from(organizations).where(eq(organizations.id, organizationId)) + const [from, to] = yearRange(year) + + const caseRows = await db + .select({ person: injuredPersons, incident: incidents, areaName: areas.name }) + .from(injuredPersons) + .innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId)) + .leftJoin(areas, eq(areas.id, incidents.areaId)) + .where(and(eq(incidents.siteId, siteId), eq(injuredPersons.recordable, true), gte(incidents.occurredAt, from), lt(incidents.occurredAt, to))) + .orderBy(asc(incidents.occurredAt), asc(injuredPersons.caseNumber)) + + const rows: OshaLogRow[] = caseRows.map(({ person, incident, areaName }) => { + const days = capDays(person.daysAwayFromWork ? person.daysAway : 0, person.restrictedOrTransfer ? person.daysRestricted : 0) + return { + caseId: person.id, + caseNumber: person.caseNumber ?? incidentReference(incident.number), + incidentNumber: incident.number, + employeeName: person.privacyCase ? 'Privacy case' : person.name, + jobTitle: person.jobTitle ?? '', + dateOfInjury: iso(incident.occurredAt), + location: [areaName, incident.locationDetail].filter(Boolean).join(', ') || site.name, + description: describeCase(person), + outcome: person.outcome ?? 'other', + daysAway: days.daysAway, + daysRestricted: days.daysRestricted, + caseType: person.caseType, + missingFields: missingFields(person), + } + }) + + const totals: SummaryTotals = { + deaths: rows.filter((r) => r.outcome === 'death').length, + daysAwayCases: rows.filter((r) => r.outcome === 'days_away').length, + restrictedCases: rows.filter((r) => r.outcome === 'restricted').length, + otherCases: rows.filter((r) => r.outcome === 'other').length, + daysAway: rows.reduce((sum, r) => sum + r.daysAway, 0), + daysRestricted: rows.reduce((sum, r) => sum + r.daysRestricted, 0), + byType: Object.fromEntries(oshaCaseTypes.map((type) => [type, rows.filter((r) => r.caseType === type).length])) as SummaryTotals['byType'], + } + + const monthRows = await db + .select() + .from(hoursWorked) + .where(and(eq(hoursWorked.siteId, siteId), gte(hoursWorked.month, `${year}-01-01`), lt(hoursWorked.month, `${year + 1}-01-01`))) + const months = Array.from({ length: 12 }, (_, i) => { + const row = monthRows.find((m) => Number(m.month.slice(5, 7)) === i + 1) + return { month: i + 1, hours: row ? Number(row.hours) : null, averageEmployees: row?.averageEmployees ?? null } + }) + const totalHoursWorked = Math.round(months.reduce((sum, m) => sum + (m.hours ?? 0), 0)) + const headcounts = months.map((m) => m.averageEmployees).filter((n): n is number => n !== null) + // 1904.32 instructions: total of employees each pay period ÷ number of pay periods; here, months. + const annualAverageEmployees = headcounts.length ? Math.round(headcounts.reduce((a, b) => a + b, 0) / headcounts.length) : 0 + const maxEmployees = headcounts.length ? Math.max(...headcounts) : 0 + const recordable = rows.length + + return { + site: { id: site.id, name: site.name, ...establishmentOf(site) }, + companyName: organization.name, + year, + rows, + totals, + months, + totalHoursWorked, + annualAverageEmployees, + maxEmployees, + rates: { + trir: incidenceRate(recordable, totalHoursWorked), + dart: incidenceRate(totals.daysAwayCases + totals.restrictedCases, totalHoursWorked), + ltir: incidenceRate(totals.daysAwayCases, totalHoursWorked), + }, + checks: summaryChecks(totals, annualAverageEmployees, totalHoursWorked), + missingEstablishmentFields: missingEstablishmentFields(site), + } +} + +/** Active sites for an organization (establishments that may need a log). */ +export function activeSites(db: Db, organizationId: string) { + return db + .select() + .from(sites) + .where(and(eq(sites.organizationId, organizationId), isNull(sites.archivedAt))) + .orderBy(asc(sites.name)) +} diff --git a/server/notifications.ts b/server/notifications.ts index a216c0a..5c9dee5 100644 --- a/server/notifications.ts +++ b/server/notifications.ts @@ -2,7 +2,8 @@ import { and, eq, inArray, isNotNull, isNull, lt, lte, ne, or } from 'drizzle-or import { incidentReference } from '../shared/format.ts' import type { Config } from './config.ts' import type { Db } from './db/client.ts' -import { correctiveActions, incidents, users } from './db/schema.ts' +import { OSHA_REPORTING_CONTACT, severeInjuryReport } from '../shared/osha.ts' +import { correctiveActions, incidents, injuredPersons, users } from './db/schema.ts' import { addDays, localDate } from './lib/dates.ts' import type { Mailer } from './mail.ts' @@ -93,6 +94,31 @@ export class Notifier { }) } + /** 1904.39: fatalities must be reported to OSHA within 8 hours; hospitalizations, amputations and eye losses within 24. */ + severeInjury(caseId: string) { + this.dispatch(async () => { + const [row] = await this.db + .select({ person: injuredPersons, incident: incidents }) + .from(injuredPersons) + .innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId)) + .where(eq(injuredPersons.id, caseId)) + const report = row && severeInjuryReport(row.person) + if (!report) return + const recipients = await this.db + .select({ email: users.email }) + .from(users) + .where(and(eq(users.organizationId, row.incident.organizationId), eq(users.active, true), inArray(users.role, ['safety_manager', 'admin']))) + const reference = incidentReference(row.incident.number) + for (const { email } of recipients) { + await this.mailer.send({ + to: email, + subject: `Report to OSHA within ${report.withinHours} hours: ${reference}`, + text: `${reference} (${row.incident.title}) was recorded with: ${report.reasons.join(', ')}. OSHA requires a direct report within ${report.withinHours} hours of learning about it (29 CFR 1904.39).\n\n${OSHA_REPORTING_CONTACT}\n\nAfter reporting, record the date and time on the case: ${this.link(`/incidents/${row.incident.number}`)}`, + }) + } + }) + } + actionAssigned(actionId: string, assignedBy: string) { this.dispatch(async () => { const [row] = await this.db diff --git a/server/routes/analytics.test.ts b/server/routes/analytics.test.ts new file mode 100644 index 0000000..04c0dd9 --- /dev/null +++ b/server/routes/analytics.test.ts @@ -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> +let manager: Awaited> +let reporter: Awaited> +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, b: Record) => ({ 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) + }) +}) diff --git a/server/routes/osha.test.ts b/server/routes/osha.test.ts new file mode 100644 index 0000000..f092ff8 --- /dev/null +++ b/server/routes/osha.test.ts @@ -0,0 +1,176 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createTestContext } from '../test/helpers.ts' + +let ctx: Awaited> +let manager: Awaited> +let supervisor: Awaited> +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' } }) + }) +}) diff --git a/server/routes/osha.ts b/server/routes/osha.ts new file mode 100644 index 0000000..b00c8bd --- /dev/null +++ b/server/routes/osha.ts @@ -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) { + 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"` }) + }) diff --git a/server/routes/stats.test.ts b/server/routes/stats.test.ts index c7912d0..20a394c 100644 --- a/server/routes/stats.test.ts +++ b/server/routes/stats.test.ts @@ -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 () => { diff --git a/server/routes/stats.ts b/server/routes/stats.ts index 61f126a..2beee81 100644 --- a/server/routes/stats.ts +++ b/server/routes/stats.ts @@ -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() 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`to_char(${correctiveActions.completedAt}, 'YYYY-MM')` - const onTimeExpr = sql`(${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)) }) diff --git a/server/routes/views.ts b/server/routes/views.ts new file mode 100644 index 0000000..8e224f4 --- /dev/null +++ b/server/routes/views.ts @@ -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() + .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) + }) diff --git a/shared/api-types.ts b/shared/api-types.ts index ee97fbb..ca5985f 100644 --- a/shared/api-types.ts +++ b/shared/api-types.ts @@ -1,9 +1,14 @@ // Response shapes returned by the API. Dates are ISO 8601 strings. +import type { SummaryTotals } from './osha.ts' import type { actionKinds, actionStatuses, AreaKind, + employmentTypes, IncidentStatus, + OshaCaseType, + OshaOutcome, + OshaSpecialCase, IncidentType, rcaMethods, Role, @@ -45,7 +50,18 @@ export interface Area { archivedAt: string | null } -export interface Site { +export interface Establishment { + streetAddress: string | null + city: string | null + state: string | null + zip: string | null + naicsCode: string | null + industryDescription: string | null + ein: string | null + establishmentType: number +} + +export interface Site extends Partial { id: string name: string address: string | null @@ -153,8 +169,10 @@ export interface IncidentDetail extends Incident { actions: CorrectiveAction[] attachments: Attachment[] transitions: TransitionOption[] + /** Number of injured or ill people recorded; details are only for safety managers. */ + injuredCount: number /** What the signed-in user may do on this incident. */ - can: { edit: boolean; manageInvestigation: boolean; manageActions: boolean } + can: { edit: boolean; manageInvestigation: boolean; manageActions: boolean; manageOsha: boolean } } export interface Comment { @@ -193,13 +211,140 @@ export interface OverviewStats { bySite: { siteId: string; name: string; count: number }[] } -export interface AnalyticsStats { - periodDays: number - total: number - byType: { type: IncidentType; count: number }[] - bySeverity: { severity: Severity; count: number }[] - topRootCause: { category: RootCauseCategory; count: number; share: number } | null - /** Share of completed actions finished on or before their due date. */ - onTimeActionRate: number | null - monthlyCompletedActions: { month: string; onTime: number; late: number }[] +/** A number for the selected period and the same-length period before it. */ +export interface Kpi { + value: number | null + previous: number | null +} + +/** Incident types grouped for trend charts. */ +export type TypeGroup = 'hazards' | 'injuries' | 'damage' + +export interface AnalyticsStats { + from: string + to: string + previousFrom: string + bucket: 'week' | 'month' + kpis: { + reports: Kpi + /** Leading: hazards and near misses reported. */ + leadingReports: Kpi + injuries: Kpi + /** Leading: hazards and near misses per injury or illness. */ + nearMissRatio: Kpi + /** Leading: share of reports moved out of "reported" within 24 hours. */ + triagedWithin24h: Kpi + /** Leading: share of actions completed by their due date. */ + actionsOnTime: Kpi + averageDaysToClose: Kpi + /** Lagging: OSHA-recordable cases (supervisors and above). */ + recordables: Kpi | null + overdueActions: number + } + trend: ({ start: string } & Record)[] + byLocation: { id: string | null; name: string; count: number }[] + byRootCause: { category: RootCauseCategory; count: number }[] + /** heatmap[weekday][hour], weekday 0 = Monday, in the server's time zone. */ + heatmap: number[][] + actionsByMonth: { month: string; onTime: number; late: number }[] + /** Rolling 12-month OSHA rates at each month end; null for reporters. */ + rates: { month: string; trir: number | null; dart: number | null }[] | null + yearly: { year: number; recordables: number; dartCases: number; hours: number; trir: number | null; dart: number | null }[] | null +} + +export interface SavedView { + id: string + page: 'analytics' | 'incidents' + name: string + filters: Record + shared: boolean + mine: boolean +} + +// ---- OSHA recordkeeping ---- + +export interface OshaCase { + id: string + incident: { id: string; number: number; reference: string; title: string; occurredAt: string } + caseNumber: string + name: string + jobTitle: string | null + employmentType: (typeof employmentTypes)[number] + bodyPart: string | null + natureOfInjury: string | null + caseType: OshaCaseType + workRelated: boolean | null + newCase: boolean | null + died: boolean + dateOfDeath: string | null + daysAwayFromWork: boolean + daysAway: number + restrictedOrTransfer: boolean + daysRestricted: number + medicalTreatment: boolean + lossOfConsciousness: boolean + significantDiagnosis: boolean + specialCase: OshaSpecialCase + privacyCase: boolean + recordable: boolean + outcome: OshaOutcome | null + inpatientHospitalization: boolean + amputation: boolean + lossOfEye: boolean + reportedToOshaAt: string | null + /** Set when 1904.39 requires a direct report to OSHA. */ + severeReport: { withinHours: 8 | 24; reasons: string[]; dueBy: string } | null + employeeAddress: string | null + dateOfBirth: string | null + dateOfHire: string | null + sex: 'M' | 'F' | null + physicianName: string | null + treatmentFacility: string | null + treatedInEmergencyRoom: boolean + timeStartedWork: string | null + activityBefore: string | null + whatHappened: string | null + objectSubstance: string | null + /** Form 301 / ITA fields still empty for a recordable case. */ + missingFields: string[] + updatedAt: string +} + +/** One line of the Form 300 Log. */ +export interface OshaLogRow { + caseId: string + caseNumber: string + incidentNumber: number + /** "Privacy case" for privacy concern cases. */ + employeeName: string + jobTitle: string + dateOfInjury: string + location: string + description: string + outcome: OshaOutcome + daysAway: number + daysRestricted: number + caseType: OshaCaseType + missingFields: string[] +} + +export interface OshaYear { + site: { id: string; name: string } & Establishment + companyName: string + year: number + rows: OshaLogRow[] + totals: SummaryTotals + months: { month: number; hours: number | null; averageEmployees: number | null }[] + totalHoursWorked: number + annualAverageEmployees: number + maxEmployees: number + rates: { trir: number | null; dart: number | null; ltir: number | null } + checks: { errors: string[]; warnings: string[] } + missingEstablishmentFields: string[] +} + +export interface Form301 { + case: OshaCase + establishment: { name: string; companyName: string } & Establishment + incident: { reference: string; occurredAt: string; title: string; description: string | null; site: string; area: string | null; locationDetail: string | null } } diff --git a/shared/enums.ts b/shared/enums.ts index 9b65f64..b22abb2 100644 --- a/shared/enums.ts +++ b/shared/enums.ts @@ -33,3 +33,15 @@ export const rootCauseCategories = ['people', 'process', 'equipment', 'materials export const actionKinds = ['corrective', 'preventive'] as const export const actionStatuses = ['open', 'in_progress', 'done', 'verified', 'cancelled'] as const + +/** Form 300 column M: injury or illness type. */ +export const oshaCaseTypes = ['injury', 'skin_disorder', 'respiratory', 'poisoning', 'hearing_loss', 'other_illness'] as const +export type OshaCaseType = (typeof oshaCaseTypes)[number] + +/** Form 300 columns G–J, most serious first. */ +export const oshaOutcomes = ['death', 'days_away', 'restricted', 'other'] as const +export type OshaOutcome = (typeof oshaOutcomes)[number] + +/** Cases recordable under 1904.8–1904.11 regardless of the general criteria. */ +export const oshaSpecialCases = ['none', 'needlestick', 'medical_removal', 'hearing_loss', 'tuberculosis'] as const +export type OshaSpecialCase = (typeof oshaSpecialCases)[number] diff --git a/shared/osha.test.ts b/shared/osha.test.ts new file mode 100644 index 0000000..8b83436 --- /dev/null +++ b/shared/osha.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest' +import { assessRecordability, capDays, FIRST_AID, incidenceRate, itaSizeCode, severeInjuryReport, summaryChecks, type RecordabilityInput, type SummaryTotals } from './osha.ts' + +const base: RecordabilityInput = { + employmentType: 'employee', + workRelated: true, + newCase: true, + died: false, + daysAwayFromWork: false, + restrictedOrTransfer: false, + medicalTreatment: false, + lossOfConsciousness: false, + significantDiagnosis: false, + specialCase: 'none', +} +const assess = (overrides: Partial) => assessRecordability({ ...base, ...overrides }) + +describe('assessRecordability', () => { + it('first aid only is not recordable', () => { + expect(assess({})).toMatchObject({ status: 'not_recordable', outcome: null }) + }) + + it('waits for the work-related and new-case answers', () => { + expect(assess({ workRelated: null, medicalTreatment: true }).status).toBe('incomplete') + expect(assess({ newCase: null, medicalTreatment: true }).status).toBe('incomplete') + }) + + it('excludes non-work-related cases and recurrences', () => { + expect(assess({ workRelated: false, died: true }).status).toBe('not_recordable') + expect(assess({ newCase: false, medicalTreatment: true }).reasons[0]).toMatch(/1904\.6/) + }) + + it('keeps contractors and visitors off your log', () => { + expect(assess({ employmentType: 'contractor', daysAwayFromWork: true }).reasons[0]).toMatch(/1904\.31/) + expect(assess({ employmentType: 'visitor', died: true }).status).toBe('not_recordable') + expect(assess({ employmentType: 'temporary', medicalTreatment: true }).status).toBe('recordable') + }) + + it('classifies by the most serious outcome', () => { + expect(assess({ medicalTreatment: true }).outcome).toBe('other') + expect(assess({ medicalTreatment: true, restrictedOrTransfer: true }).outcome).toBe('restricted') + expect(assess({ restrictedOrTransfer: true, daysAwayFromWork: true }).outcome).toBe('days_away') + expect(assess({ daysAwayFromWork: true, died: true }).outcome).toBe('death') + }) + + it('records each general criterion on its own', () => { + for (const key of ['lossOfConsciousness', 'significantDiagnosis', 'medicalTreatment'] as const) { + expect(assess({ [key]: true })).toMatchObject({ status: 'recordable', outcome: 'other' }) + } + }) + + it('records special cases even without general criteria', () => { + for (const specialCase of ['needlestick', 'medical_removal', 'hearing_loss', 'tuberculosis'] as const) { + expect(assess({ specialCase }).status).toBe('recordable') + } + expect(assess({ specialCase: 'needlestick' }).reasons[0]).toMatch(/1904\.8/) + }) + + it('lists every reason that applies', () => { + expect(assess({ medicalTreatment: true, lossOfConsciousness: true }).reasons).toHaveLength(2) + }) +}) + +describe('severeInjuryReport', () => { + it('requires fatalities within 8 hours and other severe injuries within 24', () => { + expect(severeInjuryReport({ died: true, inpatientHospitalization: true, amputation: false, lossOfEye: false })).toEqual({ withinHours: 8, reasons: ['work-related fatality'] }) + expect(severeInjuryReport({ died: false, inpatientHospitalization: false, amputation: true, lossOfEye: true })).toEqual({ withinHours: 24, reasons: ['amputation', 'loss of an eye'] }) + expect(severeInjuryReport({ died: false, inpatientHospitalization: false, amputation: false, lossOfEye: false })).toBeNull() + }) +}) + +describe('capDays', () => { + it('stops counting at 180 days in total', () => { + expect(capDays(10, 5)).toEqual({ daysAway: 10, daysRestricted: 5 }) + expect(capDays(150, 60)).toEqual({ daysAway: 150, daysRestricted: 30 }) + expect(capDays(200, 10)).toEqual({ daysAway: 180, daysRestricted: 0 }) + }) +}) + +describe('rates and codes', () => { + it('computes incidence rates per 200,000 hours', () => { + expect(incidenceRate(3, 200_000)).toBe(3) + expect(incidenceRate(2, 150_000)).toBe(2.67) + expect(incidenceRate(1, 0)).toBeNull() + }) + + it('maps headcount to ITA size codes', () => { + expect([19, 20, 99, 100, 249, 250].map(itaSizeCode)).toEqual([1, 21, 21, 22, 22, 3]) + }) + + it('has the complete 14-item first-aid list', () => { + expect(FIRST_AID).toHaveLength(14) + }) +}) + +describe('summaryChecks (OSHA ITA validations)', () => { + const totals = (overrides: Partial = {}): SummaryTotals => ({ + deaths: 0, + daysAwayCases: 1, + restrictedCases: 1, + otherCases: 1, + daysAway: 5, + daysRestricted: 3, + byType: { injury: 3, skin_disorder: 0, respiratory: 0, poisoning: 0, hearing_loss: 0, other_illness: 0 }, + ...overrides, + }) + + it('passes a consistent summary', () => { + expect(summaryChecks(totals(), 50, 100_000)).toEqual({ errors: [], warnings: [] }) + }) + + it('catches the cross-field rules OSHA enforces', () => { + expect(summaryChecks(totals({ byType: { ...totals().byType, injury: 2 } }), 50, 100_000).errors[0]).toMatch(/add up to 2/) + expect(summaryChecks(totals({ daysAway: 0 }), 50, 100_000).errors).toContain('There are days-away cases (H) but no days away (K).') + expect(summaryChecks(totals({ restrictedCases: 0, daysAwayCases: 0, otherCases: 3, daysAway: 0 }), 50, 100_000).errors).toContain( + 'There are restricted days (L) but no days-away or restriction cases (H or I).', + ) + expect(summaryChecks(totals(), 0, 0).errors).toHaveLength(2) + expect(summaryChecks(totals(), 10, 100_000).errors[0]).toMatch(/more than the hours in a year/) + expect(summaryChecks(totals(), 50, 20_000).warnings[0]).toMatch(/Fewer than 500/) + }) +}) diff --git a/shared/osha.ts b/shared/osha.ts new file mode 100644 index 0000000..14b7130 --- /dev/null +++ b/shared/osha.ts @@ -0,0 +1,173 @@ +// OSHA recordkeeping rules (29 CFR Part 1904), shared by the API and the web app. +// Section references are to 29 CFR 1904. This helps apply the rules consistently; it is not legal advice. +import type { OshaCaseType, OshaOutcome, OshaSpecialCase } from './enums.ts' + +export type EmploymentType = 'employee' | 'temporary' | 'contractor' | 'visitor' + +export interface RecordabilityInput { + employmentType: EmploymentType + /** null while not yet answered */ + workRelated: boolean | null + newCase: boolean | null + died: boolean + daysAwayFromWork: boolean + restrictedOrTransfer: boolean + medicalTreatment: boolean + lossOfConsciousness: boolean + significantDiagnosis: boolean + specialCase: OshaSpecialCase +} + +export interface Recordability { + status: 'recordable' | 'not_recordable' | 'incomplete' + /** Form 300 column G–J; null unless recordable. */ + outcome: OshaOutcome | null + /** Plain-language reasons, with the section that applies. */ + reasons: string[] +} + +const specialReasons: Record, string> = { + needlestick: 'Needlestick or sharps injury contaminated with another person’s blood or other potentially infectious material (1904.8)', + medical_removal: 'Medical removal under an OSHA standard (1904.9)', + hearing_loss: 'Standard threshold shift in hearing with overall hearing level of 25 dB or more (1904.10)', + tuberculosis: 'Tuberculosis infection after workplace exposure to active TB (1904.11)', +} + +export function assessRecordability(input: RecordabilityInput): Recordability { + if (input.employmentType === 'contractor' || input.employmentType === 'visitor') { + return { + status: 'not_recordable', + outcome: null, + reasons: ['Only your employees, and temporary or contract workers you supervise day to day, go on your log (1904.31). Record supervised contractors as “temporary”.'], + } + } + if (input.workRelated === null || input.newCase === null) return { status: 'incomplete', outcome: null, reasons: ['Answer whether the case is work-related and new.'] } + if (!input.workRelated) return { status: 'not_recordable', outcome: null, reasons: ['Not work-related (1904.5).'] } + if (!input.newCase) return { status: 'not_recordable', outcome: null, reasons: ['Not a new case: update the original case on the log instead (1904.6).'] } + + const reasons: string[] = [] + if (input.died) reasons.push('Death (1904.7(b)(2))') + if (input.daysAwayFromWork) reasons.push('Days away from work (1904.7(b)(3))') + if (input.restrictedOrTransfer) reasons.push('Restricted work or job transfer (1904.7(b)(4))') + if (input.medicalTreatment) reasons.push('Medical treatment beyond first aid (1904.7(b)(5))') + if (input.lossOfConsciousness) reasons.push('Loss of consciousness (1904.7(b)(6))') + if (input.significantDiagnosis) reasons.push('Significant injury or illness diagnosed by a physician or other licensed health care professional (1904.7(b)(7))') + if (input.specialCase !== 'none') reasons.push(specialReasons[input.specialCase]) + + if (reasons.length === 0) { + return { status: 'not_recordable', outcome: null, reasons: ['No recording criteria apply: first aid only, with no time lost, restriction or other criteria (1904.7).'] } + } + // Classified by the most serious outcome (Form 300 columns G–J) + const outcome: OshaOutcome = input.died ? 'death' : input.daysAwayFromWork ? 'days_away' : input.restrictedOrTransfer ? 'restricted' : 'other' + return { status: 'recordable', outcome, reasons } +} + +export interface SevereInjuryInput { + died: boolean + inpatientHospitalization: boolean + amputation: boolean + lossOfEye: boolean +} + +/** Whether 1904.39 requires reporting directly to OSHA, and how fast. */ +export function severeInjuryReport(input: SevereInjuryInput): { withinHours: 8 | 24; reasons: string[] } | null { + if (input.died) return { withinHours: 8, reasons: ['work-related fatality'] } + const reasons = [ + input.inpatientHospitalization && 'in-patient hospitalization', + input.amputation && 'amputation', + input.lossOfEye && 'loss of an eye', + ].filter((reason): reason is string => !!reason) + return reasons.length ? { withinHours: 24, reasons } : null +} + +export const OSHA_REPORTING_CONTACT = 'Call 1-800-321-OSHA (6742), report online at osha.gov/report, or contact your nearest OSHA area office.' + +/** 1904.7(b)(3)(vii): you may stop counting once days away plus restricted days reach 180. */ +export const DAY_COUNT_CAP = 180 + +export function capDays(daysAway: number, daysRestricted: number): { daysAway: number; daysRestricted: number } { + const away = Math.min(daysAway, DAY_COUNT_CAP) + return { daysAway: away, daysRestricted: Math.min(daysRestricted, DAY_COUNT_CAP - away) } +} + +/** The complete first-aid list of 1904.7(b)(5)(ii). Anything else is medical treatment. */ +export const FIRST_AID = [ + 'Non-prescription medication at non-prescription strength', + 'Tetanus immunizations', + 'Cleaning, flushing or soaking wounds on the surface of the skin', + 'Wound coverings such as bandages, Band-Aids™, gauze pads, butterfly bandages or Steri-Strips™ (not sutures, staples or other wound closing devices)', + 'Hot or cold therapy', + 'Non-rigid means of support, such as elastic bandages, wraps or non-rigid back belts', + 'Temporary immobilization devices while transporting an accident victim (splints, slings, neck collars, back boards)', + 'Drilling a fingernail or toenail to relieve pressure, or draining fluid from a blister', + 'Eye patches', + 'Removing foreign bodies from the eye using only irrigation or a cotton swab', + 'Removing splinters or foreign material from areas other than the eye by irrigation, tweezers, cotton swabs or other simple means', + 'Finger guards', + 'Massages', + 'Drinking fluids for relief of heat stress', +] as const + +/** 1904.29(b)(7): the employee's name must not go on the 300 Log. */ +export const PRIVACY_CASES = [ + 'Injury or illness to an intimate body part or the reproductive system', + 'Injury or illness resulting from a sexual assault', + 'Mental illness', + 'HIV infection, hepatitis or tuberculosis', + 'Needlestick or sharps injury contaminated with another person’s blood or other potentially infectious material', + 'Any other illness, if the employee asks for their name to be left off', +] as const + +/** Injuries and illnesses per 100 full-time workers a year: cases × 200,000 ÷ hours worked. */ +export function incidenceRate(cases: number, hoursWorked: number): number | null { + if (!(hoursWorked > 0)) return null + return Math.round(((cases * 200_000) / hoursWorked) * 100) / 100 +} + +/** ITA `size` code from the most employees at any point in the year. */ +export function itaSizeCode(maxEmployees: number): 1 | 21 | 22 | 3 { + if (maxEmployees < 20) return 1 + if (maxEmployees < 100) return 21 + if (maxEmployees < 250) return 22 + return 3 +} + +export const outcomeCode: Record = { death: 1, days_away: 2, restricted: 3, other: 4 } +export const caseTypeCode: Record = { injury: 1, skin_disorder: 2, respiratory: 3, poisoning: 4, hearing_loss: 5, other_illness: 6 } + +/** Form 300A totals (fields G–M). */ +export interface SummaryTotals { + deaths: number + daysAwayCases: number + restrictedCases: number + otherCases: number + daysAway: number + daysRestricted: number + byType: Record +} + +/** + * The checks OSHA's Injury Tracking Application applies to a 300A upload, so problems are caught + * before submitting. Errors would be rejected; warnings are "should" rules. + */ +export function summaryChecks(totals: SummaryTotals, annualAverageEmployees: number, totalHoursWorked: number) { + const errors: string[] = [] + const warnings: string[] = [] + const cases = totals.deaths + totals.daysAwayCases + totals.restrictedCases + totals.otherCases + const types = Object.values(totals.byType).reduce((sum, n) => sum + n, 0) + if (types !== cases) errors.push(`Injury and illness types (M1–M6) add up to ${types} but there are ${cases} cases (G–J).`) + if (totals.daysAwayCases > 0 && totals.daysAway === 0) errors.push('There are days-away cases (H) but no days away (K).') + if (totals.daysAway > 0 && totals.daysAwayCases === 0) errors.push('There are days away (K) but no days-away cases (H).') + if (totals.daysAway < totals.daysAwayCases) errors.push('Days away (K) must be at least the number of days-away cases (H).') + if (totals.restrictedCases > 0 && totals.daysRestricted === 0) errors.push('There are restriction/transfer cases (I) but no restricted days (L).') + if (totals.daysRestricted > 0 && totals.daysAwayCases + totals.restrictedCases === 0) errors.push('There are restricted days (L) but no days-away or restriction cases (H or I).') + if (!(annualAverageEmployees > 0)) errors.push('Enter the average number of employees for each month.') + if (!(totalHoursWorked > 0)) errors.push('Enter hours worked for each month.') + if (annualAverageEmployees > 0 && totalHoursWorked > 0) { + const perEmployee = totalHoursWorked / annualAverageEmployees + if (perEmployee >= 8760) errors.push('Hours worked per employee is more than the hours in a year; check hours and headcount.') + if (perEmployee <= 500) warnings.push('Fewer than 500 hours worked per employee; check hours and headcount.') + } + if (annualAverageEmployees > 0 && cases >= annualAverageEmployees) warnings.push('More cases than the average number of employees; check the headcount.') + return { errors, warnings } +} diff --git a/shared/schemas.ts b/shared/schemas.ts index 33da142..5694840 100644 --- a/shared/schemas.ts +++ b/shared/schemas.ts @@ -1,6 +1,6 @@ // Request validation schemas, shared so the web app can validate forms with the same rules as the API. import { z } from 'zod' -import { actionKinds, actionStatuses, areaKinds, incidentStatuses, incidentTypes, rcaMethods, roles, rootCauseCategories, severities } from './enums.ts' +import { actionKinds, actionStatuses, areaKinds, employmentTypes, incidentStatuses, incidentTypes, oshaCaseTypes, oshaSpecialCases, rcaMethods, roles, rootCauseCategories, severities } from './enums.ts' const email = z.string().trim().toLowerCase().max(254).pipe(z.email()) const password = z.string().min(10, 'Use at least 10 characters').max(256) @@ -20,10 +20,97 @@ export const createUserSchema = z.object({ name, email, role: z.enum(roles), pas /** `password` lets an admin set a new password when email isn't available. */ export const updateUserSchema = z.object({ name: name.optional(), role: z.enum(roles).optional(), active: z.boolean().optional(), password: password.optional() }) -export const createSiteSchema = z.object({ name, address: optionalText(500), timezone: z.string().trim().max(64).optional() }) +const digits = (count: number[], label: string) => + z + .string() + .trim() + .transform((value) => value.replace(/[\s-]/g, '')) + .pipe(z.string().refine((value) => value === '' || (count.includes(value.length) && /^\d+$/.test(value)), `${label} must be ${count.join(' or ')} digits`)) + .transform((value) => value || null) + .nullish() + +/** OSHA establishment details, as required by Form 300A and the ITA upload. */ +const establishmentFields = { + streetAddress: optionalText(100), + city: optionalText(100), + state: z + .string() + .trim() + .toUpperCase() + .refine((value) => value === '' || /^[A-Z]{2}$/.test(value), 'Use the two-letter state code') + .transform((value) => value || null) + .nullish(), + zip: digits([5, 9], 'ZIP code'), + naicsCode: digits([6], 'NAICS code'), + industryDescription: optionalText(300), + ein: digits([9], 'EIN'), + establishmentType: z.union([z.literal(1), z.literal(2), z.literal(3)]).optional(), +} + +export const createSiteSchema = z.object({ name, address: optionalText(500), timezone: z.string().trim().max(64).optional(), ...establishmentFields }) export const updateSiteSchema = createSiteSchema.partial().extend({ archived: z.boolean().optional() }) +export const hoursWorkedSchema = z.object({ + year: z.number().int().min(2000).max(2100), + months: z + .array( + z.object({ + month: z.number().int().min(1).max(12), + hours: z.number().min(0).max(100_000_000).nullable(), + averageEmployees: z.number().int().min(0).max(1_000_000).nullable(), + }), + ) + .max(12), +}) + +const dayCount = z.number().int().min(0).max(180) +const isoDateOnly = z.iso.date() + +/** An injured or ill person on an incident, with the OSHA recordability answers and Form 301 details. */ +const oshaCaseFields = { + name, + jobTitle: optionalText(255), + employmentType: z.enum(employmentTypes), + bodyPart: optionalText(255), + natureOfInjury: optionalText(500), + caseType: z.enum(oshaCaseTypes), + workRelated: z.boolean().nullable(), + newCase: z.boolean().nullable(), + died: z.boolean(), + dateOfDeath: isoDateOnly.nullish(), + daysAwayFromWork: z.boolean(), + daysAway: dayCount, + restrictedOrTransfer: z.boolean(), + daysRestricted: dayCount, + medicalTreatment: z.boolean(), + lossOfConsciousness: z.boolean(), + significantDiagnosis: z.boolean(), + specialCase: z.enum(oshaSpecialCases), + privacyCase: z.boolean(), + inpatientHospitalization: z.boolean(), + amputation: z.boolean(), + lossOfEye: z.boolean(), + reportedToOshaAt: isoDateTime.nullish(), + employeeAddress: optionalText(500), + dateOfBirth: isoDateOnly.nullish(), + dateOfHire: isoDateOnly.nullish(), + sex: z.enum(['M', 'F']).nullish(), + physicianName: optionalText(200), + treatmentFacility: optionalText(500), + treatedInEmergencyRoom: z.boolean(), + timeStartedWork: z + .string() + .regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'Use 24-hour time, e.g. 07:30') + .nullish(), + activityBefore: optionalText(2_000), + whatHappened: optionalText(5_000), + objectSubstance: optionalText(500), +} + +export const createOshaCaseSchema = z.object(oshaCaseFields).partial().required({ name: true }) +export const updateOshaCaseSchema = z.object(oshaCaseFields).partial() + export const createAreaSchema = z.object({ name, kind: z.enum(areaKinds).default('area'), parentId: z.uuid().nullish() }) export const updateAreaSchema = z.object({ name: name.optional(), kind: z.enum(areaKinds).optional(), archived: z.boolean().optional() }) @@ -96,6 +183,20 @@ export const updateKioskSchema = z.object({ name: name.optional(), siteId: z.uui /** Report filed through a reporting link (kiosk tablet or QR code), without an account. */ export const kioskReportSchema = createIncidentSchema.omit({ anonymous: true }).extend({ reporterName: optionalText(200) }) +export const analyticsQuery = z.object({ + from: z.iso.date(), + to: z.iso.date(), + siteId: z.uuid().optional(), + type: z.enum(incidentTypes).optional(), +}) + +export const savedViewSchema = z.object({ + page: z.enum(['analytics', 'incidents']), + name, + filters: z.record(z.string().max(50), z.string().max(200)).refine((value) => Object.keys(value).length <= 20, 'Too many filters'), + shared: z.boolean().default(false), +}) + export const emailOnlySchema = z.object({ email }) export const tokenSchema = z.object({ token: z.string().min(20).max(200) }) @@ -114,6 +215,8 @@ export const incidentListQuery = z.object({ export type SetupInput = z.infer export type CreateIncidentInput = z.input +export type OshaCaseInput = z.input +export type SiteInput = z.input export type KioskReportInput = z.input export type UpdateIncidentInput = z.infer export type CreateActionInput = z.input diff --git a/src/App.tsx b/src/App.tsx index 8f2b2e3..6e65dc8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,10 +4,12 @@ import { About } from './pages/About' import { Actions } from './pages/Actions' import { Analytics } from './pages/Analytics' import { Login, MagicLinkLanding, ResetPassword, Setup } from './pages/Auth' +import { Form301 } from './pages/Form301' import { IncidentDetail } from './pages/IncidentDetail' import { Kiosk } from './pages/Kiosk' import { Incidents } from './pages/Incidents' import { Locations } from './pages/Locations' +import { Osha } from './pages/Osha' import { Overview } from './pages/Overview' import { QrSheet } from './pages/QrSheet' import { ReportIncident } from './pages/ReportIncident' @@ -29,6 +31,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/SavedViews.tsx b/src/components/SavedViews.tsx new file mode 100644 index 0000000..4dfe5cd --- /dev/null +++ b/src/components/SavedViews.tsx @@ -0,0 +1,92 @@ +import { Bookmark, Trash2 } from 'lucide-react' +import { useState, type FormEvent } from 'react' +import { useOutletContext, useSearchParams } from 'react-router-dom' +import type { SavedView } from '../../shared/api-types.ts' +import { hasRole } from '../../shared/enums.ts' +import { useSavedViewMutations, useSavedViews, type Me } from '../lib/queries' +import { filterBox, iconButton, primaryButton, smallButton } from './styles' + +/** Pick or save a named set of the page's URL filters. */ +export function SavedViews({ page, keys }: { page: SavedView['page']; keys: string[] }) { + const me = useOutletContext() + const [params, setParams] = useSearchParams() + const views = useSavedViews(page) + const { create, remove } = useSavedViewMutations(page) + const [saving, setSaving] = useState(false) + const [name, setName] = useState('') + const [shared, setShared] = useState(false) + + const current = Object.fromEntries(keys.filter((key) => params.get(key)).map((key) => [key, params.get(key)!])) + // Key order isn't preserved by the database, so compare sorted entries + const canonical = (filters: Record) => JSON.stringify(Object.entries(filters).sort(([a], [b]) => a.localeCompare(b))) + const active = views.data?.find((view) => canonical(view.filters) === canonical(current)) + + const apply = (id: string) => { + const view = views.data?.find((v) => v.id === id) + if (!view) return + const next = new URLSearchParams(params) + for (const key of keys) next.delete(key) + for (const [key, value] of Object.entries(view.filters)) next.set(key, value) + setParams(next, { replace: true }) + } + + const save = (event: FormEvent) => { + event.preventDefault() + create.mutate( + { name, filters: current, shared }, + { + onSuccess: () => { + setSaving(false) + setName('') + }, + }, + ) + } + + if (saving) { + return ( +
+ setName(event.target.value)} /> + {hasRole(me.user.role, 'supervisor') && ( + + )} + + +
+ ) + } + + return ( +
+
+ + +
+ {active && (active.mine || me.user.role === 'admin') ? ( + + ) : ( + + )} +
+ ) +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 1dd87e4..9d387fd 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ import { Info, LogOut, Plus, Settings, ShieldCheck, X } from 'lucide-react' import { NavLink, useNavigate } from 'react-router-dom' +import { hasRole } from '../../shared/enums.ts' import { useI18n } from '../i18n' import { outbox } from '../lib/outbox' import { initials } from '../lib/initials' @@ -57,7 +58,9 @@ export function Sidebar({ me, open, onClose, badges }: SidebarProps) {