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
9.8 KiB
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,environmentalseverity:low,medium,high,criticaloccurredAtneeds 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 (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).
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.