Files
open-safetylog-ehs/docs/API.md
T
jayjennings 3e964d1176
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
feat(i18n): add internationalization support with English and Spanish translations
feat(kiosk): implement kiosk report submission functionality with offline support

test(outbox): add tests for outbox functionality including report submission and error handling

feat(outbox): create outbox management for queued reports with IndexedDB persistence

feat(uuid): add UUID generation utility for client IDs

feat(qr): implement QR code generation for reporting links by site and area

feat(kiosk): create Kiosk page for reporting incidents without an account
2026-10-06 11:38:40 -07:00

9.8 KiB
Raw 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).

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

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.