Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c150acf71d | ||
|
|
84b5a250e2 | ||
|
|
187681a36f | ||
|
|
dab8915375 | ||
|
|
61c876edec | ||
|
|
3e964d1176 | ||
|
|
3f6dff5f24 | ||
|
|
708da6b9f7 |
No files matched your search
@@ -10,6 +10,18 @@
|
||||
# The URL people open in their browser. Use https:// in production so session cookies are marked Secure.
|
||||
# PUBLIC_URL=http://localhost:3000
|
||||
|
||||
# Email (sign-in links, password resets, invitations, notifications). Without it, emails are
|
||||
# printed to the server log in development and dropped in production.
|
||||
# SMTP_URL=smtps://user:password@smtp.example.com:465
|
||||
# MAIL_FROM=Open SafetyLog EHS <safety@example.com>
|
||||
|
||||
# Time zone used for due dates and daily reminders, e.g. America/Chicago.
|
||||
# TZ=UTC
|
||||
|
||||
# Where uploaded photos are stored, and the per-file size limit.
|
||||
# UPLOAD_DIR=./data/uploads
|
||||
# MAX_UPLOAD_MB=10
|
||||
|
||||
# Docker Compose only: password for the bundled PostgreSQL container.
|
||||
# Use letters and numbers only (it is embedded in a connection URL).
|
||||
# POSTGRES_PASSWORD=change-me
|
||||
@@ -4,7 +4,9 @@
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }],
|
||||
"jsx-a11y/control-has-associated-label": "off"
|
||||
"jsx-a11y/control-has-associated-label": "off",
|
||||
"jsx-a11y/prefer-tag-over-role": "off",
|
||||
"jsx-a11y/label-has-associated-control": ["error", { "depth": 4 }]
|
||||
},
|
||||
"ignorePatterns": ["dist", "playwright-report", "test-results"]
|
||||
}
|
||||
@@ -39,6 +39,7 @@ CI runs the same checks on every pull request.
|
||||
- **Permissions:** every API route must check the signed-in user's role and organization. Add a test showing a lower role is refused.
|
||||
- **Styling:** Tailwind CSS. Use the theme tokens in `src/index.css` (`text-ink`, `bg-coral`, `border-line`, …) rather than new hex values; add a token if a color is reused.
|
||||
- **Tests:** put domain logic in `src/lib/` with a unit test next to it (`*.test.ts`). Cover user-facing flows with a component test or a Playwright test in `e2e/`.
|
||||
- **Translations:** user-facing text on worker screens lives in `src/i18n/en.ts` with the Spanish in `src/i18n/es.ts`. TypeScript and a test fail if a Spanish entry is missing, so add both together. If you can't translate, open the pull request anyway and ask for help.
|
||||
- **Accessibility:** every form control needs a label, and every icon-only button needs an `aria-label`. Shop-floor users may be on gloves-on touchscreens, so keep touch targets generous.
|
||||
- **Safety data:** incident and injury records can contain medical and personal information. Never log them, and never add third-party scripts, fonts or analytics that would send data off the server.
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
DATABASE_URL=pglite:///app/data/pglite
|
||||
WORKDIR /app
|
||||
# Time-zone data so TZ (e.g. America/Chicago) sets local days for due dates and reminders
|
||||
RUN apk add --no-cache tzdata
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
COPY server ./server
|
||||
|
||||
@@ -2,7 +2,21 @@
|
||||
|
||||
Free, open-source accident and safety tracking for manufacturing: incident and near-miss reporting, investigations, corrective actions and (planned) OSHA record keeping.
|
||||
|
||||
> **Status: early development.** The API and database are in place; the web app still shows demo data until it is connected in Phase 2. See [docs/ROADMAP.md](docs/ROADMAP.md) for the plan and [docs/TODO.md](docs/TODO.md) for open items.
|
||||
> **Status: early development (Phases 0–5 done).** Reporting (including offline, kiosk tablets, QR codes and Spanish), investigation, corrective actions, OSHA recordkeeping (Forms 300, 300A, 301, rates and electronic submission files), leading/lagging-indicator analytics with saved views, and email notifications work end to end. See [docs/ROADMAP.md](docs/ROADMAP.md) for the plan and [docs/TODO.md](docs/TODO.md) for open items.
|
||||
|
||||

|
||||
|
||||
## Screenshots
|
||||
|
||||
Taken with the demo data from `npm run db:seed`, signed in as the safety manager.
|
||||
|
||||
| Incident register | Incident workflow |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
| **OSHA Form 300 log** | **Analytics** |
|
||||
|  |  |
|
||||
|
||||
<p align="center"><img src="docs/screenshots/report-mobile.png" width="300" alt="Report an incident form on a phone"></p>
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -14,7 +28,9 @@ npm run db:seed # optional: demo organization, sites, users and incidents
|
||||
npm run dev # web app on http://localhost:5173, API on http://localhost:3000
|
||||
```
|
||||
|
||||
Demo users (after seeding): `admin@example.com`, `manager@example.com`, `supervisor@example.com` and `reporter@example.com`, all with password `safetylog-demo`. Without seeding, call `POST /api/setup` to create your organization and first admin (see [docs/API.md](docs/API.md)).
|
||||
`npm run db:seed` also loads a year of realistic history so the dashboards and OSHA log have data. Demo users: `admin@example.com`, `manager@example.com`, `supervisor@example.com` and `reporter@example.com`, all with password `safetylog-demo`. Without seeding, opening the app takes you to first-run setup.
|
||||
|
||||
In development, emails (sign-in links, invitations, notifications) are printed in the terminal running the server.
|
||||
|
||||
Settings are read from environment variables or a `.env` file; see [.env.example](.env.example).
|
||||
|
||||
@@ -25,7 +41,25 @@ cp .env.example .env # set POSTGRES_PASSWORD, and PUBLIC_URL if not localhost
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
This runs the app and PostgreSQL. The app applies database migrations on startup and serves the web app and API on port 3000. Use HTTPS (for example behind a reverse proxy) and set `PUBLIC_URL` to the `https://` address so session cookies are marked secure.
|
||||
This runs the app and PostgreSQL. The app applies database migrations on startup and serves the web app and API on port 3000. Uploaded photos are kept in the `app-data` volume.
|
||||
|
||||
- Use HTTPS (for example behind a reverse proxy) and set `PUBLIC_URL` to the `https://` address so session cookies are marked secure and email links point to the right place.
|
||||
- Set `SMTP_URL` and `MAIL_FROM` so sign-in links, invitations and notifications are delivered.
|
||||
- Set `TZ` to the plant's time zone (e.g. `America/Chicago`) so due dates and daily reminders follow local days.
|
||||
- Back up both volumes: `db-data` (PostgreSQL) and `app-data` (uploads).
|
||||
- Installing the app on phones and opening it offline needs HTTPS (browsers require it for service workers). Over plain HTTP everything else works, and reports made while offline are still queued once the page is open.
|
||||
|
||||
## OSHA recordkeeping
|
||||
|
||||
On an incident, safety managers add each injured or ill person and answer a short set of questions; the app decides whether the case is recordable under 29 CFR 1904 and how it's classified, flags injuries that must be reported to OSHA within 8 or 24 hours, and builds the Form 300 Log, 300A Summary and 301 reports per site. Enter monthly hours and headcount for incidence rates (TRIR, DART), add establishment details on the Locations page, and download the CSV files for OSHA's Injury Tracking Application from **OSHA log → Submit to OSHA**.
|
||||
|
||||
The app helps apply OSHA's rules consistently, but it is not legal advice: the employer remains responsible for its records.
|
||||
|
||||
## Shop-floor reporting
|
||||
|
||||
- **Kiosk tablets and QR codes:** in **Settings → Reporting links**, create a link. Open it on a shared tablet for a kiosk, or print QR codes for it from **Locations → QR codes**. Anyone can then report without an account; the link can't read anything and can be revoked at any time.
|
||||
- **Offline:** reports made without a connection are kept on the device and sent automatically when it returns.
|
||||
- **Spanish:** use the language button (sidebar, sign-in page or kiosk header). The choice is remembered on that device.
|
||||
|
||||
For a very small single-machine install, the app image also runs on its own with embedded PostgreSQL: `docker run -p 3000:3000 -v safetylog-data:/app/data <image>`.
|
||||
|
||||
@@ -72,3 +106,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). Everyone taking part follows our [Code o
|
||||
## License
|
||||
|
||||
[GNU Affero General Public License v3.0](LICENSE). If you run a modified version as a network service, you must offer its source code to its users. The app's About page links to the source; set `VITE_SOURCE_URL` at build time to point it at your fork.
|
||||
|
||||
## Contact Info
|
||||
Please contact me, Jay Jennings, at jay@equineform.com if you need assistance with site setup and configuration. I am also open to custom builds when you have special requirements or ideas that might better serve the safety community.
|
||||
@@ -26,9 +26,15 @@ services:
|
||||
environment:
|
||||
DATABASE_URL: postgres://safetylog:${POSTGRES_PASSWORD}@db:5432/safetylog
|
||||
PUBLIC_URL: ${PUBLIC_URL:-http://localhost:3000}
|
||||
SMTP_URL: ${SMTP_URL:-}
|
||||
MAIL_FROM: ${MAIL_FROM:-Open SafetyLog EHS <no-reply@localhost>}
|
||||
TZ: ${TZ:-UTC}
|
||||
volumes:
|
||||
- app-data:/app/data # uploaded photos and files
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
app-data:
|
||||
@@ -1,11 +1,13 @@
|
||||
# API reference
|
||||
|
||||
JSON over HTTP under `/api`. Authentication uses a session cookie (`safetylog_session`, http-only) set by setup, sign-in and password change.
|
||||
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 the schemas in [`shared/schemas.ts`](../shared/schemas.ts). Errors look like `{ "error": "message" }`; validation errors (400) also include `issues: [{ path, message }]`.
|
||||
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 |
|
||||
@@ -14,19 +16,24 @@ Roles, from least to most privileged: `reporter` < `supervisor` < `safety_manage
|
||||
| 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.
|
||||
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 |
|
||||
| POST | `/api/users` | admin | `{ name, email, role, password }`. 409 if the email exists |
|
||||
| PATCH | `/api/users/:id` | admin | `{ name?, role?, active? }`. Deactivating signs the user out. The last active admin can't be demoted or deactivated |
|
||||
| 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
|
||||
|
||||
@@ -35,21 +42,33 @@ Passwords need at least 10 characters.
|
||||
| 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`. Parent must be in the same site |
|
||||
| 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
|
||||
|
||||
Incidents are addressed by their number (`/api/incidents/42` is `INC-0042`). Numbers are sequential per organization with no gaps.
|
||||
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 | Paginated `{ items, page, pageSize, total }`, newest first. Reporters see only their own reports |
|
||||
| POST | `/api/incidents` | signed in | Create a report (below). 201 |
|
||||
| GET | `/api/incidents/:number` | signed in | 404 if it doesn't exist or isn't visible to you |
|
||||
| PATCH | `/api/incidents/:number` | supervisor | Any create field plus `status`. Closing sets `closedAt`; reopening clears it. Moving to another site clears the area unless one is given |
|
||||
| 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 query parameters:** `page` (default 1), `pageSize` (1–100, default 25), `q` (searches title, description and location; `INC-0042` or `42` matches the number), `type`, `severity`, `status`, `siteId`.
|
||||
**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:**
|
||||
|
||||
@@ -64,14 +83,93 @@ Incidents are addressed by their number (`/api/incidents/42` is `INC-0042`). Num
|
||||
"locationDetail": "Loading bay C",
|
||||
"description": "…",
|
||||
"immediateAction": "Area cordoned off",
|
||||
"anonymous": false
|
||||
"anonymous": false,
|
||||
"clientId": "6f1c…"
|
||||
}
|
||||
```
|
||||
|
||||
- `type`: `near_miss`, `hazard`, `injury`, `illness`, `property_damage`, `environmental`
|
||||
- `severity`: `low`, `medium`, `high`, `critical`
|
||||
- `status`: `reported`, `triage`, `investigation`, `actions`, `verification`, `closed`
|
||||
- `occurredAt` must include a time-zone offset and can't be in the future.
|
||||
- Anonymous reports don't record the reporter at all, so they won't appear in the reporter's own list.
|
||||
- `occurredAt` needs a time-zone offset and can't be in the future. Anonymous reports don't record the reporter at all.
|
||||
|
||||
Every create and update is recorded in the audit log (`audit_log` table) with who made it and what changed.
|
||||
### 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.
|
||||
@@ -15,6 +15,14 @@ This is a living document. Update it as decisions are made or scope changes.
|
||||
| Auth | Email + password (scrypt), server-side sessions in an http-only cookie, four roles: reporter < supervisor < safety manager < admin | Decided |
|
||||
| Deployment | Self-hosted, single `docker compose up` (app + Postgres); file storage added with photo upload | Decided |
|
||||
| Compliance scope | US OSHA first; data model kept extensible for other regions (e.g. RIDDOR) | Proposed |
|
||||
| Incident PDFs | Browser print-to-PDF from a print stylesheet; server-side PDF deferred (see TODO) | Decided |
|
||||
| Email | SMTP via `SMTP_URL`; without it, logged in development and dropped in production. Delivery setup deferred (see TODO) | Decided |
|
||||
| Reporting without accounts | "Reporting links": revocable, rate-limited tokens that can only file reports; used for kiosk tablets and QR codes | Decided |
|
||||
| Languages | English + Spanish with typed message files; worker-facing screens first, manager screens later (see TODO) | Decided |
|
||||
| Offline | Installable PWA; reports queued in IndexedDB and retried with a client-generated id so retries never duplicate | Decided |
|
||||
| OSHA rules | One shared rules module (`shared/osha.ts`) used by API and UI; printable forms are OSHA-permitted equivalents (1904.29(b)(4)); ITA exports follow OSHA's published CSV specs | Decided |
|
||||
| Medical privacy | Injured-person and medical details visible to safety managers and admins only; timeline and case CSV carry no names | Decided |
|
||||
| Charts | Hand-built SVG components (no chart library) following the dataviz method: validated color slots, one axis, hairline grid, hover tooltips, a table view on every chart | Decided |
|
||||
| CI | Gitea Actions (`.gitea/workflows`): lint, typecheck, unit tests, build, e2e | Decided |
|
||||
| Product name | Open SafetyLog EHS (replaced placeholder "IA Tracker" and the prototype's "Safesite" branding) | Decided |
|
||||
|
||||
@@ -46,34 +54,47 @@ This is a living document. Update it as decisions are made or scope changes.
|
||||
|
||||
## Phase 2: Core workflow (MVP)
|
||||
|
||||
- Connect the web app to the API: sign-in and first-run setup screens, incident list and report form using real data
|
||||
- Photo upload (attachments)
|
||||
- Magic-link sign-in and password reset by email
|
||||
- Incident detail page: timeline, investigation, root-cause tools (5 Whys, fishbone), corrective actions (CAPA)
|
||||
- Status workflow: Reported → Triage → Investigation → Corrective actions → Verification → Closed, with role-based transitions
|
||||
- "My actions" list and overdue tracking
|
||||
- Email notifications: assignment, due dates, high-severity alerts
|
||||
- CSV and PDF export
|
||||
- Dashboard metrics computed from real data
|
||||
- [x] Web app connected to the API: sign-in, first-run setup, every page on live data (demo data removed from the frontend)
|
||||
- [x] Photo and PDF upload, checked by file content (not name), size-limited, served with a sandboxing security policy
|
||||
- [x] Magic-link sign-in, password reset by email, email invitations for new users (admins can also set passwords when there's no email)
|
||||
- [x] Incident detail page: details editing, activity log (comments + audit trail), investigation with 5 Whys and fishbone (6M) views, corrective actions (CAPA)
|
||||
- [x] Status workflow Reported → Triage → Investigation → Corrective actions → Verification → Closed, with role rules and guards (e.g. high severity can't skip investigation; closing needs every action verified), shared between API and UI
|
||||
- [x] "My actions" and "All actions" lists with overdue tracking; assignees can update progress on their own actions
|
||||
- [x] Email notifications: high/critical alerts to safety managers, action assignment, daily due/overdue digest
|
||||
- [x] CSV export of the filtered register (protected against spreadsheet formula injection); print-friendly incident page for PDF via the browser
|
||||
- [x] Overview dashboard and Analytics computed from real data; Locations and Settings (people, roles, passwords) pages
|
||||
- [x] End-to-end tests run against the real server: full three-role lifecycle, sign-in, visibility rules, mobile reporting
|
||||
|
||||
## Phase 3: Shop-floor reporting
|
||||
|
||||
- Installable PWA with offline queue (sync when connection returns)
|
||||
- Printable QR codes per area that pre-fill the location
|
||||
- Kiosk mode and anonymous reporting
|
||||
- Multi-language support, Spanish first
|
||||
- [x] Installable app (web manifest, icons, service worker caching the app shell plus who-am-I and site lists)
|
||||
- [x] Offline outbox: reports and photos saved on the device, sent automatically on reconnect; duplicate-proof retries via a client-generated id; reports only sent for the user who made them; warning on sign-out with unsent reports
|
||||
- [x] Reporting links (managed in Settings): revocable tokens, optionally limited to one site, 30 reports per 10 minutes, photos included; kiosk page for shared tablets with large touch targets and auto-reset
|
||||
- [x] Printable QR code sheets per site/area, for signed-in users or through a reporting link, with bilingual captions; scanning pre-fills the location
|
||||
- [x] Anonymous reporting from the app, the kiosk and QR codes
|
||||
- [x] English/Spanish: sign-in pages, navigation, report form, kiosk, My actions, offline messages; language toggle remembered per device; follows the browser language by default
|
||||
- [x] Tests: outbox (IndexedDB), translations complete, reporting links and duplicate-proof retries on the server, browser tests for offline sync, Spanish kiosk via QR, QR pre-fill and installability
|
||||
|
||||
## Phase 4: Compliance (US OSHA)
|
||||
|
||||
- Recordability wizard; days away / restricted / transferred tracking
|
||||
- OSHA 300, 300A and 301 form generation; privacy-case handling
|
||||
- ITA (Injury Tracking Application) CSV export
|
||||
- Hours worked per site per month; TRIR, DART and lost-time rates
|
||||
- [x] Injured/ill people per incident with a live recordability check (1904.4–1904.11): covered worker, work-related, new case, general criteria, special cases; most-serious-outcome classification; 14-item first-aid list and privacy-case rules shown in context
|
||||
- [x] Day counts with the 180-day cap; days only counted when the matching outcome applies
|
||||
- [x] Severe injury reporting (1904.39): 8-hour/24-hour deadline banner, email alert to safety managers, "reported to OSHA at" record
|
||||
- [x] Form 300 Log (landscape print), Form 300A Summary with establishment details and certification block, printable Form 301 per case
|
||||
- [x] Establishment details per site (address, NAICS, EIN, type) with validation
|
||||
- [x] Monthly hours worked and headcount; TRIR, DART and LTIR
|
||||
- [x] ITA CSV exports for the 300A summary and 300/301 case data (no names), plus OSHA's own upload checks run in the app
|
||||
- [x] Tests: rules module unit tests, API tests incl. exact CSV output, browser test of the full recordkeeping flow
|
||||
|
||||
## Phase 5: Analytics
|
||||
|
||||
- Trends, location breakdown, top root causes, corrective-action closure time
|
||||
- Leading vs lagging indicators; filters and saved dashboards
|
||||
- [x] One filter row (date range presets or custom, site, incident type) scoping every number and chart on the page
|
||||
- [x] Leading indicators: hazards & near misses reported, near misses per injury, triaged within 24 hours, actions completed on time; each compared with the previous period
|
||||
- [x] Lagging indicators: injuries, OSHA-recordable cases, rolling 12-month TRIR/DART, average days to close; OSHA rates by calendar year
|
||||
- [x] Charts: reports per week/month by kind, rolling OSHA rates, locations (sites, or areas within a site), root causes, weekday × hour heatmap, actions completed on time vs late
|
||||
- [x] Saved views (private, or shared by supervisors and above) on Analytics and the Incidents list
|
||||
- [x] Demo seed with a year of realistic history (deterministic)
|
||||
- [x] Tests: analytics computations (periods, groupings, rates, scoping), saved views, chart scale helper, browser tests
|
||||
|
||||
## Phase 6: Release v1.0
|
||||
|
||||
@@ -85,8 +106,3 @@ This is a living document. Update it as decisions are made or scope changes.
|
||||
|
||||
Inspections and audit checklists, job safety analysis (JSA), training and certification records, management of change, SDS links, public API and webhooks.
|
||||
|
||||
## Known prototype gaps (to be resolved in Phase 2)
|
||||
|
||||
- The web app still uses in-memory demo data; the API exists but isn't connected yet
|
||||
- Dashboard, analytics and location figures are hardcoded
|
||||
- Export, Add location, Settings, Resources, search, notifications and workspace switcher are non-functional
|
||||
@@ -14,6 +14,42 @@ Open items that aren't tied to a single roadmap phase. Tick them off (or delete
|
||||
- [ ] **Docker build in CI.** Build the image in CI once a runner with Docker is available.
|
||||
- [ ] **Shared login rate limit.** The login limiter is in memory, so it resets on restart and isn't shared between processes. Fine for one container; move it to the database before running several app instances.
|
||||
|
||||
## Phase 2 follow-ups
|
||||
|
||||
- [ ] **Server-side PDF (later).** Decided 2026-10-06 to keep browser-generated PDFs for now: the "Print / PDF" button and print stylesheet are simpler and add no dependencies. Revisit if PDFs need to be produced automatically, e.g. attached to emails or archived on close.
|
||||
- [ ] **Email delivery (later).** Decided 2026-10-06 that the current behavior is fine for now: without `SMTP_URL`, emails print to the server log in development and are dropped (with a startup warning) in production. Later: configure SMTP for real deployments, add a "send test email" button for admins, and consider showing admins a warning in the app when email isn't set up.
|
||||
- [ ] **Notification preferences.** Everyone gets every email that applies to them. Add per-user opt-outs, and an alert-recipients setting per site.
|
||||
- [ ] **Edit corrective actions in the UI.** The API supports changing an action's title, assignee and due date; the incident page only offers status changes and adding new actions.
|
||||
- [ ] **HEIC thumbnails.** iPhone HEIC photos are stored and downloadable but browsers can't show them inline; convert to JPEG on upload.
|
||||
- [ ] **Session check console noise.** Signed-out visitors see one 401 in the browser console from the session check; harmless, but a `200 { user: null }` endpoint would be quieter.
|
||||
|
||||
## Phase 3 follow-ups
|
||||
|
||||
- [ ] **Translate manager screens.** Spanish currently covers what shop-floor workers use (sign-in, report form, kiosk, My actions, navigation). The incident page, investigation, dashboards, analytics, locations and settings are still English. Add their strings to `src/i18n/en.ts` and `es.ts`.
|
||||
- [ ] **Spanish emails.** Notification and sign-in emails are English only. Store a language preference per user and use it for emails.
|
||||
- [ ] **HTTPS for offline use.** Browsers only allow installing the app and the offline app shell over HTTPS (or localhost). Reports still queue offline over plain HTTP once the page is open, but the page itself can't load offline. Document a simple HTTPS setup (e.g. Caddy in front of the app) in the install guide.
|
||||
- [ ] **Have Spanish reviewed by a native speaker** familiar with plant safety terms (e.g. "casi accidente" for near miss, "planta" for site).
|
||||
- [ ] **Offline incident list.** Only reporting works offline; viewing incidents and actions needs a connection.
|
||||
|
||||
## Phase 4 follow-ups
|
||||
|
||||
- [ ] **Expert review of the OSHA rules.** `shared/osha.ts` encodes 29 CFR 1904 as plainly as possible with section references, but it should be reviewed by a recordkeeping professional before anyone relies on it. The app should say clearly that it assists, and the employer remains responsible.
|
||||
- [ ] **Deadline reminders.** Email safety managers in January to certify and post the 300A (Feb 1–Apr 30) and before the ITA submission deadline (March 2).
|
||||
- [ ] **Direct ITA submission.** OSHA offers an API for the Injury Tracking Application; submitting directly would remove the CSV download/upload step.
|
||||
- [ ] **Day counting from dates.** Calculate days away/restricted from start and return-to-work dates instead of typed counts.
|
||||
- [ ] **Employee access (1904.35).** Employees and their representatives may request the 300 Log (and their own 301). Add a way to produce those copies with privacy cases removed.
|
||||
- [ ] **OSHA rates on the dashboard.** Show TRIR/DART on Overview and Analytics, and trends over years.
|
||||
- [ ] **Translate the OSHA pages** along with the other manager screens.
|
||||
- [ ] **Other regions.** The data model keeps outcomes and case types separate from US-specific forms; UK RIDDOR or Canadian provincial rules could be added as further rule sets.
|
||||
|
||||
## Phase 5 follow-ups
|
||||
|
||||
- [ ] **Industry benchmarks.** Show the BLS average TRIR/DART for the site's NAICS code next to its own rates.
|
||||
- [ ] **Scheduled reports.** A weekly or monthly email digest of the indicators for safety managers.
|
||||
- [ ] **Chart export.** Download a chart as an image, or print the whole Analytics page as a report (print styles are in place for most of it).
|
||||
- [ ] **Dark mode.** The app is light-only; if a dark theme is added, the chart colors need their dark-mode steps validated against the dark surface.
|
||||
- [ ] **Translate Analytics** with the other manager screens.
|
||||
|
||||
## Done
|
||||
|
||||
- [x] Docker verified 2026-10-06 (Docker 29.8, Compose v5.5): image builds (~315 MB), `docker compose up` with PostgreSQL 18 passes setup → sign-in → report smoke test; data and sessions survive `down`/`up`; clean shutdown; healthcheck healthy; runs as non-root; standalone PGlite container works; `Secure` cookie with an `https://` PUBLIC_URL; web app loads with no errors.
|
||||
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 89 KiB |
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { signIn, unique } from './fixtures'
|
||||
|
||||
test('shows leading and lagging indicators with charts that have table views', async ({ page }) => {
|
||||
await signIn(page, 'safety_manager')
|
||||
await page.goto('/analytics?range=365')
|
||||
await expect(page.getByRole('heading', { name: 'Leading indicators' })).toBeVisible()
|
||||
await expect(page.getByText('Hazards & near misses reported')).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Lagging indicators' })).toBeVisible()
|
||||
|
||||
// Every chart has a table view
|
||||
const trend = page.locator('figure').filter({ hasText: 'Reports per' })
|
||||
await trend.getByRole('button', { name: 'Table' }).click()
|
||||
await expect(trend.getByRole('columnheader', { name: 'Hazards & near misses' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('filters and saved views', async ({ page, isMobile }) => {
|
||||
test.skip(isMobile, 'Covered on desktop')
|
||||
await signIn(page, 'supervisor')
|
||||
await page.goto('/analytics')
|
||||
await page.getByLabel('Date range').selectOption('30')
|
||||
await page.getByLabel('Incident type').selectOption('hazard')
|
||||
await expect(page).toHaveURL(/range=30/)
|
||||
await expect(page).toHaveURL(/type=hazard/)
|
||||
|
||||
const name = unique('Hazards this month')
|
||||
await page.getByRole('button', { name: 'Save view' }).click()
|
||||
await page.getByLabel('View name').fill(name)
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click()
|
||||
await expect(page.getByLabel('Saved views')).toHaveValue(/.+/)
|
||||
|
||||
// Clearing the filters and picking the view brings them back
|
||||
await page.goto('/analytics')
|
||||
await expect(page.getByLabel('Incident type')).toHaveValue('')
|
||||
await page.getByLabel('Saved views').selectOption({ label: name })
|
||||
await expect(page.getByLabel('Incident type')).toHaveValue('hazard')
|
||||
await expect(page.getByLabel('Date range')).toHaveValue('30')
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { PASSWORD, signIn, users } from './fixtures'
|
||||
|
||||
test('sends visitors to sign in and back after', async ({ page }) => {
|
||||
await page.goto('/incidents')
|
||||
await expect(page).toHaveURL(/\/login$/)
|
||||
await page.getByLabel('Email').fill(users.reporter.email)
|
||||
await page.getByLabel('Password').fill(PASSWORD)
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
|
||||
await expect(page).toHaveURL(/\/incidents$/)
|
||||
})
|
||||
|
||||
test('shows an error for a wrong password', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await page.getByLabel('Email').fill(users.reporter.email)
|
||||
await page.getByLabel('Password').fill('not the password')
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
|
||||
await expect(page.getByRole('alert')).toHaveText('Incorrect email or password')
|
||||
})
|
||||
|
||||
test('offers an emailed sign-in link without revealing accounts', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await page.getByRole('button', { name: 'Email me a sign-in link instead' }).click()
|
||||
await page.getByLabel('Email').fill('nobody@e2e.test')
|
||||
await page.getByRole('button', { name: 'Email me a sign-in link' }).click()
|
||||
await expect(page.getByRole('status')).toContainText('If that email belongs to an account')
|
||||
})
|
||||
|
||||
test('signs out', async ({ page, isMobile }) => {
|
||||
await signIn(page, 'reporter')
|
||||
if (isMobile) await page.getByRole('button', { name: 'Open menu' }).click()
|
||||
await page.getByRole('button', { name: 'Sign out' }).click()
|
||||
await expect(page).toHaveURL(/\/login$/)
|
||||
await page.goto('/')
|
||||
await expect(page).toHaveURL(/\/login$/)
|
||||
})
|
||||
|
||||
test('loads with no third-party requests', async ({ page }) => {
|
||||
const external: string[] = []
|
||||
page.on('request', (request) => {
|
||||
if (!request.url().startsWith('http://localhost')) external.push(request.url())
|
||||
})
|
||||
await signIn(page, 'reporter')
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
expect(await page.evaluate(() => document.fonts.check('16px "DM Sans Variable"'))).toBe(true)
|
||||
expect(external).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { expect, type Page } from '@playwright/test'
|
||||
|
||||
export const PASSWORD = 'e2e test password'
|
||||
export const users = {
|
||||
admin: { name: 'Avery Admin', email: 'admin@e2e.test' },
|
||||
supervisor: { name: 'Sam Supervisor', email: 'supervisor@e2e.test' },
|
||||
reporter: { name: 'Riley Reporter', email: 'reporter@e2e.test' },
|
||||
safety_manager: { name: 'Morgan Manager', email: 'manager@e2e.test' },
|
||||
} as const
|
||||
|
||||
export async function signIn(page: Page, who: keyof typeof users) {
|
||||
await page.goto('/login')
|
||||
await page.getByLabel('Email').fill(users[who].email)
|
||||
await page.getByLabel('Password').fill(PASSWORD)
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(users[who].name.split(' ')[0])
|
||||
}
|
||||
|
||||
/** A unique title so parallel tests (and the desktop/mobile projects) never collide. */
|
||||
export const unique = (title: string) => `${title} ${Math.random().toString(36).slice(2, 7)}`
|
||||
|
||||
/** 1×1 PNG. */
|
||||
export const photo = {
|
||||
name: 'scene.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'),
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { request } from '@playwright/test'
|
||||
import { baseURL } from '../playwright.config'
|
||||
import { PASSWORD, users } from './fixtures'
|
||||
|
||||
/** Creates the workspace, a site and one user per role in the fresh server. */
|
||||
export default async function globalSetup() {
|
||||
const api = await request.newContext({ baseURL })
|
||||
// oxlint-disable-next-line typescript/no-explicit-any -- loosely typed setup calls
|
||||
const ok = async (res: Awaited<ReturnType<typeof api.post>>): Promise<any> => {
|
||||
if (!res.ok()) throw new Error(`${res.url()} → ${res.status()} ${await res.text()}`)
|
||||
return res.json()
|
||||
}
|
||||
await ok(await api.post('/api/setup', { data: { organizationName: 'E2E Plant', ...users.admin, password: PASSWORD } }))
|
||||
const { site } = await ok(await api.post('/api/sites', { data: { name: 'Main Plant' } }))
|
||||
const { area } = await ok(await api.post(`/api/sites/${site.id}/areas`, { data: { name: 'Press Shop', kind: 'building' } }))
|
||||
const { kiosk } = await ok(await api.post('/api/kiosks', { data: { name: 'Canteen tablet' }, headers: { origin: baseURL } }))
|
||||
// Shared with the test workers
|
||||
process.env.E2E_KIOSK_TOKEN = kiosk.token
|
||||
process.env.E2E_SITE_ID = site.id
|
||||
process.env.E2E_AREA_ID = area.id
|
||||
for (const role of ['supervisor', 'reporter', 'safety_manager'] as const) {
|
||||
await ok(await api.post('/api/users', { data: { ...users[role], role, password: PASSWORD } }))
|
||||
}
|
||||
await api.dispose()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { photo, signIn, unique, users } from './fixtures'
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
/** The incident page section with this heading. */
|
||||
const section = (page: Page, heading: string) => page.locator('section').filter({ has: page.getByRole('heading', { name: heading, exact: true }) })
|
||||
|
||||
async function move(page: Page, button: string) {
|
||||
await page.getByRole('button', { name: button }).click()
|
||||
await expect(page.getByRole('button', { name: button })).toHaveCount(0)
|
||||
}
|
||||
|
||||
test('takes an incident from report to close across three roles', async ({ browser, isMobile }) => {
|
||||
test.skip(isMobile, 'Long multi-role flow runs on desktop only')
|
||||
test.setTimeout(90_000)
|
||||
const title = unique('Guard missing on press 4')
|
||||
|
||||
// Reporter files the report with a photo
|
||||
const reporter = await (await browser.newContext()).newPage()
|
||||
await signIn(reporter, 'reporter')
|
||||
await reporter.getByRole('link', { name: 'Report incident' }).click()
|
||||
await reporter.getByLabel('Incident title').fill(title)
|
||||
await reporter.getByLabel('Incident type').selectOption('hazard')
|
||||
await reporter.getByLabel('Severity').selectOption('high')
|
||||
await reporter.getByLabel('Building or area').selectOption({ label: 'Press Shop' })
|
||||
await reporter.getByLabel('What happened?').fill('Safety guard removed and left off after maintenance.')
|
||||
await reporter.locator('input[type=file]').setInputFiles(photo)
|
||||
await reporter.getByRole('button', { name: 'Submit report' }).click()
|
||||
await expect(reporter.getByText(/has been reported/)).toBeVisible()
|
||||
await expect(reporter.getByRole('heading', { level: 1 })).toHaveText(title)
|
||||
await expect(reporter.getByRole('img', { name: 'scene.png' })).toBeVisible()
|
||||
const url = reporter.url()
|
||||
|
||||
// Supervisor triages, investigates and assigns an action back to the reporter
|
||||
const supervisor = await (await browser.newContext()).newPage()
|
||||
await signIn(supervisor, 'supervisor')
|
||||
await supervisor.goto(url)
|
||||
await move(supervisor, 'Start triage')
|
||||
await move(supervisor, 'Start investigation')
|
||||
await supervisor.getByRole('button', { name: 'Add root cause' }).click()
|
||||
await supervisor.getByLabel('Category').selectOption('process')
|
||||
await supervisor.getByLabel('Root cause').fill('No step to refit guards after maintenance')
|
||||
await supervisor.getByLabel('Why 1').fill('Guard was not refitted')
|
||||
await supervisor.getByRole('button', { name: 'Add root cause' }).click()
|
||||
await expect(section(supervisor, 'Investigation').getByText('No step to refit guards after maintenance')).toBeVisible()
|
||||
await move(supervisor, 'Move to corrective actions')
|
||||
await supervisor.getByRole('button', { name: 'Add action' }).click()
|
||||
await supervisor.getByLabel('Action', { exact: true }).fill('Add guard check to maintenance sign-off')
|
||||
await supervisor.getByLabel('Assign to').selectOption({ label: users.reporter.name })
|
||||
await supervisor.getByRole('button', { name: 'Add action' }).click()
|
||||
await expect(section(supervisor, 'Corrective actions').getByText(`Assigned to ${users.reporter.name}`)).toBeVisible()
|
||||
await expect(supervisor.getByRole('button', { name: 'Send for verification' })).toBeDisabled()
|
||||
|
||||
// Reporter completes the action from My actions
|
||||
await reporter.goto('/actions')
|
||||
const row = reporter.getByRole('listitem').filter({ hasText: 'Add guard check to maintenance sign-off' })
|
||||
await row.getByRole('combobox').selectOption('done')
|
||||
await expect(row).toHaveCount(0) // leaves the Open list
|
||||
await reporter.getByRole('tab', { name: 'Done' }).click()
|
||||
await expect(row.getByRole('combobox')).toHaveValue('done')
|
||||
|
||||
// Supervisor sends it for verification; admin verifies and closes
|
||||
await supervisor.reload()
|
||||
await move(supervisor, 'Send for verification')
|
||||
const admin = await (await browser.newContext()).newPage()
|
||||
await signIn(admin, 'admin')
|
||||
await admin.goto(url)
|
||||
await expect(admin.getByRole('button', { name: 'Verify and close' })).toBeDisabled()
|
||||
await admin.getByRole('combobox', { name: /Status of Add guard check/ }).selectOption('verified')
|
||||
await admin.getByLabel('Note').fill('Checked on the floor: guard refitted and checklist updated.')
|
||||
await move(admin, 'Verify and close')
|
||||
|
||||
// The record shows the whole story
|
||||
await expect(admin.getByText('Closed', { exact: true }).first()).toBeVisible()
|
||||
const activity = section(admin, 'Activity')
|
||||
await expect(activity.getByText('moved it from Verification to Closed')).toBeVisible()
|
||||
await expect(activity.getByText('Checked on the floor')).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { signIn, unique } from './fixtures'
|
||||
|
||||
test('reports from a phone and finds the incident again', async ({ page, isMobile }) => {
|
||||
const title = unique(isMobile ? 'Phone report: oil on floor' : 'Desktop report: oil on floor')
|
||||
await signIn(page, 'reporter')
|
||||
if (isMobile) {
|
||||
await page.getByRole('button', { name: 'Open menu' }).click()
|
||||
await page.getByRole('link', { name: 'Report an incident' }).click()
|
||||
} else {
|
||||
await page.getByRole('link', { name: 'Report incident' }).click()
|
||||
}
|
||||
await page.getByLabel('Incident title').fill(title)
|
||||
await page.getByRole('button', { name: 'Submit report' }).click()
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText(title)
|
||||
const reference = (await page.locator('text=/INC-\\d{4}/').first().textContent())!.match(/INC-\d{4}/)![0]
|
||||
|
||||
await page.goto('/incidents')
|
||||
await page.getByLabel('Search incidents').last().fill(reference)
|
||||
await expect(page.getByRole('link', { name: title })).toBeVisible()
|
||||
await expect(page.getByRole('row')).toHaveCount(2) // header + match
|
||||
})
|
||||
|
||||
test('filters the register', async ({ page }) => {
|
||||
await signIn(page, 'supervisor')
|
||||
await page.goto('/incidents?status=closed&type=environmental')
|
||||
await expect(page.getByText('No incidents match these filters.')).toBeVisible()
|
||||
await expect(page.getByLabel('Filter by status')).toHaveValue('closed')
|
||||
})
|
||||
|
||||
test("reporters can't see other people's reports", async ({ page, browser }) => {
|
||||
const title = unique('Supervisor-only report')
|
||||
const supervisor = await (await browser.newContext()).newPage()
|
||||
await signIn(supervisor, 'supervisor')
|
||||
await supervisor.goto('/report')
|
||||
await supervisor.getByLabel('Incident title').fill(title)
|
||||
await supervisor.getByRole('button', { name: 'Submit report' }).click()
|
||||
await expect(supervisor.getByRole('heading', { level: 1 })).toHaveText(title)
|
||||
const url = new URL(supervisor.url()).pathname
|
||||
|
||||
await signIn(page, 'reporter')
|
||||
await page.goto(url)
|
||||
await expect(page.getByText('doesn’t exist or you don’t have access')).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { signIn, unique } from './fixtures'
|
||||
|
||||
test('records an injury on the OSHA log and exports it', async ({ page, browser, isMobile }) => {
|
||||
test.skip(isMobile, 'Recordkeeping is a desktop task')
|
||||
test.setTimeout(60_000)
|
||||
const title = unique('Burn from hot press platen')
|
||||
|
||||
// A supervisor reports it; they can't see medical details
|
||||
const supervisor = await (await browser.newContext()).newPage()
|
||||
await signIn(supervisor, 'supervisor')
|
||||
await supervisor.goto('/report')
|
||||
await supervisor.getByLabel('Incident title').fill(title)
|
||||
await supervisor.getByLabel('Incident type').selectOption('injury')
|
||||
await supervisor.getByRole('button', { name: 'Submit report' }).click()
|
||||
await expect(supervisor.getByRole('heading', { level: 1 })).toHaveText(title)
|
||||
const url = supervisor.url()
|
||||
|
||||
// The safety manager decides recordability
|
||||
await signIn(page, 'safety_manager')
|
||||
await page.goto(url)
|
||||
await page.getByRole('button', { name: 'Add person' }).click()
|
||||
await page.getByLabel('Name', { exact: true }).fill('Casey Worker')
|
||||
await page.getByPlaceholder('e.g. Press operator').fill('Press operator')
|
||||
const verdict = page.locator('[aria-live="polite"]')
|
||||
await expect(verdict).toContainText('Answer the questions above')
|
||||
await page.getByText('Is it work-related?').locator('..').getByLabel('Yes').check()
|
||||
await page.getByText('Is it a new case?').locator('..').getByLabel('Yes').check()
|
||||
await expect(verdict).toContainText('Not recordable') // first aid only
|
||||
await page.getByText('Days away from work (count').click()
|
||||
await page.getByLabel('Days away so far').fill('3')
|
||||
await expect(verdict).toContainText('Recordable: Days away from work')
|
||||
await page.getByText('Hospitalized as an in-patient').click()
|
||||
await expect(page.getByText('Must be reported to OSHA within 24 hours.')).toBeVisible()
|
||||
await page.getByPlaceholder('e.g. Laceration, sprain, burn').fill('Second-degree burn')
|
||||
await page.getByRole('button', { name: 'Add person' }).click()
|
||||
|
||||
await expect(page.getByText('OSHA recordable · Days away from work')).toBeVisible()
|
||||
await expect(page.getByRole('alert').filter({ hasText: 'Report to OSHA within 24 hours' })).toBeVisible()
|
||||
|
||||
// Supervisors see that someone was hurt, nothing more
|
||||
await supervisor.reload()
|
||||
await expect(supervisor.getByText('1 person recorded. Injury and medical details are confidential')).toBeVisible()
|
||||
await expect(supervisor.getByText('Casey Worker')).toHaveCount(0)
|
||||
|
||||
// It's on the 300 Log, and the 301 is one click away
|
||||
await page.getByRole('link', { name: 'OSHA log' }).click()
|
||||
const row = page.getByRole('row').filter({ hasText: 'Casey Worker' })
|
||||
await expect(row).toContainText('Second-degree burn')
|
||||
await expect(row).toContainText('Missing:')
|
||||
|
||||
await page.getByRole('tab', { name: 'Submit to OSHA' }).click()
|
||||
await expect(page.getByText('Enter hours worked for each month.')).toBeVisible()
|
||||
const download = page.waitForEvent('download')
|
||||
await page.getByRole('link', { name: /300A summary CSV/ }).click()
|
||||
expect((await download).suggestedFilename()).toMatch(/^osha-ita-300a-\d{4}\.csv$/)
|
||||
})
|
||||
|
||||
test("OSHA pages aren't offered to supervisors", async ({ page, isMobile }) => {
|
||||
await signIn(page, 'supervisor')
|
||||
if (isMobile) await page.getByRole('button', { name: 'Open menu' }).click()
|
||||
await expect(page.getByRole('link', { name: 'Incidents' }).first()).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'OSHA log' })).toHaveCount(0)
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { signIn, unique, users } from './fixtures'
|
||||
|
||||
const token = () => process.env.E2E_KIOSK_TOKEN!
|
||||
const site = () => process.env.E2E_SITE_ID!
|
||||
const area = () => process.env.E2E_AREA_ID!
|
||||
|
||||
test('saves a report offline and sends it when the connection returns', async ({ page, context }) => {
|
||||
const title = unique('Offline report: frayed sling')
|
||||
await signIn(page, 'reporter')
|
||||
await page.goto('/report')
|
||||
await expect(page.getByLabel('Incident title')).toBeVisible()
|
||||
|
||||
await context.setOffline(true)
|
||||
await page.getByLabel('Incident title').fill(title)
|
||||
await page.getByRole('button', { name: 'Submit report' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Saved on this device' })).toBeVisible()
|
||||
await expect(page.getByText('1 report waiting to send')).toBeVisible()
|
||||
|
||||
await context.setOffline(false)
|
||||
await expect(page.getByText('1 report waiting to send')).toBeHidden({ timeout: 15_000 })
|
||||
await page.goto('/incidents')
|
||||
await expect(page.getByRole('link', { name: title })).toBeVisible()
|
||||
})
|
||||
|
||||
test('kiosk: reports without an account, in Spanish, from a QR code', async ({ browser, page }) => {
|
||||
const title = unique('Cable suelto en el pasillo')
|
||||
const kiosk = await (await browser.newContext({ locale: 'es-MX' })).newPage()
|
||||
await kiosk.goto(`/k/${token()}?site=${site()}&area=${area()}`)
|
||||
await expect(kiosk.getByRole('heading', { name: 'Reporte un problema de seguridad' })).toBeVisible()
|
||||
await expect(kiosk.getByLabel('Edificio o área')).toHaveValue(area())
|
||||
|
||||
await kiosk.getByText('Peligro', { exact: true }).click()
|
||||
await kiosk.getByText('Alta', { exact: true }).click()
|
||||
await kiosk.getByLabel('En pocas palabras, ¿qué vio?').fill(title)
|
||||
await kiosk.getByLabel('Su nombre').fill('Luis')
|
||||
await kiosk.getByRole('button', { name: 'Enviar reporte' }).click()
|
||||
await expect(kiosk.getByRole('heading', { name: 'Gracias' })).toBeVisible()
|
||||
const reference = (await kiosk.getByText(/Su reporte INC-\d{4}/).textContent())!.match(/INC-\d{4}/)![0]
|
||||
|
||||
// Switching language works on the kiosk too
|
||||
await kiosk.getByRole('button', { name: 'Reportar otra cosa' }).click()
|
||||
await kiosk.getByRole('button', { name: 'English' }).click()
|
||||
await expect(kiosk.getByRole('heading', { name: 'Report a safety concern' })).toBeVisible()
|
||||
|
||||
// The safety team sees who reported it and through which link
|
||||
await signIn(page, 'admin')
|
||||
await page.goto(`/incidents/${Number(reference.slice(4))}`)
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText(title)
|
||||
await expect(page.getByText('Reported by Luis via Canteen tablet')).toBeVisible()
|
||||
await expect(page.getByText('Press Shop').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('kiosk: a made-up or revoked link explains itself', async ({ page }) => {
|
||||
await page.goto('/k/not-a-real-token')
|
||||
await expect(page.getByText('This reporting link isn’t working')).toBeVisible()
|
||||
})
|
||||
|
||||
test('QR codes open the report form with the location filled in', async ({ page, isMobile }) => {
|
||||
test.skip(isMobile, 'Printing QR sheets is a desktop task')
|
||||
await signIn(page, 'admin')
|
||||
await page.goto(`/locations/qr?site=${site()}`)
|
||||
await expect(page.getByRole('img', { name: /QR code for/ })).toHaveCount(2) // whole site + Press Shop
|
||||
const url = await page.locator('main').getByText(/\/report\?site=/).nth(1).textContent()
|
||||
expect(url).toContain(`area=${area()}`)
|
||||
|
||||
await page.goto(url!.trim())
|
||||
await expect(page.getByText('Location filled in from the QR code.')).toBeVisible()
|
||||
await expect(page.getByLabel('Building or area')).toHaveValue(area())
|
||||
})
|
||||
|
||||
test('the signed-in app speaks Spanish too', async ({ page, isMobile }) => {
|
||||
await signIn(page, 'reporter')
|
||||
if (isMobile) await page.getByRole('button', { name: 'Open menu' }).click()
|
||||
await page.getByRole('button', { name: 'Español' }).click()
|
||||
if (isMobile) await page.getByRole('button', { name: 'Cerrar menú' }).click()
|
||||
await page.goto('/report')
|
||||
await expect(page.getByRole('heading', { name: 'Reportar un incidente' })).toBeVisible()
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'es')
|
||||
await page.goto('/actions')
|
||||
await expect(page.getByRole('heading', { name: 'Mis acciones' })).toBeVisible()
|
||||
expect(users.reporter.email).toBeTruthy()
|
||||
})
|
||||
|
||||
test('is installable: manifest and service worker', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
const manifest = await (await page.request.get('/manifest.webmanifest')).json()
|
||||
expect(manifest).toMatchObject({ name: 'Open SafetyLog EHS', display: 'standalone' })
|
||||
expect(manifest.icons.map((icon: { sizes: string }) => icon.sizes)).toEqual(expect.arrayContaining(['192x192', '512x512']))
|
||||
const scope = await page.evaluate(async () => (await navigator.serviceWorker.ready).scope)
|
||||
expect(scope).toMatch(/\/$/)
|
||||
})
|
||||
@@ -1,33 +0,0 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('navigates between the main pages', async ({ page, isMobile }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText('Jordan')
|
||||
|
||||
for (const name of ['Incidents', 'Locations', 'Analytics']) {
|
||||
if (isMobile) await page.getByRole('button', { name: 'Open menu' }).click()
|
||||
await page.getByRole('navigation').getByRole('link', { name }).click()
|
||||
await expect(page.getByRole('heading', { level: 1, name })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('reports an incident', async ({ page }) => {
|
||||
await page.goto('/report')
|
||||
await page.getByLabel('Incident title').fill('Blocked fire exit in warehouse')
|
||||
await page.getByLabel('Specific location').fill('North exit')
|
||||
await page.getByRole('button', { name: /submit report/i }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/incidents$/)
|
||||
await expect(page.getByText('Blocked fire exit in warehouse')).toBeVisible()
|
||||
})
|
||||
|
||||
test('loads bundled fonts without third-party requests', async ({ page }) => {
|
||||
const external: string[] = []
|
||||
page.on('request', (request) => {
|
||||
if (!request.url().startsWith('http://localhost')) external.push(request.url())
|
||||
})
|
||||
await page.goto('/')
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
expect(await page.evaluate(() => document.fonts.check('16px "DM Sans Variable"'))).toBe(true)
|
||||
expect(external).toEqual([])
|
||||
})
|
||||
@@ -3,6 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<meta name="theme-color" content="#163c37" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Open SafetyLog EHS</title>
|
||||
<meta name="description" content="Open-source accident and safety tracking for manufacturing" />
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@hono/zod-validator": "^0.9.1",
|
||||
"drizzle-orm": "^0.45.3",
|
||||
"hono": "^4.13.13",
|
||||
"nodemailer": "^10.0.15",
|
||||
"pg": "^8.23.1",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
@@ -39,25 +40,32 @@
|
||||
"@fontsource/dm-mono": "^5.3.0",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/react-query": "^5.104.1",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@types/node": "^26.6.4",
|
||||
"@types/nodemailer": "^8.0.2",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"concurrently": "^10.0.5",
|
||||
"drizzle-kit": "^0.31.11",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^30.1.2",
|
||||
"lucide-react": "^1.39.0",
|
||||
"oxlint": "^1.79.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.3",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.2.2",
|
||||
"vitest": "^5.0.3"
|
||||
"vite-plugin-pwa": "^2.0.0",
|
||||
"vitest": "^5.0.3",
|
||||
"workbox-window": "^7.4.1"
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,28 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
const port = 4173
|
||||
export const baseURL = `http://localhost:${port}`
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
globalSetup: './e2e/global-setup.ts',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
|
||||
use: {
|
||||
baseURL: `http://localhost:${port}`,
|
||||
baseURL,
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'mobile', use: { ...devices['Pixel 7'] } },
|
||||
],
|
||||
// The real server with a throwaway in-memory database, serving the production build
|
||||
webServer: {
|
||||
command: `npm run build && npm run preview -- --port ${port} --strictPort`,
|
||||
command: 'npm run build && node server/index.ts',
|
||||
port,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
env: { DATABASE_URL: 'memory://', PORT: String(port), PUBLIC_URL: baseURL, UPLOAD_DIR: 'test-results/uploads', STATIC_DIR: 'dist' },
|
||||
},
|
||||
})
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
@@ -0,0 +1,28 @@
|
||||
// Renders the app icon to the PNG sizes browsers need to install the app.
|
||||
// Run after changing the icon: node scripts/generate-icons.mjs
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
const shield = (stroke) => `
|
||||
<path d="M16 6.5 8.5 9.5v6c0 4.6 3.2 8.6 7.5 9.9 4.3-1.3 7.5-5.3 7.5-9.9v-6L16 6.5Z" fill="none" stroke="#fff" stroke-width="${stroke}" stroke-linejoin="round"/>
|
||||
<path d="m12.5 16 2.4 2.4 4.6-4.8" fill="none" stroke="#fff" stroke-width="${stroke}" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
|
||||
// Regular icon: rounded tile. Maskable: full-bleed square with the mark inside the safe zone.
|
||||
const regular = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#ee7258"/>${shield(2.2)}</svg>`
|
||||
const maskable = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" fill="#ee7258"/><g transform="translate(16 16) scale(0.72) translate(-16 -16)">${shield(2.4)}</g></svg>`
|
||||
|
||||
const outputs = [
|
||||
['public/icons/icon-192.png', regular, 192],
|
||||
['public/icons/icon-512.png', regular, 512],
|
||||
['public/icons/maskable-512.png', maskable, 512],
|
||||
['public/icons/apple-touch-icon.png', maskable, 180],
|
||||
]
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
for (const [path, svg, size] of outputs) {
|
||||
await page.setViewportSize({ width: size, height: size })
|
||||
await page.setContent(`<style>html,body{margin:0;background:transparent}</style><img src="data:image/svg+xml,${encodeURIComponent(svg)}" width="${size}" height="${size}">`)
|
||||
await page.locator('img').screenshot({ path, omitBackground: true })
|
||||
console.log('wrote', path)
|
||||
}
|
||||
await browser.close()
|
||||
@@ -6,19 +6,41 @@ import { loadSession } from './auth/middleware.ts'
|
||||
import type { Config } from './config.ts'
|
||||
import type { Db } from './db/client.ts'
|
||||
import type { AppEnv } from './lib/http.ts'
|
||||
import type { Notifier } from './notifications.ts'
|
||||
import { actionRoutes } from './routes/actions.ts'
|
||||
import { attachmentRoutes } from './routes/attachments.ts'
|
||||
import { authRoutes } from './routes/auth.ts'
|
||||
import { incidentRoutes } from './routes/incidents.ts'
|
||||
import { kioskAdminRoutes, kioskPublicRoutes } from './routes/kiosks.ts'
|
||||
import { oshaRoutes } from './routes/osha.ts'
|
||||
import { rootCauseRoutes } from './routes/root-causes.ts'
|
||||
import { setupRoutes } from './routes/setup.ts'
|
||||
import { areaRoutes, siteRoutes } from './routes/sites.ts'
|
||||
import { statsRoutes } from './routes/stats.ts'
|
||||
import { userRoutes } from './routes/users.ts'
|
||||
import { viewRoutes } from './routes/views.ts'
|
||||
import type { FileStorage } from './storage.ts'
|
||||
|
||||
export function createApp({ db, config }: { db: Db; config: Config }) {
|
||||
export interface AppDeps {
|
||||
db: Db
|
||||
config: Config
|
||||
notifier: Notifier
|
||||
storage: FileStorage
|
||||
}
|
||||
|
||||
export function createApp({ db, config, notifier, storage }: AppDeps) {
|
||||
const app = new Hono<AppEnv>()
|
||||
|
||||
app.use('*', secureHeaders())
|
||||
app.use('/api/*', csrf(), async (c, next) => {
|
||||
const publicOrigin = new URL(config.publicUrl).origin
|
||||
// Form/multipart posts must come from our own pages. JSON requests are already protected by CORS preflight.
|
||||
const sameSite = csrf({ origin: (origin, c) => origin === publicOrigin || origin === new URL(c.req.url).origin })
|
||||
|
||||
app.use('/api/*', sameSite, async (c, next) => {
|
||||
c.set('db', db)
|
||||
c.set('config', config)
|
||||
c.set('notifier', notifier)
|
||||
c.set('storage', storage)
|
||||
c.header('Cache-Control', 'no-store')
|
||||
await next()
|
||||
})
|
||||
@@ -31,6 +53,14 @@ export function createApp({ db, config }: { db: Db; config: Config }) {
|
||||
app.route('/api/sites', siteRoutes)
|
||||
app.route('/api/areas', areaRoutes)
|
||||
app.route('/api/incidents', incidentRoutes)
|
||||
app.route('/api/root-causes', rootCauseRoutes)
|
||||
app.route('/api/actions', actionRoutes)
|
||||
app.route('/api/attachments', attachmentRoutes)
|
||||
app.route('/api/stats', statsRoutes)
|
||||
app.route('/api/kiosks', kioskAdminRoutes)
|
||||
app.route('/api/kiosk', kioskPublicRoutes())
|
||||
app.route('/api/osha', oshaRoutes)
|
||||
app.route('/api/views', viewRoutes)
|
||||
app.all('/api/*', (c) => c.json({ error: 'Not found' }, 404))
|
||||
|
||||
app.onError((error, c) => {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { and, eq, gt, isNull } from 'drizzle-orm'
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { authTokens, users } from '../db/schema.ts'
|
||||
|
||||
export type TokenPurpose = 'magic_link' | 'password_reset'
|
||||
|
||||
const hash = (token: string) => createHash('sha256').update(token).digest('hex')
|
||||
|
||||
export async function createAuthToken(db: Db, userId: string, purpose: TokenPurpose, ttlMs: number): Promise<string> {
|
||||
const token = randomBytes(32).toString('base64url')
|
||||
await db.insert(authTokens).values({ id: hash(token), userId, purpose, expiresAt: new Date(Date.now() + ttlMs) })
|
||||
return token
|
||||
}
|
||||
|
||||
/** Marks the token used and returns its (active) user's id, or null if invalid, expired or already used. */
|
||||
export async function consumeAuthToken(db: Db, token: string, purpose: TokenPurpose): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.update(authTokens)
|
||||
.set({ usedAt: new Date() })
|
||||
.where(and(eq(authTokens.id, hash(token)), eq(authTokens.purpose, purpose), isNull(authTokens.usedAt), gt(authTokens.expiresAt, new Date())))
|
||||
.returning({ userId: authTokens.userId })
|
||||
if (!row) return null
|
||||
const [user] = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.id, row.userId), eq(users.active, true)))
|
||||
return user?.id ?? null
|
||||
}
|
||||
@@ -7,6 +7,11 @@ export interface Config {
|
||||
production: boolean
|
||||
/** Built web app to serve, if present. */
|
||||
staticDir: string
|
||||
/** e.g. `smtps://user:pass@smtp.example.com:465`. Without it, emails are printed to the log (development only). */
|
||||
smtpUrl: string | undefined
|
||||
mailFrom: string
|
||||
uploadDir: string
|
||||
maxUploadBytes: number
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
@@ -18,5 +23,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
secureCookies: publicUrl.startsWith('https://'),
|
||||
production: env.NODE_ENV === 'production',
|
||||
staticDir: env.STATIC_DIR ?? 'dist',
|
||||
smtpUrl: env.SMTP_URL || undefined,
|
||||
mailFrom: env.MAIL_FROM ?? 'Open SafetyLog EHS <no-reply@localhost>',
|
||||
uploadDir: env.UPLOAD_DIR ?? './data/uploads',
|
||||
maxUploadBytes: Number(env.MAX_UPLOAD_MB ?? 10) * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TYPE "public"."auth_token_purpose" AS ENUM('magic_link', 'password_reset');--> statement-breakpoint
|
||||
CREATE TABLE "auth_tokens" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"purpose" "auth_token_purpose" NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"used_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "corrective_actions" ADD COLUMN "reminder_sent_on" date;--> statement-breakpoint
|
||||
ALTER TABLE "auth_tokens" ADD CONSTRAINT "auth_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "auth_tokens_user_idx" ON "auth_tokens" USING btree ("user_id");
|
||||
@@ -0,0 +1,24 @@
|
||||
CREATE TYPE "public"."report_channel" AS ENUM('web', 'kiosk');--> statement-breakpoint
|
||||
CREATE TABLE "kiosks" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"site_id" uuid,
|
||||
"created_by_id" uuid,
|
||||
"last_used_at" timestamp with time zone,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "incidents" ADD COLUMN "reporter_name" text;--> statement-breakpoint
|
||||
ALTER TABLE "incidents" ADD COLUMN "channel" "report_channel" DEFAULT 'web' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "incidents" ADD COLUMN "kiosk_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "incidents" ADD COLUMN "client_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "kiosks" ADD CONSTRAINT "kiosks_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "kiosks" ADD CONSTRAINT "kiosks_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "kiosks" ADD CONSTRAINT "kiosks_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "kiosks_token_unique" ON "kiosks" USING btree ("token");--> statement-breakpoint
|
||||
CREATE INDEX "kiosks_organization_idx" ON "kiosks" USING btree ("organization_id");--> statement-breakpoint
|
||||
ALTER TABLE "incidents" ADD CONSTRAINT "incidents_kiosk_id_kiosks_id_fk" FOREIGN KEY ("kiosk_id") REFERENCES "public"."kiosks"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "incidents_organization_client_id_unique" ON "incidents" USING btree ("organization_id","client_id");
|
||||
@@ -0,0 +1,41 @@
|
||||
CREATE TYPE "public"."osha_case_type" AS ENUM('injury', 'skin_disorder', 'respiratory', 'poisoning', 'hearing_loss', 'other_illness');--> statement-breakpoint
|
||||
CREATE TYPE "public"."osha_outcome" AS ENUM('death', 'days_away', 'restricted', 'other');--> statement-breakpoint
|
||||
CREATE TYPE "public"."osha_special_case" AS ENUM('none', 'needlestick', 'medical_removal', 'hearing_loss', 'tuberculosis');--> statement-breakpoint
|
||||
ALTER TABLE "hours_worked" ADD COLUMN "average_employees" integer;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "case_number" text;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "work_related" boolean;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "new_case" boolean;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "died" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "date_of_death" date;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "days_away_from_work" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "restricted_or_transfer" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "medical_treatment" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "loss_of_consciousness" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "significant_diagnosis" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "special_case" "osha_special_case" DEFAULT 'none' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "case_type" "osha_case_type" DEFAULT 'injury' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "recordable" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "outcome" "osha_outcome";--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "inpatient_hospitalization" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "amputation" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "loss_of_eye" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "reported_to_osha_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "employee_address" text;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "date_of_birth" date;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "date_of_hire" date;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "sex" text;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "physician_name" text;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "treatment_facility" text;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "treated_in_emergency_room" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "time_started_work" text;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "activity_before" text;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "what_happened" text;--> statement-breakpoint
|
||||
ALTER TABLE "injured_persons" ADD COLUMN "object_substance" text;--> statement-breakpoint
|
||||
ALTER TABLE "sites" ADD COLUMN "street_address" text;--> statement-breakpoint
|
||||
ALTER TABLE "sites" ADD COLUMN "city" text;--> statement-breakpoint
|
||||
ALTER TABLE "sites" ADD COLUMN "state" text;--> statement-breakpoint
|
||||
ALTER TABLE "sites" ADD COLUMN "zip" text;--> statement-breakpoint
|
||||
ALTER TABLE "sites" ADD COLUMN "naics_code" text;--> statement-breakpoint
|
||||
ALTER TABLE "sites" ADD COLUMN "industry_description" text;--> statement-breakpoint
|
||||
ALTER TABLE "sites" ADD COLUMN "ein" text;--> statement-breakpoint
|
||||
ALTER TABLE "sites" ADD COLUMN "establishment_type" integer DEFAULT 1 NOT NULL;
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE "saved_views" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"page" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"filters" jsonb NOT NULL,
|
||||
"shared" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "saved_views" ADD CONSTRAINT "saved_views_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "saved_views" ADD CONSTRAINT "saved_views_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "saved_views_organization_page_idx" ON "saved_views" USING btree ("organization_id","page");
|
||||
@@ -8,6 +8,34 @@
|
||||
"when": 1791306770507,
|
||||
"tag": "0000_initial",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1791309275942,
|
||||
"tag": "0001_workflow_and_email",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1791310953907,
|
||||
"tag": "0002_kiosks_and_offline",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1791312050891,
|
||||
"tag": "0003_osha_recordkeeping",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1791315152348,
|
||||
"tag": "0004_saved_views",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -12,6 +12,11 @@ export const rcaMethodEnum = pgEnum('rca_method', vocab.rcaMethods)
|
||||
export const rootCauseCategoryEnum = pgEnum('root_cause_category', vocab.rootCauseCategories)
|
||||
export const actionKindEnum = pgEnum('action_kind', vocab.actionKinds)
|
||||
export const actionStatusEnum = pgEnum('action_status', vocab.actionStatuses)
|
||||
export const authTokenPurposeEnum = pgEnum('auth_token_purpose', ['magic_link', 'password_reset'])
|
||||
export const reportChannelEnum = pgEnum('report_channel', ['web', 'kiosk'])
|
||||
export const oshaCaseTypeEnum = pgEnum('osha_case_type', vocab.oshaCaseTypes)
|
||||
export const oshaOutcomeEnum = pgEnum('osha_outcome', vocab.oshaOutcomes)
|
||||
export const oshaSpecialCaseEnum = pgEnum('osha_special_case', vocab.oshaSpecialCases)
|
||||
|
||||
const id = () => uuid().primaryKey().defaultRandom()
|
||||
const timestamptz = () => timestamp({ withTimezone: true })
|
||||
@@ -62,6 +67,23 @@ export const sessions = pgTable(
|
||||
(t) => [index('sessions_user_idx').on(t.userId)],
|
||||
)
|
||||
|
||||
/** Single-use links sent by email (magic sign-in, password reset/invite). */
|
||||
export const authTokens = pgTable(
|
||||
'auth_tokens',
|
||||
{
|
||||
/** SHA-256 of the token sent in the link. */
|
||||
id: text().primaryKey(),
|
||||
userId: uuid()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
purpose: authTokenPurposeEnum().notNull(),
|
||||
expiresAt: timestamptz().notNull(),
|
||||
usedAt: timestamptz(),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index('auth_tokens_user_idx').on(t.userId)],
|
||||
)
|
||||
|
||||
export const sites = pgTable(
|
||||
'sites',
|
||||
{
|
||||
@@ -72,6 +94,16 @@ export const sites = pgTable(
|
||||
name: text().notNull(),
|
||||
address: text(),
|
||||
timezone: text().notNull().default('UTC'),
|
||||
// OSHA establishment details (Form 300A and the ITA upload)
|
||||
streetAddress: text(),
|
||||
city: text(),
|
||||
state: text(),
|
||||
zip: text(),
|
||||
naicsCode: text(),
|
||||
industryDescription: text(),
|
||||
ein: text(),
|
||||
/** ITA establishment_type: 1 private, 2 state government, 3 local government. */
|
||||
establishmentType: integer().notNull().default(1),
|
||||
archivedAt: timestamptz(),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
@@ -97,6 +129,29 @@ export const areas = pgTable(
|
||||
(t) => [index('areas_site_idx').on(t.siteId), index('areas_parent_idx').on(t.parentId)],
|
||||
)
|
||||
|
||||
/**
|
||||
* Reporting links let people without accounts file reports: on a shared kiosk tablet, or by scanning
|
||||
* a QR code. The token only allows creating reports, so it is stored as-is to let admins reprint QR codes.
|
||||
*/
|
||||
export const kiosks = pgTable(
|
||||
'kiosks',
|
||||
{
|
||||
id: id(),
|
||||
organizationId: uuid()
|
||||
.notNull()
|
||||
.references(() => organizations.id),
|
||||
name: text().notNull(),
|
||||
token: text().notNull(),
|
||||
/** Limits the link to one site; null allows any. */
|
||||
siteId: uuid().references(() => sites.id, { onDelete: 'set null' }),
|
||||
createdById: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
lastUsedAt: timestamptz(),
|
||||
revokedAt: timestamptz(),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [uniqueIndex('kiosks_token_unique').on(t.token), index('kiosks_organization_idx').on(t.organizationId)],
|
||||
)
|
||||
|
||||
/** Per-organization counter for gap-free, human-friendly incident numbers. */
|
||||
export const incidentCounters = pgTable('incident_counters', {
|
||||
organizationId: uuid()
|
||||
@@ -128,12 +183,19 @@ export const incidents = pgTable(
|
||||
/** Null when reported anonymously. */
|
||||
reporterId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
anonymous: boolean().notNull().default(false),
|
||||
/** Name typed by someone reporting without an account (kiosk/QR). */
|
||||
reporterName: text(),
|
||||
channel: reportChannelEnum().notNull().default('web'),
|
||||
kioskId: uuid().references(() => kiosks.id, { onDelete: 'set null' }),
|
||||
/** Generated on the reporting device so retries after a lost response don't create duplicates. */
|
||||
clientId: uuid(),
|
||||
closedAt: timestamptz(),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('incidents_organization_number_unique').on(t.organizationId, t.number),
|
||||
uniqueIndex('incidents_organization_client_id_unique').on(t.organizationId, t.clientId),
|
||||
index('incidents_organization_occurred_idx').on(t.organizationId, t.occurredAt),
|
||||
index('incidents_organization_status_idx').on(t.organizationId, t.status),
|
||||
index('incidents_reporter_idx').on(t.reporterId),
|
||||
@@ -159,6 +221,42 @@ export const injuredPersons = pgTable(
|
||||
daysRestricted: integer().notNull().default(0),
|
||||
/** OSHA privacy case: name withheld from the 300 log. */
|
||||
privacyCase: boolean().notNull().default(false),
|
||||
|
||||
// OSHA recordability (29 CFR 1904). Null answers mean "not assessed yet".
|
||||
caseNumber: text(),
|
||||
workRelated: boolean(),
|
||||
newCase: boolean(),
|
||||
died: boolean().notNull().default(false),
|
||||
dateOfDeath: date({ mode: 'string' }),
|
||||
daysAwayFromWork: boolean().notNull().default(false),
|
||||
restrictedOrTransfer: boolean().notNull().default(false),
|
||||
medicalTreatment: boolean().notNull().default(false),
|
||||
lossOfConsciousness: boolean().notNull().default(false),
|
||||
significantDiagnosis: boolean().notNull().default(false),
|
||||
specialCase: oshaSpecialCaseEnum().notNull().default('none'),
|
||||
caseType: oshaCaseTypeEnum().notNull().default('injury'),
|
||||
/** Derived from the answers above with shared/osha.ts, stored for reporting. */
|
||||
recordable: boolean().notNull().default(false),
|
||||
outcome: oshaOutcomeEnum(),
|
||||
|
||||
// Severe injury reporting (1904.39)
|
||||
inpatientHospitalization: boolean().notNull().default(false),
|
||||
amputation: boolean().notNull().default(false),
|
||||
lossOfEye: boolean().notNull().default(false),
|
||||
reportedToOshaAt: timestamptz(),
|
||||
|
||||
// Form 301 details
|
||||
employeeAddress: text(),
|
||||
dateOfBirth: date({ mode: 'string' }),
|
||||
dateOfHire: date({ mode: 'string' }),
|
||||
sex: text(),
|
||||
physicianName: text(),
|
||||
treatmentFacility: text(),
|
||||
treatedInEmergencyRoom: boolean().notNull().default(false),
|
||||
timeStartedWork: text(),
|
||||
activityBefore: text(),
|
||||
whatHappened: text(),
|
||||
objectSubstance: text(),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
@@ -230,6 +328,8 @@ export const correctiveActions = pgTable(
|
||||
completedAt: timestamptz(),
|
||||
verifiedById: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
verifiedAt: timestamptz(),
|
||||
/** Date the last due/overdue reminder was emailed, so it goes out at most once a day. */
|
||||
reminderSentOn: date({ mode: 'string' }),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
@@ -279,8 +379,30 @@ export const hoursWorked = pgTable(
|
||||
/** First day of the month, e.g. `2026-10-01`. */
|
||||
month: date({ mode: 'string' }).notNull(),
|
||||
hours: numeric({ precision: 12, scale: 2 }).notNull(),
|
||||
/** Average number of employees on the payroll that month (for the annual average). */
|
||||
averageEmployees: integer(),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [uniqueIndex('hours_worked_site_month_unique').on(t.siteId, t.month)],
|
||||
)
|
||||
|
||||
/** Named filter sets for the Analytics and Incidents pages; private, or shared with the organization. */
|
||||
export const savedViews = pgTable(
|
||||
'saved_views',
|
||||
{
|
||||
id: id(),
|
||||
organizationId: uuid()
|
||||
.notNull()
|
||||
.references(() => organizations.id),
|
||||
userId: uuid()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
page: text().notNull(),
|
||||
name: text().notNull(),
|
||||
filters: jsonb().$type<Record<string, string>>().notNull(),
|
||||
shared: boolean().notNull().default(false),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index('saved_views_organization_page_idx').on(t.organizationId, t.page)],
|
||||
)
|
||||
@@ -2,7 +2,8 @@
|
||||
import { hashPassword } from '../auth/password.ts'
|
||||
import { loadConfig } from '../config.ts'
|
||||
import { openDatabase } from './client.ts'
|
||||
import { areas, incidentCounters, incidents, organizations, sites, users } from './schema.ts'
|
||||
import { assessRecordability } from '../../shared/osha.ts'
|
||||
import { auditLog, areas, correctiveActions, hoursWorked, incidentCounters, incidents, injuredPersons, investigations, organizations, rootCauses, sites, users } from './schema.ts'
|
||||
|
||||
const DEMO_PASSWORD = 'safetylog-demo'
|
||||
|
||||
@@ -49,7 +50,9 @@ try {
|
||||
{ title: 'Wet floor near employee entrance', type: 'hazard', severity: 'medium', status: 'actions', building: 'Building A', locationDetail: 'Ground floor lobby', reporter: 'reporter@example.com', days: 6 },
|
||||
{ title: 'Forklift reversed into pallet stack', type: 'near_miss', severity: 'high', status: 'triage', building: 'Warehouse 04', locationDetail: 'Loading bay C', reporter: 'admin@example.com', days: 5 },
|
||||
] as const
|
||||
await tx.insert(incidents).values(
|
||||
const created = await tx
|
||||
.insert(incidents)
|
||||
.values(
|
||||
demo.map((item, index) => ({
|
||||
organizationId: org.id,
|
||||
number: index + 1,
|
||||
@@ -58,16 +61,170 @@ try {
|
||||
severity: item.severity,
|
||||
status: item.status,
|
||||
occurredAt: daysAgo(item.days),
|
||||
createdAt: new Date(daysAgo(item.days).getTime() + 2 * 3_600_000),
|
||||
...areaIds[item.building],
|
||||
locationDetail: item.locationDetail,
|
||||
reporterId: person(item.reporter),
|
||||
closedAt: item.status === 'closed' ? daysAgo(item.days - 3) : null,
|
||||
})),
|
||||
)
|
||||
await tx.insert(incidentCounters).values({ organizationId: org.id, lastNumber: demo.length })
|
||||
.returning()
|
||||
await tx.insert(auditLog).values(
|
||||
created.map((incident) => ({
|
||||
organizationId: org.id,
|
||||
actorId: incident.reporterId,
|
||||
entityType: 'incident',
|
||||
entityId: incident.id,
|
||||
action: 'create',
|
||||
changes: { number: incident.number, title: incident.title },
|
||||
createdAt: incident.createdAt,
|
||||
})),
|
||||
)
|
||||
// A year of history so dashboards and the OSHA log have something to show
|
||||
const lastNumber = await seedHistory(tx as unknown as typeof db, org.id, demo.length, areaIds, people.map((p) => p.id))
|
||||
await tx.insert(incidentCounters).values({ organizationId: org.id, lastNumber })
|
||||
})
|
||||
console.log(`Seeded demo data. Sign in as admin@example.com, manager@example.com, supervisor@example.com or reporter@example.com with password "${DEMO_PASSWORD}".`)
|
||||
}
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
|
||||
/** Deterministic pseudo-random numbers, so every seed produces the same demo. */
|
||||
function random(seed: number) {
|
||||
let state = seed
|
||||
return () => {
|
||||
state = (state * 1_664_525 + 1_013_904_223) % 2 ** 32
|
||||
return state / 2 ** 32
|
||||
}
|
||||
}
|
||||
|
||||
async function seedHistory(
|
||||
database: typeof db,
|
||||
organizationId: string,
|
||||
startNumber: number,
|
||||
areaIds: Record<string, { siteId: string; areaId: string }>,
|
||||
userIds: string[],
|
||||
): Promise<number> {
|
||||
const rand = random(42)
|
||||
const pick = <T>(items: readonly T[]) => items[Math.floor(rand() * items.length)]
|
||||
const day = 86_400_000
|
||||
const now = Date.now()
|
||||
const locations = Object.values(areaIds)
|
||||
// Near misses and hazards outnumber injuries, as in a healthy reporting culture
|
||||
const types = ['near_miss', 'near_miss', 'near_miss', 'hazard', 'hazard', 'hazard', 'injury', 'injury', 'property_damage', 'environmental', 'illness'] as const
|
||||
// Busier around shift changes (06–08, 14–16) and mid-shift
|
||||
const hours = [6, 7, 7, 9, 10, 11, 13, 14, 14, 15, 15, 16, 19, 22, 2]
|
||||
const titles = {
|
||||
near_miss: ['Forklift nearly struck pedestrian', 'Load slipped from pallet jack', 'Unsecured ladder almost fell', 'Dropped tool from mezzanine'],
|
||||
hazard: ['Oil leak under press', 'Blocked emergency exit', 'Damaged guard on conveyor', 'Frayed extension cord', 'Missing machine guard'],
|
||||
injury: ['Hand laceration from sheet metal', 'Back strain lifting boxes', 'Slip on wet floor', 'Pinched finger in die'],
|
||||
illness: ['Dermatitis from cutting fluid'],
|
||||
property_damage: ['Forklift hit racking', 'Damaged dock door'],
|
||||
environmental: ['Hydraulic fluid spill', 'Coolant leak to drain'],
|
||||
} as const
|
||||
const categories = ['people', 'process', 'equipment', 'materials', 'environment', 'management'] as const
|
||||
|
||||
let number = startNumber
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const type = pick(types)
|
||||
const occurred = new Date(now - Math.floor(rand() * 360 + 3) * day)
|
||||
occurred.setHours(pick(hours), Math.floor(rand() * 60), 0, 0)
|
||||
const created = new Date(occurred.getTime() + (1 + rand() * 6) * 3_600_000)
|
||||
const age = (now - occurred.getTime()) / day
|
||||
const status = age > 60 ? (rand() < 0.85 ? 'closed' : 'actions') : age > 20 ? pick(['investigation', 'actions', 'verification', 'closed'] as const) : pick(['reported', 'triage', 'investigation'] as const)
|
||||
const closedAt = status === 'closed' ? new Date(created.getTime() + (5 + rand() * 35) * day) : null
|
||||
number++
|
||||
const [incident] = await database
|
||||
.insert(incidents)
|
||||
.values({
|
||||
organizationId,
|
||||
number,
|
||||
title: pick(titles[type]),
|
||||
type,
|
||||
severity: type === 'injury' ? pick(['medium', 'high', 'high'] as const) : pick(['low', 'medium', 'medium', 'high'] as const),
|
||||
status,
|
||||
occurredAt: occurred,
|
||||
createdAt: created,
|
||||
closedAt: closedAt && closedAt.getTime() < now ? closedAt : null,
|
||||
...pick(locations),
|
||||
reporterId: pick(userIds),
|
||||
})
|
||||
.returning()
|
||||
const auditRows: (typeof auditLog.$inferInsert)[] = [
|
||||
{ organizationId, actorId: incident.reporterId, entityType: 'incident', entityId: incident.id, action: 'create', changes: { number, title: incident.title }, createdAt: created },
|
||||
]
|
||||
if (status !== 'reported') {
|
||||
// Most reports are triaged within a day; the team got quicker over the year
|
||||
const delayHours = rand() < 0.55 + (1 - age / 365) * 0.35 ? 2 + rand() * 18 : 26 + rand() * 70
|
||||
auditRows.push({ organizationId, actorId: pick(userIds), entityType: 'incident', entityId: incident.id, action: 'status', changes: { status: ['reported', 'triage'] }, createdAt: new Date(created.getTime() + delayHours * 3_600_000) })
|
||||
}
|
||||
await database.insert(auditLog).values(auditRows)
|
||||
|
||||
if (['investigation', 'actions', 'verification', 'closed'].includes(status) && type !== 'near_miss') {
|
||||
const [investigation] = await database.insert(investigations).values({ incidentId: incident.id, method: 'five_whys', startedAt: created }).returning()
|
||||
const causes = await database
|
||||
.insert(rootCauses)
|
||||
.values(Array.from({ length: 1 + Math.floor(rand() * 2) }, () => ({ investigationId: investigation.id, category: pick(categories), description: 'Identified during investigation' })))
|
||||
.returning()
|
||||
const due = new Date(created.getTime() + 21 * day)
|
||||
const done = status === 'closed' || status === 'verification' || rand() < 0.4
|
||||
const completed = new Date(created.getTime() + (8 + rand() * 30) * day)
|
||||
await database.insert(correctiveActions).values({
|
||||
incidentId: incident.id,
|
||||
rootCauseId: causes[0].id,
|
||||
title: 'Fix the cause and brief the team',
|
||||
assigneeId: pick(userIds),
|
||||
dueDate: due.toISOString().slice(0, 10),
|
||||
status: done && completed.getTime() < now ? (status === 'closed' ? 'verified' : 'done') : 'open',
|
||||
completedAt: done && completed.getTime() < now ? completed : null,
|
||||
})
|
||||
}
|
||||
|
||||
if ((type === 'injury' || type === 'illness') && rand() < 0.6) {
|
||||
const answers = {
|
||||
employmentType: 'employee' as const,
|
||||
workRelated: true,
|
||||
newCase: true,
|
||||
died: false,
|
||||
daysAwayFromWork: rand() < 0.3,
|
||||
restrictedOrTransfer: rand() < 0.4,
|
||||
medicalTreatment: rand() < 0.7,
|
||||
lossOfConsciousness: false,
|
||||
significantDiagnosis: false,
|
||||
specialCase: 'none' as const,
|
||||
}
|
||||
const verdict = assessRecordability(answers)
|
||||
await database.insert(injuredPersons).values({
|
||||
incidentId: incident.id,
|
||||
caseNumber: `INC-${String(number).padStart(4, '0')}`,
|
||||
name: pick(['Alex Moreno', 'Jamie Patel', 'Chris Nguyen', 'Taylor Brooks', 'Morgan Diaz']),
|
||||
jobTitle: pick(['Press operator', 'Material handler', 'Maintenance technician', 'Assembler']),
|
||||
natureOfInjury: incident.title,
|
||||
caseType: type === 'illness' ? 'skin_disorder' : 'injury',
|
||||
...answers,
|
||||
daysAway: answers.daysAwayFromWork ? 1 + Math.floor(rand() * 10) : 0,
|
||||
daysRestricted: answers.restrictedOrTransfer ? 2 + Math.floor(rand() * 14) : 0,
|
||||
recordable: verdict.status === 'recordable',
|
||||
outcome: verdict.outcome,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Monthly hours worked and headcount for every site, for the last 13 months
|
||||
const siteIds = [...new Set(locations.map((l) => l.siteId))]
|
||||
const today = new Date()
|
||||
for (const siteId of siteIds) {
|
||||
for (let m = 12; m >= 0; m--) {
|
||||
const month = new Date(today.getFullYear(), today.getMonth() - m, 1)
|
||||
const employees = 70 + Math.floor(rand() * 30)
|
||||
await database.insert(hoursWorked).values({
|
||||
siteId,
|
||||
month: `${month.getFullYear()}-${String(month.getMonth() + 1).padStart(2, '0')}-01`,
|
||||
hours: String(employees * 168),
|
||||
averageEmployees: employees,
|
||||
})
|
||||
}
|
||||
}
|
||||
return number
|
||||
}
|
||||
@@ -5,10 +5,19 @@ import { join } from 'node:path'
|
||||
import { createApp } from './app.ts'
|
||||
import { loadConfig } from './config.ts'
|
||||
import { openDatabase } from './db/client.ts'
|
||||
import { createMailer } from './mail.ts'
|
||||
import { Notifier } from './notifications.ts'
|
||||
import { FileStorage } from './storage.ts'
|
||||
|
||||
const config = loadConfig()
|
||||
const database = await openDatabase(config.databaseUrl)
|
||||
const app = createApp({ db: database.db, config })
|
||||
const notifier = new Notifier(database.db, createMailer(config), config)
|
||||
const app = createApp({ db: database.db, config, notifier, storage: new FileStorage(config.uploadDir) })
|
||||
|
||||
// Hourly check for corrective actions that are due or overdue; each gets at most one reminder a day.
|
||||
const sendReminders = () => notifier.sendDueReminders().catch((error) => console.error('[reminders] failed:', error))
|
||||
const reminderTimer = setInterval(sendReminders, 60 * 60 * 1000)
|
||||
setTimeout(sendReminders, 30_000).unref()
|
||||
|
||||
// In production the server also serves the built web app.
|
||||
if (existsSync(join(config.staticDir, 'index.html'))) {
|
||||
@@ -30,7 +39,9 @@ const server = serve({ fetch: app.fetch, port: config.port }, ({ port }) => {
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.on(signal, () => {
|
||||
clearInterval(reminderTimer)
|
||||
server.close(async () => {
|
||||
await notifier.flush()
|
||||
await database.close()
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { and, eq, gte, inArray, isNotNull, lt, min } from 'drizzle-orm'
|
||||
import type { AnalyticsStats, Kpi, TypeGroup } from '../../shared/api-types.ts'
|
||||
import { hasRole, type IncidentType } from '../../shared/enums.ts'
|
||||
import { incidenceRate } from '../../shared/osha.ts'
|
||||
import type { SessionUser } from '../auth/sessions.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { areas, auditLog, correctiveActions, hoursWorked, incidents, injuredPersons, investigations, rootCauses, sites } from '../db/schema.ts'
|
||||
import { addDays, localDate } from './dates.ts'
|
||||
import { visibleTo } from './incidents.ts'
|
||||
|
||||
const DAY_MS = 86_400_000
|
||||
|
||||
export const typeGroup: Record<IncidentType, TypeGroup> = {
|
||||
near_miss: 'hazards',
|
||||
hazard: 'hazards',
|
||||
injury: 'injuries',
|
||||
illness: 'injuries',
|
||||
property_damage: 'damage',
|
||||
environmental: 'damage',
|
||||
}
|
||||
|
||||
export interface AnalyticsFilters {
|
||||
/** Inclusive start date, `YYYY-MM-DD` (server time zone). */
|
||||
from: string
|
||||
/** Inclusive end date. */
|
||||
to: string
|
||||
siteId?: string
|
||||
type?: IncidentType
|
||||
}
|
||||
|
||||
const startOf = (isoDate: string) => new Date(`${isoDate}T00:00:00`)
|
||||
const monthKey = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||
const ratio = (part: number, whole: number) => (whole > 0 ? part / whole : null)
|
||||
const round1 = (value: number | null) => (value === null ? null : Math.round(value * 10) / 10)
|
||||
|
||||
/** Monday of the week containing `date`, local time. */
|
||||
function weekStart(date: Date): Date {
|
||||
const start = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
start.setDate(start.getDate() - ((start.getDay() + 6) % 7))
|
||||
return start
|
||||
}
|
||||
|
||||
/** Buckets covering [from, to]: weeks for ranges up to ~3 months, otherwise months. */
|
||||
export function buckets(from: string, to: string): { bucket: 'week' | 'month'; starts: Date[] } {
|
||||
const start = startOf(from)
|
||||
const end = startOf(to)
|
||||
const days = (end.getTime() - start.getTime()) / DAY_MS + 1
|
||||
const starts: Date[] = []
|
||||
if (days <= 92) {
|
||||
for (let d = weekStart(start); d <= end; d = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 7)) starts.push(d)
|
||||
return { bucket: 'week', starts }
|
||||
}
|
||||
for (let d = new Date(start.getFullYear(), start.getMonth(), 1); d <= end; d = new Date(d.getFullYear(), d.getMonth() + 1, 1)) starts.push(d)
|
||||
return { bucket: 'month', starts }
|
||||
}
|
||||
|
||||
function bucketIndex(starts: Date[], date: Date): number {
|
||||
for (let i = starts.length - 1; i >= 0; i--) if (date >= starts[i]) return i
|
||||
return -1
|
||||
}
|
||||
|
||||
export async function computeAnalytics(db: Db, user: SessionUser, filters: AnalyticsFilters): Promise<AnalyticsStats> {
|
||||
const from = startOf(filters.from)
|
||||
const to = startOf(addDays(filters.to, 1)) // exclusive
|
||||
const length = to.getTime() - from.getTime()
|
||||
const previousFrom = new Date(from.getTime() - length)
|
||||
const visible = visibleTo(db, user)
|
||||
const scope = and(visible, filters.siteId ? eq(incidents.siteId, filters.siteId) : undefined, filters.type ? eq(incidents.type, filters.type) : undefined)
|
||||
const occurredIn = (start: Date, end: Date) => and(scope, gte(incidents.occurredAt, start), lt(incidents.occurredAt, end))
|
||||
|
||||
const columns = { id: incidents.id, type: incidents.type, occurredAt: incidents.occurredAt, createdAt: incidents.createdAt, siteId: incidents.siteId, areaId: incidents.areaId }
|
||||
const current = await db.select(columns).from(incidents).where(occurredIn(from, to))
|
||||
const previous = await db.select(columns).from(incidents).where(occurredIn(previousFrom, from))
|
||||
|
||||
const countGroups = (rows: typeof current) => {
|
||||
const groups = { hazards: 0, injuries: 0, damage: 0 }
|
||||
for (const row of rows) groups[typeGroup[row.type]]++
|
||||
return groups
|
||||
}
|
||||
const now = countGroups(current)
|
||||
const before = countGroups(previous)
|
||||
|
||||
// Responsiveness: time from report to the first status change
|
||||
async function triagedShare(rows: typeof current): Promise<number | null> {
|
||||
if (rows.length === 0) return null
|
||||
const firstMoves = await db
|
||||
.select({ id: auditLog.entityId, at: min(auditLog.createdAt) })
|
||||
.from(auditLog)
|
||||
.where(and(eq(auditLog.entityType, 'incident'), eq(auditLog.action, 'status'), inArray(auditLog.entityId, rows.map((r) => r.id))))
|
||||
.groupBy(auditLog.entityId)
|
||||
const moved = new Map(firstMoves.map((m) => [m.id, m.at]))
|
||||
const quick = rows.filter((r) => {
|
||||
const at = moved.get(r.id)
|
||||
return at && at.getTime() - r.createdAt.getTime() <= DAY_MS
|
||||
})
|
||||
return quick.length / rows.length
|
||||
}
|
||||
|
||||
async function averageDaysToClose(start: Date, end: Date) {
|
||||
const closed = await db
|
||||
.select({ createdAt: incidents.createdAt, closedAt: incidents.closedAt })
|
||||
.from(incidents)
|
||||
.where(and(scope, isNotNull(incidents.closedAt), gte(incidents.closedAt, start), lt(incidents.closedAt, end)))
|
||||
if (closed.length === 0) return null
|
||||
return round1(closed.reduce((sum, r) => sum + (r.closedAt!.getTime() - r.createdAt.getTime()), 0) / closed.length / DAY_MS)
|
||||
}
|
||||
|
||||
async function completedActions(start: Date, end: Date) {
|
||||
return db
|
||||
.select({ completedAt: correctiveActions.completedAt, dueDate: correctiveActions.dueDate })
|
||||
.from(correctiveActions)
|
||||
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||
.where(and(scope, isNotNull(correctiveActions.completedAt), isNotNull(correctiveActions.dueDate), gte(correctiveActions.completedAt, start), lt(correctiveActions.completedAt, end)))
|
||||
}
|
||||
const onTime = (row: { completedAt: Date | null; dueDate: string | null }) => localDate(row.completedAt!) <= row.dueDate!
|
||||
const actionsNow = await completedActions(from, to)
|
||||
const actionsBefore = await completedActions(previousFrom, from)
|
||||
|
||||
const today = localDate()
|
||||
const overdueRows = await db
|
||||
.select({ id: correctiveActions.id })
|
||||
.from(correctiveActions)
|
||||
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||
.where(and(scope, inArray(correctiveActions.status, ['open', 'in_progress']), lt(correctiveActions.dueDate, today)))
|
||||
|
||||
// Lagging: OSHA-recordable cases. Counts only, so supervisors may see them.
|
||||
const canSeeRates = hasRole(user.role, 'supervisor')
|
||||
async function recordableRows(start: Date, end: Date) {
|
||||
return db
|
||||
.select({ occurredAt: incidents.occurredAt, outcome: injuredPersons.outcome })
|
||||
.from(injuredPersons)
|
||||
.innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId))
|
||||
.where(and(eq(incidents.organizationId, user.organizationId), filters.siteId ? eq(incidents.siteId, filters.siteId) : undefined, eq(injuredPersons.recordable, true), gte(incidents.occurredAt, start), lt(incidents.occurredAt, end)))
|
||||
}
|
||||
|
||||
const kpi = (value: number | null, previousValue: number | null): Kpi => ({ value, previous: previousValue })
|
||||
const ratioOf = (groups: typeof now) => (groups.injuries ? Math.round(((groups.hazards / groups.injuries) * 10)) / 10 : null)
|
||||
|
||||
// Trend
|
||||
const { bucket, starts } = buckets(filters.from, filters.to)
|
||||
const trend = starts.map((start) => ({ start: localDate(start), hazards: 0, injuries: 0, damage: 0 }))
|
||||
for (const row of current) {
|
||||
const i = bucketIndex(starts, row.occurredAt)
|
||||
if (i >= 0) trend[i][typeGroup[row.type]]++
|
||||
}
|
||||
|
||||
// Heatmap: weekday × hour of occurrence
|
||||
const heatmap = Array.from({ length: 7 }, () => Array<number>(24).fill(0))
|
||||
for (const row of current) heatmap[(row.occurredAt.getDay() + 6) % 7][row.occurredAt.getHours()]++
|
||||
|
||||
// Location: sites, or areas within the chosen site
|
||||
let byLocation: AnalyticsStats['byLocation']
|
||||
if (filters.siteId) {
|
||||
const areaRows = await db.select({ id: areas.id, name: areas.name }).from(areas).where(eq(areas.siteId, filters.siteId))
|
||||
const names = new Map(areaRows.map((a) => [a.id, a.name]))
|
||||
const counts = new Map<string | null, number>()
|
||||
for (const row of current) counts.set(row.areaId, (counts.get(row.areaId) ?? 0) + 1)
|
||||
byLocation = [...counts].map(([id, count]) => ({ id, name: id ? (names.get(id) ?? 'Unknown area') : 'Area not specified', count }))
|
||||
} else {
|
||||
const siteRows = await db.select({ id: sites.id, name: sites.name }).from(sites).where(eq(sites.organizationId, user.organizationId))
|
||||
const names = new Map(siteRows.map((s) => [s.id, s.name]))
|
||||
const counts = new Map<string, number>()
|
||||
for (const row of current) counts.set(row.siteId, (counts.get(row.siteId) ?? 0) + 1)
|
||||
byLocation = [...counts].map(([id, count]) => ({ id, name: names.get(id) ?? 'Unknown site', count }))
|
||||
}
|
||||
byLocation.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
|
||||
|
||||
const causeRows = current.length
|
||||
? await db
|
||||
.select({ category: rootCauses.category })
|
||||
.from(rootCauses)
|
||||
.innerJoin(investigations, eq(investigations.id, rootCauses.investigationId))
|
||||
.where(inArray(investigations.incidentId, current.map((r) => r.id)))
|
||||
: []
|
||||
const causeCounts = new Map<string, number>()
|
||||
for (const row of causeRows) causeCounts.set(row.category, (causeCounts.get(row.category) ?? 0) + 1)
|
||||
const byRootCause = [...causeCounts].map(([category, count]) => ({ category: category as AnalyticsStats['byRootCause'][number]['category'], count })).sort((a, b) => b.count - a.count)
|
||||
|
||||
// Actions completed per month, on time vs late
|
||||
const actionsByMonth: AnalyticsStats['actionsByMonth'] = []
|
||||
for (let d = new Date(from.getFullYear(), from.getMonth(), 1); d < to; d = new Date(d.getFullYear(), d.getMonth() + 1, 1)) actionsByMonth.push({ month: monthKey(d), onTime: 0, late: 0 })
|
||||
for (const row of actionsNow) {
|
||||
const entry = actionsByMonth.find((m) => m.month === monthKey(row.completedAt!))
|
||||
if (entry) entry[onTime(row) ? 'onTime' : 'late']++
|
||||
}
|
||||
|
||||
// Rolling 12-month OSHA rates at each of the last 12 month ends
|
||||
let rates: AnalyticsStats['rates'] = null
|
||||
let yearly: AnalyticsStats['yearly'] = null
|
||||
let recordables: Kpi | null = null
|
||||
if (canSeeRates) {
|
||||
recordables = kpi((await recordableRows(from, to)).length, (await recordableRows(previousFrom, from)).length)
|
||||
const endMonth = new Date(startOf(filters.to).getFullYear(), startOf(filters.to).getMonth(), 1)
|
||||
const windowStart = new Date(endMonth.getFullYear() - 5, 0, 1)
|
||||
const cases = await recordableRows(windowStart, new Date(endMonth.getFullYear(), endMonth.getMonth() + 1, 1))
|
||||
const hours = await db
|
||||
.select({ month: hoursWorked.month, hours: hoursWorked.hours })
|
||||
.from(hoursWorked)
|
||||
.innerJoin(sites, eq(sites.id, hoursWorked.siteId))
|
||||
.where(and(eq(sites.organizationId, user.organizationId), filters.siteId ? eq(hoursWorked.siteId, filters.siteId) : undefined, gte(hoursWorked.month, localDate(windowStart))))
|
||||
const hoursByMonth = new Map<string, number>()
|
||||
for (const h of hours) hoursByMonth.set(h.month.slice(0, 7), (hoursByMonth.get(h.month.slice(0, 7)) ?? 0) + Number(h.hours))
|
||||
const casesIn = (start: Date, end: Date) => cases.filter((c) => c.occurredAt >= start && c.occurredAt < end)
|
||||
const hoursIn = (start: Date, end: Date) => {
|
||||
let total = 0
|
||||
for (let d = new Date(start); d < end; d = new Date(d.getFullYear(), d.getMonth() + 1, 1)) total += hoursByMonth.get(monthKey(d)) ?? 0
|
||||
return total
|
||||
}
|
||||
const isDart = (c: { outcome: string | null }) => c.outcome === 'days_away' || c.outcome === 'restricted'
|
||||
|
||||
rates = Array.from({ length: 12 }, (_, i) => {
|
||||
const monthStart = new Date(endMonth.getFullYear(), endMonth.getMonth() - 11 + i, 1)
|
||||
const windowEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1)
|
||||
const windowBegin = new Date(windowEnd.getFullYear() - 1, windowEnd.getMonth(), 1)
|
||||
const inWindow = casesIn(windowBegin, windowEnd)
|
||||
const h = hoursIn(windowBegin, windowEnd)
|
||||
return { month: monthKey(monthStart), trir: incidenceRate(inWindow.length, h), dart: incidenceRate(inWindow.filter(isDart).length, h) }
|
||||
})
|
||||
yearly = Array.from({ length: 5 }, (_, i) => {
|
||||
const year = endMonth.getFullYear() - 4 + i
|
||||
const inYear = casesIn(new Date(year, 0, 1), new Date(year + 1, 0, 1))
|
||||
const h = Math.round(hoursIn(new Date(year, 0, 1), new Date(year + 1, 0, 1)))
|
||||
const dartCases = inYear.filter(isDart).length
|
||||
return { year, recordables: inYear.length, dartCases, hours: h, trir: incidenceRate(inYear.length, h), dart: incidenceRate(dartCases, h) }
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
from: filters.from,
|
||||
to: filters.to,
|
||||
previousFrom: localDate(previousFrom),
|
||||
bucket,
|
||||
kpis: {
|
||||
reports: kpi(current.length, previous.length),
|
||||
leadingReports: kpi(now.hazards, before.hazards),
|
||||
injuries: kpi(now.injuries, before.injuries),
|
||||
nearMissRatio: kpi(ratioOf(now), ratioOf(before)),
|
||||
triagedWithin24h: kpi(await triagedShare(current), await triagedShare(previous)),
|
||||
actionsOnTime: kpi(ratio(actionsNow.filter(onTime).length, actionsNow.length), ratio(actionsBefore.filter(onTime).length, actionsBefore.length)),
|
||||
averageDaysToClose: kpi(await averageDaysToClose(from, to), await averageDaysToClose(previousFrom, from)),
|
||||
recordables,
|
||||
overdueActions: overdueRows.length,
|
||||
},
|
||||
trend,
|
||||
byLocation,
|
||||
byRootCause,
|
||||
heatmap,
|
||||
actionsByMonth,
|
||||
rates,
|
||||
yearly,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { count, eq } from 'drizzle-orm'
|
||||
import type { Config } from '../config.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { attachments } from '../db/schema.ts'
|
||||
import type { FileStorage } from '../storage.ts'
|
||||
import { detectFileType } from './file-type.ts'
|
||||
import { fail } from './http.ts'
|
||||
|
||||
export const MAX_ATTACHMENTS_PER_INCIDENT = 20
|
||||
|
||||
/** Validates and stores one uploaded file against an incident. */
|
||||
export async function saveAttachment(
|
||||
{ db, storage, config }: { db: Db; storage: FileStorage; config: Config },
|
||||
incidentId: string,
|
||||
file: unknown,
|
||||
uploadedById: string | null,
|
||||
) {
|
||||
if (!(file instanceof File)) throw fail(400, 'Attach a file in the "file" field')
|
||||
if (file.size > config.maxUploadBytes) throw fail(413, `Files can be at most ${Math.round(config.maxUploadBytes / 1024 / 1024)} MB`)
|
||||
const [{ existing }] = await db.select({ existing: count() }).from(attachments).where(eq(attachments.incidentId, incidentId))
|
||||
if (existing >= MAX_ATTACHMENTS_PER_INCIDENT) throw fail(409, `An incident can have at most ${MAX_ATTACHMENTS_PER_INCIDENT} attachments`)
|
||||
|
||||
const data = new Uint8Array(await file.arrayBuffer())
|
||||
const type = detectFileType(data)
|
||||
if (!type) throw fail(400, 'Only photos (JPEG, PNG, WebP, GIF, HEIC) and PDFs can be attached')
|
||||
|
||||
const storageKey = await storage.save(data, type.extension)
|
||||
const fileName = (file.name || `attachment.${type.extension}`).replace(/[\r\n"\\/]/g, '_').slice(0, 200)
|
||||
try {
|
||||
const [row] = await db
|
||||
.insert(attachments)
|
||||
.values({ incidentId, fileName, contentType: type.contentType, sizeBytes: data.byteLength, storageKey, uploadedById })
|
||||
.returning()
|
||||
return row
|
||||
} catch (error) {
|
||||
await storage.remove(storageKey)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toCsv } from './csv.ts'
|
||||
|
||||
describe('toCsv', () => {
|
||||
it('quotes commas, quotes and newlines', () => {
|
||||
expect(toCsv(['a', 'b'], [['x,y', 'say "hi"\nok']])).toBe('\uFEFFa,b\r\n"x,y","say ""hi""\nok"\r\n')
|
||||
})
|
||||
|
||||
it('neutralises formula prefixes and blanks nulls', () => {
|
||||
expect(toCsv(['a'], [['=1+1'], ['-2'], [null], [3]])).toBe("\uFEFFa\r\n'=1+1\r\n'-2\r\n\r\n3\r\n")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
type Cell = string | number | null | undefined
|
||||
|
||||
/**
|
||||
* RFC 4180 CSV. By default starts with a BOM so Excel opens UTF-8 correctly (`bom: false` for machine
|
||||
* uploads such as OSHA's ITA). Cells a spreadsheet would treat as a formula are prefixed with an
|
||||
* apostrophe (CSV injection).
|
||||
*/
|
||||
export function toCsv(header: string[], rows: Cell[][], { bom = true }: { bom?: boolean } = {}): string {
|
||||
const cell = (value: Cell) => {
|
||||
let text = value === null || value === undefined ? '' : String(value)
|
||||
if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`
|
||||
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
|
||||
}
|
||||
return (bom ? '\uFEFF' : '') + [header, ...rows].map((row) => row.map(cell).join(',')).join('\r\n') + '\r\n'
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** ISO string for a Date, keeping nulls. */
|
||||
export const iso = <T extends Date | null>(date: T): T extends Date ? string : string | null => (date ? date.toISOString() : null) as never
|
||||
|
||||
/** Calendar date in the server's time zone (set TZ to the plant's zone), e.g. `2026-10-06`. */
|
||||
export function localDate(date = new Date()): string {
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
export function addDays(isoDate: string, days: number): string {
|
||||
const date = new Date(`${isoDate}T12:00:00`)
|
||||
date.setDate(date.getDate() + days)
|
||||
return localDate(date)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { detectFileType } from './file-type.ts'
|
||||
|
||||
const bytes = (...values: (number | string)[]) => new Uint8Array(values.flatMap((v) => (typeof v === 'string' ? [...v].map((ch) => ch.charCodeAt(0)) : [v])))
|
||||
|
||||
describe('detectFileType', () => {
|
||||
it('recognises supported formats', () => {
|
||||
expect(detectFileType(bytes(0xff, 0xd8, 0xff, 0xe0))?.contentType).toBe('image/jpeg')
|
||||
expect(detectFileType(bytes('RIFF', 0, 0, 0, 0, 'WEBP'))?.contentType).toBe('image/webp')
|
||||
expect(detectFileType(bytes(0, 0, 0, 24, 'ftypheic'))?.contentType).toBe('image/heic')
|
||||
expect(detectFileType(bytes('%PDF-1.7'))?.contentType).toBe('application/pdf')
|
||||
})
|
||||
|
||||
it('rejects everything else', () => {
|
||||
expect(detectFileType(bytes('<svg xmlns="http://www.w3.org/2000/svg">'))).toBeNull()
|
||||
expect(detectFileType(bytes('PK', 3, 4))).toBeNull()
|
||||
expect(detectFileType(new Uint8Array())).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface DetectedType {
|
||||
contentType: string
|
||||
extension: string
|
||||
}
|
||||
|
||||
const startsWith = (bytes: Uint8Array, signature: number[], offset = 0) => signature.every((byte, i) => bytes[offset + i] === byte)
|
||||
const ascii = (bytes: Uint8Array, from: number, to: number) => String.fromCharCode(...bytes.subarray(from, to))
|
||||
|
||||
/**
|
||||
* Identifies photos and PDFs from their first bytes, ignoring the client's claimed type and file name.
|
||||
* Returns null for anything else, so only these formats are ever stored and served.
|
||||
*/
|
||||
export function detectFileType(bytes: Uint8Array): DetectedType | null {
|
||||
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return { contentType: 'image/jpeg', extension: 'jpg' }
|
||||
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return { contentType: 'image/png', extension: 'png' }
|
||||
if (ascii(bytes, 0, 6) === 'GIF87a' || ascii(bytes, 0, 6) === 'GIF89a') return { contentType: 'image/gif', extension: 'gif' }
|
||||
if (ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 12) === 'WEBP') return { contentType: 'image/webp', extension: 'webp' }
|
||||
if (ascii(bytes, 4, 8) === 'ftyp' && ['heic', 'heix', 'mif1', 'msf1'].includes(ascii(bytes, 8, 12))) return { contentType: 'image/heic', extension: 'heic' }
|
||||
if (ascii(bytes, 0, 5) === '%PDF-') return { contentType: 'application/pdf', extension: 'pdf' }
|
||||
return null
|
||||
}
|
||||
@@ -5,12 +5,16 @@ import type { ZodType } from 'zod'
|
||||
import type { SessionUser } from '../auth/sessions.ts'
|
||||
import type { Config } from '../config.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import type { Notifier } from '../notifications.ts'
|
||||
import type { FileStorage } from '../storage.ts'
|
||||
|
||||
export interface AppEnv {
|
||||
Variables: {
|
||||
db: Db
|
||||
config: Config
|
||||
user: SessionUser | null
|
||||
notifier: Notifier
|
||||
storage: FileStorage
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +27,7 @@ export function validate<Target extends keyof ValidationTargets, Schema extends
|
||||
})
|
||||
}
|
||||
|
||||
export const fail = (status: 400 | 401 | 403 | 404 | 409 | 429, message: string) => new HTTPException(status, { message })
|
||||
export const fail = (status: 400 | 401 | 403 | 404 | 409 | 413 | 429, message: string) => new HTTPException(status, { message })
|
||||
|
||||
/** The signed-in user; use after `requireAuth`. */
|
||||
export function currentUser(c: Context<AppEnv>): SessionUser {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { and, asc, count, eq, exists, or, type SQL } from 'drizzle-orm'
|
||||
import { alias } from 'drizzle-orm/pg-core'
|
||||
import type { Attachment, CorrectiveAction, Incident, IncidentDetail, Investigation, RootCause } from '../../shared/api-types.ts'
|
||||
import { hasRole } from '../../shared/enums.ts'
|
||||
import { incidentReference } from '../../shared/format.ts'
|
||||
import { availableTransitions } from '../../shared/workflow.ts'
|
||||
import type { SessionUser } from '../auth/sessions.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { areas, attachments, correctiveActions, incidents, injuredPersons, investigations, kiosks, rootCauses, sites, users } from '../db/schema.ts'
|
||||
import { audit } from './audit.ts'
|
||||
import { iso, localDate } from './dates.ts'
|
||||
import { fail } from './http.ts'
|
||||
|
||||
const reporters = alias(users, 'reporter')
|
||||
|
||||
export function selectIncidents(db: Db) {
|
||||
return db
|
||||
.select({ incident: incidents, siteName: sites.name, areaName: areas.name, reporterName: reporters.name, kioskName: kiosks.name })
|
||||
.from(incidents)
|
||||
.innerJoin(sites, eq(sites.id, incidents.siteId))
|
||||
.leftJoin(areas, eq(areas.id, incidents.areaId))
|
||||
.leftJoin(reporters, eq(reporters.id, incidents.reporterId))
|
||||
.leftJoin(kiosks, eq(kiosks.id, incidents.kioskId))
|
||||
}
|
||||
|
||||
type IncidentRow = Awaited<ReturnType<ReturnType<typeof selectIncidents>['execute']>>[number]
|
||||
|
||||
export function toIncident({ incident, siteName, areaName, reporterName, kioskName }: IncidentRow): Incident {
|
||||
return {
|
||||
id: incident.id,
|
||||
number: incident.number,
|
||||
reference: incidentReference(incident.number),
|
||||
title: incident.title,
|
||||
type: incident.type,
|
||||
severity: incident.severity,
|
||||
status: incident.status,
|
||||
occurredAt: iso(incident.occurredAt),
|
||||
reportedAt: iso(incident.createdAt),
|
||||
updatedAt: iso(incident.updatedAt),
|
||||
closedAt: iso(incident.closedAt),
|
||||
site: { id: incident.siteId, name: siteName },
|
||||
area: incident.areaId ? { id: incident.areaId, name: areaName ?? '' } : null,
|
||||
locationDetail: incident.locationDetail,
|
||||
description: incident.description,
|
||||
immediateAction: incident.immediateAction,
|
||||
anonymous: incident.anonymous,
|
||||
reporter: incident.reporterId ? { id: incident.reporterId, name: reporterName ?? '' } : null,
|
||||
reporterName: incident.reporterName,
|
||||
channel: incident.channel,
|
||||
kiosk: incident.kioskId ? { id: incident.kioskId, name: kioskName ?? '' } : null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supervisors and above see every incident in their organization. Reporters see incidents they
|
||||
* reported, plus any where they are assigned a corrective action.
|
||||
*/
|
||||
export function visibleTo(db: Db, user: SessionUser): SQL | undefined {
|
||||
const inOrg = eq(incidents.organizationId, user.organizationId)
|
||||
if (hasRole(user.role, 'supervisor')) return inOrg
|
||||
const assigned = db
|
||||
.select({ id: correctiveActions.id })
|
||||
.from(correctiveActions)
|
||||
.where(and(eq(correctiveActions.incidentId, incidents.id), eq(correctiveActions.assigneeId, user.id)))
|
||||
return and(inOrg, or(eq(incidents.reporterId, user.id), exists(assigned)))
|
||||
}
|
||||
|
||||
export async function findIncident(db: Db, user: SessionUser, number: number) {
|
||||
const [row] = await selectIncidents(db).where(and(visibleTo(db, user), eq(incidents.number, number)))
|
||||
if (!row) throw fail(404, 'Incident not found')
|
||||
return row
|
||||
}
|
||||
|
||||
const assignees = alias(users, 'assignee')
|
||||
const verifiers = alias(users, 'verifier')
|
||||
|
||||
export function selectActions(db: Db) {
|
||||
return db
|
||||
.select({
|
||||
action: correctiveActions,
|
||||
incidentNumber: incidents.number,
|
||||
incidentTitle: incidents.title,
|
||||
assigneeName: assignees.name,
|
||||
verifierName: verifiers.name,
|
||||
})
|
||||
.from(correctiveActions)
|
||||
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||
.leftJoin(assignees, eq(assignees.id, correctiveActions.assigneeId))
|
||||
.leftJoin(verifiers, eq(verifiers.id, correctiveActions.verifiedById))
|
||||
}
|
||||
|
||||
type ActionRow = Awaited<ReturnType<ReturnType<typeof selectActions>['execute']>>[number]
|
||||
|
||||
export const isOpenAction = (status: string) => status === 'open' || status === 'in_progress'
|
||||
|
||||
export function toAction({ action, incidentNumber, incidentTitle, assigneeName, verifierName }: ActionRow, today = localDate()): CorrectiveAction {
|
||||
return {
|
||||
id: action.id,
|
||||
incident: { number: incidentNumber, reference: incidentReference(incidentNumber), title: incidentTitle },
|
||||
rootCauseId: action.rootCauseId,
|
||||
kind: action.kind,
|
||||
title: action.title,
|
||||
description: action.description,
|
||||
assignee: action.assigneeId ? { id: action.assigneeId, name: assigneeName ?? '' } : null,
|
||||
dueDate: action.dueDate,
|
||||
status: action.status,
|
||||
overdue: isOpenAction(action.status) && !!action.dueDate && action.dueDate < today,
|
||||
completedAt: iso(action.completedAt),
|
||||
verifiedBy: action.verifiedById ? { id: action.verifiedById, name: verifierName ?? '' } : null,
|
||||
verifiedAt: iso(action.verifiedAt),
|
||||
createdAt: iso(action.createdAt),
|
||||
}
|
||||
}
|
||||
|
||||
export function toAttachment(row: typeof attachments.$inferSelect, uploaderName: string | null): Attachment {
|
||||
return {
|
||||
id: row.id,
|
||||
fileName: row.fileName,
|
||||
contentType: row.contentType,
|
||||
sizeBytes: row.sizeBytes,
|
||||
uploadedBy: row.uploadedById ? { id: row.uploadedById, name: uploaderName ?? '' } : null,
|
||||
createdAt: iso(row.createdAt),
|
||||
url: `/api/attachments/${row.id}`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDetail(db: Db, user: SessionUser, number: number): Promise<IncidentDetail> {
|
||||
const row = await findIncident(db, user, number)
|
||||
const incidentId = row.incident.id
|
||||
const leads = alias(users, 'lead')
|
||||
|
||||
const [investigationRow] = await db
|
||||
.select({ investigation: investigations, leadName: leads.name })
|
||||
.from(investigations)
|
||||
.leftJoin(leads, eq(leads.id, investigations.leadId))
|
||||
.where(eq(investigations.incidentId, incidentId))
|
||||
|
||||
const causes = investigationRow
|
||||
? await db.select().from(rootCauses).where(eq(rootCauses.investigationId, investigationRow.investigation.id)).orderBy(asc(rootCauses.createdAt))
|
||||
: []
|
||||
const actionRows = await selectActions(db).where(eq(correctiveActions.incidentId, incidentId)).orderBy(asc(correctiveActions.createdAt))
|
||||
const uploaders = alias(users, 'uploader')
|
||||
const files = await db
|
||||
.select({ file: attachments, uploaderName: uploaders.name })
|
||||
.from(attachments)
|
||||
.leftJoin(uploaders, eq(uploaders.id, attachments.uploadedById))
|
||||
.where(eq(attachments.incidentId, incidentId))
|
||||
.orderBy(asc(attachments.createdAt))
|
||||
|
||||
const investigation: Investigation | null = investigationRow
|
||||
? {
|
||||
id: investigationRow.investigation.id,
|
||||
lead: investigationRow.investigation.leadId ? { id: investigationRow.investigation.leadId, name: investigationRow.leadName ?? '' } : null,
|
||||
method: investigationRow.investigation.method,
|
||||
summary: investigationRow.investigation.summary,
|
||||
startedAt: iso(investigationRow.investigation.startedAt),
|
||||
completedAt: iso(investigationRow.investigation.completedAt),
|
||||
}
|
||||
: null
|
||||
const actions = actionRows.map((action) => toAction(action))
|
||||
const manage = hasRole(user.role, 'supervisor')
|
||||
const [{ injured }] = await db.select({ injured: count() }).from(injuredPersons).where(eq(injuredPersons.incidentId, incidentId))
|
||||
|
||||
return {
|
||||
...toIncident(row),
|
||||
investigation,
|
||||
rootCauses: causes.map((cause): RootCause => ({ id: cause.id, category: cause.category, description: cause.description, whys: cause.whys })),
|
||||
actions,
|
||||
attachments: files.map(({ file, uploaderName }) => toAttachment(file, uploaderName)),
|
||||
transitions: availableTransitions(row.incident.status, user.role, {
|
||||
severity: row.incident.severity,
|
||||
rootCauseCount: causes.length,
|
||||
actionStatuses: actions.map((action) => action.status),
|
||||
}),
|
||||
injuredCount: injured,
|
||||
can: { edit: manage, manageInvestigation: manage, manageActions: manage, manageOsha: hasRole(user.role, 'safety_manager') },
|
||||
}
|
||||
}
|
||||
|
||||
/** Records an event on an incident's timeline. */
|
||||
export function auditIncident(db: Db, user: SessionUser | null, organizationId: string, incidentId: string, action: string, changes?: Record<string, unknown>) {
|
||||
return audit(db, { organizationId, actorId: user?.id ?? null, entityType: 'incident', entityId: incidentId, action, changes })
|
||||
}
|
||||
|
||||
/** Throws 400 unless the user is active in the organization. */
|
||||
export async function assertActiveUser(db: Db, organizationId: string, userId: string) {
|
||||
const [user] = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.id, userId), eq(users.organizationId, organizationId), eq(users.active, true)))
|
||||
if (!user) throw fail(400, 'Unknown or inactive user')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { and, asc, eq, gte, isNull, lt } from 'drizzle-orm'
|
||||
import type { Establishment, OshaCase, OshaLogRow, OshaYear } from '../../shared/api-types.ts'
|
||||
import { oshaCaseTypes } from '../../shared/enums.ts'
|
||||
import { incidentReference } from '../../shared/format.ts'
|
||||
import { capDays, incidenceRate, severeInjuryReport, summaryChecks, type SummaryTotals } from '../../shared/osha.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { areas, hoursWorked, incidents, injuredPersons, organizations, sites } from '../db/schema.ts'
|
||||
import { iso } from './dates.ts'
|
||||
import { fail } from './http.ts'
|
||||
|
||||
type Person = typeof injuredPersons.$inferSelect
|
||||
type IncidentInfo = { id: string; number: number; title: string; occurredAt: Date; createdAt: Date }
|
||||
|
||||
/** Jan 1 to Jan 1 in the server's time zone (set TZ to the plant's zone). */
|
||||
export function yearRange(year: number): [Date, Date] {
|
||||
return [new Date(year, 0, 1), new Date(year + 1, 0, 1)]
|
||||
}
|
||||
|
||||
/** Fields the ITA case upload and Form 301 require that are still empty. */
|
||||
export function missingFields(person: Person): string[] {
|
||||
if (!person.recordable) return []
|
||||
const required: [keyof Person, string][] = [
|
||||
['jobTitle', 'job title'],
|
||||
['dateOfBirth', 'date of birth'],
|
||||
['dateOfHire', 'date hired'],
|
||||
['activityBefore', 'what they were doing'],
|
||||
['whatHappened', 'what happened'],
|
||||
['natureOfInjury', 'the injury or illness'],
|
||||
['objectSubstance', 'object or substance'],
|
||||
]
|
||||
const missing = required.filter(([key]) => !person[key]).map(([, label]) => label)
|
||||
if (person.died && !person.dateOfDeath) missing.push('date of death')
|
||||
return missing
|
||||
}
|
||||
|
||||
export function toOshaCase(person: Person, incident: IncidentInfo): OshaCase {
|
||||
const severe = severeInjuryReport(person)
|
||||
return {
|
||||
id: person.id,
|
||||
incident: { id: incident.id, number: incident.number, reference: incidentReference(incident.number), title: incident.title, occurredAt: iso(incident.occurredAt) },
|
||||
caseNumber: person.caseNumber ?? incidentReference(incident.number),
|
||||
name: person.name,
|
||||
jobTitle: person.jobTitle,
|
||||
employmentType: person.employmentType,
|
||||
bodyPart: person.bodyPart,
|
||||
natureOfInjury: person.natureOfInjury,
|
||||
caseType: person.caseType,
|
||||
workRelated: person.workRelated,
|
||||
newCase: person.newCase,
|
||||
died: person.died,
|
||||
dateOfDeath: person.dateOfDeath,
|
||||
daysAwayFromWork: person.daysAwayFromWork,
|
||||
daysAway: person.daysAway,
|
||||
restrictedOrTransfer: person.restrictedOrTransfer,
|
||||
daysRestricted: person.daysRestricted,
|
||||
medicalTreatment: person.medicalTreatment,
|
||||
lossOfConsciousness: person.lossOfConsciousness,
|
||||
significantDiagnosis: person.significantDiagnosis,
|
||||
specialCase: person.specialCase,
|
||||
privacyCase: person.privacyCase,
|
||||
recordable: person.recordable,
|
||||
outcome: person.outcome,
|
||||
inpatientHospitalization: person.inpatientHospitalization,
|
||||
amputation: person.amputation,
|
||||
lossOfEye: person.lossOfEye,
|
||||
reportedToOshaAt: iso(person.reportedToOshaAt),
|
||||
// The clock starts when the employer learns of it; the report date is the best record of that.
|
||||
severeReport: severe && { ...severe, dueBy: new Date(incident.createdAt.getTime() + severe.withinHours * 3_600_000).toISOString() },
|
||||
employeeAddress: person.employeeAddress,
|
||||
dateOfBirth: person.dateOfBirth,
|
||||
dateOfHire: person.dateOfHire,
|
||||
sex: person.sex === 'M' || person.sex === 'F' ? person.sex : null,
|
||||
physicianName: person.physicianName,
|
||||
treatmentFacility: person.treatmentFacility,
|
||||
treatedInEmergencyRoom: person.treatedInEmergencyRoom,
|
||||
timeStartedWork: person.timeStartedWork,
|
||||
activityBefore: person.activityBefore,
|
||||
whatHappened: person.whatHappened,
|
||||
objectSubstance: person.objectSubstance,
|
||||
missingFields: missingFields(person),
|
||||
updatedAt: iso(person.updatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
/** Form 300 column F: injury, body part, and what caused it. */
|
||||
export function describeCase(person: Pick<Person, 'natureOfInjury' | 'bodyPart' | 'objectSubstance'>): string {
|
||||
const injury = [person.natureOfInjury, person.bodyPart && `(${person.bodyPart})`].filter(Boolean).join(' ')
|
||||
return [injury, person.objectSubstance].filter(Boolean).join('; ')
|
||||
}
|
||||
|
||||
export const establishmentOf = (site: typeof sites.$inferSelect): Establishment => ({
|
||||
streetAddress: site.streetAddress,
|
||||
city: site.city,
|
||||
state: site.state,
|
||||
zip: site.zip,
|
||||
naicsCode: site.naicsCode,
|
||||
industryDescription: site.industryDescription,
|
||||
ein: site.ein,
|
||||
establishmentType: site.establishmentType,
|
||||
})
|
||||
|
||||
export function missingEstablishmentFields(site: typeof sites.$inferSelect): string[] {
|
||||
const required: [keyof typeof site, string][] = [
|
||||
['streetAddress', 'street address'],
|
||||
['city', 'city'],
|
||||
['state', 'state'],
|
||||
['zip', 'ZIP code'],
|
||||
['naicsCode', 'NAICS code'],
|
||||
['ein', 'EIN'],
|
||||
]
|
||||
return required.filter(([key]) => !site[key]).map(([, label]) => label)
|
||||
}
|
||||
|
||||
/** Everything for one establishment's year: the 300 Log, 300A totals, hours, rates and ITA checks. */
|
||||
export async function buildOshaYear(db: Db, organizationId: string, siteId: string, year: number): Promise<OshaYear> {
|
||||
const [site] = await db.select().from(sites).where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId)))
|
||||
if (!site) throw fail(404, 'Site not found')
|
||||
const [organization] = await db.select({ name: organizations.name }).from(organizations).where(eq(organizations.id, organizationId))
|
||||
const [from, to] = yearRange(year)
|
||||
|
||||
const caseRows = await db
|
||||
.select({ person: injuredPersons, incident: incidents, areaName: areas.name })
|
||||
.from(injuredPersons)
|
||||
.innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId))
|
||||
.leftJoin(areas, eq(areas.id, incidents.areaId))
|
||||
.where(and(eq(incidents.siteId, siteId), eq(injuredPersons.recordable, true), gte(incidents.occurredAt, from), lt(incidents.occurredAt, to)))
|
||||
.orderBy(asc(incidents.occurredAt), asc(injuredPersons.caseNumber))
|
||||
|
||||
const rows: OshaLogRow[] = caseRows.map(({ person, incident, areaName }) => {
|
||||
const days = capDays(person.daysAwayFromWork ? person.daysAway : 0, person.restrictedOrTransfer ? person.daysRestricted : 0)
|
||||
return {
|
||||
caseId: person.id,
|
||||
caseNumber: person.caseNumber ?? incidentReference(incident.number),
|
||||
incidentNumber: incident.number,
|
||||
employeeName: person.privacyCase ? 'Privacy case' : person.name,
|
||||
jobTitle: person.jobTitle ?? '',
|
||||
dateOfInjury: iso(incident.occurredAt),
|
||||
location: [areaName, incident.locationDetail].filter(Boolean).join(', ') || site.name,
|
||||
description: describeCase(person),
|
||||
outcome: person.outcome ?? 'other',
|
||||
daysAway: days.daysAway,
|
||||
daysRestricted: days.daysRestricted,
|
||||
caseType: person.caseType,
|
||||
missingFields: missingFields(person),
|
||||
}
|
||||
})
|
||||
|
||||
const totals: SummaryTotals = {
|
||||
deaths: rows.filter((r) => r.outcome === 'death').length,
|
||||
daysAwayCases: rows.filter((r) => r.outcome === 'days_away').length,
|
||||
restrictedCases: rows.filter((r) => r.outcome === 'restricted').length,
|
||||
otherCases: rows.filter((r) => r.outcome === 'other').length,
|
||||
daysAway: rows.reduce((sum, r) => sum + r.daysAway, 0),
|
||||
daysRestricted: rows.reduce((sum, r) => sum + r.daysRestricted, 0),
|
||||
byType: Object.fromEntries(oshaCaseTypes.map((type) => [type, rows.filter((r) => r.caseType === type).length])) as SummaryTotals['byType'],
|
||||
}
|
||||
|
||||
const monthRows = await db
|
||||
.select()
|
||||
.from(hoursWorked)
|
||||
.where(and(eq(hoursWorked.siteId, siteId), gte(hoursWorked.month, `${year}-01-01`), lt(hoursWorked.month, `${year + 1}-01-01`)))
|
||||
const months = Array.from({ length: 12 }, (_, i) => {
|
||||
const row = monthRows.find((m) => Number(m.month.slice(5, 7)) === i + 1)
|
||||
return { month: i + 1, hours: row ? Number(row.hours) : null, averageEmployees: row?.averageEmployees ?? null }
|
||||
})
|
||||
const totalHoursWorked = Math.round(months.reduce((sum, m) => sum + (m.hours ?? 0), 0))
|
||||
const headcounts = months.map((m) => m.averageEmployees).filter((n): n is number => n !== null)
|
||||
// 1904.32 instructions: total of employees each pay period ÷ number of pay periods; here, months.
|
||||
const annualAverageEmployees = headcounts.length ? Math.round(headcounts.reduce((a, b) => a + b, 0) / headcounts.length) : 0
|
||||
const maxEmployees = headcounts.length ? Math.max(...headcounts) : 0
|
||||
const recordable = rows.length
|
||||
|
||||
return {
|
||||
site: { id: site.id, name: site.name, ...establishmentOf(site) },
|
||||
companyName: organization.name,
|
||||
year,
|
||||
rows,
|
||||
totals,
|
||||
months,
|
||||
totalHoursWorked,
|
||||
annualAverageEmployees,
|
||||
maxEmployees,
|
||||
rates: {
|
||||
trir: incidenceRate(recordable, totalHoursWorked),
|
||||
dart: incidenceRate(totals.daysAwayCases + totals.restrictedCases, totalHoursWorked),
|
||||
ltir: incidenceRate(totals.daysAwayCases, totalHoursWorked),
|
||||
},
|
||||
checks: summaryChecks(totals, annualAverageEmployees, totalHoursWorked),
|
||||
missingEstablishmentFields: missingEstablishmentFields(site),
|
||||
}
|
||||
}
|
||||
|
||||
/** Active sites for an organization (establishments that may need a log). */
|
||||
export function activeSites(db: Db, organizationId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(and(eq(sites.organizationId, organizationId), isNull(sites.archivedAt)))
|
||||
.orderBy(asc(sites.name))
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm'
|
||||
import type { IncidentType, Severity } from '../../shared/enums.ts'
|
||||
import type { SessionUser } from '../auth/sessions.ts'
|
||||
import { isUniqueViolation, type Db } from '../db/client.ts'
|
||||
import { areas, incidentCounters, incidents, sites } from '../db/schema.ts'
|
||||
import type { Notifier } from '../notifications.ts'
|
||||
import { fail } from './http.ts'
|
||||
import { auditIncident } from './incidents.ts'
|
||||
|
||||
/** Reports may be dated slightly ahead to absorb clock differences between devices. */
|
||||
const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000
|
||||
|
||||
export function parseOccurredAt(value: string): Date {
|
||||
const date = new Date(value)
|
||||
if (date.getTime() > Date.now() + MAX_CLOCK_SKEW_MS) throw fail(400, 'Occurrence time cannot be in the future')
|
||||
return date
|
||||
}
|
||||
|
||||
export async function assertLocation(db: Db, organizationId: string, siteId: string, areaId: string | null | undefined) {
|
||||
const [site] = await db
|
||||
.select({ id: sites.id })
|
||||
.from(sites)
|
||||
.where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId), isNull(sites.archivedAt)))
|
||||
if (!site) throw fail(400, 'Unknown site')
|
||||
if (areaId) {
|
||||
const [area] = await db
|
||||
.select({ id: areas.id })
|
||||
.from(areas)
|
||||
.where(and(eq(areas.id, areaId), eq(areas.siteId, siteId)))
|
||||
if (!area) throw fail(400, 'Area must belong to the selected site')
|
||||
}
|
||||
}
|
||||
|
||||
export interface NewReport {
|
||||
organizationId: string
|
||||
title: string
|
||||
type: IncidentType
|
||||
severity: Severity
|
||||
occurredAt: string
|
||||
siteId: string
|
||||
areaId?: string | null
|
||||
locationDetail?: string | null
|
||||
description?: string | null
|
||||
immediateAction?: string | null
|
||||
clientId?: string
|
||||
/** Signed-in reporter, or null for anonymous and kiosk reports. */
|
||||
reporter: SessionUser | null
|
||||
reporterName?: string | null
|
||||
anonymous: boolean
|
||||
kioskId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Files a report with the next incident number. If `clientId` was already used in the organization
|
||||
* (a retried submission), returns the existing incident instead of creating a duplicate.
|
||||
*/
|
||||
export async function fileReport(db: Db, notifier: Notifier, report: NewReport): Promise<{ id: string; number: number; created: boolean }> {
|
||||
const { organizationId, reporter, anonymous, kioskId, occurredAt, clientId, ...fields } = report
|
||||
await assertLocation(db, organizationId, fields.siteId, fields.areaId)
|
||||
const occurred = parseOccurredAt(occurredAt)
|
||||
|
||||
const existing = async () => {
|
||||
if (!clientId) return undefined
|
||||
const [row] = await db
|
||||
.select({ id: incidents.id, number: incidents.number })
|
||||
.from(incidents)
|
||||
.where(and(eq(incidents.organizationId, organizationId), eq(incidents.clientId, clientId)))
|
||||
return row && { ...row, created: false }
|
||||
}
|
||||
const previous = await existing()
|
||||
if (previous) return previous
|
||||
|
||||
try {
|
||||
const created = await db.transaction(async (tx) => {
|
||||
// Atomic increment: concurrent reports always get distinct, gap-free numbers.
|
||||
const [{ lastNumber }] = await tx
|
||||
.insert(incidentCounters)
|
||||
.values({ organizationId, lastNumber: 1 })
|
||||
.onConflictDoUpdate({ target: incidentCounters.organizationId, set: { lastNumber: sql`${incidentCounters.lastNumber} + 1` } })
|
||||
.returning({ lastNumber: incidentCounters.lastNumber })
|
||||
const [incident] = await tx
|
||||
.insert(incidents)
|
||||
.values({
|
||||
...fields,
|
||||
occurredAt: occurred,
|
||||
organizationId,
|
||||
number: lastNumber,
|
||||
clientId,
|
||||
anonymous,
|
||||
reporterId: anonymous ? null : (reporter?.id ?? null),
|
||||
reporterName: anonymous ? null : (fields.reporterName ?? null),
|
||||
channel: kioskId ? 'kiosk' : 'web',
|
||||
kioskId,
|
||||
})
|
||||
.returning({ id: incidents.id, number: incidents.number })
|
||||
await auditIncident(tx, anonymous ? null : reporter, organizationId, incident.id, 'create', {
|
||||
number: lastNumber,
|
||||
title: fields.title,
|
||||
type: fields.type,
|
||||
severity: fields.severity,
|
||||
...(kioskId ? { kioskId } : {}),
|
||||
})
|
||||
return incident
|
||||
})
|
||||
notifier.incidentReported(created.id)
|
||||
return { ...created, created: true }
|
||||
} catch (error) {
|
||||
// Two copies of the same submission raced; the other one won.
|
||||
if (clientId && isUniqueViolation(error)) {
|
||||
const winner = await existing()
|
||||
if (winner) return winner
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
import type { Config } from './config.ts'
|
||||
|
||||
export interface Mail {
|
||||
to: string
|
||||
subject: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface Mailer {
|
||||
send(mail: Mail): Promise<void>
|
||||
}
|
||||
|
||||
export function createMailer(config: Config): Mailer {
|
||||
if (config.smtpUrl) {
|
||||
const transport = nodemailer.createTransport(config.smtpUrl)
|
||||
return { send: async (mail) => void (await transport.sendMail({ from: config.mailFrom, ...mail })) }
|
||||
}
|
||||
if (config.production) {
|
||||
console.warn('[mail] SMTP_URL is not set: emails (sign-in links, password resets, notifications) will not be sent.')
|
||||
return { send: async (mail) => console.warn(`[mail] Not sent (no SMTP): "${mail.subject}" to ${mail.to}`) }
|
||||
}
|
||||
// Development: print the email so links can be clicked from the terminal.
|
||||
return { send: async (mail) => console.log(`\n[mail] To: ${mail.to}\n[mail] Subject: ${mail.subject}\n\n${mail.text}\n`) }
|
||||
}
|
||||
|
||||
/** Collects emails instead of sending them (tests). */
|
||||
export class MemoryMailer implements Mailer {
|
||||
sent: Mail[] = []
|
||||
|
||||
async send(mail: Mail) {
|
||||
this.sent.push(mail)
|
||||
}
|
||||
|
||||
/** Most recent email to `to`. */
|
||||
last(to: string): Mail | undefined {
|
||||
return this.sent.findLast((mail) => mail.to === to)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { and, eq, inArray, isNotNull, isNull, lt, lte, ne, or } from 'drizzle-orm'
|
||||
import { incidentReference } from '../shared/format.ts'
|
||||
import type { Config } from './config.ts'
|
||||
import type { Db } from './db/client.ts'
|
||||
import { OSHA_REPORTING_CONTACT, severeInjuryReport } from '../shared/osha.ts'
|
||||
import { correctiveActions, incidents, injuredPersons, users } from './db/schema.ts'
|
||||
import { addDays, localDate } from './lib/dates.ts'
|
||||
import type { Mailer } from './mail.ts'
|
||||
|
||||
const severityLabel = { low: 'Low', medium: 'Medium', high: 'High', critical: 'Critical' } as const
|
||||
|
||||
/** Sends emails in the background so slow mail servers never delay or fail a request. */
|
||||
export class Notifier {
|
||||
private readonly pending = new Set<Promise<void>>()
|
||||
private readonly db: Db
|
||||
private readonly mailer: Mailer
|
||||
private readonly config: Config
|
||||
|
||||
constructor(db: Db, mailer: Mailer, config: Config) {
|
||||
this.db = db
|
||||
this.mailer = mailer
|
||||
this.config = config
|
||||
}
|
||||
|
||||
/** Waits for queued emails (tests, shutdown). */
|
||||
async flush(): Promise<void> {
|
||||
await Promise.all([...this.pending])
|
||||
}
|
||||
|
||||
private dispatch(task: () => Promise<void>) {
|
||||
const run = task()
|
||||
.catch((error) => console.error('[notify] failed:', error))
|
||||
.finally(() => this.pending.delete(run))
|
||||
this.pending.add(run)
|
||||
}
|
||||
|
||||
private link(path: string) {
|
||||
return new URL(path, this.config.publicUrl).toString()
|
||||
}
|
||||
|
||||
magicLink(email: string, token: string) {
|
||||
this.dispatch(() =>
|
||||
this.mailer.send({
|
||||
to: email,
|
||||
subject: 'Your sign-in link',
|
||||
text: `Use this link to sign in to Open SafetyLog EHS. It works once and expires in 15 minutes.\n\n${this.link(`/auth/magic?token=${token}`)}\n\nIf you didn't ask for it, you can ignore this email.`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
passwordReset(email: string, token: string, invite: { organization: string; invitedBy: string } | null) {
|
||||
const link = this.link(`/auth/reset?token=${token}`)
|
||||
this.dispatch(() =>
|
||||
this.mailer.send(
|
||||
invite
|
||||
? {
|
||||
to: email,
|
||||
subject: `You've been invited to ${invite.organization} on Open SafetyLog EHS`,
|
||||
text: `${invite.invitedBy} added you to ${invite.organization}'s safety workspace.\n\nSet your password to get started (link valid for 7 days):\n\n${link}`,
|
||||
}
|
||||
: {
|
||||
to: email,
|
||||
subject: 'Reset your password',
|
||||
text: `Use this link to choose a new password. It works once and expires in 1 hour.\n\n${link}\n\nIf you didn't ask for it, you can ignore this email; your password hasn't changed.`,
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Alerts safety managers and admins about high and critical reports. */
|
||||
incidentReported(incidentId: string) {
|
||||
this.dispatch(async () => {
|
||||
const [incident] = await this.db.select().from(incidents).where(eq(incidents.id, incidentId))
|
||||
if (!incident || (incident.severity !== 'high' && incident.severity !== 'critical')) return
|
||||
const recipients = await this.db
|
||||
.select({ email: users.email })
|
||||
.from(users)
|
||||
.where(
|
||||
and(
|
||||
eq(users.organizationId, incident.organizationId),
|
||||
eq(users.active, true),
|
||||
inArray(users.role, ['safety_manager', 'admin']),
|
||||
incident.reporterId ? ne(users.id, incident.reporterId) : undefined,
|
||||
),
|
||||
)
|
||||
const reference = incidentReference(incident.number)
|
||||
for (const { email } of recipients) {
|
||||
await this.mailer.send({
|
||||
to: email,
|
||||
subject: `[${severityLabel[incident.severity]}] ${reference}: ${incident.title}`,
|
||||
text: `A ${severityLabel[incident.severity].toLowerCase()}-severity incident was reported.\n\n${reference}: ${incident.title}\nOccurred: ${incident.occurredAt.toLocaleString('en-US')}\n\n${this.link(`/incidents/${incident.number}`)}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 1904.39: fatalities must be reported to OSHA within 8 hours; hospitalizations, amputations and eye losses within 24. */
|
||||
severeInjury(caseId: string) {
|
||||
this.dispatch(async () => {
|
||||
const [row] = await this.db
|
||||
.select({ person: injuredPersons, incident: incidents })
|
||||
.from(injuredPersons)
|
||||
.innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId))
|
||||
.where(eq(injuredPersons.id, caseId))
|
||||
const report = row && severeInjuryReport(row.person)
|
||||
if (!report) return
|
||||
const recipients = await this.db
|
||||
.select({ email: users.email })
|
||||
.from(users)
|
||||
.where(and(eq(users.organizationId, row.incident.organizationId), eq(users.active, true), inArray(users.role, ['safety_manager', 'admin'])))
|
||||
const reference = incidentReference(row.incident.number)
|
||||
for (const { email } of recipients) {
|
||||
await this.mailer.send({
|
||||
to: email,
|
||||
subject: `Report to OSHA within ${report.withinHours} hours: ${reference}`,
|
||||
text: `${reference} (${row.incident.title}) was recorded with: ${report.reasons.join(', ')}. OSHA requires a direct report within ${report.withinHours} hours of learning about it (29 CFR 1904.39).\n\n${OSHA_REPORTING_CONTACT}\n\nAfter reporting, record the date and time on the case: ${this.link(`/incidents/${row.incident.number}`)}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
actionAssigned(actionId: string, assignedBy: string) {
|
||||
this.dispatch(async () => {
|
||||
const [row] = await this.db
|
||||
.select({ action: correctiveActions, email: users.email, number: incidents.number, incidentTitle: incidents.title })
|
||||
.from(correctiveActions)
|
||||
.innerJoin(users, eq(users.id, correctiveActions.assigneeId))
|
||||
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||
.where(and(eq(correctiveActions.id, actionId), eq(users.active, true)))
|
||||
if (!row) return
|
||||
const due = row.action.dueDate ? `Due: ${row.action.dueDate}\n` : ''
|
||||
await this.mailer.send({
|
||||
to: row.email,
|
||||
subject: `Action assigned: ${row.action.title}`,
|
||||
text: `${assignedBy} assigned you a corrective action on ${incidentReference(row.number)} (${row.incidentTitle}).\n\n${row.action.title}\n${due}\n${this.link(`/incidents/${row.number}`)}`,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Emails each assignee a digest of open actions due tomorrow or earlier, at most once a day per action.
|
||||
* Returns how many emails were queued.
|
||||
*/
|
||||
async sendDueReminders(today = localDate()): Promise<number> {
|
||||
const rows = await this.db
|
||||
.select({ action: correctiveActions, email: users.email, number: incidents.number })
|
||||
.from(correctiveActions)
|
||||
.innerJoin(users, eq(users.id, correctiveActions.assigneeId))
|
||||
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||
.where(
|
||||
and(
|
||||
inArray(correctiveActions.status, ['open', 'in_progress']),
|
||||
eq(users.active, true),
|
||||
isNotNull(correctiveActions.dueDate),
|
||||
lte(correctiveActions.dueDate, addDays(today, 1)),
|
||||
or(isNull(correctiveActions.reminderSentOn), lt(correctiveActions.reminderSentOn, today)),
|
||||
),
|
||||
)
|
||||
const byEmail = Map.groupBy(rows, (row) => row.email)
|
||||
for (const [email, items] of byEmail) {
|
||||
const lines = items.map(({ action, number }) => {
|
||||
const when = action.dueDate! < today ? `OVERDUE since ${action.dueDate}` : action.dueDate === today ? 'due today' : 'due tomorrow'
|
||||
return `- ${action.title} (${incidentReference(number)}), ${when}\n ${this.link(`/incidents/${number}`)}`
|
||||
})
|
||||
const overdue = items.filter(({ action }) => action.dueDate! < today).length
|
||||
this.dispatch(() =>
|
||||
this.mailer.send({
|
||||
to: email,
|
||||
subject: overdue ? `${overdue} overdue corrective action${overdue === 1 ? '' : 's'}` : 'Corrective actions due soon',
|
||||
text: `These corrective actions assigned to you need attention:\n\n${lines.join('\n')}\n\nAll your actions: ${this.link('/actions')}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (rows.length) {
|
||||
await this.db
|
||||
.update(correctiveActions)
|
||||
.set({ reminderSentOn: today })
|
||||
.where(inArray(correctiveActions.id, rows.map(({ action }) => action.id)))
|
||||
}
|
||||
return byEmail.size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { and, asc, eq, inArray, lt, sql } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import { hasRole } from '../../shared/enums.ts'
|
||||
import { actionListQuery, updateActionSchema } from '../../shared/schemas.ts'
|
||||
import { requireAuth } from '../auth/middleware.ts'
|
||||
import { correctiveActions, incidents, investigations, rootCauses } from '../db/schema.ts'
|
||||
import { diff } from '../lib/audit.ts'
|
||||
import { localDate } from '../lib/dates.ts'
|
||||
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||
import { assertActiveUser, auditIncident, selectActions, toAction } from '../lib/incidents.ts'
|
||||
|
||||
const idParam = z.object({ id: z.uuid() })
|
||||
const OPEN = ['open', 'in_progress'] as const
|
||||
|
||||
export const actionRoutes = new Hono<AppEnv>()
|
||||
.get('/', requireAuth, validate('query', actionListQuery), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const { scope, filter } = c.req.valid('query')
|
||||
if (scope === 'all' && !hasRole(me.role, 'supervisor')) throw fail(403, 'You do not have permission to do that')
|
||||
const today = localDate()
|
||||
const rows = await selectActions(c.get('db'))
|
||||
.where(
|
||||
and(
|
||||
eq(incidents.organizationId, me.organizationId),
|
||||
scope === 'mine' ? eq(correctiveActions.assigneeId, me.id) : undefined,
|
||||
filter === 'open' ? inArray(correctiveActions.status, OPEN) : undefined,
|
||||
filter === 'overdue' ? and(inArray(correctiveActions.status, OPEN), lt(correctiveActions.dueDate, today)) : undefined,
|
||||
filter === 'done' ? inArray(correctiveActions.status, ['done', 'verified']) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(sql`${correctiveActions.dueDate} asc nulls last`, asc(correctiveActions.createdAt))
|
||||
.limit(500)
|
||||
return c.json({ actions: rows.map((row) => toAction(row, today)) })
|
||||
})
|
||||
|
||||
.patch('/:id', requireAuth, validate('param', idParam), validate('json', updateActionSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const [row] = await selectActions(db).where(and(eq(correctiveActions.id, c.req.valid('param').id), eq(incidents.organizationId, me.organizationId)))
|
||||
if (!row) throw fail(404, 'Action not found')
|
||||
const { action } = row
|
||||
const input = c.req.valid('json')
|
||||
|
||||
const isManager = hasRole(me.role, 'supervisor')
|
||||
const isAssignee = action.assigneeId === me.id
|
||||
if (!isManager) {
|
||||
// Assignees can report progress on their own actions, nothing else.
|
||||
const onlyStatus = Object.keys(input).every((key) => key === 'status')
|
||||
if (!isAssignee || !onlyStatus || (input.status && !['open', 'in_progress', 'done'].includes(input.status))) {
|
||||
throw fail(403, 'You can only update the progress of actions assigned to you')
|
||||
}
|
||||
}
|
||||
if (input.status === 'verified' && !hasRole(me.role, 'safety_manager')) throw fail(403, 'Only safety managers can verify actions')
|
||||
if (action.status === 'verified' && input.status && input.status !== 'verified' && !hasRole(me.role, 'safety_manager')) {
|
||||
throw fail(403, 'Only safety managers can reopen a verified action')
|
||||
}
|
||||
if (input.assigneeId) await assertActiveUser(db, me.organizationId, input.assigneeId)
|
||||
if (input.rootCauseId) {
|
||||
const [cause] = await db
|
||||
.select({ id: rootCauses.id })
|
||||
.from(rootCauses)
|
||||
.innerJoin(investigations, eq(investigations.id, rootCauses.investigationId))
|
||||
.where(and(eq(rootCauses.id, input.rootCauseId), eq(investigations.incidentId, action.incidentId)))
|
||||
if (!cause) throw fail(400, 'Root cause must belong to this incident')
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const status = input.status ?? action.status
|
||||
const update = {
|
||||
...input,
|
||||
completedAt: status === 'done' || status === 'verified' ? (action.completedAt ?? now) : null,
|
||||
verifiedAt: status === 'verified' ? (action.verifiedAt ?? now) : null,
|
||||
verifiedById: status === 'verified' ? (action.verifiedById ?? me.id) : null,
|
||||
// A new due date deserves a fresh reminder
|
||||
reminderSentOn: input.dueDate !== undefined && input.dueDate !== action.dueDate ? null : undefined,
|
||||
}
|
||||
const changes = diff(action, update)
|
||||
delete changes.reminderSentOn
|
||||
if (Object.keys(changes).length > 0) {
|
||||
await db.update(correctiveActions).set(update).where(eq(correctiveActions.id, action.id))
|
||||
await auditIncident(db, me, me.organizationId, action.incidentId, 'action.update', { actionId: action.id, title: action.title, ...changes })
|
||||
if (input.assigneeId && input.assigneeId !== action.assigneeId && input.assigneeId !== me.id) c.get('notifier').actionAssigned(action.id, me.name)
|
||||
}
|
||||
const [updated] = await selectActions(db).where(eq(correctiveActions.id, action.id))
|
||||
return c.json({ action: toAction(updated) })
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { buckets } from '../lib/analytics.ts'
|
||||
import { localDate } from '../lib/dates.ts'
|
||||
import { createTestContext } from '../test/helpers.ts'
|
||||
|
||||
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||
let manager: Awaited<ReturnType<typeof ctx.createUser>>
|
||||
let reporter: Awaited<ReturnType<typeof ctx.createUser>>
|
||||
let north: { id: string }
|
||||
let south: { id: string }
|
||||
|
||||
const daysAgo = (days: number, hour = 10) => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - days)
|
||||
d.setHours(hour, 0, 0, 0)
|
||||
return d
|
||||
}
|
||||
const range = (fromDaysAgo: number, toDaysAgo = 0) => `from=${localDate(daysAgo(fromDaysAgo))}&to=${localDate(daysAgo(toDaysAgo))}`
|
||||
|
||||
async function report(type: string, when: Date, siteId = north.id, by = manager) {
|
||||
const res = await by.post('/api/incidents', { title: `${type} report`, type, severity: 'low', occurredAt: when.toISOString(), siteId })
|
||||
return res.body.incident.number as number
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createTestContext()
|
||||
north = await ctx.createSite('North')
|
||||
south = await ctx.createSite('South')
|
||||
manager = await ctx.createUser('safety_manager')
|
||||
reporter = await ctx.createUser('reporter')
|
||||
// Current 30-day period: 3 near misses/hazards, 1 injury, 1 spill
|
||||
await report('near_miss', daysAgo(2, 14))
|
||||
await report('near_miss', daysAgo(3, 14))
|
||||
await report('hazard', daysAgo(5, 9), south.id)
|
||||
const injury = await report('injury', daysAgo(6, 22))
|
||||
await report('environmental', daysAgo(10))
|
||||
// Previous period: 1 near miss, 2 injuries
|
||||
await report('near_miss', daysAgo(40))
|
||||
await report('injury', daysAgo(45))
|
||||
await report('injury', daysAgo(50))
|
||||
|
||||
// Triage one report straight away
|
||||
await manager.post(`/api/incidents/${injury}/transitions`, { to: 'triage' })
|
||||
// A recordable case with hours worked
|
||||
await manager.post(`/api/osha/incidents/${injury}/cases`, { name: 'Case', workRelated: true, newCase: true, daysAwayFromWork: true, daysAway: 2 })
|
||||
const now = new Date()
|
||||
await manager.put(`/api/osha/sites/${north.id}/hours`, { year: now.getFullYear(), months: [{ month: now.getMonth() + 1, hours: 10_000, averageEmployees: 60 }] })
|
||||
})
|
||||
afterAll(() => ctx.close())
|
||||
|
||||
describe('analytics', () => {
|
||||
it('compares the period with the one before', async () => {
|
||||
const stats = (await manager.get(`/api/stats/analytics?${range(29)}`)).body
|
||||
expect(stats.kpis.reports).toEqual({ value: 5, previous: 3 })
|
||||
expect(stats.kpis.leadingReports).toEqual({ value: 3, previous: 1 })
|
||||
expect(stats.kpis.injuries).toEqual({ value: 1, previous: 2 })
|
||||
expect(stats.kpis.nearMissRatio).toEqual({ value: 3, previous: 0.5 })
|
||||
expect(stats.kpis.triagedWithin24h.value).toBeCloseTo(0.2)
|
||||
expect(stats.kpis.recordables).toEqual({ value: 1, previous: 0 })
|
||||
})
|
||||
|
||||
it('groups the trend, locations and the time-of-day heatmap', async () => {
|
||||
const stats = (await manager.get(`/api/stats/analytics?${range(29)}`)).body
|
||||
expect(stats.bucket).toBe('week')
|
||||
const totals = stats.trend.reduce((sum: Record<string, number>, b: Record<string, number>) => ({ hazards: sum.hazards + b.hazards, injuries: sum.injuries + b.injuries, damage: sum.damage + b.damage }), { hazards: 0, injuries: 0, damage: 0 })
|
||||
expect(totals).toEqual({ hazards: 3, injuries: 1, damage: 1 })
|
||||
expect(stats.byLocation).toEqual([
|
||||
{ id: north.id, name: 'North', count: 4 },
|
||||
{ id: south.id, name: 'South', count: 1 },
|
||||
])
|
||||
expect(stats.heatmap).toHaveLength(7)
|
||||
expect(stats.heatmap.flat().reduce((a: number, b: number) => a + b, 0)).toBe(5)
|
||||
expect(stats.heatmap[(daysAgo(6).getDay() + 6) % 7][22]).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('narrows to a site (showing its areas) and a type', async () => {
|
||||
const stats = (await manager.get(`/api/stats/analytics?${range(29)}&siteId=${north.id}&type=near_miss`)).body
|
||||
expect(stats.kpis.reports.value).toBe(2)
|
||||
expect(stats.byLocation).toEqual([{ id: null, name: 'Area not specified', count: 2 }])
|
||||
})
|
||||
|
||||
it('computes rolling 12-month OSHA rates from hours worked', async () => {
|
||||
const stats = (await manager.get(`/api/stats/analytics?${range(29)}`)).body
|
||||
expect(stats.rates).toHaveLength(12)
|
||||
expect(stats.rates.at(-1)).toEqual({ month: localDate(new Date()).slice(0, 7), trir: 20, dart: 20 })
|
||||
// Summed over the years, so the test holds even when the injury falls in last year (early January)
|
||||
expect(stats.yearly.reduce((sum: number, y: { recordables: number }) => sum + y.recordables, 0)).toBe(1)
|
||||
expect(stats.yearly.at(-1)).toMatchObject({ year: new Date().getFullYear(), hours: 10_000 })
|
||||
})
|
||||
|
||||
it('scopes reporters to their own reports and hides rates', async () => {
|
||||
await report('hazard', daysAgo(1), north.id, reporter)
|
||||
const stats = (await reporter.get(`/api/stats/analytics?${range(29)}`)).body
|
||||
expect(stats.kpis.reports.value).toBe(1)
|
||||
expect(stats.rates).toBeNull()
|
||||
expect(stats.kpis.recordables).toBeNull()
|
||||
})
|
||||
|
||||
it('validates the range', async () => {
|
||||
expect((await manager.get(`/api/stats/analytics?from=2026-02-01&to=2026-01-01`)).status).toBe(400)
|
||||
expect((await manager.get(`/api/stats/analytics?from=2015-01-01&to=2026-01-01`)).status).toBe(400)
|
||||
})
|
||||
|
||||
it('uses weeks for short ranges and months for long ones', () => {
|
||||
expect(buckets('2026-01-01', '2026-03-31').bucket).toBe('week')
|
||||
const long = buckets('2025-01-15', '2025-12-31')
|
||||
expect(long.bucket).toBe('month')
|
||||
expect(long.starts).toHaveLength(12)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saved views', () => {
|
||||
it('keeps private views private and lets supervisors share', async () => {
|
||||
const supervisor = await ctx.createUser('supervisor')
|
||||
const mine = await reporter.post('/api/views', { page: 'analytics', name: 'My hazards', filters: { type: 'hazard', range: '90' } })
|
||||
expect(mine.status).toBe(201)
|
||||
expect((await reporter.post('/api/views', { page: 'analytics', name: 'Shared?', filters: {}, shared: true })).status).toBe(403)
|
||||
await supervisor.post('/api/views', { page: 'analytics', name: 'Plant review', filters: { range: '365' }, shared: true })
|
||||
|
||||
const reporterViews = (await reporter.get('/api/views?page=analytics')).body.views.map((v: { name: string; mine: boolean }) => [v.name, v.mine])
|
||||
expect(reporterViews).toEqual([
|
||||
['My hazards', true],
|
||||
['Plant review', false],
|
||||
])
|
||||
expect((await supervisor.get('/api/views?page=analytics')).body.views.map((v: { name: string }) => v.name)).toEqual(['Plant review'])
|
||||
|
||||
expect((await reporter.delete(`/api/views/${(await supervisor.get('/api/views?page=analytics')).body.views[0].id}`)).status).toBe(403)
|
||||
expect((await reporter.delete(`/api/views/${mine.body.view.id}`)).status).toBe(204)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createTestContext, hoursAgo } from '../test/helpers.ts'
|
||||
|
||||
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||
let reporter: Awaited<ReturnType<typeof ctx.createUser>>
|
||||
let number: number
|
||||
|
||||
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52])
|
||||
const form = (bytes: Uint8Array<ArrayBuffer>, name: string, type = 'image/png') => {
|
||||
const data = new FormData()
|
||||
data.append('file', new File([bytes], name, { type }))
|
||||
return data
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createTestContext()
|
||||
const site = await ctx.createSite()
|
||||
reporter = await ctx.createUser('reporter')
|
||||
const res = await reporter.post('/api/incidents', { title: 'Spill', type: 'hazard', severity: 'low', occurredAt: hoursAgo(1), siteId: site.id })
|
||||
number = res.body.incident.number
|
||||
})
|
||||
afterAll(() => ctx.close())
|
||||
|
||||
describe('attachments', () => {
|
||||
it('stores a photo and serves it back safely', async () => {
|
||||
const res = await reporter.post(`/api/incidents/${number}/attachments`, form(PNG, 'spill.png'))
|
||||
expect(res.status).toBe(201)
|
||||
expect(res.body.attachment).toMatchObject({ fileName: 'spill.png', contentType: 'image/png', sizeBytes: PNG.byteLength })
|
||||
|
||||
const file = await reporter.get(res.body.attachment.url)
|
||||
expect(file.status).toBe(200)
|
||||
expect(file.headers.get('content-type')).toBe('image/png')
|
||||
expect(file.headers.get('content-security-policy')).toContain('sandbox')
|
||||
expect(new Uint8Array(file.body)).toEqual(PNG)
|
||||
|
||||
const detail = await reporter.get(`/api/incidents/${number}`)
|
||||
expect(detail.body.incident.attachments).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('identifies files by content, not by name or claimed type', async () => {
|
||||
const html = new TextEncoder().encode('<script>alert(1)</script>')
|
||||
expect((await reporter.post(`/api/incidents/${number}/attachments`, form(html, 'photo.png', 'image/png'))).status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects files over the size limit', async () => {
|
||||
const big = new Uint8Array(ctx.config.maxUploadBytes + 1)
|
||||
big.set(PNG)
|
||||
expect((await reporter.post(`/api/incidents/${number}/attachments`, form(big, 'big.png'))).status).toBe(413)
|
||||
})
|
||||
|
||||
it('hides files from people who cannot see the incident', async () => {
|
||||
const res = await reporter.post(`/api/incidents/${number}/attachments`, form(PNG, 'second.png'))
|
||||
const outsider = await ctx.createUser('reporter', 'outsider@example.com')
|
||||
expect((await outsider.get(res.body.attachment.url)).status).toBe(404)
|
||||
expect((await outsider.delete(res.body.attachment.url)).status).toBe(404)
|
||||
})
|
||||
|
||||
it('lets the uploader remove their file', async () => {
|
||||
const res = await reporter.post(`/api/incidents/${number}/attachments`, form(PNG, 'remove-me.png'))
|
||||
expect((await reporter.delete(res.body.attachment.url)).status).toBe(204)
|
||||
expect((await reporter.get(res.body.attachment.url)).status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
describe('upload origin check', () => {
|
||||
it('rejects uploads posted from another site', async () => {
|
||||
const res = await ctx.app.request(`/api/incidents/${number}/attachments`, {
|
||||
method: 'POST',
|
||||
headers: { cookie: reporter.cookie!, origin: 'http://evil.example' },
|
||||
body: form(PNG, 'x.png'),
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import { hasRole } from '../../shared/enums.ts'
|
||||
import { requireAuth } from '../auth/middleware.ts'
|
||||
import type { SessionUser } from '../auth/sessions.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { attachments, incidents } from '../db/schema.ts'
|
||||
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||
import { auditIncident, visibleTo } from '../lib/incidents.ts'
|
||||
|
||||
const idParam = z.object({ id: z.uuid() })
|
||||
|
||||
async function findAttachment(db: Db, user: SessionUser, id: string) {
|
||||
const [row] = await db
|
||||
.select({ file: attachments })
|
||||
.from(attachments)
|
||||
.innerJoin(incidents, eq(incidents.id, attachments.incidentId))
|
||||
.where(and(eq(attachments.id, id), visibleTo(db, user)))
|
||||
if (!row) throw fail(404, 'Attachment not found')
|
||||
return row.file
|
||||
}
|
||||
|
||||
export const attachmentRoutes = new Hono<AppEnv>()
|
||||
.get('/:id', requireAuth, validate('param', idParam), async (c) => {
|
||||
const file = await findAttachment(c.get('db'), currentUser(c), c.req.valid('param').id)
|
||||
const data = await c.get('storage').read(file.storageKey)
|
||||
const disposition = file.contentType.startsWith('image/') ? 'inline' : 'attachment'
|
||||
return c.body(new Uint8Array(data), 200, {
|
||||
'Content-Type': file.contentType,
|
||||
'Content-Length': String(data.byteLength),
|
||||
'Content-Disposition': `${disposition}; filename*=UTF-8''${encodeURIComponent(file.fileName)}`,
|
||||
// Uploaded content never runs scripts, even if a file slipped past type detection
|
||||
'Content-Security-Policy': "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox",
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
})
|
||||
})
|
||||
|
||||
.delete('/:id', requireAuth, validate('param', idParam), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const file = await findAttachment(db, me, c.req.valid('param').id)
|
||||
if (file.uploadedById !== me.id && !hasRole(me.role, 'supervisor')) throw fail(403, 'Only the uploader or a supervisor can remove this file')
|
||||
await db.delete(attachments).where(eq(attachments.id, file.id))
|
||||
await c.get('storage').remove(file.storageKey)
|
||||
await auditIncident(db, me, me.organizationId, file.incidentId, 'attachment.remove', { fileName: file.fileName })
|
||||
return c.body(null, 204)
|
||||
})
|
||||
@@ -1,17 +1,29 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
import { changePasswordSchema, loginSchema } from '../../shared/schemas.ts'
|
||||
import { changePasswordSchema, emailOnlySchema, loginSchema, resetPasswordSchema, tokenSchema } from '../../shared/schemas.ts'
|
||||
import { endSession, requireAuth, startSession } from '../auth/middleware.ts'
|
||||
import { hashPassword, verifyAgainstDummy, verifyPassword } from '../auth/password.ts'
|
||||
import { RateLimiter } from '../auth/rate-limit.ts'
|
||||
import { deleteUserSessions } from '../auth/sessions.ts'
|
||||
import { consumeAuthToken, createAuthToken } from '../auth/tokens.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { organizations, users } from '../db/schema.ts'
|
||||
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||
import { publicUser } from './users.ts'
|
||||
|
||||
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000
|
||||
const RESET_TTL_MS = 60 * 60 * 1000
|
||||
|
||||
async function activeUserByEmail(db: Db, email: string) {
|
||||
const [user] = await db.select({ id: users.id, email: users.email, active: users.active }).from(users).where(eq(users.email, email))
|
||||
return user?.active ? user : null
|
||||
}
|
||||
|
||||
export function authRoutes() {
|
||||
// 10 failed-or-successful attempts per email per 15 minutes
|
||||
const loginLimiter = new RateLimiter(10, 15 * 60 * 1000)
|
||||
// Emailed links: 5 per email per 15 minutes
|
||||
const emailLimiter = new RateLimiter(5, 15 * 60 * 1000)
|
||||
|
||||
return new Hono<AppEnv>()
|
||||
.post('/login', validate('json', loginSchema), async (c) => {
|
||||
@@ -30,6 +42,48 @@ export function authRoutes() {
|
||||
return c.json({ user: result })
|
||||
})
|
||||
|
||||
// Always 204, so the response never reveals whether an account exists.
|
||||
.post('/magic-link', validate('json', emailOnlySchema), async (c) => {
|
||||
const { email } = c.req.valid('json')
|
||||
if (!emailLimiter.attempt(`magic:${email}`)) throw fail(429, 'Too many requests. Try again in 15 minutes.')
|
||||
const db = c.get('db')
|
||||
const user = await activeUserByEmail(db, email)
|
||||
if (user) c.get('notifier').magicLink(user.email, await createAuthToken(db, user.id, 'magic_link', MAGIC_LINK_TTL_MS))
|
||||
return c.body(null, 204)
|
||||
})
|
||||
|
||||
// POST rather than GET so email link scanners can't use up the token.
|
||||
.post('/magic-link/verify', validate('json', tokenSchema), async (c) => {
|
||||
const db = c.get('db')
|
||||
const userId = await consumeAuthToken(db, c.req.valid('json').token, 'magic_link')
|
||||
if (!userId) throw fail(400, 'This sign-in link is invalid or has expired. Request a new one.')
|
||||
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, userId))
|
||||
await startSession(c, userId)
|
||||
const [user] = await db.select(publicUser).from(users).where(eq(users.id, userId))
|
||||
return c.json({ user })
|
||||
})
|
||||
|
||||
.post('/password-reset', validate('json', emailOnlySchema), async (c) => {
|
||||
const { email } = c.req.valid('json')
|
||||
if (!emailLimiter.attempt(`reset:${email}`)) throw fail(429, 'Too many requests. Try again in 15 minutes.')
|
||||
const db = c.get('db')
|
||||
const user = await activeUserByEmail(db, email)
|
||||
if (user) c.get('notifier').passwordReset(user.email, await createAuthToken(db, user.id, 'password_reset', RESET_TTL_MS), null)
|
||||
return c.body(null, 204)
|
||||
})
|
||||
|
||||
.post('/password-reset/confirm', validate('json', resetPasswordSchema), async (c) => {
|
||||
const { token, newPassword } = c.req.valid('json')
|
||||
const db = c.get('db')
|
||||
const userId = await consumeAuthToken(db, token, 'password_reset')
|
||||
if (!userId) throw fail(400, 'This link is invalid or has expired. Request a new one.')
|
||||
await db.update(users).set({ passwordHash: await hashPassword(newPassword), lastLoginAt: new Date() }).where(eq(users.id, userId))
|
||||
await deleteUserSessions(db, userId)
|
||||
await startSession(c, userId)
|
||||
const [user] = await db.select(publicUser).from(users).where(eq(users.id, userId))
|
||||
return c.json({ user })
|
||||
})
|
||||
|
||||
.post('/logout', async (c) => {
|
||||
await endSession(c)
|
||||
return c.body(null, 204)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createTestContext, tokenFrom } from '../test/helpers.ts'
|
||||
|
||||
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||
beforeAll(async () => {
|
||||
ctx = await createTestContext()
|
||||
await ctx.createUser('supervisor', 'linked@example.com')
|
||||
})
|
||||
afterAll(() => ctx.close())
|
||||
|
||||
const lastToken = async (email: string) => {
|
||||
await ctx.notifier.flush()
|
||||
return tokenFrom(ctx.mailer.last(email)?.text)
|
||||
}
|
||||
|
||||
describe('magic links', () => {
|
||||
it('signs in once with an emailed link', async () => {
|
||||
const client = ctx.anonymous()
|
||||
expect((await client.post('/api/auth/magic-link', { email: 'Linked@Example.com' })).status).toBe(204)
|
||||
const token = await lastToken('linked@example.com')
|
||||
expect(ctx.mailer.last('linked@example.com')?.text).toContain(`http://safety.test/auth/magic?token=${token}`)
|
||||
|
||||
const res = await client.post('/api/auth/magic-link/verify', { token })
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.user.email).toBe('linked@example.com')
|
||||
expect((await client.get('/api/auth/me')).status).toBe(200)
|
||||
|
||||
expect((await ctx.anonymous().post('/api/auth/magic-link/verify', { token })).status).toBe(400)
|
||||
})
|
||||
|
||||
it('answers the same for unknown emails and sends nothing', async () => {
|
||||
const before = ctx.mailer.sent.length
|
||||
expect((await ctx.anonymous().post('/api/auth/magic-link', { email: 'nobody@example.com' })).status).toBe(204)
|
||||
await ctx.notifier.flush()
|
||||
expect(ctx.mailer.sent.length).toBe(before)
|
||||
})
|
||||
|
||||
it("doesn't accept a magic link token as a password reset", async () => {
|
||||
await ctx.anonymous().post('/api/auth/magic-link', { email: 'linked@example.com' })
|
||||
const token = await lastToken('linked@example.com')
|
||||
expect((await ctx.anonymous().post('/api/auth/password-reset/confirm', { token, newPassword: 'a whole new password' })).status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('password reset', () => {
|
||||
it('sets a new password and signs out other sessions', async () => {
|
||||
const user = await ctx.createUser('reporter', 'forgetful@example.com')
|
||||
await ctx.anonymous().post('/api/auth/password-reset', { email: 'forgetful@example.com' })
|
||||
const token = await lastToken('forgetful@example.com')
|
||||
|
||||
const client = ctx.anonymous()
|
||||
const res = await client.post('/api/auth/password-reset/confirm', { token, newPassword: 'remembered at last' })
|
||||
expect(res.status).toBe(200)
|
||||
expect((await client.get('/api/auth/me')).status).toBe(200)
|
||||
expect((await user.get('/api/auth/me')).status).toBe(401)
|
||||
expect((await ctx.anonymous().post('/api/auth/login', { email: 'forgetful@example.com', password: 'remembered at last' })).status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invitations', () => {
|
||||
it('emails new users without a password a link to set one', async () => {
|
||||
const res = await ctx.admin.post('/api/users', { name: 'New Starter', email: 'starter@example.com', role: 'reporter' })
|
||||
expect(res.body).toMatchObject({ invited: true })
|
||||
await ctx.notifier.flush()
|
||||
const mail = ctx.mailer.last('starter@example.com')
|
||||
expect(mail?.subject).toBe("You've been invited to Northstar Co. on Open SafetyLog EHS")
|
||||
|
||||
// Can't sign in until the password is set
|
||||
expect((await ctx.anonymous().post('/api/auth/login', { email: 'starter@example.com', password: 'anything at all' })).status).toBe(401)
|
||||
const client = ctx.anonymous()
|
||||
expect((await client.post('/api/auth/password-reset/confirm', { token: tokenFrom(mail?.text), newPassword: 'my first password' })).status).toBe(200)
|
||||
expect((await client.get('/api/auth/me')).body.user.email).toBe('starter@example.com')
|
||||
})
|
||||
|
||||
it('lets admins set a password directly when email is not available', async () => {
|
||||
const created = await ctx.admin.post('/api/users', { name: 'No Email', email: 'offline@example.com', role: 'reporter' })
|
||||
await ctx.admin.patch(`/api/users/${created.body.user.id}`, { password: 'set by the admin' })
|
||||
expect((await ctx.anonymous().post('/api/auth/login', { email: 'offline@example.com', password: 'set by the admin' })).status).toBe(200)
|
||||
})
|
||||
})
|
||||
@@ -108,27 +108,62 @@ describe('listing', () => {
|
||||
})
|
||||
|
||||
describe('updating', () => {
|
||||
it('lets supervisors update and close incidents, recording what changed', async () => {
|
||||
it('lets supervisors edit details, recording what changed', async () => {
|
||||
const created = await reporter.post('/api/incidents', report({ title: 'Wet floor' }))
|
||||
const number = created.body.incident.number
|
||||
expect((await reporter.patch(`/api/incidents/${number}`, { status: 'closed' })).status).toBe(403)
|
||||
expect((await reporter.patch(`/api/incidents/${number}`, { severity: 'low' })).status).toBe(403)
|
||||
|
||||
const closed = await supervisor.patch(`/api/incidents/${number}`, { status: 'closed', severity: 'low' })
|
||||
expect(closed.status).toBe(200)
|
||||
expect(closed.body.incident.status).toBe('closed')
|
||||
expect(closed.body.incident.closedAt).not.toBeNull()
|
||||
const edited = await supervisor.patch(`/api/incidents/${number}`, { severity: 'low', title: 'Wet floor by entrance' })
|
||||
expect(edited.status).toBe(200)
|
||||
expect(edited.body.incident).toMatchObject({ severity: 'low', title: 'Wet floor by entrance' })
|
||||
|
||||
const [entry] = await ctx.db.select().from(auditLog).where(eq(auditLog.action, 'update'))
|
||||
expect(entry.changes).toMatchObject({ status: ['reported', 'closed'], severity: ['high', 'low'] })
|
||||
expect(entry.changes).toMatchObject({ severity: ['high', 'low'], title: ['Wet floor', 'Wet floor by entrance'] })
|
||||
})
|
||||
|
||||
const reopened = await supervisor.patch(`/api/incidents/${number}`, { status: 'investigation' })
|
||||
expect(reopened.body.incident.closedAt).toBeNull()
|
||||
it('does not change status through edits', async () => {
|
||||
const created = await reporter.post('/api/incidents', report())
|
||||
const res = await supervisor.patch(`/api/incidents/${created.body.incident.number}`, { status: 'closed' })
|
||||
expect(res.body.incident.status).toBe('reported')
|
||||
})
|
||||
|
||||
it('clears the area when moving an incident to another site', async () => {
|
||||
const created = await reporter.post('/api/incidents', report({ areaId: area.id }))
|
||||
const south = await ctx.createSite('West Campus')
|
||||
const moved = await supervisor.patch(`/api/incidents/${created.body.incident.number}`, { siteId: south.id })
|
||||
expect(moved.body.incident).toMatchObject({ site: { id: south.id }, area: null })
|
||||
const west = await ctx.createSite('West Campus')
|
||||
const moved = await supervisor.patch(`/api/incidents/${created.body.incident.number}`, { siteId: west.id })
|
||||
expect(moved.body.incident).toMatchObject({ site: { id: west.id }, area: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('notifications', () => {
|
||||
it('alerts safety managers about high-severity reports, not low ones', async () => {
|
||||
const manager = await ctx.createUser('safety_manager', 'alerts@example.com')
|
||||
await reporter.post('/api/incidents', report({ title: 'Arm caught in conveyor', type: 'injury', severity: 'critical' }))
|
||||
await reporter.post('/api/incidents', report({ title: 'Scuffed paint', severity: 'low' }))
|
||||
await ctx.notifier.flush()
|
||||
const mails = ctx.mailer.sent.filter((mail) => mail.to === manager.user.email)
|
||||
expect(mails).toHaveLength(1)
|
||||
expect(mails[0].subject).toMatch(/^\[Critical\] INC-\d{4}: Arm caught in conveyor$/)
|
||||
expect(mails[0].text).toContain('http://safety.test/incidents/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('export', () => {
|
||||
it('exports the filtered list as CSV, neutralising spreadsheet formulas', async () => {
|
||||
await reporter.post('/api/incidents', report({ title: '=HYPERLINK("http://evil")', type: 'environmental', severity: 'low' }))
|
||||
const res = await supervisor.get('/api/incidents/export?type=environmental')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toContain('text/csv')
|
||||
const csv = res.body.toString('utf8')
|
||||
const lines = csv.trimEnd().split('\r\n')
|
||||
expect(lines[0]).toMatch(/^\uFEFFReference,Title,Type/)
|
||||
expect(lines).toHaveLength(2)
|
||||
expect(lines[1]).toContain(`"'=HYPERLINK(""http://evil"")"`)
|
||||
})
|
||||
|
||||
it('only exports what the user can see', async () => {
|
||||
const other = await ctx.createUser('reporter', 'csv-reporter@example.com')
|
||||
const res = await other.get('/api/incidents/export')
|
||||
expect(res.body.toString('utf8').trimEnd().split('\r\n')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,102 +1,40 @@
|
||||
import { and, count, desc, eq, ilike, isNull, or, sql, type SQL } from 'drizzle-orm'
|
||||
import { and, asc, count, desc, eq, ilike, or, type SQL } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
import { bodyLimit } from 'hono/body-limit'
|
||||
import { z } from 'zod'
|
||||
import type { Comment, TimelineEntry } from '../../shared/api-types.ts'
|
||||
import { hasRole } from '../../shared/enums.ts'
|
||||
import { incidentReference } from '../../shared/format.ts'
|
||||
import { createIncidentSchema, incidentListQuery, updateIncidentSchema } from '../../shared/schemas.ts'
|
||||
import type { SessionUser } from '../auth/sessions.ts'
|
||||
import {
|
||||
commentSchema,
|
||||
createActionSchema,
|
||||
createIncidentSchema,
|
||||
incidentListQuery,
|
||||
investigationSchema,
|
||||
rootCauseSchema,
|
||||
transitionSchema,
|
||||
updateIncidentSchema,
|
||||
type IncidentListQuery,
|
||||
} from '../../shared/schemas.ts'
|
||||
import { transitionBlocker, transitions } from '../../shared/workflow.ts'
|
||||
import { requireAuth, requireRole } from '../auth/middleware.ts'
|
||||
import type { SessionUser } from '../auth/sessions.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { areas, incidentCounters, incidents, sites, users } from '../db/schema.ts'
|
||||
import { audit, diff } from '../lib/audit.ts'
|
||||
import { auditLog, comments, correctiveActions, incidents, investigations, rootCauses, users } from '../db/schema.ts'
|
||||
import { diff } from '../lib/audit.ts'
|
||||
import { toCsv } from '../lib/csv.ts'
|
||||
import { saveAttachment } from '../lib/attachments.ts'
|
||||
import { iso } from '../lib/dates.ts'
|
||||
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||
import { assertActiveUser, auditIncident, findIncident, loadDetail, selectIncidents, toAttachment, toIncident, visibleTo } from '../lib/incidents.ts'
|
||||
import { assertLocation, fileReport, parseOccurredAt } from '../lib/reporting.ts'
|
||||
|
||||
const numberParam = z.object({ number: z.coerce.number().int().positive() })
|
||||
|
||||
/** Reports may be dated slightly ahead to absorb clock differences between devices. */
|
||||
const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000
|
||||
|
||||
const columns = {
|
||||
incident: incidents,
|
||||
siteName: sites.name,
|
||||
areaName: areas.name,
|
||||
reporterName: users.name,
|
||||
}
|
||||
|
||||
type IncidentRow = { incident: typeof incidents.$inferSelect; siteName: string; areaName: string | null; reporterName: string | null }
|
||||
|
||||
function toDto({ incident, siteName, areaName, reporterName }: IncidentRow) {
|
||||
return {
|
||||
id: incident.id,
|
||||
number: incident.number,
|
||||
reference: incidentReference(incident.number),
|
||||
title: incident.title,
|
||||
type: incident.type,
|
||||
severity: incident.severity,
|
||||
status: incident.status,
|
||||
occurredAt: incident.occurredAt,
|
||||
reportedAt: incident.createdAt,
|
||||
updatedAt: incident.updatedAt,
|
||||
closedAt: incident.closedAt,
|
||||
site: { id: incident.siteId, name: siteName },
|
||||
area: incident.areaId ? { id: incident.areaId, name: areaName } : null,
|
||||
locationDetail: incident.locationDetail,
|
||||
description: incident.description,
|
||||
immediateAction: incident.immediateAction,
|
||||
anonymous: incident.anonymous,
|
||||
reporter: incident.reporterId ? { id: incident.reporterId, name: reporterName } : null,
|
||||
}
|
||||
}
|
||||
|
||||
function selectIncidents(db: Db) {
|
||||
return db
|
||||
.select(columns)
|
||||
.from(incidents)
|
||||
.innerJoin(sites, eq(sites.id, incidents.siteId))
|
||||
.leftJoin(areas, eq(areas.id, incidents.areaId))
|
||||
.leftJoin(users, eq(users.id, incidents.reporterId))
|
||||
}
|
||||
|
||||
/** Reporters only see what they reported; supervisors and above see everything in the organization. */
|
||||
function visibleTo(user: SessionUser): SQL | undefined {
|
||||
return and(eq(incidents.organizationId, user.organizationId), hasRole(user.role, 'supervisor') ? undefined : eq(incidents.reporterId, user.id))
|
||||
}
|
||||
|
||||
async function findIncident(db: Db, user: SessionUser, number: number) {
|
||||
const [row] = await selectIncidents(db).where(and(visibleTo(user), eq(incidents.number, number)))
|
||||
if (!row) throw fail(404, 'Incident not found')
|
||||
return row
|
||||
}
|
||||
|
||||
async function assertLocation(db: Db, organizationId: string, siteId: string, areaId: string | null | undefined) {
|
||||
const [site] = await db
|
||||
.select({ id: sites.id })
|
||||
.from(sites)
|
||||
.where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId), isNull(sites.archivedAt)))
|
||||
if (!site) throw fail(400, 'Unknown site')
|
||||
if (areaId) {
|
||||
const [area] = await db
|
||||
.select({ id: areas.id })
|
||||
.from(areas)
|
||||
.where(and(eq(areas.id, areaId), eq(areas.siteId, siteId)))
|
||||
if (!area) throw fail(400, 'Area must belong to the selected site')
|
||||
}
|
||||
}
|
||||
|
||||
function parseOccurredAt(value: string): Date {
|
||||
const date = new Date(value)
|
||||
if (date.getTime() > Date.now() + MAX_CLOCK_SKEW_MS) throw fail(400, 'Occurrence time cannot be in the future')
|
||||
return date
|
||||
}
|
||||
const EXPORT_LIMIT = 10_000
|
||||
|
||||
const escapeLike = (value: string) => value.replace(/[\\%_]/g, '\\$&')
|
||||
|
||||
export const incidentRoutes = new Hono<AppEnv>()
|
||||
.get('/', requireAuth, validate('query', incidentListQuery), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const { page, pageSize, q, type, severity, status, siteId } = c.req.valid('query')
|
||||
const db = c.get('db')
|
||||
|
||||
function listFilter(db: Db, user: SessionUser, { q, type, severity, status, siteId }: Omit<IncidentListQuery, 'page' | 'pageSize'>) {
|
||||
let search: SQL | undefined
|
||||
if (q) {
|
||||
const pattern = `%${escapeLike(q)}%`
|
||||
@@ -108,60 +46,79 @@ export const incidentRoutes = new Hono<AppEnv>()
|
||||
number ? eq(incidents.number, Number(number)) : undefined,
|
||||
)
|
||||
}
|
||||
const where = and(
|
||||
visibleTo(me),
|
||||
return and(
|
||||
visibleTo(db, user),
|
||||
search,
|
||||
type ? eq(incidents.type, type) : undefined,
|
||||
severity ? eq(incidents.severity, severity) : undefined,
|
||||
status ? eq(incidents.status, status) : undefined,
|
||||
siteId ? eq(incidents.siteId, siteId) : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
/** Creates the incident's investigation if it doesn't exist yet and returns its id. */
|
||||
async function ensureInvestigation(db: Db, incidentId: string): Promise<string> {
|
||||
await db.insert(investigations).values({ incidentId }).onConflictDoNothing({ target: investigations.incidentId })
|
||||
const [row] = await db.select({ id: investigations.id }).from(investigations).where(eq(investigations.incidentId, incidentId))
|
||||
return row.id
|
||||
}
|
||||
|
||||
export const incidentRoutes = new Hono<AppEnv>()
|
||||
.get('/', requireAuth, validate('query', incidentListQuery), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const { page, pageSize, ...filters } = c.req.valid('query')
|
||||
const db = c.get('db')
|
||||
const where = listFilter(db, me, filters)
|
||||
const [{ total }] = await db.select({ total: count() }).from(incidents).where(where)
|
||||
const rows = await selectIncidents(db)
|
||||
.where(where)
|
||||
.orderBy(desc(incidents.occurredAt), desc(incidents.number))
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize)
|
||||
return c.json({ items: rows.map(toDto), page, pageSize, total })
|
||||
return c.json({ items: rows.map(toIncident), page, pageSize, total })
|
||||
})
|
||||
|
||||
.get('/export', requireAuth, validate('query', incidentListQuery.omit({ page: true, pageSize: true })), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const rows = await selectIncidents(db)
|
||||
.where(listFilter(db, me, c.req.valid('query')))
|
||||
.orderBy(desc(incidents.occurredAt), desc(incidents.number))
|
||||
.limit(EXPORT_LIMIT)
|
||||
const csv = toCsv(
|
||||
['Reference', 'Title', 'Type', 'Severity', 'Status', 'Occurred at', 'Reported at', 'Closed at', 'Site', 'Area', 'Location detail', 'Reporter', 'Description', 'Immediate action'],
|
||||
rows.map(toIncident).map((i) => [
|
||||
i.reference,
|
||||
i.title,
|
||||
i.type,
|
||||
i.severity,
|
||||
i.status,
|
||||
i.occurredAt,
|
||||
i.reportedAt,
|
||||
i.closedAt,
|
||||
i.site.name,
|
||||
i.area?.name,
|
||||
i.locationDetail,
|
||||
i.anonymous ? 'Anonymous' : (i.reporter?.name ?? i.reporterName ?? (i.kiosk ? `Reporting link: ${i.kiosk.name}` : null)),
|
||||
i.description,
|
||||
i.immediateAction,
|
||||
]),
|
||||
)
|
||||
const date = new Date().toISOString().slice(0, 10)
|
||||
return c.body(csv, 200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="incidents-${date}.csv"` })
|
||||
})
|
||||
|
||||
.post('/', requireAuth, validate('json', createIncidentSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const { anonymous, occurredAt, ...input } = c.req.valid('json')
|
||||
const input = c.req.valid('json')
|
||||
const db = c.get('db')
|
||||
await assertLocation(db, me.organizationId, input.siteId, input.areaId)
|
||||
const occurred = parseOccurredAt(occurredAt)
|
||||
|
||||
const number = await db.transaction(async (tx) => {
|
||||
// Atomic increment: concurrent reports always get distinct, gap-free numbers.
|
||||
const [{ lastNumber }] = await tx
|
||||
.insert(incidentCounters)
|
||||
.values({ organizationId: me.organizationId, lastNumber: 1 })
|
||||
.onConflictDoUpdate({ target: incidentCounters.organizationId, set: { lastNumber: sql`${incidentCounters.lastNumber} + 1` } })
|
||||
.returning({ lastNumber: incidentCounters.lastNumber })
|
||||
const [incident] = await tx
|
||||
.insert(incidents)
|
||||
.values({ ...input, occurredAt: occurred, organizationId: me.organizationId, number: lastNumber, anonymous, reporterId: anonymous ? null : me.id })
|
||||
.returning({ id: incidents.id })
|
||||
await audit(tx, {
|
||||
organizationId: me.organizationId,
|
||||
actorId: anonymous ? null : me.id,
|
||||
entityType: 'incident',
|
||||
entityId: incident.id,
|
||||
action: 'create',
|
||||
changes: { number: lastNumber, title: input.title, type: input.type, severity: input.severity },
|
||||
})
|
||||
return lastNumber
|
||||
})
|
||||
|
||||
const [row] = await selectIncidents(db).where(and(eq(incidents.organizationId, me.organizationId), eq(incidents.number, number)))
|
||||
return c.json({ incident: toDto(row) }, 201)
|
||||
const { id, created } = await fileReport(db, c.get('notifier'), { ...input, organizationId: me.organizationId, reporter: me })
|
||||
const [row] = await selectIncidents(db).where(eq(incidents.id, id))
|
||||
return c.json({ incident: toIncident(row) }, created ? 201 : 200)
|
||||
})
|
||||
|
||||
.get('/:number', requireAuth, validate('param', numberParam), async (c) => {
|
||||
const row = await findIncident(c.get('db'), currentUser(c), c.req.valid('param').number)
|
||||
return c.json({ incident: toDto(row) })
|
||||
return c.json({ incident: await loadDetail(c.get('db'), currentUser(c), c.req.valid('param').number) })
|
||||
})
|
||||
|
||||
.patch('/:number', requireRole('supervisor'), validate('param', numberParam), validate('json', updateIncidentSchema), async (c) => {
|
||||
@@ -175,17 +132,160 @@ export const incidentRoutes = new Hono<AppEnv>()
|
||||
if (input.siteId && input.siteId !== incident.siteId && input.areaId === undefined) areaId = null // moving site clears the old area
|
||||
if (input.siteId !== undefined || input.areaId !== undefined) await assertLocation(db, me.organizationId, siteId, areaId)
|
||||
|
||||
const update = {
|
||||
...input,
|
||||
areaId,
|
||||
occurredAt: occurredAt ? parseOccurredAt(occurredAt) : undefined,
|
||||
closedAt: input.status === undefined ? undefined : input.status === 'closed' ? (incident.closedAt ?? new Date()) : null,
|
||||
}
|
||||
const update = { ...input, areaId, occurredAt: occurredAt ? parseOccurredAt(occurredAt) : undefined }
|
||||
const changes = diff(incident, update)
|
||||
if (Object.keys(changes).length > 0) {
|
||||
await db.update(incidents).set(update).where(eq(incidents.id, incident.id))
|
||||
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'incident', entityId: incident.id, action: 'update', changes })
|
||||
await auditIncident(db, me, me.organizationId, incident.id, 'update', changes)
|
||||
}
|
||||
const row = await findIncident(db, me, incident.number)
|
||||
return c.json({ incident: toDto(row) })
|
||||
return c.json({ incident: await loadDetail(db, me, incident.number) })
|
||||
})
|
||||
|
||||
.post('/:number/transitions', requireAuth, validate('param', numberParam), validate('json', transitionSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { to, note } = c.req.valid('json')
|
||||
const detail = await loadDetail(db, me, c.req.valid('param').number)
|
||||
|
||||
const rule = transitions.find((t) => t.from === detail.status && t.to === to)
|
||||
if (!rule) throw fail(409, `An incident in "${detail.status}" can't move to "${to}"`)
|
||||
if (!hasRole(me.role, rule.minRole)) throw fail(403, 'You do not have permission to do that')
|
||||
const blocker = transitionBlocker(rule, {
|
||||
severity: detail.severity,
|
||||
rootCauseCount: detail.rootCauses.length,
|
||||
actionStatuses: detail.actions.map((action) => action.status),
|
||||
})
|
||||
if (blocker) throw fail(409, blocker)
|
||||
|
||||
// Conditional update guards against two people moving the same incident at once.
|
||||
const moved = await db
|
||||
.update(incidents)
|
||||
.set({ status: to, closedAt: to === 'closed' ? new Date() : null })
|
||||
.where(and(eq(incidents.id, detail.id), eq(incidents.status, detail.status)))
|
||||
.returning({ id: incidents.id })
|
||||
if (moved.length === 0) throw fail(409, 'This incident was just changed by someone else. Reload and try again.')
|
||||
|
||||
await auditIncident(db, me, me.organizationId, detail.id, 'status', { status: [detail.status, to], ...(note ? { note } : {}) })
|
||||
if (note) await db.insert(comments).values({ incidentId: detail.id, authorId: me.id, body: note })
|
||||
return c.json({ incident: await loadDetail(db, me, detail.number) })
|
||||
})
|
||||
|
||||
.get('/:number/comments', requireAuth, validate('param', numberParam), async (c) => {
|
||||
const db = c.get('db')
|
||||
const { incident } = await findIncident(db, currentUser(c), c.req.valid('param').number)
|
||||
const rows = await db
|
||||
.select({ comment: comments, authorName: users.name })
|
||||
.from(comments)
|
||||
.leftJoin(users, eq(users.id, comments.authorId))
|
||||
.where(eq(comments.incidentId, incident.id))
|
||||
.orderBy(asc(comments.createdAt))
|
||||
const result: Comment[] = rows.map(({ comment, authorName }) => ({
|
||||
id: comment.id,
|
||||
author: comment.authorId ? { id: comment.authorId, name: authorName ?? '' } : null,
|
||||
body: comment.body,
|
||||
createdAt: iso(comment.createdAt),
|
||||
}))
|
||||
return c.json({ comments: result })
|
||||
})
|
||||
|
||||
.post('/:number/comments', requireAuth, validate('param', numberParam), validate('json', commentSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { incident } = await findIncident(db, me, c.req.valid('param').number)
|
||||
const [comment] = await db.insert(comments).values({ incidentId: incident.id, authorId: me.id, body: c.req.valid('json').body }).returning()
|
||||
const result: Comment = { id: comment.id, author: { id: me.id, name: me.name }, body: comment.body, createdAt: iso(comment.createdAt) }
|
||||
return c.json({ comment: result }, 201)
|
||||
})
|
||||
|
||||
.get('/:number/timeline', requireAuth, validate('param', numberParam), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { incident } = await findIncident(db, me, c.req.valid('param').number)
|
||||
const rows = await db
|
||||
.select({ entry: auditLog, actorName: users.name })
|
||||
.from(auditLog)
|
||||
.leftJoin(users, eq(users.id, auditLog.actorId))
|
||||
.where(and(eq(auditLog.organizationId, me.organizationId), eq(auditLog.entityType, 'incident'), eq(auditLog.entityId, incident.id)))
|
||||
.orderBy(asc(auditLog.id))
|
||||
const timeline: TimelineEntry[] = rows.map(({ entry, actorName }) => ({
|
||||
id: entry.id,
|
||||
at: iso(entry.createdAt),
|
||||
actor: entry.actorId ? { id: entry.actorId, name: actorName ?? '' } : null,
|
||||
action: entry.action,
|
||||
changes: (entry.changes as Record<string, unknown> | null) ?? null,
|
||||
}))
|
||||
return c.json({ timeline })
|
||||
})
|
||||
|
||||
.put('/:number/investigation', requireRole('supervisor'), validate('param', numberParam), validate('json', investigationSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { incident } = await findIncident(db, me, c.req.valid('param').number)
|
||||
const { completed, ...input } = c.req.valid('json')
|
||||
if (input.leadId) await assertActiveUser(db, me.organizationId, input.leadId)
|
||||
|
||||
const investigationId = await ensureInvestigation(db, incident.id)
|
||||
const [current] = await db.select().from(investigations).where(eq(investigations.id, investigationId))
|
||||
const update = { ...input, completedAt: completed === undefined ? undefined : completed ? (current.completedAt ?? new Date()) : null }
|
||||
const changes = diff(current, update)
|
||||
if (Object.keys(changes).length > 0) {
|
||||
await db.update(investigations).set(update).where(eq(investigations.id, investigationId))
|
||||
await auditIncident(db, me, me.organizationId, incident.id, 'investigation.update', changes)
|
||||
}
|
||||
return c.json({ incident: await loadDetail(db, me, incident.number) })
|
||||
})
|
||||
|
||||
.post('/:number/root-causes', requireRole('supervisor'), validate('param', numberParam), validate('json', rootCauseSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { incident } = await findIncident(db, me, c.req.valid('param').number)
|
||||
const input = c.req.valid('json')
|
||||
const investigationId = await ensureInvestigation(db, incident.id)
|
||||
await db.insert(rootCauses).values({ ...input, investigationId })
|
||||
await auditIncident(db, me, me.organizationId, incident.id, 'root_cause.add', { category: input.category, description: input.description })
|
||||
return c.json({ incident: await loadDetail(db, me, incident.number) }, 201)
|
||||
})
|
||||
|
||||
.post('/:number/actions', requireRole('supervisor'), validate('param', numberParam), validate('json', createActionSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { incident } = await findIncident(db, me, c.req.valid('param').number)
|
||||
if (incident.status === 'closed') throw fail(409, 'Reopen the incident before adding actions')
|
||||
const input = c.req.valid('json')
|
||||
if (input.assigneeId) await assertActiveUser(db, me.organizationId, input.assigneeId)
|
||||
if (input.rootCauseId) {
|
||||
const [cause] = await db
|
||||
.select({ id: rootCauses.id })
|
||||
.from(rootCauses)
|
||||
.innerJoin(investigations, eq(investigations.id, rootCauses.investigationId))
|
||||
.where(and(eq(rootCauses.id, input.rootCauseId), eq(investigations.incidentId, incident.id)))
|
||||
if (!cause) throw fail(400, 'Root cause must belong to this incident')
|
||||
}
|
||||
|
||||
const [action] = await db
|
||||
.insert(correctiveActions)
|
||||
.values({ ...input, incidentId: incident.id })
|
||||
.returning()
|
||||
await auditIncident(db, me, me.organizationId, incident.id, 'action.add', { actionId: action.id, title: action.title, assigneeId: action.assigneeId, dueDate: action.dueDate })
|
||||
if (action.assigneeId && action.assigneeId !== me.id) c.get('notifier').actionAssigned(action.id, me.name)
|
||||
return c.json({ incident: await loadDetail(db, me, incident.number) }, 201)
|
||||
})
|
||||
|
||||
.post(
|
||||
'/:number/attachments',
|
||||
requireAuth,
|
||||
validate('param', numberParam),
|
||||
// Reject oversized bodies before reading them into memory
|
||||
async (c, next) => bodyLimit({ maxSize: c.get('config').maxUploadBytes + 64 * 1024, onError: (c) => c.json({ error: 'File is too large' }, 413) })(c, next),
|
||||
async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const config = c.get('config')
|
||||
const { incident } = await findIncident(db, me, c.req.valid('param').number)
|
||||
|
||||
const body = await c.req.parseBody()
|
||||
const row = await saveAttachment({ db, storage: c.get('storage'), config }, incident.id, body.file, me.id)
|
||||
await auditIncident(db, me, me.organizationId, incident.id, 'attachment.add', { attachmentId: row.id, fileName: row.fileName })
|
||||
return c.json({ attachment: toAttachment(row, me.name) }, 201)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
// oxlint-disable typescript/no-explicit-any -- tests read arbitrary JSON
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createTestContext, hoursAgo } from '../test/helpers.ts'
|
||||
|
||||
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||
let north: { id: string }
|
||||
let south: { id: string }
|
||||
|
||||
const json = (res: Response): Promise<any> => res.json()
|
||||
|
||||
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52])
|
||||
|
||||
/** Submits like the kiosk page: multipart with the token header. */
|
||||
function kioskReport(token: string, report: Record<string, unknown>, files: File[] = []) {
|
||||
const form = new FormData()
|
||||
form.append('report', JSON.stringify(report))
|
||||
for (const file of files) form.append('files', file)
|
||||
return ctx.app.request('/api/kiosk/reports', { method: 'POST', headers: { 'x-kiosk-token': token, origin: 'http://safety.test' }, body: form })
|
||||
}
|
||||
const report = (overrides: Record<string, unknown> = {}) => ({ title: 'Leaking valve', type: 'hazard', severity: 'medium', occurredAt: hoursAgo(1), siteId: north.id, ...overrides })
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createTestContext()
|
||||
north = await ctx.createSite('North')
|
||||
south = await ctx.createSite('South')
|
||||
})
|
||||
afterAll(() => ctx.close())
|
||||
|
||||
describe('reporting links', () => {
|
||||
it('are managed by safety managers', async () => {
|
||||
const supervisor = await ctx.createUser('supervisor')
|
||||
expect((await supervisor.post('/api/kiosks', { name: 'Canteen tablet' })).status).toBe(403)
|
||||
const res = await ctx.admin.post('/api/kiosks', { name: 'Canteen tablet' })
|
||||
expect(res.status).toBe(201)
|
||||
expect(res.body.kiosk.token).toMatch(/^[\w-]{32}$/)
|
||||
expect((await ctx.admin.get('/api/kiosks')).body.kiosks.map((k: { name: string }) => k.name)).toContain('Canteen tablet')
|
||||
})
|
||||
|
||||
it('let anyone with the token file a report, with photos, and nothing more', async () => {
|
||||
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Gate 2' })).body
|
||||
|
||||
const config = await ctx.app.request('/api/kiosk/config', { headers: { 'x-kiosk-token': kiosk.token } })
|
||||
expect(await json(config)).toMatchObject({ organization: { name: 'Northstar Co.' }, kiosk: { name: 'Gate 2' } })
|
||||
|
||||
const res = await kioskReport(kiosk.token, report({ reporterName: 'Luis' }), [new File([PNG], 'valve.png', { type: 'image/png' })])
|
||||
expect(res.status).toBe(201)
|
||||
const { reference, failedFiles } = await json(res)
|
||||
expect(reference).toMatch(/^INC-\d{4}$/)
|
||||
expect(failedFiles).toEqual([])
|
||||
|
||||
const number = Number(reference.slice(4))
|
||||
const incident = (await ctx.admin.get(`/api/incidents/${number}`)).body.incident
|
||||
expect(incident).toMatchObject({ channel: 'kiosk', kiosk: { name: 'Gate 2' }, reporterName: 'Luis', reporter: null, anonymous: false })
|
||||
expect(incident.attachments).toHaveLength(1)
|
||||
|
||||
// The token is not a session
|
||||
expect((await ctx.app.request('/api/incidents', { headers: { 'x-kiosk-token': kiosk.token } })).status).toBe(401)
|
||||
})
|
||||
|
||||
it('treat reports without a name as anonymous', async () => {
|
||||
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Anon' })).body
|
||||
const { reference } = await json(await kioskReport(kiosk.token, report()))
|
||||
const incident = (await ctx.admin.get(`/api/incidents/${Number(reference.slice(4))}`)).body.incident
|
||||
expect(incident).toMatchObject({ anonymous: true, reporterName: null })
|
||||
})
|
||||
|
||||
it('stop working when revoked', async () => {
|
||||
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Old tablet' })).body
|
||||
await ctx.admin.patch(`/api/kiosks/${kiosk.id}`, { revoked: true })
|
||||
expect((await kioskReport(kiosk.token, report())).status).toBe(401)
|
||||
expect((await kioskReport('made-up-token', report())).status).toBe(401)
|
||||
})
|
||||
|
||||
it('can be limited to one site', async () => {
|
||||
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'North only', siteId: north.id })).body
|
||||
const config = await json(await ctx.app.request('/api/kiosk/config', { headers: { 'x-kiosk-token': kiosk.token } }))
|
||||
expect(config.sites.map((s: { id: string }) => s.id)).toEqual([north.id])
|
||||
expect((await kioskReport(kiosk.token, report({ siteId: south.id }))).status).toBe(400)
|
||||
})
|
||||
|
||||
it('validate reports', async () => {
|
||||
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Validation' })).body
|
||||
const res = await kioskReport(kiosk.token, report({ type: 'explosion' }))
|
||||
expect(res.status).toBe(400)
|
||||
expect((await json(res)).issues[0].path).toBe('type')
|
||||
})
|
||||
|
||||
it('are rate limited', async () => {
|
||||
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Busy' })).body
|
||||
for (let i = 0; i < 30; i++) expect((await kioskReport(kiosk.token, report({ severity: 'low' }))).status).toBe(201)
|
||||
expect((await kioskReport(kiosk.token, report({ severity: 'low' }))).status).toBe(429)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resubmitting the same report (offline retries)', () => {
|
||||
it('returns the original instead of creating a duplicate', async () => {
|
||||
const reporter = await ctx.createUser('reporter')
|
||||
const clientId = crypto.randomUUID()
|
||||
const first = await reporter.post('/api/incidents', { ...report(), clientId })
|
||||
const again = await reporter.post('/api/incidents', { ...report(), clientId })
|
||||
expect(first.status).toBe(201)
|
||||
expect(again.status).toBe(200)
|
||||
expect(again.body.incident.number).toBe(first.body.incident.number)
|
||||
})
|
||||
|
||||
it('holds under simultaneous retries', async () => {
|
||||
const reporter = await ctx.createUser('reporter', 'racer@example.com')
|
||||
const clientId = crypto.randomUUID()
|
||||
const results = await Promise.all([1, 2, 3].map(() => reporter.post('/api/incidents', { ...report(), clientId })))
|
||||
expect(new Set(results.map((r) => r.body.incident.number)).size).toBe(1)
|
||||
})
|
||||
|
||||
it('does not attach photos twice from a kiosk retry', async () => {
|
||||
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Retry' })).body
|
||||
const clientId = crypto.randomUUID()
|
||||
const file = () => new File([PNG], 'p.png', { type: 'image/png' })
|
||||
const a = await json(await kioskReport(kiosk.token, report({ clientId }), [file()]))
|
||||
const b = await kioskReport(kiosk.token, report({ clientId }), [file()])
|
||||
expect(b.status).toBe(200)
|
||||
expect((await json(b)).reference).toBe(a.reference)
|
||||
const incident = (await ctx.admin.get(`/api/incidents/${Number(a.reference.slice(4))}`)).body.incident
|
||||
expect(incident.attachments).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
import { and, asc, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import { Hono, type Context } from 'hono'
|
||||
import { bodyLimit } from 'hono/body-limit'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
import type { Kiosk, KioskConfig } from '../../shared/api-types.ts'
|
||||
import { incidentReference } from '../../shared/format.ts'
|
||||
import { createKioskSchema, kioskReportSchema, updateKioskSchema } from '../../shared/schemas.ts'
|
||||
import { requireRole } from '../auth/middleware.ts'
|
||||
import { RateLimiter } from '../auth/rate-limit.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { areas, kiosks, organizations, sites } from '../db/schema.ts'
|
||||
import { saveAttachment } from '../lib/attachments.ts'
|
||||
import { audit, diff } from '../lib/audit.ts'
|
||||
import { iso } from '../lib/dates.ts'
|
||||
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||
import { auditIncident } from '../lib/incidents.ts'
|
||||
import { fileReport } from '../lib/reporting.ts'
|
||||
|
||||
export const KIOSK_HEADER = 'x-kiosk-token'
|
||||
const MAX_FILES_PER_REPORT = 5
|
||||
const idParam = z.object({ id: z.uuid() })
|
||||
|
||||
async function toKiosk(db: Db, row: typeof kiosks.$inferSelect): Promise<Kiosk> {
|
||||
const [site] = row.siteId ? await db.select({ id: sites.id, name: sites.name }).from(sites).where(eq(sites.id, row.siteId)) : []
|
||||
return { id: row.id, name: row.name, token: row.token, site: site ?? null, lastUsedAt: iso(row.lastUsedAt), revokedAt: iso(row.revokedAt), createdAt: iso(row.createdAt) }
|
||||
}
|
||||
|
||||
async function assertSite(db: Db, organizationId: string, siteId: string | null | undefined) {
|
||||
if (!siteId) return
|
||||
const [site] = await db.select({ id: sites.id }).from(sites).where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId)))
|
||||
if (!site) throw fail(400, 'Unknown site')
|
||||
}
|
||||
|
||||
/** Managing reporting links (signed-in safety managers). */
|
||||
export const kioskAdminRoutes = new Hono<AppEnv>()
|
||||
.get('/', requireRole('safety_manager'), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const rows = await db.select().from(kiosks).where(eq(kiosks.organizationId, me.organizationId)).orderBy(asc(kiosks.name))
|
||||
return c.json({ kiosks: await Promise.all(rows.map((row) => toKiosk(db, row))) })
|
||||
})
|
||||
|
||||
.post('/', requireRole('safety_manager'), validate('json', createKioskSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const input = c.req.valid('json')
|
||||
await assertSite(db, me.organizationId, input.siteId)
|
||||
const [row] = await db
|
||||
.insert(kiosks)
|
||||
.values({ ...input, organizationId: me.organizationId, token: randomBytes(24).toString('base64url'), createdById: me.id })
|
||||
.returning()
|
||||
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'kiosk', entityId: row.id, action: 'create', changes: { name: row.name } })
|
||||
return c.json({ kiosk: await toKiosk(db, row) }, 201)
|
||||
})
|
||||
|
||||
.patch('/:id', requireRole('safety_manager'), validate('param', idParam), validate('json', updateKioskSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const [current] = await db
|
||||
.select()
|
||||
.from(kiosks)
|
||||
.where(and(eq(kiosks.id, c.req.valid('param').id), eq(kiosks.organizationId, me.organizationId)))
|
||||
if (!current) throw fail(404, 'Reporting link not found')
|
||||
const { revoked, ...fields } = c.req.valid('json')
|
||||
await assertSite(db, me.organizationId, fields.siteId)
|
||||
const update = { ...fields, revokedAt: revoked === undefined ? undefined : revoked ? (current.revokedAt ?? new Date()) : null }
|
||||
const changes = diff(current, update)
|
||||
if (Object.keys(changes).length === 0) return c.json({ kiosk: await toKiosk(db, current) })
|
||||
const [row] = await db.update(kiosks).set(update).where(eq(kiosks.id, current.id)).returning()
|
||||
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'kiosk', entityId: row.id, action: 'update', changes })
|
||||
return c.json({ kiosk: await toKiosk(db, row) })
|
||||
})
|
||||
|
||||
/**
|
||||
* Endpoints used with a reporting link's token instead of a session. The token travels in a custom
|
||||
* header, which browsers never send cross-site without a CORS preflight, so these can't be forged
|
||||
* from another site.
|
||||
*/
|
||||
export function kioskPublicRoutes() {
|
||||
// Each link can file 30 reports per 10 minutes: plenty for a busy shop floor, a brake on abuse.
|
||||
const limiter = new RateLimiter(30, 10 * 60 * 1000)
|
||||
|
||||
async function kioskFrom(c: Context<AppEnv>) {
|
||||
const token = c.req.header(KIOSK_HEADER)
|
||||
if (!token) throw fail(401, 'Missing reporting link token')
|
||||
const [row] = await c
|
||||
.get('db')
|
||||
.select()
|
||||
.from(kiosks)
|
||||
.where(and(eq(kiosks.token, token), isNull(kiosks.revokedAt)))
|
||||
if (!row) throw fail(401, 'This reporting link is no longer active. Ask your safety team for a new one.')
|
||||
return row
|
||||
}
|
||||
|
||||
return new Hono<AppEnv>()
|
||||
.get('/config', async (c) => {
|
||||
const kiosk = await kioskFrom(c)
|
||||
const db = c.get('db')
|
||||
const [organization] = await db.select({ name: organizations.name }).from(organizations).where(eq(organizations.id, kiosk.organizationId))
|
||||
const siteRows = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(and(eq(sites.organizationId, kiosk.organizationId), isNull(sites.archivedAt), kiosk.siteId ? eq(sites.id, kiosk.siteId) : undefined))
|
||||
.orderBy(asc(sites.name))
|
||||
const areaRows = siteRows.length
|
||||
? await db
|
||||
.select()
|
||||
.from(areas)
|
||||
.where(and(inArray(areas.siteId, siteRows.map((s) => s.id)), isNull(areas.archivedAt)))
|
||||
.orderBy(asc(areas.name))
|
||||
: []
|
||||
const config: KioskConfig = {
|
||||
organization,
|
||||
kiosk: { name: kiosk.name },
|
||||
sites: siteRows.map((site) => ({
|
||||
id: site.id,
|
||||
name: site.name,
|
||||
address: null,
|
||||
timezone: site.timezone,
|
||||
archivedAt: null,
|
||||
areas: areaRows
|
||||
.filter((area) => area.siteId === site.id)
|
||||
.map((area) => ({ id: area.id, siteId: area.siteId, parentId: area.parentId, name: area.name, kind: area.kind, archivedAt: null })),
|
||||
})),
|
||||
}
|
||||
return c.json(config)
|
||||
})
|
||||
|
||||
// Multipart: a `report` field holding the JSON report, plus up to 5 `files`.
|
||||
.post('/reports', async (c, next) => bodyLimit({ maxSize: c.get('config').maxUploadBytes * MAX_FILES_PER_REPORT + 256 * 1024, onError: (c) => c.json({ error: 'Files are too large' }, 413) })(c, next), async (c) => {
|
||||
const kiosk = await kioskFrom(c)
|
||||
if (!limiter.attempt(kiosk.id)) throw fail(429, 'Too many reports from this link right now. Please try again in a few minutes.')
|
||||
const db = c.get('db')
|
||||
|
||||
const body = await c.req.parseBody({ all: true })
|
||||
let json: unknown
|
||||
try {
|
||||
json = JSON.parse(String(body.report ?? ''))
|
||||
} catch {
|
||||
throw fail(400, 'Missing report')
|
||||
}
|
||||
const parsed = kioskReportSchema.safeParse(json)
|
||||
if (!parsed.success) {
|
||||
return c.json({ error: 'Invalid request', issues: parsed.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })) }, 400)
|
||||
}
|
||||
if (kiosk.siteId && parsed.data.siteId !== kiosk.siteId) throw fail(400, 'This reporting link is for a different site')
|
||||
const files = ([] as unknown[]).concat(body.files ?? []).filter((file) => file instanceof File)
|
||||
if (files.length > MAX_FILES_PER_REPORT) throw fail(400, `Attach at most ${MAX_FILES_PER_REPORT} photos`)
|
||||
|
||||
const { reporterName, ...report } = parsed.data
|
||||
const { id, number, created } = await fileReport(db, c.get('notifier'), {
|
||||
...report,
|
||||
organizationId: kiosk.organizationId,
|
||||
reporter: null,
|
||||
reporterName,
|
||||
anonymous: !reporterName,
|
||||
kioskId: kiosk.id,
|
||||
})
|
||||
await db.update(kiosks).set({ lastUsedAt: new Date() }).where(eq(kiosks.id, kiosk.id))
|
||||
|
||||
// Files are only taken with the first copy of a submission, so retries don't duplicate photos.
|
||||
const failed: string[] = []
|
||||
if (created) {
|
||||
for (const file of files) {
|
||||
try {
|
||||
const row = await saveAttachment({ db, storage: c.get('storage'), config: c.get('config') }, id, file, null)
|
||||
await auditIncident(db, null, kiosk.organizationId, id, 'attachment.add', { attachmentId: row.id, fileName: row.fileName })
|
||||
} catch (error) {
|
||||
failed.push(`${(file as File).name}: ${error instanceof Error ? error.message : 'upload failed'}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only the reference goes back: people using a link can't read reports.
|
||||
return c.json({ reference: incidentReference(number), failedFiles: failed }, created ? 201 : 200)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createTestContext } from '../test/helpers.ts'
|
||||
|
||||
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||
let manager: Awaited<ReturnType<typeof ctx.createUser>>
|
||||
let supervisor: Awaited<ReturnType<typeof ctx.createUser>>
|
||||
let site: { id: string }
|
||||
const year = new Date().getFullYear()
|
||||
|
||||
/** An incident earlier this year (Jan 15, local time) so it lands on this year's log. */
|
||||
async function incident(title = 'Hand caught in press') {
|
||||
const occurredAt = new Date(year, 0, 15, 10, 30).toISOString()
|
||||
const res = await supervisor.post('/api/incidents', { title, type: 'injury', severity: 'high', occurredAt, siteId: site.id, locationDetail: 'Line 3' })
|
||||
return res.body.incident.number as number
|
||||
}
|
||||
|
||||
const recordableAnswers = {
|
||||
workRelated: true,
|
||||
newCase: true,
|
||||
jobTitle: 'Press operator',
|
||||
natureOfInjury: 'Laceration',
|
||||
bodyPart: 'left hand',
|
||||
objectSubstance: 'Press die',
|
||||
dateOfBirth: '1990-04-02',
|
||||
dateOfHire: '2019-06-10',
|
||||
activityBefore: 'Clearing a jam',
|
||||
whatHappened: 'Hand slipped into the die area',
|
||||
sex: 'M',
|
||||
timeStartedWork: '07:00',
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createTestContext()
|
||||
site = await ctx.createSite('Main Plant')
|
||||
manager = await ctx.createUser('safety_manager')
|
||||
supervisor = await ctx.createUser('supervisor')
|
||||
})
|
||||
afterAll(() => ctx.close())
|
||||
|
||||
describe('injured people (OSHA cases)', () => {
|
||||
it('keeps medical details to safety managers', async () => {
|
||||
const number = await incident()
|
||||
await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Pat Doe' })
|
||||
expect((await supervisor.get(`/api/osha/incidents/${number}/cases`)).status).toBe(403)
|
||||
expect((await supervisor.post(`/api/osha/incidents/${number}/cases`, { name: 'X' })).status).toBe(403)
|
||||
const detail = (await supervisor.get(`/api/incidents/${number}`)).body.incident
|
||||
expect(detail).toMatchObject({ injuredCount: 1, can: { manageOsha: false } })
|
||||
// The timeline says a person was added, without naming them
|
||||
const timeline = (await supervisor.get(`/api/incidents/${number}/timeline`)).body.timeline
|
||||
expect(JSON.stringify(timeline)).not.toContain('Pat Doe')
|
||||
})
|
||||
|
||||
it('works out recordability from the answers, and re-checks on every change', async () => {
|
||||
const number = await incident()
|
||||
let res = await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Sam Lee' })
|
||||
expect(res.body.case).toMatchObject({ recordable: false, outcome: null, caseNumber: `INC-${String(number).padStart(4, '0')}` })
|
||||
const id = res.body.case.id
|
||||
|
||||
res = await manager.patch(`/api/osha/cases/${id}`, { ...recordableAnswers })
|
||||
expect(res.body.case).toMatchObject({ recordable: false }) // first aid only so far
|
||||
|
||||
res = await manager.patch(`/api/osha/cases/${id}`, { medicalTreatment: true })
|
||||
expect(res.body.case).toMatchObject({ recordable: true, outcome: 'other' })
|
||||
|
||||
res = await manager.patch(`/api/osha/cases/${id}`, { daysAwayFromWork: true, daysAway: 4, restrictedOrTransfer: true, daysRestricted: 10 })
|
||||
expect(res.body.case).toMatchObject({ outcome: 'days_away', daysAway: 4, daysRestricted: 10, missingFields: [] })
|
||||
|
||||
res = await manager.patch(`/api/osha/cases/${id}`, { workRelated: false })
|
||||
expect(res.body.case.recordable).toBe(false)
|
||||
await manager.patch(`/api/osha/cases/${id}`, { workRelated: true })
|
||||
})
|
||||
|
||||
it('numbers each person on an incident and enforces the 180-day cap', async () => {
|
||||
const number = await incident()
|
||||
await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'First' })
|
||||
const second = await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Second' })
|
||||
expect(second.body.case.caseNumber).toBe(`INC-${String(number).padStart(4, '0')}-2`)
|
||||
const res = await manager.patch(`/api/osha/cases/${second.body.case.id}`, { daysAwayFromWork: true, daysAway: 150, restrictedOrTransfer: true, daysRestricted: 40 })
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.body.error).toMatch(/180/)
|
||||
})
|
||||
|
||||
it('ignores day counts when the outcome does not apply', async () => {
|
||||
const number = await incident()
|
||||
const created = await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Counts', daysAway: 12 })
|
||||
expect(created.body.case.daysAway).toBe(0)
|
||||
})
|
||||
|
||||
it('alerts safety managers when a case must be reported to OSHA, once', async () => {
|
||||
const number = await incident('Finger amputation at shear')
|
||||
const created = await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Alex Kim', ...recordableAnswers, amputation: true, medicalTreatment: true })
|
||||
expect(created.body.case.severeReport).toMatchObject({ withinHours: 24, reasons: ['amputation'] })
|
||||
await ctx.notifier.flush()
|
||||
const alert = ctx.mailer.last(manager.user.email)
|
||||
expect(alert?.subject).toMatch(/^Report to OSHA within 24 hours: INC-\d{4}$/)
|
||||
expect(alert?.text).toContain('1-800-321-OSHA')
|
||||
|
||||
const before = ctx.mailer.sent.length
|
||||
await manager.patch(`/api/osha/cases/${created.body.case.id}`, { reportedToOshaAt: new Date().toISOString(), lossOfEye: true })
|
||||
await ctx.notifier.flush()
|
||||
expect(ctx.mailer.sent.length).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OSHA log, summary and rates', () => {
|
||||
let other: { id: string }
|
||||
|
||||
beforeAll(async () => {
|
||||
other = await ctx.createSite('Second Plant')
|
||||
const occurredAt = new Date(year, 1, 3, 14, 5).toISOString()
|
||||
const res = await supervisor.post('/api/incidents', { title: 'Needlestick', type: 'injury', severity: 'medium', occurredAt, siteId: other.id })
|
||||
const number = res.body.incident.number
|
||||
const created = await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Jordan Private', ...recordableAnswers, specialCase: 'needlestick', privacyCase: true, restrictedOrTransfer: true, daysRestricted: 3, caseType: 'other_illness' })
|
||||
if (!created.body.case.recordable) throw new Error('setup: needlestick case should be recordable')
|
||||
// Not recordable: should stay off the log
|
||||
await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'First Aid Only', ...recordableAnswers })
|
||||
await manager.put(`/api/osha/sites/${other.id}/hours`, {
|
||||
year,
|
||||
months: Array.from({ length: 12 }, (_, i) => ({ month: i + 1, hours: 10_000, averageEmployees: i < 6 ? 50 : 70 })),
|
||||
})
|
||||
})
|
||||
|
||||
it('builds the 300 Log with privacy cases hidden', async () => {
|
||||
const log = (await manager.get(`/api/osha/log?siteId=${other.id}&year=${year}`)).body
|
||||
expect(log.rows).toHaveLength(1)
|
||||
expect(log.rows[0]).toMatchObject({ employeeName: 'Privacy case', jobTitle: 'Press operator', outcome: 'restricted', daysRestricted: 3, caseType: 'other_illness' })
|
||||
expect(log.rows[0].description).toBe('Laceration (left hand); Press die')
|
||||
})
|
||||
|
||||
it('totals the 300A, computes rates and runs the ITA checks', async () => {
|
||||
const log = (await manager.get(`/api/osha/log?siteId=${other.id}&year=${year}`)).body
|
||||
expect(log.totals).toMatchObject({ deaths: 0, daysAwayCases: 0, restrictedCases: 1, otherCases: 0, daysRestricted: 3 })
|
||||
expect(log).toMatchObject({ totalHoursWorked: 120_000, annualAverageEmployees: 60, maxEmployees: 70 })
|
||||
expect(log.rates).toEqual({ trir: 1.67, dart: 1.67, ltir: 0 })
|
||||
expect(log.checks.errors).toEqual([])
|
||||
expect(log.missingEstablishmentFields).toEqual(['street address', 'city', 'state', 'ZIP code', 'NAICS code', 'EIN'])
|
||||
|
||||
const empty = (await manager.get(`/api/osha/log?siteId=${site.id}&year=${year}`)).body
|
||||
expect(empty.checks.errors).toContain('Enter hours worked for each month.')
|
||||
})
|
||||
|
||||
it('validates and normalises establishment details', async () => {
|
||||
expect((await manager.patch(`/api/sites/${other.id}`, { naicsCode: '33211' })).status).toBe(400)
|
||||
const res = await manager.patch(`/api/sites/${other.id}`, { streetAddress: '1 Mill Rd', city: 'Dayton', state: 'oh', zip: '45402-1234', naicsCode: '332119', ein: '12-3456789' })
|
||||
expect(res.body.site).toMatchObject({ state: 'OH', zip: '454021234', ein: '123456789' })
|
||||
})
|
||||
|
||||
it('exports the ITA 300A file in OSHA’s format', async () => {
|
||||
const res = await manager.get(`/api/osha/ita/summary.csv?year=${year}`)
|
||||
const lines = res.body.toString('utf8').trimEnd().split('\r\n')
|
||||
expect(lines[0].charCodeAt(0)).not.toBe(0xfeff)
|
||||
expect(lines[0]).toBe(
|
||||
'establishment_name,ein_number,company_name,street_address,city,state,zip,naics_code,industry_description,size,establishment_type,year_filing_for,annual_average_employees,total_hours_worked,no_injuries_illnesses,total_deaths,total_dafw_cases,total_djtr_cases,total_other_cases,total_dafw_days,total_djtr_days,total_injuries,total_skin_disorders,total_respiratory_conditions,total_poisonings,total_hearing_loss,total_other_illnesses,change_reason',
|
||||
)
|
||||
const second = lines.find((line: string) => line.startsWith('Second Plant'))
|
||||
expect(second).toBe(`Second Plant,123456789,Northstar Co.,1 Mill Rd,Dayton,OH,454021234,332119,,21,1,${year},60,120000,1,0,0,1,0,0,3,0,0,0,0,0,1,`)
|
||||
})
|
||||
|
||||
it('exports ITA case data without names', async () => {
|
||||
const res = await manager.get(`/api/osha/ita/cases.csv?year=${year}`)
|
||||
const csv = res.body.toString('utf8')
|
||||
expect(csv).not.toContain('Jordan Private')
|
||||
expect(csv).not.toContain('First Aid Only')
|
||||
const [header, ...rows] = csv.trimEnd().split('\r\n')
|
||||
expect(header.split(',')).toHaveLength(24)
|
||||
const row = rows.find((line: string) => line.startsWith('Second Plant'))!
|
||||
expect(row).toContain(`02-03-${year}`) // mm-dd-yyyy
|
||||
expect(row).toContain(',3,0,3,6,04-02-1990,06-10-2019,M,0,0,07:00,14:05,,') // outcome, days, type, dates, sex, treatment, times
|
||||
})
|
||||
|
||||
it('produces a Form 301 for a case', async () => {
|
||||
const log = (await manager.get(`/api/osha/log?siteId=${other.id}&year=${year}`)).body
|
||||
const form = (await manager.get(`/api/osha/cases/${log.rows[0].caseId}/301`)).body
|
||||
expect(form).toMatchObject({ establishment: { name: 'Second Plant', city: 'Dayton' }, case: { name: 'Jordan Private', privacyCase: true }, incident: { title: 'Needlestick' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,289 @@
|
||||
import { and, asc, count, eq } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import type { Form301 } from '../../shared/api-types.ts'
|
||||
import { incidentReference } from '../../shared/format.ts'
|
||||
import { assessRecordability, caseTypeCode, outcomeCode, severeInjuryReport, itaSizeCode } from '../../shared/osha.ts'
|
||||
import { createOshaCaseSchema, hoursWorkedSchema, updateOshaCaseSchema } from '../../shared/schemas.ts'
|
||||
import { requireRole } from '../auth/middleware.ts'
|
||||
import type { SessionUser } from '../auth/sessions.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { areas, hoursWorked, incidents, injuredPersons, sites } from '../db/schema.ts'
|
||||
import { toCsv } from '../lib/csv.ts'
|
||||
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||
import { auditIncident, findIncident } from '../lib/incidents.ts'
|
||||
import { activeSites, buildOshaYear, describeCase, establishmentOf, toOshaCase, yearRange } from '../lib/osha.ts'
|
||||
|
||||
const numberParam = z.object({ number: z.coerce.number().int().positive() })
|
||||
const idParam = z.object({ id: z.uuid() })
|
||||
const yearQuery = z.object({ year: z.coerce.number().int().min(2000).max(2100) })
|
||||
|
||||
type Person = typeof injuredPersons.$inferSelect
|
||||
|
||||
/** Days only count when the matching outcome applies; recordability is always derived, never sent. */
|
||||
function derive(person: Omit<Person, 'recordable' | 'outcome'>) {
|
||||
if (person.daysAway + person.daysRestricted > 180) throw fail(400, 'Stop counting once days away and restricted days reach 180 in total (1904.7(b)(3)(vii)).')
|
||||
const assessment = assessRecordability(person)
|
||||
return {
|
||||
daysAway: person.daysAwayFromWork ? person.daysAway : 0,
|
||||
daysRestricted: person.restrictedOrTransfer ? person.daysRestricted : 0,
|
||||
recordable: assessment.status === 'recordable',
|
||||
outcome: assessment.outcome,
|
||||
}
|
||||
}
|
||||
|
||||
async function findCase(db: Db, user: SessionUser, id: string) {
|
||||
const [row] = await db
|
||||
.select({ person: injuredPersons, incident: incidents })
|
||||
.from(injuredPersons)
|
||||
.innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId))
|
||||
.where(and(eq(injuredPersons.id, id), eq(incidents.organizationId, user.organizationId)))
|
||||
if (!row) throw fail(404, 'Case not found')
|
||||
return row
|
||||
}
|
||||
|
||||
const dateMDY = (isoDate: string | null) => (isoDate ? `${isoDate.slice(5, 7)}-${isoDate.slice(8, 10)}-${isoDate.slice(0, 4)}` : '')
|
||||
const localTime = (date: Date) => `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
const localDateMDY = (date: Date) => `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}-${date.getFullYear()}`
|
||||
const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
|
||||
|
||||
/**
|
||||
* OSHA recordkeeping. Injury and medical details are limited to safety managers and admins
|
||||
* (they hold the confidential records); everyone else only sees that a person was involved.
|
||||
*/
|
||||
export const oshaRoutes = new Hono<AppEnv>()
|
||||
.get('/incidents/:number/cases', requireRole('safety_manager'), validate('param', numberParam), async (c) => {
|
||||
const db = c.get('db')
|
||||
const { incident } = await findIncident(db, currentUser(c), c.req.valid('param').number)
|
||||
const people = await db.select().from(injuredPersons).where(eq(injuredPersons.incidentId, incident.id)).orderBy(asc(injuredPersons.createdAt))
|
||||
return c.json({ cases: people.map((person) => toOshaCase(person, incident)) })
|
||||
})
|
||||
|
||||
.post('/incidents/:number/cases', requireRole('safety_manager'), validate('param', numberParam), validate('json', createOshaCaseSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { incident } = await findIncident(db, me, c.req.valid('param').number)
|
||||
const input = c.req.valid('json')
|
||||
const [{ existing }] = await db.select({ existing: count() }).from(injuredPersons).where(eq(injuredPersons.incidentId, incident.id))
|
||||
const reference = incidentReference(incident.number)
|
||||
|
||||
const draft = {
|
||||
employmentType: 'employee' as const,
|
||||
caseType: 'injury' as const,
|
||||
workRelated: null,
|
||||
newCase: null,
|
||||
died: false,
|
||||
daysAwayFromWork: false,
|
||||
daysAway: 0,
|
||||
restrictedOrTransfer: false,
|
||||
daysRestricted: 0,
|
||||
medicalTreatment: false,
|
||||
lossOfConsciousness: false,
|
||||
significantDiagnosis: false,
|
||||
specialCase: 'none' as const,
|
||||
...input,
|
||||
reportedToOshaAt: input.reportedToOshaAt ? new Date(input.reportedToOshaAt) : null,
|
||||
}
|
||||
const [person] = await db
|
||||
.insert(injuredPersons)
|
||||
.values({ ...draft, ...derive(draft as Person), incidentId: incident.id, caseNumber: existing ? `${reference}-${existing + 1}` : reference })
|
||||
.returning()
|
||||
// The timeline is visible beyond the safety team, so it carries no names or medical details.
|
||||
await auditIncident(db, me, me.organizationId, incident.id, 'osha_case.add', { caseNumber: person.caseNumber, recordable: person.recordable })
|
||||
if (severeInjuryReport(person) && !person.reportedToOshaAt) c.get('notifier').severeInjury(person.id)
|
||||
return c.json({ case: toOshaCase(person, incident) }, 201)
|
||||
})
|
||||
|
||||
.patch('/cases/:id', requireRole('safety_manager'), validate('param', idParam), validate('json', updateOshaCaseSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { person, incident } = await findCase(db, me, c.req.valid('param').id)
|
||||
const input = c.req.valid('json')
|
||||
const merged = { ...person, ...input, reportedToOshaAt: input.reportedToOshaAt === undefined ? person.reportedToOshaAt : input.reportedToOshaAt ? new Date(input.reportedToOshaAt) : null }
|
||||
const update = { ...merged, ...derive(merged) }
|
||||
const [saved] = await db
|
||||
.update(injuredPersons)
|
||||
.set({ ...update, id: undefined, incidentId: undefined, createdAt: undefined, updatedAt: undefined })
|
||||
.where(eq(injuredPersons.id, person.id))
|
||||
.returning()
|
||||
await auditIncident(db, me, me.organizationId, incident.id, 'osha_case.update', {
|
||||
caseNumber: saved.caseNumber,
|
||||
...(saved.recordable !== person.recordable ? { recordable: [person.recordable, saved.recordable] } : {}),
|
||||
})
|
||||
const becameSevere = severeInjuryReport(saved) && !severeInjuryReport(person)
|
||||
if (becameSevere && !saved.reportedToOshaAt) c.get('notifier').severeInjury(saved.id)
|
||||
return c.json({ case: toOshaCase(saved, incident) })
|
||||
})
|
||||
|
||||
.delete('/cases/:id', requireRole('safety_manager'), validate('param', idParam), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { person, incident } = await findCase(db, me, c.req.valid('param').id)
|
||||
await db.delete(injuredPersons).where(eq(injuredPersons.id, person.id))
|
||||
await auditIncident(db, me, me.organizationId, incident.id, 'osha_case.remove', { caseNumber: person.caseNumber })
|
||||
return c.body(null, 204)
|
||||
})
|
||||
|
||||
.get('/cases/:id/301', requireRole('safety_manager'), validate('param', idParam), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { person, incident } = await findCase(db, me, c.req.valid('param').id)
|
||||
const [site] = await db.select().from(sites).where(eq(sites.id, incident.siteId))
|
||||
const [area] = incident.areaId ? await db.select({ name: areas.name }).from(areas).where(eq(areas.id, incident.areaId)) : []
|
||||
const year = await buildOshaYear(db, me.organizationId, site.id, incident.occurredAt.getFullYear())
|
||||
const form: Form301 = {
|
||||
case: toOshaCase(person, incident),
|
||||
establishment: { name: site.name, companyName: year.companyName, ...establishmentOf(site) },
|
||||
incident: {
|
||||
reference: incidentReference(incident.number),
|
||||
occurredAt: incident.occurredAt.toISOString(),
|
||||
title: incident.title,
|
||||
description: incident.description,
|
||||
site: site.name,
|
||||
area: area?.name ?? null,
|
||||
locationDetail: incident.locationDetail,
|
||||
},
|
||||
}
|
||||
return c.json(form)
|
||||
})
|
||||
|
||||
.get('/log', requireRole('safety_manager'), validate('query', yearQuery.extend({ siteId: z.uuid() })), async (c) => {
|
||||
const { siteId, year } = c.req.valid('query')
|
||||
return c.json(await buildOshaYear(c.get('db'), currentUser(c).organizationId, siteId, year))
|
||||
})
|
||||
|
||||
.get('/sites/:id/hours', requireRole('safety_manager'), validate('param', idParam), validate('query', yearQuery), async (c) => {
|
||||
const year = await buildOshaYear(c.get('db'), currentUser(c).organizationId, c.req.valid('param').id, c.req.valid('query').year)
|
||||
return c.json({ months: year.months })
|
||||
})
|
||||
|
||||
.put('/sites/:id/hours', requireRole('safety_manager'), validate('param', idParam), validate('json', hoursWorkedSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const siteId = c.req.valid('param').id
|
||||
const { year, months } = c.req.valid('json')
|
||||
await buildOshaYear(db, me.organizationId, siteId, year) // 404 unless the site is ours
|
||||
for (const { month, hours, averageEmployees } of months) {
|
||||
const monthDate = `${year}-${String(month).padStart(2, '0')}-01`
|
||||
if (hours === null && averageEmployees === null) {
|
||||
await db.delete(hoursWorked).where(and(eq(hoursWorked.siteId, siteId), eq(hoursWorked.month, monthDate)))
|
||||
continue
|
||||
}
|
||||
await db
|
||||
.insert(hoursWorked)
|
||||
.values({ siteId, month: monthDate, hours: String(hours ?? 0), averageEmployees })
|
||||
.onConflictDoUpdate({ target: [hoursWorked.siteId, hoursWorked.month], set: { hours: String(hours ?? 0), averageEmployees } })
|
||||
}
|
||||
const updated = await buildOshaYear(db, me.organizationId, siteId, year)
|
||||
return c.json({ months: updated.months })
|
||||
})
|
||||
|
||||
// OSHA Injury Tracking Application uploads. Formats follow OSHA's published CSV specifications.
|
||||
.get('/ita/summary.csv', requireRole('safety_manager'), validate('query', yearQuery), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { year } = c.req.valid('query')
|
||||
const rows = []
|
||||
for (const site of await activeSites(db, me.organizationId)) {
|
||||
const y = await buildOshaYear(db, me.organizationId, site.id, year)
|
||||
const t = y.totals
|
||||
rows.push([
|
||||
site.name,
|
||||
site.ein,
|
||||
y.companyName,
|
||||
site.streetAddress,
|
||||
site.city,
|
||||
site.state,
|
||||
site.zip,
|
||||
site.naicsCode,
|
||||
site.industryDescription,
|
||||
itaSizeCode(y.maxEmployees),
|
||||
site.establishmentType,
|
||||
year,
|
||||
y.annualAverageEmployees,
|
||||
y.totalHoursWorked,
|
||||
y.rows.length ? 1 : 2,
|
||||
t.deaths,
|
||||
t.daysAwayCases,
|
||||
t.restrictedCases,
|
||||
t.otherCases,
|
||||
t.daysAway,
|
||||
t.daysRestricted,
|
||||
t.byType.injury,
|
||||
t.byType.skin_disorder,
|
||||
t.byType.respiratory,
|
||||
t.byType.poisoning,
|
||||
t.byType.hearing_loss,
|
||||
t.byType.other_illness,
|
||||
'',
|
||||
])
|
||||
}
|
||||
const csv = toCsv(
|
||||
[
|
||||
'establishment_name', 'ein_number', 'company_name', 'street_address', 'city', 'state', 'zip', 'naics_code', 'industry_description', 'size',
|
||||
'establishment_type', 'year_filing_for', 'annual_average_employees', 'total_hours_worked', 'no_injuries_illnesses', 'total_deaths',
|
||||
'total_dafw_cases', 'total_djtr_cases', 'total_other_cases', 'total_dafw_days', 'total_djtr_days', 'total_injuries', 'total_skin_disorders',
|
||||
'total_respiratory_conditions', 'total_poisonings', 'total_hearing_loss', 'total_other_illnesses', 'change_reason',
|
||||
],
|
||||
rows,
|
||||
{ bom: false },
|
||||
)
|
||||
return c.body(csv, 200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="osha-ita-300a-${year}.csv"` })
|
||||
})
|
||||
|
||||
.get('/ita/cases.csv', requireRole('safety_manager'), validate('query', yearQuery), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { year } = c.req.valid('query')
|
||||
const [from, to] = yearRange(year)
|
||||
const rows = []
|
||||
for (const site of await activeSites(db, me.organizationId)) {
|
||||
const y = await buildOshaYear(db, me.organizationId, site.id, year)
|
||||
for (const row of y.rows) {
|
||||
const [{ person, incident }] = await db
|
||||
.select({ person: injuredPersons, incident: incidents })
|
||||
.from(injuredPersons)
|
||||
.innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId))
|
||||
.where(eq(injuredPersons.id, row.caseId))
|
||||
if (incident.occurredAt < from || incident.occurredAt >= to) continue
|
||||
// No names or other personal identifiers: OSHA publishes this data.
|
||||
rows.push([
|
||||
site.name,
|
||||
year,
|
||||
row.caseNumber,
|
||||
clip(row.jobTitle, 255),
|
||||
localDateMDY(incident.occurredAt),
|
||||
clip(row.location, 255),
|
||||
clip(describeCase(person), 255),
|
||||
outcomeCode[row.outcome],
|
||||
row.daysAway,
|
||||
row.daysRestricted,
|
||||
caseTypeCode[row.caseType],
|
||||
dateMDY(person.dateOfBirth),
|
||||
dateMDY(person.dateOfHire),
|
||||
person.sex ?? '',
|
||||
person.treatedInEmergencyRoom ? 1 : 0,
|
||||
person.inpatientHospitalization ? 1 : 0,
|
||||
person.timeStartedWork ?? '',
|
||||
localTime(incident.occurredAt),
|
||||
'',
|
||||
person.activityBefore ?? '',
|
||||
person.whatHappened ?? incident.description ?? '',
|
||||
person.natureOfInjury ?? '',
|
||||
person.objectSubstance ?? '',
|
||||
dateMDY(person.dateOfDeath),
|
||||
])
|
||||
}
|
||||
}
|
||||
const csv = toCsv(
|
||||
[
|
||||
'establishment_name', 'year_of_filing', 'case_number', 'job_title', 'date_of_incident', 'incident_location', 'incident_description',
|
||||
'incident_outcome', 'dafw_num_away', 'djtr_num_tr', 'type_of_incident', 'date_of_birth', 'date_of_hire', 'sex', 'treatment_facility_type',
|
||||
'treatment_in_patient', 'time_started_work', 'time_of_incident', 'time_unknown', 'nar_before_incident', 'nar_what_happened',
|
||||
'nar_injury_illness', 'nar_object_substance', 'date_of_death',
|
||||
],
|
||||
rows,
|
||||
{ bom: false },
|
||||
)
|
||||
return c.body(csv, 200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="osha-ita-cases-${year}.csv"` })
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import { updateRootCauseSchema } from '../../shared/schemas.ts'
|
||||
import { requireRole } from '../auth/middleware.ts'
|
||||
import type { SessionUser } from '../auth/sessions.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { incidents, investigations, rootCauses } from '../db/schema.ts'
|
||||
import { diff } from '../lib/audit.ts'
|
||||
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||
import { auditIncident, loadDetail } from '../lib/incidents.ts'
|
||||
|
||||
const idParam = z.object({ id: z.uuid() })
|
||||
|
||||
async function findRootCause(db: Db, user: SessionUser, id: string) {
|
||||
const [row] = await db
|
||||
.select({ cause: rootCauses, incidentId: incidents.id, number: incidents.number })
|
||||
.from(rootCauses)
|
||||
.innerJoin(investigations, eq(investigations.id, rootCauses.investigationId))
|
||||
.innerJoin(incidents, eq(incidents.id, investigations.incidentId))
|
||||
.where(and(eq(rootCauses.id, id), eq(incidents.organizationId, user.organizationId)))
|
||||
if (!row) throw fail(404, 'Root cause not found')
|
||||
return row
|
||||
}
|
||||
|
||||
export const rootCauseRoutes = new Hono<AppEnv>()
|
||||
.patch('/:id', requireRole('supervisor'), validate('param', idParam), validate('json', updateRootCauseSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { cause, incidentId, number } = await findRootCause(db, me, c.req.valid('param').id)
|
||||
const input = c.req.valid('json')
|
||||
const changes = diff(cause, input)
|
||||
if (Object.keys(changes).length > 0) {
|
||||
await db.update(rootCauses).set(input).where(eq(rootCauses.id, cause.id))
|
||||
await auditIncident(db, me, me.organizationId, incidentId, 'root_cause.update', changes)
|
||||
}
|
||||
return c.json({ incident: await loadDetail(db, me, number) })
|
||||
})
|
||||
|
||||
.delete('/:id', requireRole('supervisor'), validate('param', idParam), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const { cause, incidentId, number } = await findRootCause(db, me, c.req.valid('param').id)
|
||||
await db.delete(rootCauses).where(eq(rootCauses.id, cause.id))
|
||||
await auditIncident(db, me, me.organizationId, incidentId, 'root_cause.remove', { category: cause.category, description: cause.description })
|
||||
return c.json({ incident: await loadDetail(db, me, number) })
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createTestContext, daysFromToday, hoursAgo } from '../test/helpers.ts'
|
||||
|
||||
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||
beforeAll(async () => {
|
||||
ctx = await createTestContext()
|
||||
})
|
||||
afterAll(() => ctx.close())
|
||||
|
||||
describe('stats', () => {
|
||||
it('summarises the dashboard from real data', async () => {
|
||||
const north = await ctx.createSite('North')
|
||||
await ctx.createSite('South')
|
||||
const supervisor = await ctx.createUser('supervisor')
|
||||
const report = (title: string, severity = 'medium', type = 'hazard') =>
|
||||
supervisor.post('/api/incidents', { title, type, severity, occurredAt: hoursAgo(3), siteId: north.id }).then((r) => r.body.incident.number as number)
|
||||
|
||||
const a = await report('A', 'low')
|
||||
await report('B', 'high', 'injury')
|
||||
await supervisor.post(`/api/incidents/${a}/transitions`, { to: 'triage' })
|
||||
await supervisor.post(`/api/incidents/${a}/transitions`, { to: 'closed' })
|
||||
const c = await report('C')
|
||||
await supervisor.post(`/api/incidents/${c}/actions`, { title: 'Overdue', assigneeId: supervisor.user.id, dueDate: daysFromToday(-1) })
|
||||
await supervisor.post(`/api/incidents/${c}/actions`, { title: 'This week', assigneeId: supervisor.user.id, dueDate: daysFromToday(3) })
|
||||
await supervisor.post(`/api/incidents/${c}/root-causes`, { category: 'equipment', description: 'Worn' })
|
||||
|
||||
const overview = (await supervisor.get('/api/stats/overview')).body
|
||||
expect(overview).toMatchObject({ openIncidents: 2, reportedLast7Days: 3, actionsDueThisWeek: 1, overdueActions: 1, actionProgress: { total: 2, completed: 0 } })
|
||||
expect(overview.byStatus).toMatchObject({ reported: 2, closed: 1 })
|
||||
expect(overview.averageCloseDays).toBeGreaterThanOrEqual(0)
|
||||
expect(overview.weeklyTrend).toHaveLength(12)
|
||||
expect(overview.weeklyTrend.at(-1).count).toBeGreaterThanOrEqual(1)
|
||||
expect(overview.bySite).toEqual([
|
||||
{ siteId: north.id, name: 'North', count: 3 },
|
||||
{ siteId: expect.any(String), name: 'South', count: 0 },
|
||||
])
|
||||
|
||||
const to = new Date().toISOString().slice(0, 10)
|
||||
const from = new Date(Date.now() - 29 * 86_400_000).toISOString().slice(0, 10)
|
||||
const analytics = (await supervisor.get(`/api/stats/analytics?from=${from}&to=${to}`)).body
|
||||
expect(analytics.kpis.reports.value).toBe(3)
|
||||
expect(analytics.byRootCause).toEqual([{ category: 'equipment', count: 1 }])
|
||||
})
|
||||
|
||||
it("scopes a reporter's numbers to what they can see", async () => {
|
||||
const reporter = await ctx.createUser('reporter')
|
||||
expect((await reporter.get('/api/stats/overview')).body.openIncidents).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { and, asc, count, desc, eq, gte, inArray, isNotNull, isNull, lt, ne, sql } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
import type { OverviewStats } from '../../shared/api-types.ts'
|
||||
import { incidentStatuses, type IncidentStatus } from '../../shared/enums.ts'
|
||||
import { requireAuth } from '../auth/middleware.ts'
|
||||
import { correctiveActions, incidents, sites } from '../db/schema.ts'
|
||||
import { addDays, localDate } from '../lib/dates.ts'
|
||||
import { computeAnalytics } from '../lib/analytics.ts'
|
||||
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||
import { analyticsQuery } from '../../shared/schemas.ts'
|
||||
import { visibleTo } from '../lib/incidents.ts'
|
||||
|
||||
const DAY_MS = 86_400_000
|
||||
const OPEN_ACTION = ['open', 'in_progress'] as const
|
||||
|
||||
/** Monday 00:00 UTC of the week containing `date` (matches Postgres `date_trunc('week', …)` in UTC). */
|
||||
function weekStart(date: Date): Date {
|
||||
const start = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
|
||||
start.setUTCDate(start.getUTCDate() - ((start.getUTCDay() + 6) % 7))
|
||||
return start
|
||||
}
|
||||
|
||||
export const statsRoutes = new Hono<AppEnv>()
|
||||
.get('/overview', requireAuth, async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const visible = visibleTo(db, me)
|
||||
const now = new Date()
|
||||
const today = localDate(now)
|
||||
|
||||
const statusRows = await db.select({ status: incidents.status, n: count() }).from(incidents).where(visible).groupBy(incidents.status)
|
||||
const byStatus = Object.fromEntries(incidentStatuses.map((status) => [status, 0])) as Record<IncidentStatus, number>
|
||||
for (const row of statusRows) byStatus[row.status] = row.n
|
||||
const openIncidents = Object.entries(byStatus).reduce((sum, [status, n]) => (status === 'closed' ? sum : sum + n), 0)
|
||||
|
||||
const [{ recent }] = await db
|
||||
.select({ recent: count() })
|
||||
.from(incidents)
|
||||
.where(and(visible, gte(incidents.createdAt, new Date(now.getTime() - 7 * DAY_MS))))
|
||||
|
||||
const actionsBase = db.select({ n: count() }).from(correctiveActions).innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||
const [{ n: dueThisWeek }] = await actionsBase.where(
|
||||
and(visible, inArray(correctiveActions.status, OPEN_ACTION), gte(correctiveActions.dueDate, today), sql`${correctiveActions.dueDate} <= ${addDays(today, 7)}`),
|
||||
)
|
||||
const [{ n: overdue }] = await db
|
||||
.select({ n: count() })
|
||||
.from(correctiveActions)
|
||||
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||
.where(and(visible, inArray(correctiveActions.status, OPEN_ACTION), lt(correctiveActions.dueDate, today)))
|
||||
const progressRows = await db
|
||||
.select({ status: correctiveActions.status, n: count() })
|
||||
.from(correctiveActions)
|
||||
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||
.where(and(visible, ne(incidents.status, 'closed'), ne(correctiveActions.status, 'cancelled')))
|
||||
.groupBy(correctiveActions.status)
|
||||
const actionProgress = {
|
||||
total: progressRows.reduce((sum, row) => sum + row.n, 0),
|
||||
completed: progressRows.filter((row) => row.status === 'done' || row.status === 'verified').reduce((sum, row) => sum + row.n, 0),
|
||||
}
|
||||
|
||||
const [{ avgDays }] = await db
|
||||
.select({ avgDays: sql<string | null>`avg(extract(epoch from ${incidents.closedAt} - ${incidents.createdAt})) / 86400` })
|
||||
.from(incidents)
|
||||
.where(and(visible, isNotNull(incidents.closedAt), gte(incidents.closedAt, new Date(now.getTime() - 90 * DAY_MS))))
|
||||
|
||||
const firstWeek = weekStart(new Date(now.getTime() - 11 * 7 * DAY_MS))
|
||||
const weekExpr = sql<string>`to_char(date_trunc('week', ${incidents.occurredAt} at time zone 'UTC'), 'YYYY-MM-DD')`
|
||||
const weekRows = await db
|
||||
.select({ week: weekExpr, n: count() })
|
||||
.from(incidents)
|
||||
.where(and(visible, gte(incidents.occurredAt, firstWeek)))
|
||||
.groupBy(weekExpr)
|
||||
const weeklyTrend = Array.from({ length: 12 }, (_, i) => {
|
||||
const start = new Date(firstWeek.getTime() + i * 7 * DAY_MS).toISOString().slice(0, 10)
|
||||
return { weekStart: start, count: weekRows.find((row) => row.week === start)?.n ?? 0 }
|
||||
})
|
||||
|
||||
const yearStart = new Date(now.getFullYear(), 0, 1)
|
||||
const siteRows = await db
|
||||
.select({ siteId: sites.id, name: sites.name, n: count(incidents.id) })
|
||||
.from(sites)
|
||||
.leftJoin(incidents, and(eq(incidents.siteId, sites.id), gte(incidents.occurredAt, yearStart), visible))
|
||||
.where(and(eq(sites.organizationId, me.organizationId), isNull(sites.archivedAt)))
|
||||
.groupBy(sites.id, sites.name)
|
||||
.orderBy(desc(count(incidents.id)), asc(sites.name))
|
||||
|
||||
const stats: OverviewStats = {
|
||||
openIncidents,
|
||||
reportedLast7Days: recent,
|
||||
actionsDueThisWeek: dueThisWeek,
|
||||
overdueActions: overdue,
|
||||
averageCloseDays: avgDays === null ? null : Math.round(Number(avgDays) * 10) / 10,
|
||||
byStatus,
|
||||
actionProgress,
|
||||
weeklyTrend,
|
||||
bySite: siteRows.map((row) => ({ siteId: row.siteId, name: row.name, count: row.n })),
|
||||
}
|
||||
return c.json(stats)
|
||||
})
|
||||
|
||||
.get('/analytics', requireAuth, validate('query', analyticsQuery), async (c) => {
|
||||
const filters = c.req.valid('query')
|
||||
if (filters.from > filters.to) throw fail(400, 'The start date must be before the end date')
|
||||
const days = (Date.parse(filters.to) - Date.parse(filters.from)) / 86_400_000
|
||||
if (days > 3 * 366) throw fail(400, 'Choose a range of up to three years')
|
||||
return c.json(await computeAnalytics(c.get('db'), currentUser(c), filters))
|
||||
})
|
||||
@@ -3,10 +3,11 @@ import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import { createUserSchema, updateUserSchema } from '../../shared/schemas.ts'
|
||||
import { requireRole } from '../auth/middleware.ts'
|
||||
import { createAuthToken } from '../auth/tokens.ts'
|
||||
import { hashPassword } from '../auth/password.ts'
|
||||
import { deleteUserSessions } from '../auth/sessions.ts'
|
||||
import { isUniqueViolation } from '../db/client.ts'
|
||||
import { users } from '../db/schema.ts'
|
||||
import { organizations, users } from '../db/schema.ts'
|
||||
import { audit, diff } from '../lib/audit.ts'
|
||||
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||
|
||||
@@ -22,6 +23,7 @@ export const publicUser = {
|
||||
}
|
||||
|
||||
const idParam = z.object({ id: z.uuid() })
|
||||
const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
export const userRoutes = new Hono<AppEnv>()
|
||||
.get('/', requireRole('safety_manager'), async (c) => {
|
||||
@@ -30,6 +32,18 @@ export const userRoutes = new Hono<AppEnv>()
|
||||
return c.json({ users: rows })
|
||||
})
|
||||
|
||||
/** Active people who can lead investigations or be assigned actions. */
|
||||
.get('/assignable', requireRole('supervisor'), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const rows = await c
|
||||
.get('db')
|
||||
.select({ id: users.id, name: users.name, role: users.role })
|
||||
.from(users)
|
||||
.where(and(eq(users.organizationId, me.organizationId), eq(users.active, true)))
|
||||
.orderBy(asc(users.name))
|
||||
return c.json({ users: rows })
|
||||
})
|
||||
|
||||
.post('/', requireRole('admin'), validate('json', createUserSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const { password, ...input } = c.req.valid('json')
|
||||
@@ -37,10 +51,15 @@ export const userRoutes = new Hono<AppEnv>()
|
||||
try {
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({ ...input, organizationId: me.organizationId, passwordHash: await hashPassword(password) })
|
||||
.values({ ...input, organizationId: me.organizationId, passwordHash: password ? await hashPassword(password) : null })
|
||||
.returning(publicUser)
|
||||
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'user', entityId: user.id, action: 'create', changes: { email: user.email, role: user.role } })
|
||||
return c.json({ user }, 201)
|
||||
if (!password) {
|
||||
const [organization] = await db.select({ name: organizations.name }).from(organizations).where(eq(organizations.id, me.organizationId))
|
||||
const token = await createAuthToken(db, user.id, 'password_reset', INVITE_TTL_MS)
|
||||
c.get('notifier').passwordReset(user.email, token, { organization: organization.name, invitedBy: me.name })
|
||||
}
|
||||
return c.json({ user, invited: !password }, 201)
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) throw fail(409, 'A user with that email already exists')
|
||||
throw error
|
||||
@@ -66,14 +85,20 @@ export const userRoutes = new Hono<AppEnv>()
|
||||
if (otherAdmins.length === 0) throw fail(409, 'At least one active admin is required')
|
||||
}
|
||||
|
||||
const changes = diff(target, input)
|
||||
const { password, ...fields } = input
|
||||
const changes: Record<string, unknown> = diff(target, fields)
|
||||
if (password) changes.password = 'reset by admin'
|
||||
if (Object.keys(changes).length === 0) {
|
||||
const [user] = await db.select(publicUser).from(users).where(eq(users.id, id))
|
||||
return c.json({ user })
|
||||
}
|
||||
const [user] = await db.update(users).set(input).where(eq(users.id, id)).returning(publicUser)
|
||||
// Roles are re-read on every request, so only deactivation needs to end sessions.
|
||||
if (input.active === false) await deleteUserSessions(db, id)
|
||||
const [user] = await db
|
||||
.update(users)
|
||||
.set({ ...fields, ...(password ? { passwordHash: await hashPassword(password) } : {}) })
|
||||
.where(eq(users.id, id))
|
||||
.returning(publicUser)
|
||||
// Roles are re-read on every request, so only deactivation and password resets need to end sessions.
|
||||
if (input.active === false || (password && id !== me.id)) await deleteUserSessions(db, id)
|
||||
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'user', entityId: id, action: 'update', changes })
|
||||
return c.json({ user })
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { and, asc, eq, or } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import type { SavedView } from '../../shared/api-types.ts'
|
||||
import { hasRole } from '../../shared/enums.ts'
|
||||
import { savedViewSchema } from '../../shared/schemas.ts'
|
||||
import { requireAuth } from '../auth/middleware.ts'
|
||||
import { savedViews } from '../db/schema.ts'
|
||||
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||
|
||||
const toView = (row: typeof savedViews.$inferSelect, userId: string): SavedView => ({
|
||||
id: row.id,
|
||||
page: row.page as SavedView['page'],
|
||||
name: row.name,
|
||||
filters: row.filters,
|
||||
shared: row.shared,
|
||||
mine: row.userId === userId,
|
||||
})
|
||||
|
||||
/** Saved filter sets. Supervisors and above can share views with the whole organization. */
|
||||
export const viewRoutes = new Hono<AppEnv>()
|
||||
.get('/', requireAuth, validate('query', z.object({ page: z.enum(['analytics', 'incidents']) })), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const rows = await c
|
||||
.get('db')
|
||||
.select()
|
||||
.from(savedViews)
|
||||
.where(and(eq(savedViews.organizationId, me.organizationId), eq(savedViews.page, c.req.valid('query').page), or(eq(savedViews.userId, me.id), eq(savedViews.shared, true))))
|
||||
.orderBy(asc(savedViews.name))
|
||||
return c.json({ views: rows.map((row) => toView(row, me.id)) })
|
||||
})
|
||||
|
||||
.post('/', requireAuth, validate('json', savedViewSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const input = c.req.valid('json')
|
||||
if (input.shared && !hasRole(me.role, 'supervisor')) throw fail(403, 'Only supervisors and above can share views')
|
||||
const [row] = await c
|
||||
.get('db')
|
||||
.insert(savedViews)
|
||||
.values({ ...input, organizationId: me.organizationId, userId: me.id })
|
||||
.returning()
|
||||
return c.json({ view: toView(row, me.id) }, 201)
|
||||
})
|
||||
|
||||
.delete('/:id', requireAuth, validate('param', z.object({ id: z.uuid() })), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(savedViews)
|
||||
.where(and(eq(savedViews.id, c.req.valid('param').id), eq(savedViews.organizationId, me.organizationId)))
|
||||
if (!row || (!row.shared && row.userId !== me.id)) throw fail(404, 'View not found')
|
||||
if (row.userId !== me.id && me.role !== 'admin') throw fail(403, 'Only the owner or an admin can delete this view')
|
||||
await db.delete(savedViews).where(eq(savedViews.id, row.id))
|
||||
return c.body(null, 204)
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createTestContext, daysFromToday, hoursAgo } from '../test/helpers.ts'
|
||||
|
||||
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||
let siteId: string
|
||||
let reporter: Awaited<ReturnType<typeof ctx.createUser>>
|
||||
let supervisor: Awaited<ReturnType<typeof ctx.createUser>>
|
||||
let manager: Awaited<ReturnType<typeof ctx.createUser>>
|
||||
|
||||
async function newIncident(severity = 'high') {
|
||||
const res = await reporter.post('/api/incidents', { title: 'Guard missing on press', type: 'hazard', severity, occurredAt: hoursAgo(1), siteId })
|
||||
return res.body.incident.number as number
|
||||
}
|
||||
const move = (client: typeof supervisor, number: number, to: string, note?: string) => client.post(`/api/incidents/${number}/transitions`, { to, note })
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createTestContext()
|
||||
siteId = (await ctx.createSite()).id
|
||||
reporter = await ctx.createUser('reporter')
|
||||
supervisor = await ctx.createUser('supervisor')
|
||||
manager = await ctx.createUser('safety_manager')
|
||||
})
|
||||
afterAll(() => ctx.close())
|
||||
|
||||
describe('incident workflow', () => {
|
||||
it('runs an incident from report to close', async () => {
|
||||
const number = await newIncident()
|
||||
|
||||
expect((await move(reporter, number, 'triage')).status).toBe(403)
|
||||
let res = await move(supervisor, number, 'triage', 'Looking into it')
|
||||
expect(res.body.incident.status).toBe('triage')
|
||||
expect(res.body.incident.transitions.map((t: { to: string }) => t.to)).toEqual(['investigation', 'actions', 'closed'])
|
||||
|
||||
// High severity can't be closed straight from triage
|
||||
expect((await move(supervisor, number, 'closed')).body.error).toMatch(/must be investigated/)
|
||||
await move(supervisor, number, 'investigation')
|
||||
|
||||
// Need a root cause before moving on
|
||||
expect((await move(supervisor, number, 'actions')).status).toBe(409)
|
||||
res = await supervisor.put(`/api/incidents/${number}/investigation`, { method: 'five_whys', summary: 'Guard removed for maintenance and not refitted', leadId: supervisor.user.id })
|
||||
expect(res.body.incident.investigation).toMatchObject({ method: 'five_whys', lead: { id: supervisor.user.id } })
|
||||
res = await supervisor.post(`/api/incidents/${number}/root-causes`, {
|
||||
category: 'process',
|
||||
description: 'No lockout checklist for guard removal',
|
||||
whys: ['Guard was off', 'Maintenance removed it', 'No step to refit it'],
|
||||
})
|
||||
const rootCauseId = res.body.incident.rootCauses[0].id
|
||||
await move(supervisor, number, 'actions')
|
||||
|
||||
// Need finished actions before verification
|
||||
expect((await move(supervisor, number, 'verification')).body.error).toMatch(/at least one corrective action/)
|
||||
res = await supervisor.post(`/api/incidents/${number}/actions`, {
|
||||
title: 'Add guard refit step to maintenance checklist',
|
||||
assigneeId: reporter.user.id,
|
||||
dueDate: daysFromToday(7),
|
||||
rootCauseId,
|
||||
})
|
||||
const action = res.body.incident.actions[0]
|
||||
expect(action).toMatchObject({ status: 'open', assignee: { id: reporter.user.id }, overdue: false })
|
||||
expect((await move(supervisor, number, 'verification')).body.error).toMatch(/must be done/)
|
||||
|
||||
// The assignee (a reporter) can see the incident and report progress, but not edit the action
|
||||
expect((await reporter.get(`/api/incidents/${number}`)).status).toBe(200)
|
||||
expect((await reporter.patch(`/api/actions/${action.id}`, { title: 'Changed' })).status).toBe(403)
|
||||
expect((await reporter.patch(`/api/actions/${action.id}`, { status: 'verified' })).status).toBe(403)
|
||||
res = await reporter.patch(`/api/actions/${action.id}`, { status: 'done' })
|
||||
expect(res.body.action.completedAt).not.toBeNull()
|
||||
|
||||
await move(supervisor, number, 'verification')
|
||||
expect((await move(supervisor, number, 'closed')).status).toBe(403)
|
||||
expect((await move(manager, number, 'closed')).body.error).toMatch(/Verify every corrective action/)
|
||||
expect((await supervisor.patch(`/api/actions/${action.id}`, { status: 'verified' })).status).toBe(403)
|
||||
res = await manager.patch(`/api/actions/${action.id}`, { status: 'verified' })
|
||||
expect(res.body.action.verifiedBy).toMatchObject({ id: manager.user.id })
|
||||
|
||||
res = await move(manager, number, 'closed')
|
||||
expect(res.body.incident).toMatchObject({ status: 'closed' })
|
||||
expect(res.body.incident.closedAt).not.toBeNull()
|
||||
|
||||
// The timeline tells the whole story
|
||||
const timeline = (await supervisor.get(`/api/incidents/${number}/timeline`)).body.timeline
|
||||
expect(timeline.map((entry: { action: string }) => entry.action)).toEqual([
|
||||
'create',
|
||||
'status',
|
||||
'status',
|
||||
'investigation.update',
|
||||
'root_cause.add',
|
||||
'status',
|
||||
'action.add',
|
||||
'action.update',
|
||||
'status',
|
||||
'action.update',
|
||||
'status',
|
||||
])
|
||||
// The note on the first transition became a comment
|
||||
const comments = (await supervisor.get(`/api/incidents/${number}/comments`)).body.comments
|
||||
expect(comments[0]).toMatchObject({ body: 'Looking into it', author: { id: supervisor.user.id } })
|
||||
|
||||
// Reopening is for safety managers
|
||||
expect((await move(supervisor, number, 'investigation')).status).toBe(403)
|
||||
expect((await move(manager, number, 'investigation')).body.incident.closedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('lets low-severity incidents close straight from triage', async () => {
|
||||
const number = await newIncident('low')
|
||||
await move(supervisor, number, 'triage')
|
||||
expect((await move(supervisor, number, 'closed')).body.incident.status).toBe('closed')
|
||||
})
|
||||
|
||||
it('rejects transitions that skip steps', async () => {
|
||||
const number = await newIncident()
|
||||
expect((await move(manager, number, 'closed')).status).toBe(409)
|
||||
})
|
||||
|
||||
it('edits and removes root causes', async () => {
|
||||
const number = await newIncident()
|
||||
let res = await supervisor.post(`/api/incidents/${number}/root-causes`, { category: 'equipment', description: 'Worn belt' })
|
||||
const id = res.body.incident.rootCauses[0].id
|
||||
res = await supervisor.patch(`/api/root-causes/${id}`, { whys: ['Belt slipped', 'Not inspected'] })
|
||||
expect(res.body.incident.rootCauses[0].whys).toEqual(['Belt slipped', 'Not inspected'])
|
||||
res = await supervisor.delete(`/api/root-causes/${id}`)
|
||||
expect(res.body.incident.rootCauses).toEqual([])
|
||||
expect((await reporter.delete(`/api/root-causes/${id}`)).status).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe('comments', () => {
|
||||
it('lets anyone who can see an incident comment on it', async () => {
|
||||
const number = await newIncident()
|
||||
expect((await reporter.post(`/api/incidents/${number}/comments`, { body: 'It happened again today' })).status).toBe(201)
|
||||
const other = await ctx.createUser('reporter', 'outsider@example.com')
|
||||
expect((await other.post(`/api/incidents/${number}/comments`, { body: 'Hi' })).status).toBe(404)
|
||||
expect((await reporter.post(`/api/incidents/${number}/comments`, { body: ' ' })).status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('corrective actions', () => {
|
||||
it('lists my open and overdue actions and all actions for supervisors', async () => {
|
||||
const number = await newIncident()
|
||||
await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Late one', assigneeId: manager.user.id, dueDate: daysFromToday(-2) })
|
||||
await supervisor.post(`/api/incidents/${number}/actions`, { title: 'On time', assigneeId: manager.user.id, dueDate: daysFromToday(10) })
|
||||
|
||||
const mine = (await manager.get('/api/actions')).body.actions
|
||||
expect(mine.map((a: { title: string }) => a.title)).toEqual(['Late one', 'On time'])
|
||||
expect(mine[0].overdue).toBe(true)
|
||||
const overdue = (await manager.get('/api/actions?filter=overdue')).body.actions
|
||||
expect(overdue.map((a: { title: string }) => a.title)).toEqual(['Late one'])
|
||||
|
||||
expect((await reporter.get('/api/actions?scope=all')).status).toBe(403)
|
||||
expect((await supervisor.get('/api/actions?scope=all')).body.actions.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('emails the assignee', async () => {
|
||||
const number = await newIncident()
|
||||
await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Replace mat', assigneeId: manager.user.id })
|
||||
await ctx.notifier.flush()
|
||||
expect(ctx.mailer.last(manager.user.email)?.subject).toBe('Action assigned: Replace mat')
|
||||
})
|
||||
|
||||
it('sends one daily reminder per assignee for due and overdue actions', async () => {
|
||||
const assignee = await ctx.createUser('reporter', 'reminders@example.com')
|
||||
const number = await newIncident()
|
||||
await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Overdue fix', assigneeId: assignee.user.id, dueDate: daysFromToday(-1) })
|
||||
await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Due tomorrow', assigneeId: assignee.user.id, dueDate: daysFromToday(1) })
|
||||
await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Next month', assigneeId: assignee.user.id, dueDate: daysFromToday(30) })
|
||||
|
||||
await ctx.notifier.sendDueReminders()
|
||||
await ctx.notifier.flush()
|
||||
const reminders = ctx.mailer.sent.filter((mail) => mail.to === assignee.user.email && mail.subject !== 'Action assigned: Overdue fix')
|
||||
const digest = reminders.find((mail) => mail.subject === '1 overdue corrective action')
|
||||
expect(digest?.text).toContain('Overdue fix')
|
||||
expect(digest?.text).toContain('Due tomorrow')
|
||||
expect(digest?.text).not.toContain('Next month')
|
||||
|
||||
const before = ctx.mailer.sent.length
|
||||
await ctx.notifier.sendDueReminders()
|
||||
await ctx.notifier.flush()
|
||||
expect(ctx.mailer.sent.length).toBe(before)
|
||||
})
|
||||
|
||||
it("can't be added to a closed incident", async () => {
|
||||
const number = await newIncident('low')
|
||||
await move(supervisor, number, 'triage')
|
||||
await move(supervisor, number, 'closed')
|
||||
expect((await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Too late' })).status).toBe(409)
|
||||
})
|
||||
})
|
||||
|
||||
describe('partial updates', () => {
|
||||
it('leave omitted fields alone', async () => {
|
||||
const number = await newIncident()
|
||||
let res = await supervisor.post(`/api/incidents/${number}/root-causes`, { category: 'people', description: 'Rushed', whys: ['Late shipment'] })
|
||||
const id = res.body.incident.rootCauses[0].id
|
||||
res = await supervisor.patch(`/api/root-causes/${id}`, { description: 'Rushed to meet shipment' })
|
||||
expect(res.body.incident.rootCauses[0]).toMatchObject({ description: 'Rushed to meet shipment', whys: ['Late shipment'] })
|
||||
|
||||
res = await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Preventive check', kind: 'preventive' })
|
||||
const actionId = res.body.incident.actions[0].id
|
||||
const updated = await supervisor.patch(`/api/actions/${actionId}`, { status: 'in_progress' })
|
||||
expect(updated.body.action).toMatchObject({ kind: 'preventive', status: 'in_progress' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const KEY_PATTERN = /^[0-9a-f-]{36}\.[a-z]{3,4}$/
|
||||
|
||||
/** Stores uploaded files on local disk under random names. */
|
||||
export class FileStorage {
|
||||
readonly dir: string
|
||||
|
||||
constructor(dir: string) {
|
||||
this.dir = dir
|
||||
}
|
||||
|
||||
async save(data: Uint8Array, extension: string): Promise<string> {
|
||||
await mkdir(this.dir, { recursive: true })
|
||||
const key = `${randomUUID()}.${extension}`
|
||||
await writeFile(this.path(key), data, { flag: 'wx' })
|
||||
return key
|
||||
}
|
||||
|
||||
read(key: string): Promise<Buffer> {
|
||||
return readFile(this.path(key))
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
await rm(this.path(key), { force: true })
|
||||
}
|
||||
|
||||
private path(key: string) {
|
||||
if (!KEY_PATTERN.test(key)) throw new Error(`Invalid storage key: ${key}`)
|
||||
return join(this.dir, key)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createApp } from '../app.ts'
|
||||
import { loadConfig } from '../config.ts'
|
||||
import { openDatabase } from '../db/client.ts'
|
||||
import { MemoryMailer } from '../mail.ts'
|
||||
import { Notifier } from '../notifications.ts'
|
||||
import { FileStorage } from '../storage.ts'
|
||||
|
||||
type App = ReturnType<typeof createApp>
|
||||
|
||||
@@ -15,12 +21,16 @@ export class Client {
|
||||
|
||||
async request(method: string, path: string, body?: unknown) {
|
||||
const headers: Record<string, string> = {}
|
||||
if (body !== undefined) headers['content-type'] = 'application/json'
|
||||
const isForm = body instanceof FormData
|
||||
if (body !== undefined && !isForm) headers['content-type'] = 'application/json'
|
||||
if (method !== 'GET') headers.origin = 'http://safety.test' // browsers send Origin on every non-GET request
|
||||
if (this.cookie) headers.cookie = this.cookie
|
||||
const res = await this.app.request(path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) })
|
||||
const payload = body === undefined ? undefined : isForm ? body : JSON.stringify(body)
|
||||
const res = await this.app.request(path, { method, headers, body: payload })
|
||||
const setCookie = res.headers.get('set-cookie')
|
||||
if (setCookie) this.cookie = /max-age=0|expires=thu, 01 jan 1970/i.test(setCookie) ? undefined : setCookie.split(';')[0]
|
||||
const text = await res.text()
|
||||
const text = res.headers.get('content-type')?.includes('json') || res.status >= 400 ? await res.text() : ''
|
||||
if (!text && res.ok && res.status !== 204) return { status: res.status, body: Buffer.from(await res.arrayBuffer()), headers: res.headers }
|
||||
// oxlint-disable-next-line typescript/no-explicit-any -- tests read arbitrary JSON shapes
|
||||
const json: any = text ? JSON.parse(text) : null
|
||||
return { status: res.status, body: json, headers: res.headers }
|
||||
@@ -28,7 +38,9 @@ export class Client {
|
||||
|
||||
get = (path: string) => this.request('GET', path)
|
||||
post = (path: string, body?: unknown) => this.request('POST', path, body ?? {})
|
||||
put = (path: string, body: unknown) => this.request('PUT', path, body)
|
||||
patch = (path: string, body: unknown) => this.request('PATCH', path, body)
|
||||
delete = (path: string) => this.request('DELETE', path)
|
||||
}
|
||||
|
||||
export const ADMIN = { organizationName: 'Northstar Co.', name: 'Avery Admin', email: 'admin@example.com', password: 'correct horse battery' }
|
||||
@@ -36,7 +48,11 @@ export const ADMIN = { organizationName: 'Northstar Co.', name: 'Avery Admin', e
|
||||
/** Fresh in-memory database and app, with the first-run setup already done and the admin signed in. */
|
||||
export async function createTestContext() {
|
||||
const database = await openDatabase('memory://')
|
||||
const app = createApp({ db: database.db, config: { ...loadConfig({}), databaseUrl: 'memory://' } })
|
||||
const uploadDir = await mkdtemp(join(tmpdir(), 'safetylog-test-'))
|
||||
const config = { ...loadConfig({ PUBLIC_URL: 'http://safety.test' }), databaseUrl: 'memory://', uploadDir, maxUploadBytes: 1024 * 1024 }
|
||||
const mailer = new MemoryMailer()
|
||||
const notifier = new Notifier(database.db, mailer, config)
|
||||
const app = createApp({ db: database.db, config, notifier, storage: new FileStorage(uploadDir) })
|
||||
const admin = new Client(app)
|
||||
const setup = await admin.post('/api/setup', ADMIN)
|
||||
if (setup.status !== 201) throw new Error(`setup failed: ${JSON.stringify(setup.body)}`)
|
||||
@@ -55,7 +71,27 @@ export async function createTestContext() {
|
||||
return res.body.site as { id: string; name: string }
|
||||
}
|
||||
|
||||
return { app, db: database.db, admin, anonymous: () => new Client(app), createUser, createSite, close: database.close }
|
||||
async function close() {
|
||||
await notifier.flush()
|
||||
await database.close()
|
||||
await rm(uploadDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
return { app, db: database.db, admin, mailer, notifier, config, anonymous: () => new Client(app), createUser, createSite, close }
|
||||
}
|
||||
|
||||
export const hoursAgo = (hours: number) => new Date(Date.now() - hours * 3_600_000).toISOString()
|
||||
|
||||
/** Pulls the token out of the link in an email body. */
|
||||
export function tokenFrom(text: string | undefined): string {
|
||||
const match = /token=([\w-]+)/.exec(text ?? '')
|
||||
if (!match) throw new Error(`No token in: ${text}`)
|
||||
return match[1]
|
||||
}
|
||||
|
||||
export const daysFromToday = (days: number) => {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + days)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
// Response shapes returned by the API. Dates are ISO 8601 strings.
|
||||
import type { SummaryTotals } from './osha.ts'
|
||||
import type {
|
||||
actionKinds,
|
||||
actionStatuses,
|
||||
AreaKind,
|
||||
employmentTypes,
|
||||
IncidentStatus,
|
||||
OshaCaseType,
|
||||
OshaOutcome,
|
||||
OshaSpecialCase,
|
||||
IncidentType,
|
||||
rcaMethods,
|
||||
Role,
|
||||
rootCauseCategories,
|
||||
Severity,
|
||||
} from './enums.ts'
|
||||
|
||||
export type ActionKind = (typeof actionKinds)[number]
|
||||
export type ActionStatus = (typeof actionStatuses)[number]
|
||||
export type RcaMethod = (typeof rcaMethods)[number]
|
||||
export type RootCauseCategory = (typeof rootCauseCategories)[number]
|
||||
|
||||
export interface UserRef {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: Role
|
||||
active: boolean
|
||||
lastLoginAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Area {
|
||||
id: string
|
||||
siteId: string
|
||||
parentId: string | null
|
||||
name: string
|
||||
kind: AreaKind
|
||||
archivedAt: string | null
|
||||
}
|
||||
|
||||
export interface Establishment {
|
||||
streetAddress: string | null
|
||||
city: string | null
|
||||
state: string | null
|
||||
zip: string | null
|
||||
naicsCode: string | null
|
||||
industryDescription: string | null
|
||||
ein: string | null
|
||||
establishmentType: number
|
||||
}
|
||||
|
||||
export interface Site extends Partial<Establishment> {
|
||||
id: string
|
||||
name: string
|
||||
address: string | null
|
||||
timezone: string
|
||||
archivedAt: string | null
|
||||
areas: Area[]
|
||||
}
|
||||
|
||||
export interface Incident {
|
||||
id: string
|
||||
number: number
|
||||
reference: string
|
||||
title: string
|
||||
type: IncidentType
|
||||
severity: Severity
|
||||
status: IncidentStatus
|
||||
occurredAt: string
|
||||
reportedAt: string
|
||||
updatedAt: string
|
||||
closedAt: string | null
|
||||
site: { id: string; name: string }
|
||||
area: { id: string; name: string } | null
|
||||
locationDetail: string | null
|
||||
description: string | null
|
||||
immediateAction: string | null
|
||||
anonymous: boolean
|
||||
reporter: UserRef | null
|
||||
/** Name given by someone reporting without an account. */
|
||||
reporterName: string | null
|
||||
/** How the report arrived; `kiosk` covers kiosk tablets and QR codes on reporting links. */
|
||||
channel: 'web' | 'kiosk'
|
||||
kiosk: { id: string; name: string } | null
|
||||
}
|
||||
|
||||
export interface Kiosk {
|
||||
id: string
|
||||
name: string
|
||||
token: string
|
||||
site: { id: string; name: string } | null
|
||||
lastUsedAt: string | null
|
||||
revokedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** What a reporting link may see: just enough to fill in the report form. */
|
||||
export interface KioskConfig {
|
||||
organization: { name: string }
|
||||
kiosk: { name: string }
|
||||
sites: Site[]
|
||||
}
|
||||
|
||||
export interface Investigation {
|
||||
id: string
|
||||
lead: UserRef | null
|
||||
method: RcaMethod
|
||||
summary: string | null
|
||||
startedAt: string
|
||||
completedAt: string | null
|
||||
}
|
||||
|
||||
export interface RootCause {
|
||||
id: string
|
||||
category: RootCauseCategory
|
||||
description: string
|
||||
whys: string[]
|
||||
}
|
||||
|
||||
export interface CorrectiveAction {
|
||||
id: string
|
||||
incident: { number: number; reference: string; title: string }
|
||||
rootCauseId: string | null
|
||||
kind: ActionKind
|
||||
title: string
|
||||
description: string | null
|
||||
assignee: UserRef | null
|
||||
dueDate: string | null
|
||||
status: ActionStatus
|
||||
overdue: boolean
|
||||
completedAt: string | null
|
||||
verifiedBy: UserRef | null
|
||||
verifiedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string
|
||||
fileName: string
|
||||
contentType: string
|
||||
sizeBytes: number
|
||||
uploadedBy: UserRef | null
|
||||
createdAt: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface TransitionOption {
|
||||
to: IncidentStatus
|
||||
label: string
|
||||
/** Why the transition can't happen yet; absent when it is allowed. */
|
||||
blockedReason?: string
|
||||
}
|
||||
|
||||
export interface IncidentDetail extends Incident {
|
||||
investigation: Investigation | null
|
||||
rootCauses: RootCause[]
|
||||
actions: CorrectiveAction[]
|
||||
attachments: Attachment[]
|
||||
transitions: TransitionOption[]
|
||||
/** Number of injured or ill people recorded; details are only for safety managers. */
|
||||
injuredCount: number
|
||||
/** What the signed-in user may do on this incident. */
|
||||
can: { edit: boolean; manageInvestigation: boolean; manageActions: boolean; manageOsha: boolean }
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string
|
||||
author: UserRef | null
|
||||
body: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface TimelineEntry {
|
||||
id: number
|
||||
at: string
|
||||
actor: UserRef | null
|
||||
action: string
|
||||
changes: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface Paginated<T> {
|
||||
items: T[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface OverviewStats {
|
||||
openIncidents: number
|
||||
reportedLast7Days: number
|
||||
actionsDueThisWeek: number
|
||||
overdueActions: number
|
||||
/** Average days from report to close for incidents closed in the last 90 days. */
|
||||
averageCloseDays: number | null
|
||||
byStatus: Record<IncidentStatus, number>
|
||||
actionProgress: { completed: number; total: number }
|
||||
/** Incidents per week for the last 12 weeks, oldest first. */
|
||||
weeklyTrend: { weekStart: string; count: number }[]
|
||||
bySite: { siteId: string; name: string; count: number }[]
|
||||
}
|
||||
|
||||
/** A number for the selected period and the same-length period before it. */
|
||||
export interface Kpi {
|
||||
value: number | null
|
||||
previous: number | null
|
||||
}
|
||||
|
||||
/** Incident types grouped for trend charts. */
|
||||
export type TypeGroup = 'hazards' | 'injuries' | 'damage'
|
||||
|
||||
export interface AnalyticsStats {
|
||||
from: string
|
||||
to: string
|
||||
previousFrom: string
|
||||
bucket: 'week' | 'month'
|
||||
kpis: {
|
||||
reports: Kpi
|
||||
/** Leading: hazards and near misses reported. */
|
||||
leadingReports: Kpi
|
||||
injuries: Kpi
|
||||
/** Leading: hazards and near misses per injury or illness. */
|
||||
nearMissRatio: Kpi
|
||||
/** Leading: share of reports moved out of "reported" within 24 hours. */
|
||||
triagedWithin24h: Kpi
|
||||
/** Leading: share of actions completed by their due date. */
|
||||
actionsOnTime: Kpi
|
||||
averageDaysToClose: Kpi
|
||||
/** Lagging: OSHA-recordable cases (supervisors and above). */
|
||||
recordables: Kpi | null
|
||||
overdueActions: number
|
||||
}
|
||||
trend: ({ start: string } & Record<TypeGroup, number>)[]
|
||||
byLocation: { id: string | null; name: string; count: number }[]
|
||||
byRootCause: { category: RootCauseCategory; count: number }[]
|
||||
/** heatmap[weekday][hour], weekday 0 = Monday, in the server's time zone. */
|
||||
heatmap: number[][]
|
||||
actionsByMonth: { month: string; onTime: number; late: number }[]
|
||||
/** Rolling 12-month OSHA rates at each month end; null for reporters. */
|
||||
rates: { month: string; trir: number | null; dart: number | null }[] | null
|
||||
yearly: { year: number; recordables: number; dartCases: number; hours: number; trir: number | null; dart: number | null }[] | null
|
||||
}
|
||||
|
||||
export interface SavedView {
|
||||
id: string
|
||||
page: 'analytics' | 'incidents'
|
||||
name: string
|
||||
filters: Record<string, string>
|
||||
shared: boolean
|
||||
mine: boolean
|
||||
}
|
||||
|
||||
// ---- OSHA recordkeeping ----
|
||||
|
||||
export interface OshaCase {
|
||||
id: string
|
||||
incident: { id: string; number: number; reference: string; title: string; occurredAt: string }
|
||||
caseNumber: string
|
||||
name: string
|
||||
jobTitle: string | null
|
||||
employmentType: (typeof employmentTypes)[number]
|
||||
bodyPart: string | null
|
||||
natureOfInjury: string | null
|
||||
caseType: OshaCaseType
|
||||
workRelated: boolean | null
|
||||
newCase: boolean | null
|
||||
died: boolean
|
||||
dateOfDeath: string | null
|
||||
daysAwayFromWork: boolean
|
||||
daysAway: number
|
||||
restrictedOrTransfer: boolean
|
||||
daysRestricted: number
|
||||
medicalTreatment: boolean
|
||||
lossOfConsciousness: boolean
|
||||
significantDiagnosis: boolean
|
||||
specialCase: OshaSpecialCase
|
||||
privacyCase: boolean
|
||||
recordable: boolean
|
||||
outcome: OshaOutcome | null
|
||||
inpatientHospitalization: boolean
|
||||
amputation: boolean
|
||||
lossOfEye: boolean
|
||||
reportedToOshaAt: string | null
|
||||
/** Set when 1904.39 requires a direct report to OSHA. */
|
||||
severeReport: { withinHours: 8 | 24; reasons: string[]; dueBy: string } | null
|
||||
employeeAddress: string | null
|
||||
dateOfBirth: string | null
|
||||
dateOfHire: string | null
|
||||
sex: 'M' | 'F' | null
|
||||
physicianName: string | null
|
||||
treatmentFacility: string | null
|
||||
treatedInEmergencyRoom: boolean
|
||||
timeStartedWork: string | null
|
||||
activityBefore: string | null
|
||||
whatHappened: string | null
|
||||
objectSubstance: string | null
|
||||
/** Form 301 / ITA fields still empty for a recordable case. */
|
||||
missingFields: string[]
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** One line of the Form 300 Log. */
|
||||
export interface OshaLogRow {
|
||||
caseId: string
|
||||
caseNumber: string
|
||||
incidentNumber: number
|
||||
/** "Privacy case" for privacy concern cases. */
|
||||
employeeName: string
|
||||
jobTitle: string
|
||||
dateOfInjury: string
|
||||
location: string
|
||||
description: string
|
||||
outcome: OshaOutcome
|
||||
daysAway: number
|
||||
daysRestricted: number
|
||||
caseType: OshaCaseType
|
||||
missingFields: string[]
|
||||
}
|
||||
|
||||
export interface OshaYear {
|
||||
site: { id: string; name: string } & Establishment
|
||||
companyName: string
|
||||
year: number
|
||||
rows: OshaLogRow[]
|
||||
totals: SummaryTotals
|
||||
months: { month: number; hours: number | null; averageEmployees: number | null }[]
|
||||
totalHoursWorked: number
|
||||
annualAverageEmployees: number
|
||||
maxEmployees: number
|
||||
rates: { trir: number | null; dart: number | null; ltir: number | null }
|
||||
checks: { errors: string[]; warnings: string[] }
|
||||
missingEstablishmentFields: string[]
|
||||
}
|
||||
|
||||
export interface Form301 {
|
||||
case: OshaCase
|
||||
establishment: { name: string; companyName: string } & Establishment
|
||||
incident: { reference: string; occurredAt: string; title: string; description: string | null; site: string; area: string | null; locationDetail: string | null }
|
||||
}
|
||||
@@ -33,3 +33,15 @@ export const rootCauseCategories = ['people', 'process', 'equipment', 'materials
|
||||
export const actionKinds = ['corrective', 'preventive'] as const
|
||||
|
||||
export const actionStatuses = ['open', 'in_progress', 'done', 'verified', 'cancelled'] as const
|
||||
|
||||
/** Form 300 column M: injury or illness type. */
|
||||
export const oshaCaseTypes = ['injury', 'skin_disorder', 'respiratory', 'poisoning', 'hearing_loss', 'other_illness'] as const
|
||||
export type OshaCaseType = (typeof oshaCaseTypes)[number]
|
||||
|
||||
/** Form 300 columns G–J, most serious first. */
|
||||
export const oshaOutcomes = ['death', 'days_away', 'restricted', 'other'] as const
|
||||
export type OshaOutcome = (typeof oshaOutcomes)[number]
|
||||
|
||||
/** Cases recordable under 1904.8–1904.11 regardless of the general criteria. */
|
||||
export const oshaSpecialCases = ['none', 'needlestick', 'medical_removal', 'hearing_loss', 'tuberculosis'] as const
|
||||
export type OshaSpecialCase = (typeof oshaSpecialCases)[number]
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assessRecordability, capDays, FIRST_AID, incidenceRate, itaSizeCode, severeInjuryReport, summaryChecks, type RecordabilityInput, type SummaryTotals } from './osha.ts'
|
||||
|
||||
const base: RecordabilityInput = {
|
||||
employmentType: 'employee',
|
||||
workRelated: true,
|
||||
newCase: true,
|
||||
died: false,
|
||||
daysAwayFromWork: false,
|
||||
restrictedOrTransfer: false,
|
||||
medicalTreatment: false,
|
||||
lossOfConsciousness: false,
|
||||
significantDiagnosis: false,
|
||||
specialCase: 'none',
|
||||
}
|
||||
const assess = (overrides: Partial<RecordabilityInput>) => assessRecordability({ ...base, ...overrides })
|
||||
|
||||
describe('assessRecordability', () => {
|
||||
it('first aid only is not recordable', () => {
|
||||
expect(assess({})).toMatchObject({ status: 'not_recordable', outcome: null })
|
||||
})
|
||||
|
||||
it('waits for the work-related and new-case answers', () => {
|
||||
expect(assess({ workRelated: null, medicalTreatment: true }).status).toBe('incomplete')
|
||||
expect(assess({ newCase: null, medicalTreatment: true }).status).toBe('incomplete')
|
||||
})
|
||||
|
||||
it('excludes non-work-related cases and recurrences', () => {
|
||||
expect(assess({ workRelated: false, died: true }).status).toBe('not_recordable')
|
||||
expect(assess({ newCase: false, medicalTreatment: true }).reasons[0]).toMatch(/1904\.6/)
|
||||
})
|
||||
|
||||
it('keeps contractors and visitors off your log', () => {
|
||||
expect(assess({ employmentType: 'contractor', daysAwayFromWork: true }).reasons[0]).toMatch(/1904\.31/)
|
||||
expect(assess({ employmentType: 'visitor', died: true }).status).toBe('not_recordable')
|
||||
expect(assess({ employmentType: 'temporary', medicalTreatment: true }).status).toBe('recordable')
|
||||
})
|
||||
|
||||
it('classifies by the most serious outcome', () => {
|
||||
expect(assess({ medicalTreatment: true }).outcome).toBe('other')
|
||||
expect(assess({ medicalTreatment: true, restrictedOrTransfer: true }).outcome).toBe('restricted')
|
||||
expect(assess({ restrictedOrTransfer: true, daysAwayFromWork: true }).outcome).toBe('days_away')
|
||||
expect(assess({ daysAwayFromWork: true, died: true }).outcome).toBe('death')
|
||||
})
|
||||
|
||||
it('records each general criterion on its own', () => {
|
||||
for (const key of ['lossOfConsciousness', 'significantDiagnosis', 'medicalTreatment'] as const) {
|
||||
expect(assess({ [key]: true })).toMatchObject({ status: 'recordable', outcome: 'other' })
|
||||
}
|
||||
})
|
||||
|
||||
it('records special cases even without general criteria', () => {
|
||||
for (const specialCase of ['needlestick', 'medical_removal', 'hearing_loss', 'tuberculosis'] as const) {
|
||||
expect(assess({ specialCase }).status).toBe('recordable')
|
||||
}
|
||||
expect(assess({ specialCase: 'needlestick' }).reasons[0]).toMatch(/1904\.8/)
|
||||
})
|
||||
|
||||
it('lists every reason that applies', () => {
|
||||
expect(assess({ medicalTreatment: true, lossOfConsciousness: true }).reasons).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('severeInjuryReport', () => {
|
||||
it('requires fatalities within 8 hours and other severe injuries within 24', () => {
|
||||
expect(severeInjuryReport({ died: true, inpatientHospitalization: true, amputation: false, lossOfEye: false })).toEqual({ withinHours: 8, reasons: ['work-related fatality'] })
|
||||
expect(severeInjuryReport({ died: false, inpatientHospitalization: false, amputation: true, lossOfEye: true })).toEqual({ withinHours: 24, reasons: ['amputation', 'loss of an eye'] })
|
||||
expect(severeInjuryReport({ died: false, inpatientHospitalization: false, amputation: false, lossOfEye: false })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('capDays', () => {
|
||||
it('stops counting at 180 days in total', () => {
|
||||
expect(capDays(10, 5)).toEqual({ daysAway: 10, daysRestricted: 5 })
|
||||
expect(capDays(150, 60)).toEqual({ daysAway: 150, daysRestricted: 30 })
|
||||
expect(capDays(200, 10)).toEqual({ daysAway: 180, daysRestricted: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('rates and codes', () => {
|
||||
it('computes incidence rates per 200,000 hours', () => {
|
||||
expect(incidenceRate(3, 200_000)).toBe(3)
|
||||
expect(incidenceRate(2, 150_000)).toBe(2.67)
|
||||
expect(incidenceRate(1, 0)).toBeNull()
|
||||
})
|
||||
|
||||
it('maps headcount to ITA size codes', () => {
|
||||
expect([19, 20, 99, 100, 249, 250].map(itaSizeCode)).toEqual([1, 21, 21, 22, 22, 3])
|
||||
})
|
||||
|
||||
it('has the complete 14-item first-aid list', () => {
|
||||
expect(FIRST_AID).toHaveLength(14)
|
||||
})
|
||||
})
|
||||
|
||||
describe('summaryChecks (OSHA ITA validations)', () => {
|
||||
const totals = (overrides: Partial<SummaryTotals> = {}): SummaryTotals => ({
|
||||
deaths: 0,
|
||||
daysAwayCases: 1,
|
||||
restrictedCases: 1,
|
||||
otherCases: 1,
|
||||
daysAway: 5,
|
||||
daysRestricted: 3,
|
||||
byType: { injury: 3, skin_disorder: 0, respiratory: 0, poisoning: 0, hearing_loss: 0, other_illness: 0 },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
it('passes a consistent summary', () => {
|
||||
expect(summaryChecks(totals(), 50, 100_000)).toEqual({ errors: [], warnings: [] })
|
||||
})
|
||||
|
||||
it('catches the cross-field rules OSHA enforces', () => {
|
||||
expect(summaryChecks(totals({ byType: { ...totals().byType, injury: 2 } }), 50, 100_000).errors[0]).toMatch(/add up to 2/)
|
||||
expect(summaryChecks(totals({ daysAway: 0 }), 50, 100_000).errors).toContain('There are days-away cases (H) but no days away (K).')
|
||||
expect(summaryChecks(totals({ restrictedCases: 0, daysAwayCases: 0, otherCases: 3, daysAway: 0 }), 50, 100_000).errors).toContain(
|
||||
'There are restricted days (L) but no days-away or restriction cases (H or I).',
|
||||
)
|
||||
expect(summaryChecks(totals(), 0, 0).errors).toHaveLength(2)
|
||||
expect(summaryChecks(totals(), 10, 100_000).errors[0]).toMatch(/more than the hours in a year/)
|
||||
expect(summaryChecks(totals(), 50, 20_000).warnings[0]).toMatch(/Fewer than 500/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
// OSHA recordkeeping rules (29 CFR Part 1904), shared by the API and the web app.
|
||||
// Section references are to 29 CFR 1904. This helps apply the rules consistently; it is not legal advice.
|
||||
import type { OshaCaseType, OshaOutcome, OshaSpecialCase } from './enums.ts'
|
||||
|
||||
export type EmploymentType = 'employee' | 'temporary' | 'contractor' | 'visitor'
|
||||
|
||||
export interface RecordabilityInput {
|
||||
employmentType: EmploymentType
|
||||
/** null while not yet answered */
|
||||
workRelated: boolean | null
|
||||
newCase: boolean | null
|
||||
died: boolean
|
||||
daysAwayFromWork: boolean
|
||||
restrictedOrTransfer: boolean
|
||||
medicalTreatment: boolean
|
||||
lossOfConsciousness: boolean
|
||||
significantDiagnosis: boolean
|
||||
specialCase: OshaSpecialCase
|
||||
}
|
||||
|
||||
export interface Recordability {
|
||||
status: 'recordable' | 'not_recordable' | 'incomplete'
|
||||
/** Form 300 column G–J; null unless recordable. */
|
||||
outcome: OshaOutcome | null
|
||||
/** Plain-language reasons, with the section that applies. */
|
||||
reasons: string[]
|
||||
}
|
||||
|
||||
const specialReasons: Record<Exclude<OshaSpecialCase, 'none'>, string> = {
|
||||
needlestick: 'Needlestick or sharps injury contaminated with another person’s blood or other potentially infectious material (1904.8)',
|
||||
medical_removal: 'Medical removal under an OSHA standard (1904.9)',
|
||||
hearing_loss: 'Standard threshold shift in hearing with overall hearing level of 25 dB or more (1904.10)',
|
||||
tuberculosis: 'Tuberculosis infection after workplace exposure to active TB (1904.11)',
|
||||
}
|
||||
|
||||
export function assessRecordability(input: RecordabilityInput): Recordability {
|
||||
if (input.employmentType === 'contractor' || input.employmentType === 'visitor') {
|
||||
return {
|
||||
status: 'not_recordable',
|
||||
outcome: null,
|
||||
reasons: ['Only your employees, and temporary or contract workers you supervise day to day, go on your log (1904.31). Record supervised contractors as “temporary”.'],
|
||||
}
|
||||
}
|
||||
if (input.workRelated === null || input.newCase === null) return { status: 'incomplete', outcome: null, reasons: ['Answer whether the case is work-related and new.'] }
|
||||
if (!input.workRelated) return { status: 'not_recordable', outcome: null, reasons: ['Not work-related (1904.5).'] }
|
||||
if (!input.newCase) return { status: 'not_recordable', outcome: null, reasons: ['Not a new case: update the original case on the log instead (1904.6).'] }
|
||||
|
||||
const reasons: string[] = []
|
||||
if (input.died) reasons.push('Death (1904.7(b)(2))')
|
||||
if (input.daysAwayFromWork) reasons.push('Days away from work (1904.7(b)(3))')
|
||||
if (input.restrictedOrTransfer) reasons.push('Restricted work or job transfer (1904.7(b)(4))')
|
||||
if (input.medicalTreatment) reasons.push('Medical treatment beyond first aid (1904.7(b)(5))')
|
||||
if (input.lossOfConsciousness) reasons.push('Loss of consciousness (1904.7(b)(6))')
|
||||
if (input.significantDiagnosis) reasons.push('Significant injury or illness diagnosed by a physician or other licensed health care professional (1904.7(b)(7))')
|
||||
if (input.specialCase !== 'none') reasons.push(specialReasons[input.specialCase])
|
||||
|
||||
if (reasons.length === 0) {
|
||||
return { status: 'not_recordable', outcome: null, reasons: ['No recording criteria apply: first aid only, with no time lost, restriction or other criteria (1904.7).'] }
|
||||
}
|
||||
// Classified by the most serious outcome (Form 300 columns G–J)
|
||||
const outcome: OshaOutcome = input.died ? 'death' : input.daysAwayFromWork ? 'days_away' : input.restrictedOrTransfer ? 'restricted' : 'other'
|
||||
return { status: 'recordable', outcome, reasons }
|
||||
}
|
||||
|
||||
export interface SevereInjuryInput {
|
||||
died: boolean
|
||||
inpatientHospitalization: boolean
|
||||
amputation: boolean
|
||||
lossOfEye: boolean
|
||||
}
|
||||
|
||||
/** Whether 1904.39 requires reporting directly to OSHA, and how fast. */
|
||||
export function severeInjuryReport(input: SevereInjuryInput): { withinHours: 8 | 24; reasons: string[] } | null {
|
||||
if (input.died) return { withinHours: 8, reasons: ['work-related fatality'] }
|
||||
const reasons = [
|
||||
input.inpatientHospitalization && 'in-patient hospitalization',
|
||||
input.amputation && 'amputation',
|
||||
input.lossOfEye && 'loss of an eye',
|
||||
].filter((reason): reason is string => !!reason)
|
||||
return reasons.length ? { withinHours: 24, reasons } : null
|
||||
}
|
||||
|
||||
export const OSHA_REPORTING_CONTACT = 'Call 1-800-321-OSHA (6742), report online at osha.gov/report, or contact your nearest OSHA area office.'
|
||||
|
||||
/** 1904.7(b)(3)(vii): you may stop counting once days away plus restricted days reach 180. */
|
||||
export const DAY_COUNT_CAP = 180
|
||||
|
||||
export function capDays(daysAway: number, daysRestricted: number): { daysAway: number; daysRestricted: number } {
|
||||
const away = Math.min(daysAway, DAY_COUNT_CAP)
|
||||
return { daysAway: away, daysRestricted: Math.min(daysRestricted, DAY_COUNT_CAP - away) }
|
||||
}
|
||||
|
||||
/** The complete first-aid list of 1904.7(b)(5)(ii). Anything else is medical treatment. */
|
||||
export const FIRST_AID = [
|
||||
'Non-prescription medication at non-prescription strength',
|
||||
'Tetanus immunizations',
|
||||
'Cleaning, flushing or soaking wounds on the surface of the skin',
|
||||
'Wound coverings such as bandages, Band-Aids™, gauze pads, butterfly bandages or Steri-Strips™ (not sutures, staples or other wound closing devices)',
|
||||
'Hot or cold therapy',
|
||||
'Non-rigid means of support, such as elastic bandages, wraps or non-rigid back belts',
|
||||
'Temporary immobilization devices while transporting an accident victim (splints, slings, neck collars, back boards)',
|
||||
'Drilling a fingernail or toenail to relieve pressure, or draining fluid from a blister',
|
||||
'Eye patches',
|
||||
'Removing foreign bodies from the eye using only irrigation or a cotton swab',
|
||||
'Removing splinters or foreign material from areas other than the eye by irrigation, tweezers, cotton swabs or other simple means',
|
||||
'Finger guards',
|
||||
'Massages',
|
||||
'Drinking fluids for relief of heat stress',
|
||||
] as const
|
||||
|
||||
/** 1904.29(b)(7): the employee's name must not go on the 300 Log. */
|
||||
export const PRIVACY_CASES = [
|
||||
'Injury or illness to an intimate body part or the reproductive system',
|
||||
'Injury or illness resulting from a sexual assault',
|
||||
'Mental illness',
|
||||
'HIV infection, hepatitis or tuberculosis',
|
||||
'Needlestick or sharps injury contaminated with another person’s blood or other potentially infectious material',
|
||||
'Any other illness, if the employee asks for their name to be left off',
|
||||
] as const
|
||||
|
||||
/** Injuries and illnesses per 100 full-time workers a year: cases × 200,000 ÷ hours worked. */
|
||||
export function incidenceRate(cases: number, hoursWorked: number): number | null {
|
||||
if (!(hoursWorked > 0)) return null
|
||||
return Math.round(((cases * 200_000) / hoursWorked) * 100) / 100
|
||||
}
|
||||
|
||||
/** ITA `size` code from the most employees at any point in the year. */
|
||||
export function itaSizeCode(maxEmployees: number): 1 | 21 | 22 | 3 {
|
||||
if (maxEmployees < 20) return 1
|
||||
if (maxEmployees < 100) return 21
|
||||
if (maxEmployees < 250) return 22
|
||||
return 3
|
||||
}
|
||||
|
||||
export const outcomeCode: Record<OshaOutcome, 1 | 2 | 3 | 4> = { death: 1, days_away: 2, restricted: 3, other: 4 }
|
||||
export const caseTypeCode: Record<OshaCaseType, 1 | 2 | 3 | 4 | 5 | 6> = { injury: 1, skin_disorder: 2, respiratory: 3, poisoning: 4, hearing_loss: 5, other_illness: 6 }
|
||||
|
||||
/** Form 300A totals (fields G–M). */
|
||||
export interface SummaryTotals {
|
||||
deaths: number
|
||||
daysAwayCases: number
|
||||
restrictedCases: number
|
||||
otherCases: number
|
||||
daysAway: number
|
||||
daysRestricted: number
|
||||
byType: Record<OshaCaseType, number>
|
||||
}
|
||||
|
||||
/**
|
||||
* The checks OSHA's Injury Tracking Application applies to a 300A upload, so problems are caught
|
||||
* before submitting. Errors would be rejected; warnings are "should" rules.
|
||||
*/
|
||||
export function summaryChecks(totals: SummaryTotals, annualAverageEmployees: number, totalHoursWorked: number) {
|
||||
const errors: string[] = []
|
||||
const warnings: string[] = []
|
||||
const cases = totals.deaths + totals.daysAwayCases + totals.restrictedCases + totals.otherCases
|
||||
const types = Object.values(totals.byType).reduce((sum, n) => sum + n, 0)
|
||||
if (types !== cases) errors.push(`Injury and illness types (M1–M6) add up to ${types} but there are ${cases} cases (G–J).`)
|
||||
if (totals.daysAwayCases > 0 && totals.daysAway === 0) errors.push('There are days-away cases (H) but no days away (K).')
|
||||
if (totals.daysAway > 0 && totals.daysAwayCases === 0) errors.push('There are days away (K) but no days-away cases (H).')
|
||||
if (totals.daysAway < totals.daysAwayCases) errors.push('Days away (K) must be at least the number of days-away cases (H).')
|
||||
if (totals.restrictedCases > 0 && totals.daysRestricted === 0) errors.push('There are restriction/transfer cases (I) but no restricted days (L).')
|
||||
if (totals.daysRestricted > 0 && totals.daysAwayCases + totals.restrictedCases === 0) errors.push('There are restricted days (L) but no days-away or restriction cases (H or I).')
|
||||
if (!(annualAverageEmployees > 0)) errors.push('Enter the average number of employees for each month.')
|
||||
if (!(totalHoursWorked > 0)) errors.push('Enter hours worked for each month.')
|
||||
if (annualAverageEmployees > 0 && totalHoursWorked > 0) {
|
||||
const perEmployee = totalHoursWorked / annualAverageEmployees
|
||||
if (perEmployee >= 8760) errors.push('Hours worked per employee is more than the hours in a year; check hours and headcount.')
|
||||
if (perEmployee <= 500) warnings.push('Fewer than 500 hours worked per employee; check hours and headcount.')
|
||||
}
|
||||
if (annualAverageEmployees > 0 && cases >= annualAverageEmployees) warnings.push('More cases than the average number of employees; check the headcount.')
|
||||
return { errors, warnings }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Request validation schemas, shared so the web app can validate forms with the same rules as the API.
|
||||
import { z } from 'zod'
|
||||
import { areaKinds, incidentStatuses, incidentTypes, roles, severities } from './enums.ts'
|
||||
import { actionKinds, actionStatuses, areaKinds, employmentTypes, incidentStatuses, incidentTypes, oshaCaseTypes, oshaSpecialCases, rcaMethods, roles, rootCauseCategories, severities } from './enums.ts'
|
||||
|
||||
const email = z.string().trim().toLowerCase().max(254).pipe(z.email())
|
||||
const password = z.string().min(10, 'Use at least 10 characters').max(256)
|
||||
@@ -14,14 +14,103 @@ export const loginSchema = z.object({ email, password: z.string().min(1).max(256
|
||||
|
||||
export const changePasswordSchema = z.object({ currentPassword: z.string().min(1).max(256), newPassword: password })
|
||||
|
||||
export const createUserSchema = z.object({ name, email, role: z.enum(roles), password })
|
||||
/** Without a password, the user is emailed an invitation to set one. */
|
||||
export const createUserSchema = z.object({ name, email, role: z.enum(roles), password: password.optional() })
|
||||
|
||||
export const updateUserSchema = z.object({ name: name.optional(), role: z.enum(roles).optional(), active: z.boolean().optional() })
|
||||
/** `password` lets an admin set a new password when email isn't available. */
|
||||
export const updateUserSchema = z.object({ name: name.optional(), role: z.enum(roles).optional(), active: z.boolean().optional(), password: password.optional() })
|
||||
|
||||
export const createSiteSchema = z.object({ name, address: optionalText(500), timezone: z.string().trim().max(64).optional() })
|
||||
const digits = (count: number[], label: string) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => value.replace(/[\s-]/g, ''))
|
||||
.pipe(z.string().refine((value) => value === '' || (count.includes(value.length) && /^\d+$/.test(value)), `${label} must be ${count.join(' or ')} digits`))
|
||||
.transform((value) => value || null)
|
||||
.nullish()
|
||||
|
||||
/** OSHA establishment details, as required by Form 300A and the ITA upload. */
|
||||
const establishmentFields = {
|
||||
streetAddress: optionalText(100),
|
||||
city: optionalText(100),
|
||||
state: z
|
||||
.string()
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.refine((value) => value === '' || /^[A-Z]{2}$/.test(value), 'Use the two-letter state code')
|
||||
.transform((value) => value || null)
|
||||
.nullish(),
|
||||
zip: digits([5, 9], 'ZIP code'),
|
||||
naicsCode: digits([6], 'NAICS code'),
|
||||
industryDescription: optionalText(300),
|
||||
ein: digits([9], 'EIN'),
|
||||
establishmentType: z.union([z.literal(1), z.literal(2), z.literal(3)]).optional(),
|
||||
}
|
||||
|
||||
export const createSiteSchema = z.object({ name, address: optionalText(500), timezone: z.string().trim().max(64).optional(), ...establishmentFields })
|
||||
|
||||
export const updateSiteSchema = createSiteSchema.partial().extend({ archived: z.boolean().optional() })
|
||||
|
||||
export const hoursWorkedSchema = z.object({
|
||||
year: z.number().int().min(2000).max(2100),
|
||||
months: z
|
||||
.array(
|
||||
z.object({
|
||||
month: z.number().int().min(1).max(12),
|
||||
hours: z.number().min(0).max(100_000_000).nullable(),
|
||||
averageEmployees: z.number().int().min(0).max(1_000_000).nullable(),
|
||||
}),
|
||||
)
|
||||
.max(12),
|
||||
})
|
||||
|
||||
const dayCount = z.number().int().min(0).max(180)
|
||||
const isoDateOnly = z.iso.date()
|
||||
|
||||
/** An injured or ill person on an incident, with the OSHA recordability answers and Form 301 details. */
|
||||
const oshaCaseFields = {
|
||||
name,
|
||||
jobTitle: optionalText(255),
|
||||
employmentType: z.enum(employmentTypes),
|
||||
bodyPart: optionalText(255),
|
||||
natureOfInjury: optionalText(500),
|
||||
caseType: z.enum(oshaCaseTypes),
|
||||
workRelated: z.boolean().nullable(),
|
||||
newCase: z.boolean().nullable(),
|
||||
died: z.boolean(),
|
||||
dateOfDeath: isoDateOnly.nullish(),
|
||||
daysAwayFromWork: z.boolean(),
|
||||
daysAway: dayCount,
|
||||
restrictedOrTransfer: z.boolean(),
|
||||
daysRestricted: dayCount,
|
||||
medicalTreatment: z.boolean(),
|
||||
lossOfConsciousness: z.boolean(),
|
||||
significantDiagnosis: z.boolean(),
|
||||
specialCase: z.enum(oshaSpecialCases),
|
||||
privacyCase: z.boolean(),
|
||||
inpatientHospitalization: z.boolean(),
|
||||
amputation: z.boolean(),
|
||||
lossOfEye: z.boolean(),
|
||||
reportedToOshaAt: isoDateTime.nullish(),
|
||||
employeeAddress: optionalText(500),
|
||||
dateOfBirth: isoDateOnly.nullish(),
|
||||
dateOfHire: isoDateOnly.nullish(),
|
||||
sex: z.enum(['M', 'F']).nullish(),
|
||||
physicianName: optionalText(200),
|
||||
treatmentFacility: optionalText(500),
|
||||
treatedInEmergencyRoom: z.boolean(),
|
||||
timeStartedWork: z
|
||||
.string()
|
||||
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'Use 24-hour time, e.g. 07:30')
|
||||
.nullish(),
|
||||
activityBefore: optionalText(2_000),
|
||||
whatHappened: optionalText(5_000),
|
||||
objectSubstance: optionalText(500),
|
||||
}
|
||||
|
||||
export const createOshaCaseSchema = z.object(oshaCaseFields).partial().required({ name: true })
|
||||
export const updateOshaCaseSchema = z.object(oshaCaseFields).partial()
|
||||
|
||||
export const createAreaSchema = z.object({ name, kind: z.enum(areaKinds).default('area'), parentId: z.uuid().nullish() })
|
||||
|
||||
export const updateAreaSchema = z.object({ name: name.optional(), kind: z.enum(areaKinds).optional(), archived: z.boolean().optional() })
|
||||
@@ -37,12 +126,82 @@ export const createIncidentSchema = z.object({
|
||||
description: optionalText(10_000),
|
||||
immediateAction: optionalText(10_000),
|
||||
anonymous: z.boolean().default(false),
|
||||
/** Random id from the reporting device; resubmitting the same id returns the existing report. */
|
||||
clientId: z.uuid().optional(),
|
||||
})
|
||||
|
||||
export const updateIncidentSchema = createIncidentSchema
|
||||
.omit({ anonymous: true })
|
||||
.partial()
|
||||
.extend({ status: z.enum(incidentStatuses).optional() })
|
||||
/** Status changes go through transitions, not updates. */
|
||||
export const updateIncidentSchema = createIncidentSchema.omit({ anonymous: true, clientId: true }).partial()
|
||||
|
||||
export const transitionSchema = z.object({ to: z.enum(incidentStatuses), note: optionalText(5_000) })
|
||||
|
||||
export const commentSchema = z.object({ body: z.string().trim().min(1).max(5_000) })
|
||||
|
||||
export const investigationSchema = z.object({
|
||||
leadId: z.uuid().nullish(),
|
||||
method: z.enum(rcaMethods).optional(),
|
||||
summary: optionalText(20_000),
|
||||
completed: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const rootCauseFields = {
|
||||
category: z.enum(rootCauseCategories),
|
||||
description: z.string().trim().min(1).max(2_000),
|
||||
whys: z.array(z.string().trim().min(1).max(1_000)).max(10),
|
||||
}
|
||||
|
||||
export const rootCauseSchema = z.object({ ...rootCauseFields, whys: rootCauseFields.whys.default([]) })
|
||||
|
||||
// Update schemas are written out rather than derived with .partial(): in Zod 4, .partial() keeps
|
||||
// defaults, so a partial update would silently reset omitted fields.
|
||||
export const updateRootCauseSchema = z.object(rootCauseFields).partial()
|
||||
|
||||
const isoDate = z.iso.date()
|
||||
|
||||
const actionFields = {
|
||||
title: name,
|
||||
description: optionalText(5_000),
|
||||
kind: z.enum(actionKinds),
|
||||
assigneeId: z.uuid().nullish(),
|
||||
dueDate: isoDate.nullish(),
|
||||
rootCauseId: z.uuid().nullish(),
|
||||
}
|
||||
|
||||
export const createActionSchema = z.object({ ...actionFields, kind: actionFields.kind.default('corrective') })
|
||||
|
||||
export const updateActionSchema = z.object({ ...actionFields, status: z.enum(actionStatuses) }).partial()
|
||||
|
||||
export const actionListQuery = z.object({
|
||||
scope: z.enum(['mine', 'all']).default('mine'),
|
||||
filter: z.enum(['open', 'overdue', 'done', 'all']).default('open'),
|
||||
})
|
||||
|
||||
export const createKioskSchema = z.object({ name, siteId: z.uuid().nullish() })
|
||||
|
||||
export const updateKioskSchema = z.object({ name: name.optional(), siteId: z.uuid().nullish(), revoked: z.boolean().optional() })
|
||||
|
||||
/** Report filed through a reporting link (kiosk tablet or QR code), without an account. */
|
||||
export const kioskReportSchema = createIncidentSchema.omit({ anonymous: true }).extend({ reporterName: optionalText(200) })
|
||||
|
||||
export const analyticsQuery = z.object({
|
||||
from: z.iso.date(),
|
||||
to: z.iso.date(),
|
||||
siteId: z.uuid().optional(),
|
||||
type: z.enum(incidentTypes).optional(),
|
||||
})
|
||||
|
||||
export const savedViewSchema = z.object({
|
||||
page: z.enum(['analytics', 'incidents']),
|
||||
name,
|
||||
filters: z.record(z.string().max(50), z.string().max(200)).refine((value) => Object.keys(value).length <= 20, 'Too many filters'),
|
||||
shared: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export const emailOnlySchema = z.object({ email })
|
||||
|
||||
export const tokenSchema = z.object({ token: z.string().min(20).max(200) })
|
||||
|
||||
export const resetPasswordSchema = z.object({ token: z.string().min(20).max(200), newPassword: password })
|
||||
|
||||
export const incidentListQuery = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
@@ -56,5 +215,13 @@ export const incidentListQuery = z.object({
|
||||
|
||||
export type SetupInput = z.infer<typeof setupSchema>
|
||||
export type CreateIncidentInput = z.input<typeof createIncidentSchema>
|
||||
export type OshaCaseInput = z.input<typeof updateOshaCaseSchema>
|
||||
export type SiteInput = z.input<typeof updateSiteSchema>
|
||||
export type KioskReportInput = z.input<typeof kioskReportSchema>
|
||||
export type UpdateIncidentInput = z.infer<typeof updateIncidentSchema>
|
||||
export type CreateActionInput = z.input<typeof createActionSchema>
|
||||
export type UpdateActionInput = z.input<typeof updateActionSchema>
|
||||
export type RootCauseInput = z.input<typeof rootCauseSchema>
|
||||
export type UpdateRootCauseInput = z.input<typeof updateRootCauseSchema>
|
||||
export type InvestigationInput = z.input<typeof investigationSchema>
|
||||
export type IncidentListQuery = z.infer<typeof incidentListQuery>
|
||||
@@ -0,0 +1,55 @@
|
||||
// Incident status workflow, shared so the web app can show the same options and reasons the API enforces.
|
||||
import type { ActionStatus } from './api-types.ts'
|
||||
import { hasRole, type IncidentStatus, type Role, type Severity } from './enums.ts'
|
||||
|
||||
export interface Transition {
|
||||
from: IncidentStatus
|
||||
to: IncidentStatus
|
||||
minRole: Role
|
||||
label: string
|
||||
}
|
||||
|
||||
export const transitions: Transition[] = [
|
||||
{ from: 'reported', to: 'triage', minRole: 'supervisor', label: 'Start triage' },
|
||||
{ from: 'triage', to: 'investigation', minRole: 'supervisor', label: 'Start investigation' },
|
||||
{ from: 'triage', to: 'actions', minRole: 'supervisor', label: 'Skip to corrective actions' },
|
||||
{ from: 'triage', to: 'closed', minRole: 'supervisor', label: 'Close: no further action' },
|
||||
{ from: 'investigation', to: 'actions', minRole: 'supervisor', label: 'Move to corrective actions' },
|
||||
{ from: 'actions', to: 'investigation', minRole: 'supervisor', label: 'Back to investigation' },
|
||||
{ from: 'actions', to: 'verification', minRole: 'supervisor', label: 'Send for verification' },
|
||||
{ from: 'verification', to: 'actions', minRole: 'safety_manager', label: 'Return to actions' },
|
||||
{ from: 'verification', to: 'closed', minRole: 'safety_manager', label: 'Verify and close' },
|
||||
{ from: 'closed', to: 'investigation', minRole: 'safety_manager', label: 'Reopen' },
|
||||
]
|
||||
|
||||
export interface WorkflowFacts {
|
||||
severity: Severity
|
||||
rootCauseCount: number
|
||||
actionStatuses: ActionStatus[]
|
||||
}
|
||||
|
||||
/** Why a transition isn't possible yet, or undefined if it is. */
|
||||
export function transitionBlocker(transition: Transition, facts: WorkflowFacts): string | undefined {
|
||||
const live = facts.actionStatuses.filter((status) => status !== 'cancelled')
|
||||
if (transition.from === 'triage' && transition.to === 'closed' && (facts.severity === 'high' || facts.severity === 'critical')) {
|
||||
return 'High and critical incidents must be investigated before closing'
|
||||
}
|
||||
if (transition.to === 'actions' && transition.from === 'investigation' && facts.rootCauseCount === 0) {
|
||||
return 'Record at least one root cause first'
|
||||
}
|
||||
if (transition.to === 'verification') {
|
||||
if (live.length === 0) return 'Add at least one corrective action first'
|
||||
if (live.some((status) => status === 'open' || status === 'in_progress')) return 'All corrective actions must be done first'
|
||||
}
|
||||
if (transition.from === 'verification' && transition.to === 'closed' && live.some((status) => status !== 'verified')) {
|
||||
return 'Verify every corrective action first'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Transitions from `status` that `role` may perform, with blockers explained. */
|
||||
export function availableTransitions(status: IncidentStatus, role: Role, facts: WorkflowFacts) {
|
||||
return transitions
|
||||
.filter((t) => t.from === status && hasRole(role, t.minRole))
|
||||
.map((t) => ({ to: t.to, label: t.label, blockedReason: transitionBlocker(t, facts) }))
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import App from './App'
|
||||
|
||||
function renderAt(path: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('App', () => {
|
||||
it('renders the overview with recent incidents', () => {
|
||||
renderAt('/')
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(/Jordan/)
|
||||
expect(screen.getByText('Forklift reversed into pallet stack')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds a submitted report to the incident register', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAt('/report')
|
||||
|
||||
await user.type(screen.getByLabelText('Incident title'), 'Oil leak under press 4')
|
||||
await user.selectOptions(screen.getByLabelText('Incident type'), 'Hazard')
|
||||
await user.type(screen.getByLabelText('Specific location'), 'Press line')
|
||||
await user.click(screen.getByRole('button', { name: /submit report/i }))
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Incidents' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Oil leak under press 4')).toBeInTheDocument()
|
||||
expect(screen.getByText(/INC-2409/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters the incident register by search text', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAt('/incidents')
|
||||
|
||||
await user.type(screen.getByLabelText('Search incidents'), 'guardrail')
|
||||
|
||||
expect(screen.getByText('Damaged guardrail on mezzanine')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Forklift reversed into pallet stack')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('links to the source code from the about page', () => {
|
||||
renderAt('/about')
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'About Open SafetyLog EHS' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /view source code/i })).toHaveAttribute('href', 'https://www.equineform.com')
|
||||
})
|
||||
})
|
||||
@@ -1,30 +1,40 @@
|
||||
import { useState } from 'react'
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AppLayout } from './components/AppLayout'
|
||||
import { createIncident, isOpen } from './lib/incidents'
|
||||
import { currentUser, seedIncidents } from './lib/seed'
|
||||
import type { NewIncident } from './lib/types'
|
||||
import { About } from './pages/About'
|
||||
import { Actions } from './pages/Actions'
|
||||
import { Analytics } from './pages/Analytics'
|
||||
import { Login, MagicLinkLanding, ResetPassword, Setup } from './pages/Auth'
|
||||
import { Form301 } from './pages/Form301'
|
||||
import { IncidentDetail } from './pages/IncidentDetail'
|
||||
import { Kiosk } from './pages/Kiosk'
|
||||
import { Incidents } from './pages/Incidents'
|
||||
import { Locations } from './pages/Locations'
|
||||
import { Osha } from './pages/Osha'
|
||||
import { Overview } from './pages/Overview'
|
||||
import { QrSheet } from './pages/QrSheet'
|
||||
import { ReportIncident } from './pages/ReportIncident'
|
||||
import { Settings } from './pages/Settings'
|
||||
|
||||
export default function App() {
|
||||
// In-memory store until the backend lands (Phase 1)
|
||||
const [incidents, setIncidents] = useState(seedIncidents)
|
||||
const addIncident = (report: NewIncident) =>
|
||||
setIncidents((current) => [createIncident(report, current, currentUser.name, new Date()), ...current])
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<AppLayout openIncidentCount={incidents.filter(isOpen).length} />}>
|
||||
<Route index element={<Overview incidents={incidents} />} />
|
||||
<Route path="incidents" element={<Incidents incidents={incidents} />} />
|
||||
<Route path="login" element={<Login />} />
|
||||
<Route path="setup" element={<Setup />} />
|
||||
<Route path="auth/magic" element={<MagicLinkLanding />} />
|
||||
<Route path="auth/reset" element={<ResetPassword />} />
|
||||
<Route path="k/:token" element={<Kiosk />} />
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<Overview />} />
|
||||
<Route path="incidents" element={<Incidents />} />
|
||||
<Route path="incidents/:number" element={<IncidentDetail />} />
|
||||
<Route path="actions" element={<Actions />} />
|
||||
<Route path="locations" element={<Locations />} />
|
||||
<Route path="locations/qr" element={<QrSheet />} />
|
||||
<Route path="analytics" element={<Analytics />} />
|
||||
<Route path="report" element={<ReportIncident onSubmit={addIncident} />} />
|
||||
<Route path="osha" element={<Osha />} />
|
||||
<Route path="osha/cases/:id/301" element={<Form301 />} />
|
||||
<Route path="report" element={<ReportIncident />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="about" element={<About />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
|
||||
@@ -1,16 +1,41 @@
|
||||
import { useState } from 'react'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useActions, useMe, useSetupStatus } from '../lib/queries'
|
||||
import type { QueuedItem } from '../lib/outbox'
|
||||
import { useOutboxSync } from '../lib/use-outbox'
|
||||
import { Header } from './Header'
|
||||
import { OutboxBanner } from './OutboxBanner'
|
||||
import { ErrorMessage, Loading } from './QueryState'
|
||||
import { Sidebar } from './Sidebar'
|
||||
|
||||
export function AppLayout({ openIncidentCount }: { openIncidentCount: number }) {
|
||||
/** Signed-in shell. Sends visitors to setup (first run) or sign-in. */
|
||||
export function AppLayout() {
|
||||
const me = useMe()
|
||||
const setup = useSetupStatus()
|
||||
const location = useLocation()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const myOpenActions = useActions('mine', 'open', !!me.data)
|
||||
const userId = me.data?.user.id
|
||||
const belongs = userId ? (item: QueuedItem) => item.kind === 'report' && item.userId === userId : null
|
||||
useOutboxSync(belongs)
|
||||
|
||||
if (me.isPending) return <Loading />
|
||||
if (me.isError) return <ErrorMessage error={me.error} className="m-6" />
|
||||
if (!me.data) {
|
||||
if (setup.data?.needsSetup) return <Navigate to="/setup" replace />
|
||||
if (setup.isPending) return <Loading />
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname + location.search }} />
|
||||
}
|
||||
|
||||
const open = myOpenActions.data ?? []
|
||||
const overdue = open.filter((action) => action.overdue).length
|
||||
return (
|
||||
<div className="flex min-h-screen bg-canvas">
|
||||
<Sidebar open={menuOpen} onClose={() => setMenuOpen(false)} openIncidentCount={openIncidentCount} />
|
||||
<Sidebar me={me.data} open={menuOpen} onClose={() => setMenuOpen(false)} badges={{ '/actions': open.length }} />
|
||||
<main className="min-w-0 flex-1">
|
||||
<Header onOpenMenu={() => setMenuOpen(true)} />
|
||||
<Outlet />
|
||||
<Header me={me.data} onOpenMenu={() => setMenuOpen(true)} overdueActions={overdue} />
|
||||
<OutboxBanner belongs={belongs!} />
|
||||
<Outlet context={me.data} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ShieldCheck } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { LanguageToggle } from './LanguageToggle'
|
||||
import { panel } from './styles'
|
||||
|
||||
/** Centered card for sign-in, setup and email-link pages. */
|
||||
export function AuthLayout({ title, copy, children }: { title: string; copy?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid min-h-screen place-items-center bg-canvas px-4 py-10">
|
||||
<div className="w-full max-w-[420px]">
|
||||
<div className="mb-6 flex items-center justify-center gap-2.5">
|
||||
<div className="grid size-[34px] place-items-center rounded-[9px] bg-coral text-white">
|
||||
<ShieldCheck size={20} />
|
||||
</div>
|
||||
<strong className="font-display text-xl font-bold tracking-[-0.5px] text-forest-950">Open SafetyLog EHS</strong>
|
||||
</div>
|
||||
<div className={`${panel} p-7`}>
|
||||
<h1 className="font-display text-2xl font-semibold tracking-[-0.5px] text-ink">{title}</h1>
|
||||
{copy && <p className="mt-1.5 text-xs leading-[1.6] text-muted">{copy}</p>}
|
||||
<div className="mt-6">{children}</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-center">
|
||||
<LanguageToggle className="text-muted hover:text-ink" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ActionStatus } from '../../shared/api-types.ts'
|
||||
import type { IncidentStatus, Severity } from '../../shared/enums.ts'
|
||||
import { actionStatusClass, actionStatusLabel, severityDotClass, severityLabel, statusClass, statusLabel } from '../lib/labels'
|
||||
|
||||
const chip = 'inline-block rounded px-2 py-[5px] text-3xs whitespace-nowrap'
|
||||
|
||||
export const StatusBadge = ({ status }: { status: IncidentStatus }) => <span className={`${chip} ${statusClass[status]}`}>{statusLabel[status]}</span>
|
||||
|
||||
export const ActionStatusBadge = ({ status }: { status: ActionStatus }) => <span className={`${chip} ${actionStatusClass[status]}`}>{actionStatusLabel[status]}</span>
|
||||
|
||||
export function SeverityLabel({ severity }: { severity: Severity }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-[5px]">
|
||||
<i className={`size-1.5 rounded-full ${severityDotClass[severity]}`} />
|
||||
{severityLabel[severity]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const OverdueBadge = ({ label = 'Overdue' }: { label?: string }) => <span className={`${chip} bg-coral-soft font-semibold text-coral-strong`}>{label}</span>
|
||||
@@ -22,11 +22,19 @@ export function FormSection({ number, title, copy, children }: FormSectionProps)
|
||||
)
|
||||
}
|
||||
|
||||
export function Field({ label, optional = false, children }: { label: string; optional?: boolean; children: ReactNode }) {
|
||||
interface FieldProps {
|
||||
label: string
|
||||
optional?: boolean
|
||||
/** Translated "Optional" marker. */
|
||||
optionalLabel?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Field({ label, optional = false, optionalLabel = 'Optional', children }: FieldProps) {
|
||||
return (
|
||||
<label className="mb-[17px] block text-2xs font-semibold text-[#586762]">
|
||||
{label}
|
||||
{optional && <em className="float-right font-normal text-[#9ba7a2] not-italic">Optional</em>}
|
||||
{optional && <em className="float-right font-normal text-[#9ba7a2] not-italic">{optionalLabel}</em>}
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
|
||||
@@ -1,30 +1,57 @@
|
||||
import { Bell, Menu, Search } from 'lucide-react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { pageTitles } from '../lib/navigation'
|
||||
import { currentUser } from '../lib/seed'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useI18n } from '../i18n'
|
||||
import { initials } from '../lib/initials'
|
||||
import { pageTitle } from '../lib/navigation'
|
||||
import type { Me } from '../lib/queries'
|
||||
import { iconButton } from './styles'
|
||||
|
||||
export function Header({ onOpenMenu }: { onOpenMenu: () => void }) {
|
||||
export function Header({ me, onOpenMenu, overdueActions }: { me: Me; onOpenMenu: () => void; overdueActions: number }) {
|
||||
const { pathname } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [query, setQuery] = useState('')
|
||||
const { t } = useI18n()
|
||||
|
||||
const search = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
navigate(`/incidents${query.trim() ? `?q=${encodeURIComponent(query.trim())}` : ''}`)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex h-[62px] items-center justify-between border-b border-line bg-white/60 px-[17px] sm:h-[72px] sm:px-6 lg:px-[42px]">
|
||||
<button type="button" className={`${iconButton} mr-2.5 sm:hidden`} onClick={onOpenMenu} aria-label="Open menu">
|
||||
<header className="flex h-[62px] items-center justify-between gap-3 border-b border-line bg-white/60 px-[17px] sm:h-[72px] sm:px-6 lg:px-[42px] print:hidden">
|
||||
<button type="button" className={`${iconButton} sm:hidden`} onClick={onOpenMenu} aria-label={t.nav.openMenu}>
|
||||
<Menu size={21} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2.5 text-xs text-[#94a09b]">
|
||||
<div className="flex min-w-0 items-center gap-2.5 text-xs text-[#94a09b]">
|
||||
<span className="max-sm:hidden">Safety workspace</span>
|
||||
<span className="max-sm:hidden">/</span>
|
||||
<strong className="text-ink">{pageTitles[pathname] ?? 'Not found'}</strong>
|
||||
<strong className="truncate text-ink">{pageTitle(pathname)}</strong>
|
||||
</div>
|
||||
<div className="flex items-center gap-[15px]">
|
||||
<button type="button" className={iconButton} aria-label="Search">
|
||||
<search className="hidden md:block">
|
||||
<form onSubmit={search} className="flex items-center gap-2 rounded-[5px] border border-line bg-white px-2.5 text-[#85938e]">
|
||||
<Search size={15} />
|
||||
<input
|
||||
className="h-[34px] w-[180px] bg-transparent text-xs text-ink outline-none"
|
||||
placeholder={t.nav.searchIncidents}
|
||||
aria-label={t.nav.searchIncidents}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</search>
|
||||
<Link to="/incidents" className={`${iconButton} md:hidden`} aria-label={t.nav.searchIncidents}>
|
||||
<Search size={19} />
|
||||
</button>
|
||||
<button type="button" className={`${iconButton} relative`} aria-label="Notifications">
|
||||
</Link>
|
||||
<Link to="/actions?filter=overdue" className={`${iconButton} relative`} aria-label={overdueActions ? `${overdueActions} overdue actions` : 'My actions'}>
|
||||
<Bell size={19} />
|
||||
<i className="absolute top-1 right-[3px] size-1.5 rounded-full border border-canvas bg-coral" />
|
||||
</button>
|
||||
<div className="grid size-8 place-items-center rounded-full bg-[#d2e0d8] font-mono text-2xs text-teal">{currentUser.initials}</div>
|
||||
{overdueActions > 0 && <i className="absolute top-1 right-[3px] size-1.5 rounded-full border border-canvas bg-coral" />}
|
||||
</Link>
|
||||
<Link to="/settings" className="grid size-8 place-items-center rounded-full bg-[#d2e0d8] font-mono text-2xs text-teal" aria-label="Your settings">
|
||||
{initials(me.user.name)}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { AlertTriangle, MapPin } from 'lucide-react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import type { Incident } from '../../shared/api-types.ts'
|
||||
import { formatDate } from '../lib/dates'
|
||||
import { severityDotClass, statusClass, typeTone } from '../lib/incidents'
|
||||
import { incidentTypeLabel, incidentTypeTone } from '../lib/labels'
|
||||
import { toneClasses } from '../lib/tones'
|
||||
import type { Incident } from '../lib/types'
|
||||
import { iconButton } from './styles'
|
||||
import { SeverityLabel, StatusBadge } from './Badges'
|
||||
|
||||
const th = 'border-b border-line px-2.5 py-3 font-mono text-3xs font-medium tracking-[0.06em] text-faint uppercase first:pl-6 last:pr-[18px]'
|
||||
const td = 'border-b border-line-soft px-2.5 py-3.5 text-2xs whitespace-nowrap text-body first:pl-6 last:pr-[18px]'
|
||||
const th = 'border-b border-line px-2.5 py-3 font-mono text-3xs font-medium tracking-[0.06em] text-faint uppercase first:pl-6 last:pr-6'
|
||||
const td = 'border-b border-line-soft px-2.5 py-3.5 text-2xs whitespace-nowrap text-body first:pl-6 last:pr-6'
|
||||
|
||||
interface IncidentTableProps {
|
||||
incidents: Incident[]
|
||||
limit?: number
|
||||
wide?: boolean
|
||||
}
|
||||
|
||||
export function IncidentTable({ incidents, limit, wide = false }: IncidentTableProps) {
|
||||
const rows = limit ? incidents.slice(0, limit) : incidents
|
||||
export function IncidentTable({ incidents, wide = false }: { incidents: Incident[]; wide?: boolean }) {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<table className={`w-full border-collapse text-left ${wide ? 'min-w-[900px]' : 'min-w-[720px]'}`}>
|
||||
@@ -26,51 +21,50 @@ export function IncidentTable({ incidents, limit, wide = false }: IncidentTableP
|
||||
<th className={th}>Severity</th>
|
||||
<th className={th}>Location</th>
|
||||
<th className={th}>Status</th>
|
||||
<th className={th}>Date</th>
|
||||
<th className={th}>
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
<th className={th}>Occurred</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((incident) => (
|
||||
<tr key={incident.id} className="[&:last-child>td]:border-b-0">
|
||||
{incidents.map((incident) => (
|
||||
<tr
|
||||
key={incident.id}
|
||||
className="cursor-pointer hover:bg-canvas [&:last-child>td]:border-b-0"
|
||||
onClick={() => navigate(`/incidents/${incident.number}`)}
|
||||
>
|
||||
<td className={td}>
|
||||
<div className="flex min-w-[250px] items-center gap-2.5">
|
||||
<span className={`grid size-7 place-items-center rounded-[7px] ${toneClasses[typeTone[incident.type]]}`}>
|
||||
<span className={`grid size-7 flex-none place-items-center rounded-[7px] ${toneClasses[incidentTypeTone[incident.type]]}`}>
|
||||
<AlertTriangle size={14} />
|
||||
</span>
|
||||
<div>
|
||||
<strong className="block text-xs font-semibold text-ink">{incident.title}</strong>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to={`/incidents/${incident.number}`}
|
||||
className="block max-w-[320px] truncate text-xs font-semibold text-ink hover:underline"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{incident.title}
|
||||
</Link>
|
||||
<small className="mt-1 block text-3xs text-faint">
|
||||
{incident.id} · {incident.reporter}
|
||||
{incident.reference} · {incident.anonymous ? 'Anonymous' : (incident.reporter?.name ?? incident.reporterName ?? 'Unknown')}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={td}>{incident.type}</td>
|
||||
<td className={td}>{incidentTypeLabel[incident.type]}</td>
|
||||
<td className={td}>
|
||||
<span className="inline-flex items-center gap-[5px]">
|
||||
<i className={`size-1.5 rounded-full ${severityDotClass[incident.severity]}`} />
|
||||
{incident.severity}
|
||||
</span>
|
||||
<SeverityLabel severity={incident.severity} />
|
||||
</td>
|
||||
<td className={td}>
|
||||
<div className="flex max-w-[120px] flex-wrap items-center gap-1">
|
||||
<MapPin size={14} />
|
||||
{incident.location}
|
||||
<small className="mt-1 ml-[18px] block w-full text-3xs text-faint">{incident.building}</small>
|
||||
<div className="flex max-w-[160px] flex-wrap items-center gap-1">
|
||||
<MapPin size={14} className="flex-none" />
|
||||
<span className="truncate">{incident.area?.name ?? incident.site.name}</span>
|
||||
<small className="mt-1 ml-[18px] block w-full truncate text-3xs text-faint">{incident.locationDetail || incident.site.name}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td className={td}>
|
||||
<span className={`rounded px-2 py-[5px] text-3xs ${statusClass(incident.status)}`}>{incident.status}</span>
|
||||
</td>
|
||||
<td className={`${td} text-[#87938f]`}>{formatDate(incident.date)}</td>
|
||||
<td className={td}>
|
||||
<button type="button" className={iconButton} aria-label={`More actions for ${incident.id}`}>
|
||||
<span className="tracking-[2px] text-[#91aaa0]">•••</span>
|
||||
</button>
|
||||
<StatusBadge status={incident.status} />
|
||||
</td>
|
||||
<td className={`${td} text-[#87938f]`}>{formatDate(incident.occurredAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Languages } from 'lucide-react'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
/** Switches between English and Spanish; shows the language you'd switch to, in that language. */
|
||||
export function LanguageToggle({ className = '' }: { className?: string }) {
|
||||
const { lang, setLang, t } = useI18n()
|
||||
return (
|
||||
<button type="button" className={`inline-flex cursor-pointer items-center gap-1.5 text-2xs font-semibold ${className}`} onClick={() => setLang(lang === 'en' ? 'es' : 'en')} lang={lang === 'en' ? 'es' : 'en'}>
|
||||
<Languages size={15} /> {t.language.switchTo}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { toneClasses, type Tone } from '../lib/tones'
|
||||
interface MetricCardProps {
|
||||
label: string
|
||||
value: string
|
||||
delta: string
|
||||
delta?: string
|
||||
icon: LucideIcon
|
||||
tone: Tone
|
||||
}
|
||||
@@ -17,9 +17,7 @@ export function MetricCard({ label, value, delta, icon: Icon, tone }: MetricCard
|
||||
</div>
|
||||
<span className="mt-2.5 mb-1.5 block text-xs text-muted sm:mt-[15px]">{label}</span>
|
||||
<strong className="block font-display text-[25px] font-semibold tracking-[-1px] text-ink sm:text-[30px]">{value}</strong>
|
||||
<small className="mt-[9px] block text-[9px] text-[#7d8b86] sm:text-2xs">
|
||||
<b className="text-positive">↗</b> {delta}
|
||||
</small>
|
||||
{delta && <small className="mt-[9px] block text-[9px] text-[#7d8b86] sm:text-2xs">{delta}</small>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { CloudOff, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { useI18n } from '../i18n'
|
||||
import { outbox, type QueuedItem } from '../lib/outbox'
|
||||
import { useOnline, useOutbox } from '../lib/use-outbox'
|
||||
|
||||
/** Offline notice plus any reports still waiting on this device. */
|
||||
export function OutboxBanner({ belongs }: { belongs: (item: QueuedItem) => boolean }) {
|
||||
const { t } = useI18n()
|
||||
const online = useOnline()
|
||||
const { items, syncing } = useOutbox()
|
||||
const mine = items.filter(belongs)
|
||||
if (online && mine.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="border-b border-[#f3d9a4] bg-[#fff8e6] px-[17px] py-2.5 text-2xs text-[#7a5a12] sm:px-6 lg:px-[42px] print:hidden" role="status">
|
||||
{!online && (
|
||||
<p className="flex items-center gap-2">
|
||||
<CloudOff size={15} className="flex-none" /> {t.outbox.offline}
|
||||
</p>
|
||||
)}
|
||||
{mine.length > 0 && (
|
||||
<div className={!online ? 'mt-1.5' : ''}>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<strong className="font-semibold">{t.outbox.waiting(mine.length)}</strong>
|
||||
{online && (
|
||||
<button type="button" className="inline-flex items-center gap-1 font-semibold underline disabled:opacity-50" disabled={syncing} onClick={() => void outbox.flush(belongs)}>
|
||||
<RefreshCw size={13} className={syncing ? 'animate-spin' : ''} /> {syncing ? t.outbox.sending : t.outbox.retry}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{mine
|
||||
.filter((item) => item.rejected)
|
||||
.map((item) => (
|
||||
<p key={item.clientId} className="mt-1 flex items-center gap-2">
|
||||
“{item.report.title}”: {t.outbox.failed(item.lastError ?? '')}
|
||||
<button type="button" className="inline-flex items-center gap-1 underline" onClick={() => confirm(t.outbox.discardConfirm) && void outbox.discard(item.clientId)}>
|
||||
<Trash2 size={12} /> {t.outbox.discard}
|
||||
</button>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { iconButton } from './styles'
|
||||
|
||||
interface PaginationProps {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
onPage: (page: number) => void
|
||||
}
|
||||
|
||||
export function Pagination({ page, pageSize, total, onPage }: PaginationProps) {
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const first = total === 0 ? 0 : (page - 1) * pageSize + 1
|
||||
return (
|
||||
<div className="flex items-center justify-between border-t border-line px-6 py-3 text-2xs text-muted print:hidden">
|
||||
<span>
|
||||
{first}–{Math.min(page * pageSize, total)} of {total}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" className={`${iconButton} disabled:opacity-30`} disabled={page <= 1} onClick={() => onPage(page - 1)} aria-label="Previous page">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span>
|
||||
Page {page} of {pages}
|
||||
</span>
|
||||
<button type="button" className={`${iconButton} disabled:opacity-30`} disabled={page >= pages} onClick={() => onPage(page + 1)} aria-label="Next page">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import QRCode from 'qrcode'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
/** QR code as crisp SVG squares (prints sharply at any size). */
|
||||
export function QrCode({ value, size = 160, label }: { value: string; size?: number; label: string }) {
|
||||
const { path, count } = useMemo(() => {
|
||||
const { modules } = QRCode.create(value, { errorCorrectionLevel: 'M' })
|
||||
let d = ''
|
||||
for (let y = 0; y < modules.size; y++) for (let x = 0; x < modules.size; x++) if (modules.get(x, y)) d += `M${x} ${y}h1v1h-1z`
|
||||
return { path: d, count: modules.size }
|
||||
}, [value])
|
||||
const margin = 2
|
||||
return (
|
||||
<svg width={size} height={size} viewBox={`${-margin} ${-margin} ${count + margin * 2} ${count + margin * 2}`} role="img" aria-label={label} shapeRendering="crispEdges">
|
||||
<rect x={-margin} y={-margin} width={count + margin * 2} height={count + margin * 2} fill="#fff" />
|
||||
<path d={path} fill="#000" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||