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

13 KiB
Raw Permalink Blame History

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; response shapes are in 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:

{
  "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 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 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; 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.