- 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.
176 lines
13 KiB
Markdown
176 lines
13 KiB
Markdown
# 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.
|