Files
jayjennings 61c876edec
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
Add OSHA Form 301 and Osha page components
- Implemented Form301 component for displaying OSHA Form 301 equivalent, including employee and case information.
- Created Osha component to manage OSHA recordkeeping, including tabs for 300 Log, 300A Summary, Hours & rates, and Submit to OSHA.
- Added functionality for printing and downloading CSV files for OSHA submissions.
- Integrated loading and error handling states for data fetching.
2026-10-06 13:09:57 -07:00

176 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# API reference
JSON over HTTP under `/api`. Authentication uses a session cookie (`safetylog_session`, http-only) set by setup, sign-in, emailed links and password changes.
Request bodies are validated against [`shared/schemas.ts`](../shared/schemas.ts); response shapes are in [`shared/api-types.ts`](../shared/api-types.ts). Errors look like `{ "error": "message" }`; validation errors (400) also include `issues: [{ path, message }]`.
Roles, from least to most privileged: `reporter` < `supervisor` < `safety_manager` < `admin`. A role can do everything the roles below it can.
Browsers must send an `Origin` header matching the site on form/multipart and body-less requests (all browsers do); JSON requests are protected by CORS.
## Setup and authentication
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| GET | `/api/health` | anyone | `{ ok: true }` |
| GET | `/api/setup` | anyone | `{ needsSetup }`: true until the first admin exists |
| POST | `/api/setup` | anyone, once | `{ organizationName, name, email, password }`. Creates the organization and an admin, signs them in. 409 afterwards |
| POST | `/api/auth/login` | anyone | `{ email, password }`. 401 on bad credentials, 429 after 10 attempts per email in 15 minutes |
| POST | `/api/auth/magic-link` | anyone | `{ email }`. Emails a single-use sign-in link (15 minutes). Always 204 |
| POST | `/api/auth/magic-link/verify` | anyone | `{ token }`. Signs in |
| POST | `/api/auth/password-reset` | anyone | `{ email }`. Emails a reset link (1 hour). Always 204 |
| POST | `/api/auth/password-reset/confirm` | anyone | `{ token, newPassword }`. Sets the password, signs out other sessions, signs in. Also completes invitations |
| POST | `/api/auth/logout` | anyone | Ends the session. 204 |
| GET | `/api/auth/me` | signed in | `{ user, organization }` |
| POST | `/api/auth/password` | signed in | `{ currentPassword, newPassword }`. Signs out all other sessions. 204 |
Passwords need at least 10 characters. Email requests are limited to 5 per address per 15 minutes. Emailed links land on web pages (`/auth/magic`, `/auth/reset`) that POST the token, so link scanners in email systems can't use them up.
## Users
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| GET | `/api/users` | safety_manager | All users in the organization |
| GET | `/api/users/assignable` | supervisor | Active users `{ id, name, role }`, for assigning leads and actions |
| POST | `/api/users` | admin | `{ name, email, role, password? }`. Without `password` an invitation email is sent (link valid 7 days). Returns `{ user, invited }` |
| PATCH | `/api/users/:id` | admin | `{ name?, role?, active?, password? }`. Deactivating or setting a password signs the user out. The last active admin can't be demoted or deactivated |
## Sites and areas
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| GET | `/api/sites` | signed in | Sites with their `areas` (flat list; build the tree from `parentId`). `?includeArchived=true` to include archived |
| POST | `/api/sites` | safety_manager | `{ name, address?, timezone? }` |
| PATCH | `/api/sites/:id` | safety_manager | `{ name?, address?, timezone?, archived? }` |
| POST | `/api/sites/:id/areas` | safety_manager | `{ name, kind?, parentId? }`. `kind`: `building`, `area` (default), `line`, `other` |
| PATCH | `/api/areas/:id` | safety_manager | `{ name?, kind?, archived? }` |
## Incidents
Addressed by number (`/api/incidents/42` is `INC-0042`). Numbers are sequential per organization with no gaps.
**Visibility:** supervisors and above see every incident. Reporters see incidents they reported plus any where they're assigned a corrective action. Anything else answers 404.
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| GET | `/api/incidents` | signed in | `{ items, page, pageSize, total }`, newest first |
| GET | `/api/incidents/export` | signed in | CSV of the same filters (no paging, up to 10,000 rows) |
| POST | `/api/incidents` | signed in | Create a report (below). High/critical reports email safety managers and admins |
| GET | `/api/incidents/:number` | signed in | Full detail: investigation, root causes, actions, attachments, the `transitions` you may perform (with `blockedReason` when not yet possible) and `can` flags |
| PATCH | `/api/incidents/:number` | supervisor | Any create field except `anonymous`. Status changes use transitions |
| POST | `/api/incidents/:number/transitions` | see below | `{ to, note? }`. The note is also posted as a comment |
| GET/POST | `/api/incidents/:number/comments` | can see it | POST `{ body }` |
| GET | `/api/incidents/:number/timeline` | can see it | Audit entries `{ id, at, actor, action, changes }` |
| PUT | `/api/incidents/:number/investigation` | supervisor | `{ leadId?, method?, summary?, completed? }`. Creates the investigation if needed |
| POST | `/api/incidents/:number/root-causes` | supervisor | `{ category, description, whys? }` |
| POST | `/api/incidents/:number/actions` | supervisor | `{ title, description?, kind?, assigneeId?, dueDate?, rootCauseId? }`. Not on closed incidents. Emails the assignee |
| POST | `/api/incidents/:number/attachments` | can see it | Multipart, field `file`. Photos (JPEG, PNG, WebP, GIF, HEIC) and PDFs only, detected from content; `MAX_UPLOAD_MB` each (default 10), 20 per incident |
**List and export filters:** `page`, `pageSize` (1–100, default 25), `q` (title, description, location; `INC-0042` or `42` matches the number), `type`, `severity`, `status`, `siteId`.
Send a random `clientId` (UUID) with each report. Resubmitting the same `clientId` returns the original report (200 instead of 201) instead of creating a duplicate, so devices can safely retry after a dropped connection.
**Create body:**
```json
{
"title": "Forklift reversed into pallet stack",
"type": "near_miss",
"severity": "high",
"occurredAt": "2026-10-06T08:15:00-05:00",
"siteId": "…",
"areaId": "…",
"locationDetail": "Loading bay C",
"description": "…",
"immediateAction": "Area cordoned off",
"anonymous": false,
"clientId": "6f1c…"
}
```
- `type`: `near_miss`, `hazard`, `injury`, `illness`, `property_damage`, `environmental`
- `severity`: `low`, `medium`, `high`, `critical`
- `occurredAt` needs a time-zone offset and can't be in the future. Anonymous reports don't record the reporter at all.
### Workflow
`reported` → `triage` → `investigation` → `actions` → `verification` → `closed`. The rules live in [`shared/workflow.ts`](../shared/workflow.ts) and are enforced by the API.
| From | To | Who | Condition |
| --- | --- | --- | --- |
| reported | triage | supervisor | |
| triage | investigation | supervisor | |
| triage | actions | supervisor | |
| triage | closed | supervisor | Low or medium severity only |
| investigation | actions | supervisor | At least one root cause |
| actions | investigation | supervisor | |
| actions | verification | supervisor | At least one action, none open or in progress |
| verification | actions | safety_manager | |
| verification | closed | safety_manager | Every (non-cancelled) action verified |
| closed | investigation | safety_manager | Reopen |
## Root causes, actions, attachments
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| PATCH | `/api/root-causes/:id` | supervisor | `{ category?, description?, whys? }` |
| DELETE | `/api/root-causes/:id` | supervisor | |
| GET | `/api/actions` | signed in | `?scope=mine\|all` (`all` needs supervisor) and `?filter=open\|overdue\|done\|all`. Sorted by due date |
| PATCH | `/api/actions/:id` | see notes | Supervisors: any field and status except `verified`. Safety managers: also `verified`. Assignees: only `status` among `open`, `in_progress`, `done` |
| GET | `/api/attachments/:id` | can see the incident | The file, with a sandboxing Content-Security-Policy |
| DELETE | `/api/attachments/:id` | uploader or supervisor | |
Each action has `overdue: true` when it's open or in progress and its due date has passed (server time zone, `TZ`). Assignees get one email digest per day listing their actions due tomorrow or overdue.
## Reporting links (kiosks and QR codes)
Reporting links let people file reports without an account. Each has a token that can **only** create reports.
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| GET | `/api/kiosks` | safety_manager | All links, including their tokens (to reprint QR codes) |
| POST | `/api/kiosks` | safety_manager | `{ name, siteId? }`. `siteId` restricts the link to one site |
| PATCH | `/api/kiosks/:id` | safety_manager | `{ name?, siteId?, revoked? }` |
| GET | `/api/kiosk/config` | link token | Organization name, link name, and the sites/areas it may report on |
| POST | `/api/kiosk/reports` | link token | Multipart: `report` (JSON: the create body minus `anonymous`, plus optional `reporterName`) and up to 5 `files`. Returns `{ reference, failedFiles }`. 30 reports per link per 10 minutes |
The token goes in the `X-Kiosk-Token` header. Browsers can't send custom headers cross-site without a CORS preflight, which the API doesn't allow, so other websites can't submit through a link. Reports without `reporterName` are anonymous. Incidents filed this way have `channel: "kiosk"` and `kiosk: { id, name }`.
The web app opens links at `/k/<token>`, optionally with `?site=<id>&area=<id>` 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 | `?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.
Every create and update on an incident, its investigation, actions and files is recorded in the audit log with who made it and what changed.