Compare commits

...
9 Commits
Author SHA1 Message Date
jayjennings c150acf71d docs: improve contact information for clarity and professionalism
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
2026-10-13 21:40:32 -07:00
jayjennings 84b5a250e2 docs: update contact information section for clarity and correctness
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
2026-10-13 21:26:06 -07:00
jayjennings 187681a36f docs: add contact information for setup assistance and custom builds
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
2026-10-13 21:25:26 -07:00
jayjennings dab8915375 Refactor code structure for improved readability and maintainability
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
2026-10-13 21:06:10 -07:00
jayjennings 61c876edec Add OSHA Form 301 and Osha page components
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
- Implemented Form301 component for displaying OSHA Form 301 equivalent, including employee and case information.
- Created Osha component to manage OSHA recordkeeping, including tabs for 300 Log, 300A Summary, Hours & rates, and Submit to OSHA.
- Added functionality for printing and downloading CSV files for OSHA submissions.
- Integrated loading and error handling states for data fetching.
2026-10-06 13:09:57 -07:00
jayjennings 3e964d1176 feat(i18n): add internationalization support with English and Spanish translations
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
feat(kiosk): implement kiosk report submission functionality with offline support

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

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

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

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

feat(kiosk): create Kiosk page for reporting incidents without an account
2026-10-06 11:38:40 -07:00
jayjennings 3f6dff5f24 feat: implement permissions and actions management
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
- Add permissions logic for status options in corrective actions.
- Create tests for permissions functionality.
- Implement queries for user authentication and actions management.
- Develop Actions page to display and manage corrective actions.
- Create authentication pages for login, setup, and password reset.
- Add IncidentDetail page to view incident details and associated actions.
- Implement Settings page for user management and password changes.
2026-10-06 11:20:44 -07:00
jayjennings 708da6b9f7 refactor: remove incident management code and related tests 2026-10-06 11:20:32 -07:00
jayjennings c69eccb5c1 feat: implement incident reporting and management system
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
- Add incident reporting routes and logic to handle creation, updating, and retrieval of incidents.
- Implement visibility rules for users based on roles (reporters, supervisors).
- Create tests for incident reporting functionality to ensure correctness and reliability.
- Introduce site and area management routes, including validation for area hierarchy.
- Establish user management routes with role-based access control for creating and updating users.
- Add setup routes for initializing the application with an organization and admin user.
- Implement shared enums and schemas for consistent validation and data handling across the application.
- Create a test helper for setting up an in-memory database and API client for testing.
- Add TypeScript configuration for server-side code with strict type checking and module resolution.
2026-10-06 10:51:18 -07:00
185 changed files with 32189 additions and 710 deletions

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
data
.git
.env
test-results
playwright-report
coverage
+27
View File
@@ -0,0 +1,27 @@
# Copy to .env and adjust. All settings are optional for local development.
# Database. Defaults to embedded PostgreSQL (PGlite) stored in ./data/pglite.
# DATABASE_URL=postgres://safetylog:password@localhost:5432/safetylog
# DATABASE_URL=pglite://./data/pglite
# Port for the API server (and the web app in production).
# PORT=3000
# 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
View File
@@ -31,3 +31,7 @@ coverage
test-results test-results
playwright-report playwright-report
blob-report blob-report
# Local data and config
data
.env
+3 -1
View File
@@ -4,7 +4,9 @@
"rules": { "rules": {
"react/rules-of-hooks": "error", "react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }], "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"] "ignorePatterns": ["dist", "playwright-report", "test-results"]
} }
+1 -1
View File
@@ -36,7 +36,7 @@ This Code of Conduct applies within all community spaces, and also applies when
## Enforcement ## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [CONTACT EMAIL TO BE ADDED — see docs/TODO.md]. All complaints will be reviewed and investigated promptly and fairly. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at jay@equineform.com. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident. All community leaders are obligated to respect the privacy and security of the reporter of any incident.
+8 -3
View File
@@ -12,12 +12,13 @@ Everyone taking part is expected to follow our [Code of Conduct](CODE_OF_CONDUCT
## Development setup ## Development setup
Requires Node.js 22.12 or later (see `.nvmrc`). Requires Node.js 22.18 or later (see `.nvmrc`). No database install needed: development uses embedded PostgreSQL in `./data/`.
```sh ```sh
npm install npm install
npx playwright install chromium # once, for end-to-end tests npx playwright install chromium # once, for end-to-end tests
npm run dev npm run db:seed # optional demo data
npm run dev # web app :5173, API :3000
``` ```
Before opening a pull request, run: Before opening a pull request, run:
@@ -32,9 +33,13 @@ CI runs the same checks on every pull request.
## Code guidelines ## Code guidelines
- **TypeScript** everywhere; avoid `any`. - **TypeScript** everywhere; avoid `any`.
- **Structure:** routes go in `src/pages/`, reusable UI in `src/components/`, and types and domain logic in `src/lib/` (no React components there). - **Structure:** routes go in `src/pages/`, reusable UI in `src/components/`, and types and domain logic in `src/lib/` (no React components there). API routes go in `server/routes/`, one file per resource. Enums and request schemas used by both sides live in `shared/`.
- **Server code runs without a build step** (Node strips the types), so use only erasable TypeScript: no `enum`, `namespace` or constructor parameter properties, and include the `.ts` extension in relative imports. `npm run typecheck` enforces this.
- **Database changes:** edit `server/db/schema.ts`, then run `npm run db:generate` and commit the generated migration. Migrations run automatically when the server starts.
- **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. - **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/`. - **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. - **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. - **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.
+29
View File
@@ -0,0 +1,29 @@
# syntax=docker/dockerfile:1
# --- Build the web app ---
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# --- Runtime: API server + built web app ---
FROM node:24-alpine
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
COPY shared ./shared
COPY --from=build /app/dist ./dist
RUN mkdir -p /app/data && chown node:node /app/data
USER node
VOLUME /app/data
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
CMD ["node", "server/index.ts"]
+68 -7
View File
@@ -2,26 +2,79 @@
Free, open-source accident and safety tracking for manufacturing: incident and near-miss reporting, investigations, corrective actions and (planned) OSHA record keeping. Free, open-source accident and safety tracking for manufacturing: incident and near-miss reporting, investigations, corrective actions and (planned) OSHA record keeping.
> **Status: early prototype.** The UI runs on in-memory demo data; nothing is saved yet. 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.
![Overview dashboard: open incidents, actions due and overdue, average time to close, recent incidents and where incidents are in the workflow](docs/screenshots/dashboard.png)
## Screenshots
Taken with the demo data from `npm run db:seed`, signed in as the safety manager.
| Incident register | Incident workflow |
| --- | --- |
| ![Incidents list with filters, saved views and CSV export](docs/screenshots/incidents.png) | ![An incident in triage with the workflow panel](docs/screenshots/incident-detail.png) |
| **OSHA Form 300 log** | **Analytics** |
| ![OSHA Form 300 log for one site, with missing details flagged](docs/screenshots/osha-300-log.png) | ![Reports per month chart by incident type](docs/screenshots/analytics.png) |
<p align="center"><img src="docs/screenshots/report-mobile.png" width="300" alt="Report an incident form on a phone"></p>
## Getting started ## Getting started
Requires Node.js 22.12 or later (see `.nvmrc`). Requires Node.js 22.18 or later (see `.nvmrc`). No database install is needed for development: the server uses embedded PostgreSQL (PGlite) stored in `./data/`.
```sh ```sh
npm install npm install
npm run dev 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
``` ```
`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).
## Running in production
```sh
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. 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>`.
## Scripts ## Scripts
| Command | What it does | | Command | What it does |
| --- | --- | | --- | --- |
| `npm run dev` | Start the dev server | | `npm run dev` | Start the web app and API together with auto-reload |
| `npm start` | Start the API server (serves `dist/` too when built) |
| `npm run build` | Production build to `dist/` | | `npm run build` | Production build to `dist/` |
| `npm run lint` | Lint with Oxlint (warnings fail) | | `npm run lint` | Lint with Oxlint (warnings fail) |
| `npm run typecheck` | Type-check with TypeScript | | `npm run typecheck` | Type-check with TypeScript |
| `npm test` | Unit and component tests (Vitest) | | `npm test` | Web and server tests (Vitest; server tests use an in-memory database) |
| `npm run db:generate` | Create a migration after changing `server/db/schema.ts` |
| `npm run db:seed` | Load demo data into an empty database |
| `npm run test:e2e` | End-to-end tests (Playwright, desktop and mobile). Run `npx playwright install chromium` once first | | `npm run test:e2e` | End-to-end tests (Playwright, desktop and mobile). Run `npx playwright install chromium` once first |
| `npm run check` | Lint, typecheck, test and build in one go | | `npm run check` | Lint, typecheck, test and build in one go |
@@ -30,13 +83,18 @@ CI runs the same checks on every push and pull request (`.gitea/workflows/ci.yml
## Project layout ## Project layout
``` ```
src/ src/ Web app (React)
pages/ One component per route pages/ One component per route
components/ Shared UI (layout, tables, form pieces) and shared class strings in styles.ts components/ Shared UI (layout, tables, form pieces) and shared class strings in styles.ts
lib/ Types, domain logic, demo data; no React components lib/ Types, domain logic, demo data; no React components
index.css Tailwind import and design tokens (@theme) index.css Tailwind import and design tokens (@theme)
server/ API (Hono on Node)
routes/ One file per resource, with its tests alongside
auth/ Passwords, sessions, permissions
db/ Drizzle schema, migrations, seed script
shared/ Enums and validation schemas used by both
e2e/ Playwright tests e2e/ Playwright tests
docs/ Roadmap and design notes docs/ Roadmap, to-do list, API reference
``` ```
Styling is Tailwind CSS v4. Colors, fonts and small text sizes are defined as theme tokens in `src/index.css`. Fonts are bundled through Fontsource, so the app makes no third-party requests and works on isolated plant networks. Styling is Tailwind CSS v4. Colors, fonts and small text sizes are defined as theme tokens in `src/index.css`. Fonts are bundled through Fontsource, so the app makes no third-party requests and works on isolated plant networks.
@@ -48,3 +106,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). Everyone taking part follows our [Code o
## License ## 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. [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.
+40
View File
@@ -0,0 +1,40 @@
# Production setup: the app plus PostgreSQL.
# cp .env.example .env # set POSTGRES_PASSWORD (and PUBLIC_URL if not localhost)
# docker compose up -d
# Then open http://localhost:3000 and complete first-run setup.
services:
db:
image: postgres:18-alpine
environment:
POSTGRES_USER: safetylog
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
POSTGRES_DB: safetylog
volumes:
- db-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U safetylog -d safetylog"]
interval: 5s
retries: 10
restart: unless-stopped
app:
build: .
depends_on:
db:
condition: service_healthy
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:
+175
View File
@@ -0,0 +1,175 @@
# API reference
JSON over HTTP under `/api`. Authentication uses a session cookie (`safetylog_session`, http-only) set by setup, sign-in, emailed links and password changes.
Request bodies are validated against [`shared/schemas.ts`](../shared/schemas.ts); response shapes are in [`shared/api-types.ts`](../shared/api-types.ts). Errors look like `{ "error": "message" }`; validation errors (400) also include `issues: [{ path, message }]`.
Roles, from least to most privileged: `reporter` < `supervisor` < `safety_manager` < `admin`. A role can do everything the roles below it can.
Browsers must send an `Origin` header matching the site on form/multipart and body-less requests (all browsers do); JSON requests are protected by CORS.
## Setup and authentication
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| GET | `/api/health` | anyone | `{ ok: true }` |
| GET | `/api/setup` | anyone | `{ needsSetup }`: true until the first admin exists |
| POST | `/api/setup` | anyone, once | `{ organizationName, name, email, password }`. Creates the organization and an admin, signs them in. 409 afterwards |
| POST | `/api/auth/login` | anyone | `{ email, password }`. 401 on bad credentials, 429 after 10 attempts per email in 15 minutes |
| POST | `/api/auth/magic-link` | anyone | `{ email }`. Emails a single-use sign-in link (15 minutes). Always 204 |
| POST | `/api/auth/magic-link/verify` | anyone | `{ token }`. Signs in |
| POST | `/api/auth/password-reset` | anyone | `{ email }`. Emails a reset link (1 hour). Always 204 |
| POST | `/api/auth/password-reset/confirm` | anyone | `{ token, newPassword }`. Sets the password, signs out other sessions, signs in. Also completes invitations |
| POST | `/api/auth/logout` | anyone | Ends the session. 204 |
| GET | `/api/auth/me` | signed in | `{ user, organization }` |
| POST | `/api/auth/password` | signed in | `{ currentPassword, newPassword }`. Signs out all other sessions. 204 |
Passwords need at least 10 characters. Email requests are limited to 5 per address per 15 minutes. Emailed links land on web pages (`/auth/magic`, `/auth/reset`) that POST the token, so link scanners in email systems can't use them up.
## Users
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| GET | `/api/users` | safety_manager | All users in the organization |
| GET | `/api/users/assignable` | supervisor | Active users `{ id, name, role }`, for assigning leads and actions |
| POST | `/api/users` | admin | `{ name, email, role, password? }`. Without `password` an invitation email is sent (link valid 7 days). Returns `{ user, invited }` |
| PATCH | `/api/users/:id` | admin | `{ name?, role?, active?, password? }`. Deactivating or setting a password signs the user out. The last active admin can't be demoted or deactivated |
## Sites and areas
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| GET | `/api/sites` | signed in | Sites with their `areas` (flat list; build the tree from `parentId`). `?includeArchived=true` to include archived |
| POST | `/api/sites` | safety_manager | `{ name, address?, timezone? }` |
| PATCH | `/api/sites/:id` | safety_manager | `{ name?, address?, timezone?, archived? }` |
| POST | `/api/sites/:id/areas` | safety_manager | `{ name, kind?, parentId? }`. `kind`: `building`, `area` (default), `line`, `other` |
| PATCH | `/api/areas/:id` | safety_manager | `{ name?, kind?, archived? }` |
## Incidents
Addressed by number (`/api/incidents/42` is `INC-0042`). Numbers are sequential per organization with no gaps.
**Visibility:** supervisors and above see every incident. Reporters see incidents they reported plus any where they're assigned a corrective action. Anything else answers 404.
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| GET | `/api/incidents` | signed in | `{ items, page, pageSize, total }`, newest first |
| GET | `/api/incidents/export` | signed in | CSV of the same filters (no paging, up to 10,000 rows) |
| POST | `/api/incidents` | signed in | Create a report (below). High/critical reports email safety managers and admins |
| GET | `/api/incidents/:number` | signed in | Full detail: investigation, root causes, actions, attachments, the `transitions` you may perform (with `blockedReason` when not yet possible) and `can` flags |
| PATCH | `/api/incidents/:number` | supervisor | Any create field except `anonymous`. Status changes use transitions |
| POST | `/api/incidents/:number/transitions` | see below | `{ to, note? }`. The note is also posted as a comment |
| GET/POST | `/api/incidents/:number/comments` | can see it | POST `{ body }` |
| GET | `/api/incidents/:number/timeline` | can see it | Audit entries `{ id, at, actor, action, changes }` |
| PUT | `/api/incidents/:number/investigation` | supervisor | `{ leadId?, method?, summary?, completed? }`. Creates the investigation if needed |
| POST | `/api/incidents/:number/root-causes` | supervisor | `{ category, description, whys? }` |
| POST | `/api/incidents/:number/actions` | supervisor | `{ title, description?, kind?, assigneeId?, dueDate?, rootCauseId? }`. Not on closed incidents. Emails the assignee |
| POST | `/api/incidents/:number/attachments` | can see it | Multipart, field `file`. Photos (JPEG, PNG, WebP, GIF, HEIC) and PDFs only, detected from content; `MAX_UPLOAD_MB` each (default 10), 20 per incident |
**List and export filters:** `page`, `pageSize` (1–100, default 25), `q` (title, description, location; `INC-0042` or `42` matches the number), `type`, `severity`, `status`, `siteId`.
Send a random `clientId` (UUID) with each report. Resubmitting the same `clientId` returns the original report (200 instead of 201) instead of creating a duplicate, so devices can safely retry after a dropped connection.
**Create body:**
```json
{
"title": "Forklift reversed into pallet stack",
"type": "near_miss",
"severity": "high",
"occurredAt": "2026-10-06T08:15:00-05:00",
"siteId": "…",
"areaId": "…",
"locationDetail": "Loading bay C",
"description": "…",
"immediateAction": "Area cordoned off",
"anonymous": false,
"clientId": "6f1c…"
}
```
- `type`: `near_miss`, `hazard`, `injury`, `illness`, `property_damage`, `environmental`
- `severity`: `low`, `medium`, `high`, `critical`
- `occurredAt` needs a time-zone offset and can't be in the future. Anonymous reports don't record the reporter at all.
### Workflow
`reported` → `triage` → `investigation` → `actions` → `verification` → `closed`. The rules live in [`shared/workflow.ts`](../shared/workflow.ts) and are enforced by the API.
| From | To | Who | Condition |
| --- | --- | --- | --- |
| reported | triage | supervisor | |
| triage | investigation | supervisor | |
| triage | actions | supervisor | |
| triage | closed | supervisor | Low or medium severity only |
| investigation | actions | supervisor | At least one root cause |
| actions | investigation | supervisor | |
| actions | verification | supervisor | At least one action, none open or in progress |
| verification | actions | safety_manager | |
| verification | closed | safety_manager | Every (non-cancelled) action verified |
| closed | investigation | safety_manager | Reopen |
## Root causes, actions, attachments
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| PATCH | `/api/root-causes/:id` | supervisor | `{ category?, description?, whys? }` |
| DELETE | `/api/root-causes/:id` | supervisor | |
| GET | `/api/actions` | signed in | `?scope=mine\|all` (`all` needs supervisor) and `?filter=open\|overdue\|done\|all`. Sorted by due date |
| PATCH | `/api/actions/:id` | see notes | Supervisors: any field and status except `verified`. Safety managers: also `verified`. Assignees: only `status` among `open`, `in_progress`, `done` |
| GET | `/api/attachments/:id` | can see the incident | The file, with a sandboxing Content-Security-Policy |
| DELETE | `/api/attachments/:id` | uploader or supervisor | |
Each action has `overdue: true` when it's open or in progress and its due date has passed (server time zone, `TZ`). Assignees get one email digest per day listing their actions due tomorrow or overdue.
## Reporting links (kiosks and QR codes)
Reporting links let people file reports without an account. Each has a token that can **only** create reports.
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| GET | `/api/kiosks` | safety_manager | All links, including their tokens (to reprint QR codes) |
| POST | `/api/kiosks` | safety_manager | `{ name, siteId? }`. `siteId` restricts the link to one site |
| PATCH | `/api/kiosks/:id` | safety_manager | `{ name?, siteId?, revoked? }` |
| GET | `/api/kiosk/config` | link token | Organization name, link name, and the sites/areas it may report on |
| POST | `/api/kiosk/reports` | link token | Multipart: `report` (JSON: the create body minus `anonymous`, plus optional `reporterName`) and up to 5 `files`. Returns `{ reference, failedFiles }`. 30 reports per link per 10 minutes |
The token goes in the `X-Kiosk-Token` header. Browsers can't send custom headers cross-site without a CORS preflight, which the API doesn't allow, so other websites can't submit through a link. Reports without `reporterName` are anonymous. Incidents filed this way have `channel: "kiosk"` and `kiosk: { id, name }`.
The web app opens links at `/k/<token>`, optionally with `?site=<id>&area=<id>` to pre-fill the location (as QR codes do).
## OSHA recordkeeping
All endpoints need **safety_manager**: they hold confidential medical information. Other roles only see `injuredCount` on an incident. Rules are in [`shared/osha.ts`](../shared/osha.ts); section numbers refer to 29 CFR 1904.
| Method | Path | Notes |
| --- | --- | --- |
| GET | `/api/osha/incidents/:number/cases` | Injured or ill people on an incident |
| POST | `/api/osha/incidents/:number/cases` | `{ name, ...answers }`. Case number is the incident reference, with `-2`, `-3`… for further people |
| PATCH | `/api/osha/cases/:id` | Any case field. `recordable` and `outcome` are always recalculated from the answers; days away + restricted may not exceed 180 |
| DELETE | `/api/osha/cases/:id` | |
| GET | `/api/osha/cases/:id/301` | Data for Form 301 |
| GET | `/api/osha/log?siteId=&year=` | 300 Log rows, 300A totals, monthly hours, annual average employees, TRIR/DART/LTIR, ITA checks, missing establishment fields |
| GET/PUT | `/api/osha/sites/:id/hours?year=` | Monthly `{ month, hours, averageEmployees }`; `null` for both clears a month |
| GET | `/api/osha/ita/summary.csv?year=` | ITA Establishment and 300A file, one row per active site |
| GET | `/api/osha/ita/cases.csv?year=` | ITA case data (Forms 300/301) file, without names |
**Case answers:** `employmentType`, `workRelated`, `newCase`, `died`, `dateOfDeath`, `daysAwayFromWork`, `daysAway`, `restrictedOrTransfer`, `daysRestricted`, `medicalTreatment`, `lossOfConsciousness`, `significantDiagnosis`, `specialCase` (`none`, `needlestick`, `medical_removal`, `hearing_loss`, `tuberculosis`), `caseType` (300 column M), `privacyCase`, severe-injury flags (`inpatientHospitalization`, `amputation`, `lossOfEye`, `reportedToOshaAt`) and the Form 301 fields (`jobTitle`, `natureOfInjury`, `bodyPart`, `objectSubstance`, `activityBefore`, `whatHappened`, `dateOfBirth`, `dateOfHire`, `sex`, `employeeAddress`, `physicianName`, `treatmentFacility`, `treatedInEmergencyRoom`, `timeStartedWork`).
Responses include `severeReport` (`{ withinHours, reasons, dueBy }`) when 1904.39 requires a direct report, and `missingFields` listing what Form 301 and the ITA still need. A case newly needing a direct report emails safety managers and admins.
Site establishment fields (`streetAddress`, `city`, `state`, `zip`, `naicsCode`, `industryDescription`, `ein`, `establishmentType`) are set with `PATCH /api/sites/:id`.
## Stats
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| GET | `/api/stats/overview` | signed in | Open incidents, actions due/overdue, average close time, status counts, weekly trend (12 weeks), incidents by site this year |
| GET | `/api/stats/analytics` | signed in | `?from=YYYY-MM-DD&to=YYYY-MM-DD` (up to 3 years), optional `siteId`, `type`. Leading/lagging KPIs with the previous period, trend (weekly up to ~3 months, else monthly), locations, root causes, weekday × hour heatmap, actions on time vs late, and for supervisors+ rolling 12-month TRIR/DART and yearly rates |
| GET | `/api/views?page=analytics\|incidents` | signed in | Your saved views plus shared ones |
| POST | `/api/views` | signed in | `{ page, name, filters, shared }`. Sharing needs supervisor |
| DELETE | `/api/views/:id` | owner or admin | |
Stats only count what the signed-in user can see.
Every create and update on an incident, its investigation, actions and files is recorded in the audit log with who made it and what changed.
+53 -27
View File
@@ -10,9 +10,19 @@ This is a living document. Update it as decisions are made or scope changes.
| --- | --- | --- | | --- | --- | --- |
| License | AGPL-3.0-only | Decided | | License | AGPL-3.0-only | Decided |
| Frontend | React + Vite + TypeScript, Tailwind CSS v4 | Decided | | Frontend | React + Vite + TypeScript, Tailwind CSS v4 | Decided |
| Backend | Node (Fastify or Hono) + PostgreSQL + Drizzle ORM, shared Zod schemas | Proposed | | Backend | Node + Hono, PostgreSQL via Drizzle ORM, Zod schemas shared with the web app (`shared/`). Node runs the TypeScript directly (no build step for the server) | Decided |
| Deployment | Self-hosted, single `docker compose up` (app + Postgres + file storage) | Proposed | | Database | PostgreSQL in production; embedded PGlite for local development, tests and very small installs | Decided |
| 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 | | 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 | | 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 | | Product name | Open SafetyLog EHS (replaced placeholder "IA Tracker" and the prototype's "Safesite" branding) | Decided |
@@ -32,38 +42,59 @@ This is a living document. Update it as decisions are made or scope changes.
## Phase 1: Backend and data model ## Phase 1: Backend and data model
- Entities: Organization, Site → Area (location tree), User, Role, Incident (type, severity, status, timestamps, reporter or anonymous), InjuredPerson and injury details, Attachment, Investigation, RootCause, CorrectiveAction, Comment, AuditLog, HoursWorked - [x] Database schema and first migration for all entities: Organization, Site → Area (location tree), User, Session, Incident, InjuredPerson, Attachment, Investigation, RootCause, CorrectiveAction, Comment, AuditLog, HoursWorked
- Auth: email/password and magic links; SSO/OIDC later; role-based permissions - [x] First-run setup (creates the organization and first admin)
- REST API with pagination, filtering and sequential incident numbering - [x] Email/password sign-in, sessions, change password, login rate limiting
- [x] Role-based permissions (reporters see only their own reports)
- [x] REST API: users, sites/areas, incidents with pagination, search, filters and gap-free sequential numbers (`INC-0001`); see [API.md](API.md)
- [x] Audit log of every change
- [x] Server tests (Vitest + in-memory PostgreSQL), demo seed data
- [x] Dockerfile and `docker-compose.yml`
- Moved to Phase 2: magic-link sign-in and password reset by email (both need the email sending built for notifications). SSO/OIDC stays "later".
## Phase 2: Core workflow (MVP) ## Phase 2: Core workflow (MVP)
- Reporting that saves to the database, with photo upload - [x] Web app connected to the API: sign-in, first-run setup, every page on live data (demo data removed from the frontend)
- Incident detail page: timeline, investigation, root-cause tools (5 Whys, fishbone), corrective actions (CAPA) - [x] Photo and PDF upload, checked by file content (not name), size-limited, served with a sandboxing security policy
- Status workflow: Reported → Triage → Investigation → Corrective actions → Verification → Closed, with role-based transitions - [x] Magic-link sign-in, password reset by email, email invitations for new users (admins can also set passwords when there's no email)
- "My actions" list and overdue tracking - [x] Incident detail page: details editing, activity log (comments + audit trail), investigation with 5 Whys and fishbone (6M) views, corrective actions (CAPA)
- Email notifications: assignment, due dates, high-severity alerts - [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
- CSV and PDF export - [x] "My actions" and "All actions" lists with overdue tracking; assignees can update progress on their own actions
- Dashboard metrics computed from real data - [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 ## Phase 3: Shop-floor reporting
- Installable PWA with offline queue (sync when connection returns) - [x] Installable app (web manifest, icons, service worker caching the app shell plus who-am-I and site lists)
- Printable QR codes per area that pre-fill the location - [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
- Kiosk mode and anonymous reporting - [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
- Multi-language support, Spanish first - [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) ## Phase 4: Compliance (US OSHA)
- Recordability wizard; days away / restricted / transferred tracking - [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
- OSHA 300, 300A and 301 form generation; privacy-case handling - [x] Day counts with the 180-day cap; days only counted when the matching outcome applies
- ITA (Injury Tracking Application) CSV export - [x] Severe injury reporting (1904.39): 8-hour/24-hour deadline banner, email alert to safety managers, "reported to OSHA at" record
- Hours worked per site per month; TRIR, DART and lost-time rates - [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 ## Phase 5: Analytics
- Trends, location breakdown, top root causes, corrective-action closure time - [x] One filter row (date range presets or custom, site, incident type) scoping every number and chart on the page
- Leading vs lagging indicators; filters and saved dashboards - [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 ## Phase 6: Release v1.0
@@ -75,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. 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 Phases 1–2)
- All data is in memory and resets on refresh
- Dashboard, analytics and location figures are hardcoded
- Export, Add location, Settings, Resources, search, notifications and workspace switcher are non-functional
+45 -1
View File
@@ -4,12 +4,56 @@ Open items that aren't tied to a single roadmap phase. Tick them off (or delete
## Project setup ## Project setup
- [ ] **Code of Conduct contact.** Replace the placeholder in [CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md) with a reporting email address, ideally a project alias rather than a personal one. - [ ] **Code of Conduct contact.** [CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md) uses `jay@equineform.com` for now. Change it to a project alias (e.g. `conduct@<project-domain>`) once the project has its own domain.
- [ ] **Source code link.** The About page links to `https://www.equineform.com` for now (default in `src/lib/config.ts`, overridable with `VITE_SOURCE_URL`). Change it to the public git repository once one exists, and use the same URL for issue links in CONTRIBUTING.md. - [ ] **Source code link.** The About page links to `https://www.equineform.com` for now (default in `src/lib/config.ts`, overridable with `VITE_SOURCE_URL`). Change it to the public git repository once one exists, and use the same URL for issue links in CONTRIBUTING.md.
- [ ] **CI on Gitea.** Confirm Actions is enabled for the repository and a runner is registered, then check the first run of `.gitea/workflows/ci.yml` passes. Keep the Playwright container tag in the workflow in sync with `@playwright/test`. - [ ] **CI on Gitea.** Confirm Actions is enabled for the repository and a runner is registered, then check the first run of `.gitea/workflows/ci.yml` passes. Keep the Playwright container tag in the workflow in sync with `@playwright/test`.
## Phase 1 follow-ups
- [ ] **Test against real PostgreSQL.** Server tests use PGlite (PostgreSQL compiled to WebAssembly). Add a CI job with a `postgres` service container that runs the server tests against real PostgreSQL too.
- [ ] **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 ## 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.
- [x] Product name: **Open SafetyLog EHS** (decided 2026-10-06). Note: Insight Works sells a similarly named free "Safety Logbook" EHS app for Microsoft Business Central; run a USPTO trademark search before a public launch. - [x] Product name: **Open SafetyLog EHS** (decided 2026-10-06). Note: Insight Works sells a similarly named free "Safety Logbook" EHS app for Microsoft Business Central; run a USPTO trademark search before a public launch.
- [x] CONTRIBUTING.md and CODE_OF_CONDUCT.md (Contributor Covenant 2.1) - [x] CONTRIBUTING.md and CODE_OF_CONDUCT.md (Contributor Covenant 2.1)
- [x] About page with source-code link (AGPL-3.0 section 13) - [x] About page with source-code link (AGPL-3.0 section 13)
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
dialect: 'postgresql',
schema: './server/db/schema.ts',
out: './server/db/migrations',
casing: 'snake_case',
})
+38
View File
@@ -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')
})
+47
View File
@@ -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([])
})
+27
View File
@@ -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'),
}
+25
View File
@@ -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()
}
+79
View File
@@ -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()
})
+44
View File
@@ -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()
})
+64
View File
@@ -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)
})
+92
View File
@@ -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(/\/$/)
})
-33
View File
@@ -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([])
})
+2
View File
@@ -3,6 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Open SafetyLog EHS</title> <title>Open SafetyLog EHS</title>
<meta name="description" content="Open-source accident and safety tracking for manufacturing" /> <meta name="description" content="Open-source accident and safety tracking for manufacturing" />
+7730 -15
View File
File diff suppressed because it is too large. Load diff
+34 -11
View File
@@ -6,10 +6,13 @@
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.12" "node": ">=22.18"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "concurrently -k -n web,api -c cyan,magenta \"npm:dev:web\" \"npm:dev:server\"",
"dev:web": "vite",
"dev:server": "node --watch --env-file-if-exists=.env server/index.ts",
"start": "node server/index.ts",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "oxlint --deny-warnings", "lint": "oxlint --deny-warnings",
@@ -17,32 +20,52 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"db:generate": "drizzle-kit generate",
"db:seed": "node --env-file-if-exists=.env server/db/seed.ts",
"check": "npm run lint && npm run typecheck && npm test && npm run build" "check": "npm run lint && npm run typecheck && npm test && npm run build"
}, },
"dependencies": { "dependencies": {
"@electric-sql/pglite": "^0.5.8",
"@hono/node-server": "^2.1.3",
"@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"
},
"devDependencies": {
"@fontsource-variable/dm-sans": "^5.3.0", "@fontsource-variable/dm-sans": "^5.3.0",
"@fontsource-variable/space-grotesk": "^5.3.0", "@fontsource-variable/space-grotesk": "^5.3.0",
"@fontsource/dm-mono": "^5.3.0", "@fontsource/dm-mono": "^5.3.0",
"@tailwindcss/vite": "^4.3.3",
"lucide-react": "^1.39.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.3",
"tailwindcss": "^4.3.3"
},
"devDependencies": {
"@playwright/test": "^1.63.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/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3", "@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7", "@testing-library/user-event": "^14.6.7",
"@types/node": "^26.6.4", "@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": "^19.2.18",
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.1.0", "@vitejs/plugin-react": "^6.1.0",
"concurrently": "^10.0.5",
"drizzle-kit": "^0.31.11",
"fake-indexeddb": "^6.2.5",
"jsdom": "^30.1.2", "jsdom": "^30.1.2",
"lucide-react": "^1.39.0",
"oxlint": "^1.79.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", "typescript": "^7.0.2",
"vite": "^8.2.2", "vite": "^8.2.2",
"vitest": "^5.0.3" "vite-plugin-pwa": "^2.0.0",
"vitest": "^5.0.3",
"workbox-window": "^7.4.1"
} }
} }
+6 -2
View File
@@ -1,24 +1,28 @@
import { defineConfig, devices } from '@playwright/test' import { defineConfig, devices } from '@playwright/test'
const port = 4173 const port = 4173
export const baseURL = `http://localhost:${port}`
export default defineConfig({ export default defineConfig({
testDir: './e2e', testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
fullyParallel: true, fullyParallel: true,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
use: { use: {
baseURL: `http://localhost:${port}`, baseURL,
trace: 'on-first-retry', trace: 'on-first-retry',
}, },
projects: [ projects: [
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } }, { name: 'desktop', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile', use: { ...devices['Pixel 7'] } }, { name: 'mobile', use: { ...devices['Pixel 7'] } },
], ],
// The real server with a throwaway in-memory database, serving the production build
webServer: { webServer: {
command: `npm run build && npm run preview -- --port ${port} --strictPort`, command: 'npm run build && node server/index.ts',
port, port,
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
env: { DATABASE_URL: 'memory://', PORT: String(port), PUBLIC_URL: baseURL, UPLOAD_DIR: 'test-results/uploads', STATIC_DIR: 'dist' },
}, },
}) })
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

+28
View File
@@ -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()
+75
View File
@@ -0,0 +1,75 @@
import { Hono } from 'hono'
import { csrf } from 'hono/csrf'
import { HTTPException } from 'hono/http-exception'
import { secureHeaders } from 'hono/secure-headers'
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 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())
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()
})
app.use('/api/*', loadSession)
app.get('/api/health', (c) => c.json({ ok: true }))
app.route('/api/setup', setupRoutes)
app.route('/api/auth', authRoutes())
app.route('/api/users', userRoutes)
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) => {
if (error instanceof HTTPException) return c.json({ error: error.message }, error.status)
console.error(error)
return c.json({ error: 'Internal server error' }, 500)
})
return app
}
export type App = ReturnType<typeof createApp>
+42
View File
@@ -0,0 +1,42 @@
import type { Context, MiddlewareHandler } from 'hono'
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
import { hasRole, type Role } from '../../shared/enums.ts'
import { currentUser, fail, type AppEnv } from '../lib/http.ts'
import { createSession, deleteSession, SESSION_COOKIE, validateSession } from './sessions.ts'
function writeCookie(c: Context<AppEnv>, token: string, expires: Date) {
setCookie(c, SESSION_COOKIE, token, { httpOnly: true, sameSite: 'Lax', secure: c.get('config').secureCookies, path: '/', expires })
}
/** Loads the user from the session cookie, if any. */
export const loadSession: MiddlewareHandler<AppEnv> = async (c, next) => {
const token = getCookie(c, SESSION_COOKIE)
const session = token ? await validateSession(c.get('db'), token) : null
if (token && !session) deleteCookie(c, SESSION_COOKIE, { path: '/' })
if (token && session?.renewedUntil) writeCookie(c, token, session.renewedUntil)
c.set('user', session?.user ?? null)
await next()
}
export const requireAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
currentUser(c)
await next()
}
export function requireRole(minimum: Role): MiddlewareHandler<AppEnv> {
return async (c, next) => {
if (!hasRole(currentUser(c).role, minimum)) throw fail(403, 'You do not have permission to do that')
await next()
}
}
export async function startSession(c: Context<AppEnv>, userId: string): Promise<void> {
const { token, expiresAt } = await createSession(c.get('db'), userId)
writeCookie(c, token, expiresAt)
}
export async function endSession(c: Context<AppEnv>): Promise<void> {
const token = getCookie(c, SESSION_COOKIE)
if (token) await deleteSession(c.get('db'), token)
deleteCookie(c, SESSION_COOKIE, { path: '/' })
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { hashPassword, verifyPassword } from './password.ts'
describe('password hashing', () => {
it('verifies the right password and rejects the wrong one', async () => {
const hash = await hashPassword('correct horse battery')
expect(hash).toMatch(/^scrypt\$32768\$8\$3\$/)
expect(await verifyPassword('correct horse battery', hash)).toBe(true)
expect(await verifyPassword('wrong horse battery', hash)).toBe(false)
})
it('salts each hash', async () => {
expect(await hashPassword('same password')).not.toBe(await hashPassword('same password'))
})
it('rejects malformed hashes', async () => {
expect(await verifyPassword('anything', 'not-a-hash')).toBe(false)
})
})
+34
View File
@@ -0,0 +1,34 @@
import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from 'node:crypto'
// scrypt parameters from the OWASP Password Storage Cheat Sheet (N=2^15, r=8, p=3: ~32 MiB per hash).
const params = { N: 2 ** 15, r: 8, p: 3 }
const keyLength = 64
function derive(password: string, salt: Buffer, options: ScryptOptions): Promise<Buffer> {
return new Promise((resolve, reject) =>
scrypt(password.normalize('NFKC'), salt, keyLength, { ...options, maxmem: 64 * 1024 * 1024 }, (error, key) => (error ? reject(error) : resolve(key))),
)
}
/** Returns `scrypt$N$r$p$salt$hash` so parameters can be raised later without breaking old hashes. */
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16)
const key = await derive(password, salt, params)
return ['scrypt', params.N, params.r, params.p, salt.toString('base64'), key.toString('base64')].join('$')
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const [algorithm, N, r, p, salt, hash] = stored.split('$')
if (algorithm !== 'scrypt' || !salt || !hash) return false
const expected = Buffer.from(hash, 'base64')
const actual = await derive(password, Buffer.from(salt, 'base64'), { N: Number(N), r: Number(r), p: Number(p) })
return actual.length === expected.length && timingSafeEqual(actual, expected)
}
/** Burns comparable time when the user doesn't exist, so login timing doesn't reveal valid emails. */
let dummyHash: Promise<string> | undefined
export async function verifyAgainstDummy(password: string): Promise<false> {
dummyHash ??= hashPassword('dummy-password-for-timing')
await verifyPassword(password, await dummyHash)
return false
}
+31
View File
@@ -0,0 +1,31 @@
/** Fixed-window in-memory limiter. Fine for a single server process; move to the database if we scale out. */
export class RateLimiter {
private hits = new Map<string, { count: number; resetAt: number }>()
private readonly limit: number
private readonly windowMs: number
constructor(limit: number, windowMs: number) {
this.limit = limit
this.windowMs = windowMs
}
/** Records an attempt; returns false once the key has exceeded the limit. */
attempt(key: string, now = Date.now()): boolean {
const entry = this.hits.get(key)
if (!entry || entry.resetAt <= now) {
this.hits.set(key, { count: 1, resetAt: now + this.windowMs })
if (this.hits.size > 10_000) this.prune(now)
return true
}
entry.count += 1
return entry.count <= this.limit
}
reset(key: string): void {
this.hits.delete(key)
}
private prune(now: number) {
for (const [key, entry] of this.hits) if (entry.resetAt <= now) this.hits.delete(key)
}
}
+54
View File
@@ -0,0 +1,54 @@
import { and, eq, gt } from 'drizzle-orm'
import { createHash, randomBytes } from 'node:crypto'
import type { Role } from '../../shared/enums.ts'
import type { Db } from '../db/client.ts'
import { sessions, users } from '../db/schema.ts'
export const SESSION_COOKIE = 'safetylog_session'
export const SESSION_TTL_MS = 14 * 24 * 60 * 60 * 1000
export interface SessionUser {
id: string
organizationId: string
email: string
name: string
role: Role
}
const hashToken = (token: string) => createHash('sha256').update(token).digest('hex')
export async function createSession(db: Db, userId: string): Promise<{ token: string; expiresAt: Date }> {
const token = randomBytes(32).toString('base64url')
const expiresAt = new Date(Date.now() + SESSION_TTL_MS)
await db.insert(sessions).values({ id: hashToken(token), userId, expiresAt })
return { token, expiresAt }
}
/** Returns the session's user, extending the session once it is past half its lifetime. */
export async function validateSession(db: Db, token: string): Promise<{ user: SessionUser; renewedUntil?: Date } | null> {
const id = hashToken(token)
const [row] = await db
.select({
expiresAt: sessions.expiresAt,
user: { id: users.id, organizationId: users.organizationId, email: users.email, name: users.name, role: users.role },
})
.from(sessions)
.innerJoin(users, eq(users.id, sessions.userId))
.where(and(eq(sessions.id, id), gt(sessions.expiresAt, new Date()), eq(users.active, true)))
if (!row) return null
if (row.expiresAt.getTime() - Date.now() < SESSION_TTL_MS / 2) {
const renewedUntil = new Date(Date.now() + SESSION_TTL_MS)
await db.update(sessions).set({ expiresAt: renewedUntil }).where(eq(sessions.id, id))
return { user: row.user, renewedUntil }
}
return { user: row.user }
}
export async function deleteSession(db: Db, token: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.id, hashToken(token)))
}
export async function deleteUserSessions(db: Db, userId: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.userId, userId))
}
+29
View File
@@ -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
}
+31
View File
@@ -0,0 +1,31 @@
export interface Config {
databaseUrl: string
port: number
/** Public URL users open in their browser; HTTPS here turns on secure cookies. */
publicUrl: string
secureCookies: boolean
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 {
const publicUrl = env.PUBLIC_URL ?? `http://localhost:${env.PORT ?? 3000}`
return {
databaseUrl: env.DATABASE_URL ?? 'pglite://./data/pglite',
port: Number(env.PORT ?? 3000),
publicUrl,
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,
}
}
+55
View File
@@ -0,0 +1,55 @@
import { PGlite } from '@electric-sql/pglite'
import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres'
import { migrate as migratePg } from 'drizzle-orm/node-postgres/migrator'
import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core'
import { drizzle as drizzlePglite } from 'drizzle-orm/pglite'
import { migrate as migratePglite } from 'drizzle-orm/pglite/migrator'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import pg from 'pg'
import * as schema from './schema.ts'
export type Db = PgDatabase<PgQueryResultHKT, typeof schema>
export interface Database {
db: Db
close(): Promise<void>
}
const migrationsFolder = join(import.meta.dirname, 'migrations')
/**
* Opens the database and applies pending migrations.
*
* - `postgres://…` connects to PostgreSQL (production).
* - `pglite://<dir>` uses embedded PostgreSQL stored in `<dir>` (local development, very small installs).
* - `memory://` uses a throwaway in-memory database (tests).
*/
export async function openDatabase(url: string): Promise<Database> {
if (/^postgres(ql)?:\/\//.test(url)) {
const pool = new pg.Pool({ connectionString: url })
const db = drizzlePg({ client: pool, schema, casing: 'snake_case' })
await migratePg(db, { migrationsFolder })
return { db: db as unknown as Db, close: () => pool.end() }
}
if (url !== 'memory://' && !url.startsWith('pglite://')) {
throw new Error(`Unsupported DATABASE_URL: expected postgres://, pglite:// or memory://`)
}
const dataDir = url === 'memory://' ? undefined : url.slice('pglite://'.length)
if (dataDir) mkdirSync(dataDir, { recursive: true })
const client = new PGlite(dataDir)
const db = drizzlePglite({ client, schema, casing: 'snake_case' })
await migratePglite(db, { migrationsFolder })
return { db: db as unknown as Db, close: () => client.close() }
}
/** True for a unique-constraint violation from either driver (Drizzle wraps driver errors in `cause`). */
export function isUniqueViolation(error: unknown): boolean {
let current: unknown = error
for (let depth = 0; current && typeof current === 'object' && depth < 5; depth++) {
if ((current as { code?: unknown }).code === '23505') return true
current = (current as { cause?: unknown }).cause
}
return false
}
+225
View File
@@ -0,0 +1,225 @@
CREATE TYPE "public"."action_kind" AS ENUM('corrective', 'preventive');--> statement-breakpoint
CREATE TYPE "public"."action_status" AS ENUM('open', 'in_progress', 'done', 'verified', 'cancelled');--> statement-breakpoint
CREATE TYPE "public"."area_kind" AS ENUM('building', 'area', 'line', 'other');--> statement-breakpoint
CREATE TYPE "public"."employment_type" AS ENUM('employee', 'temporary', 'contractor', 'visitor');--> statement-breakpoint
CREATE TYPE "public"."incident_status" AS ENUM('reported', 'triage', 'investigation', 'actions', 'verification', 'closed');--> statement-breakpoint
CREATE TYPE "public"."incident_type" AS ENUM('near_miss', 'hazard', 'injury', 'illness', 'property_damage', 'environmental');--> statement-breakpoint
CREATE TYPE "public"."rca_method" AS ENUM('five_whys', 'fishbone', 'other');--> statement-breakpoint
CREATE TYPE "public"."role" AS ENUM('reporter', 'supervisor', 'safety_manager', 'admin');--> statement-breakpoint
CREATE TYPE "public"."root_cause_category" AS ENUM('people', 'process', 'equipment', 'materials', 'environment', 'management');--> statement-breakpoint
CREATE TYPE "public"."severity" AS ENUM('low', 'medium', 'high', 'critical');--> statement-breakpoint
CREATE TYPE "public"."treatment_level" AS ENUM('none', 'first_aid', 'medical_treatment', 'hospitalized', 'fatality');--> statement-breakpoint
CREATE TABLE "areas" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"site_id" uuid NOT NULL,
"parent_id" uuid,
"name" text NOT NULL,
"kind" "area_kind" DEFAULT 'area' NOT NULL,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "attachments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"incident_id" uuid NOT NULL,
"file_name" text NOT NULL,
"content_type" text NOT NULL,
"size_bytes" integer NOT NULL,
"storage_key" text NOT NULL,
"uploaded_by_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "attachments_storageKey_unique" UNIQUE("storage_key")
);
--> statement-breakpoint
CREATE TABLE "audit_log" (
"id" bigserial PRIMARY KEY NOT NULL,
"organization_id" uuid NOT NULL,
"actor_id" uuid,
"entity_type" text NOT NULL,
"entity_id" text NOT NULL,
"action" text NOT NULL,
"changes" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "comments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"incident_id" uuid NOT NULL,
"author_id" uuid,
"body" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "corrective_actions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"incident_id" uuid NOT NULL,
"root_cause_id" uuid,
"kind" "action_kind" DEFAULT 'corrective' NOT NULL,
"title" text NOT NULL,
"description" text,
"assignee_id" uuid,
"due_date" date,
"status" "action_status" DEFAULT 'open' NOT NULL,
"completed_at" timestamp with time zone,
"verified_by_id" uuid,
"verified_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "hours_worked" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"site_id" uuid NOT NULL,
"month" date NOT NULL,
"hours" numeric(12, 2) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "incident_counters" (
"organization_id" uuid PRIMARY KEY NOT NULL,
"last_number" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "incidents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"number" integer NOT NULL,
"title" text NOT NULL,
"type" "incident_type" NOT NULL,
"severity" "severity" NOT NULL,
"status" "incident_status" DEFAULT 'reported' NOT NULL,
"occurred_at" timestamp with time zone NOT NULL,
"site_id" uuid NOT NULL,
"area_id" uuid,
"location_detail" text,
"description" text,
"immediate_action" text,
"reporter_id" uuid,
"anonymous" boolean DEFAULT false NOT NULL,
"closed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "injured_persons" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"incident_id" uuid NOT NULL,
"user_id" uuid,
"name" text NOT NULL,
"job_title" text,
"employment_type" "employment_type" DEFAULT 'employee' NOT NULL,
"body_part" text,
"nature_of_injury" text,
"treatment" "treatment_level" DEFAULT 'none' NOT NULL,
"days_away" integer DEFAULT 0 NOT NULL,
"days_restricted" integer DEFAULT 0 NOT NULL,
"privacy_case" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "investigations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"incident_id" uuid NOT NULL,
"lead_id" uuid,
"method" "rca_method" DEFAULT 'five_whys' NOT NULL,
"summary" text,
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "investigations_incidentId_unique" UNIQUE("incident_id")
);
--> statement-breakpoint
CREATE TABLE "organizations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "root_causes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"investigation_id" uuid NOT NULL,
"category" "root_cause_category" NOT NULL,
"description" text NOT NULL,
"whys" jsonb DEFAULT '[]'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" text PRIMARY KEY NOT NULL,
"user_id" uuid NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sites" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"name" text NOT NULL,
"address" text,
"timezone" text DEFAULT 'UTC' NOT NULL,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"email" text NOT NULL,
"name" text NOT NULL,
"role" "role" DEFAULT 'reporter' NOT NULL,
"password_hash" text,
"active" boolean DEFAULT true NOT NULL,
"last_login_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "areas" ADD CONSTRAINT "areas_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "areas" ADD CONSTRAINT "areas_parent_id_areas_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."areas"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_incident_id_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "public"."incidents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_uploaded_by_id_users_id_fk" FOREIGN KEY ("uploaded_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_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 "audit_log" ADD CONSTRAINT "audit_log_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_incident_id_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "public"."incidents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "corrective_actions" ADD CONSTRAINT "corrective_actions_incident_id_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "public"."incidents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "corrective_actions" ADD CONSTRAINT "corrective_actions_root_cause_id_root_causes_id_fk" FOREIGN KEY ("root_cause_id") REFERENCES "public"."root_causes"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "corrective_actions" ADD CONSTRAINT "corrective_actions_assignee_id_users_id_fk" FOREIGN KEY ("assignee_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "corrective_actions" ADD CONSTRAINT "corrective_actions_verified_by_id_users_id_fk" FOREIGN KEY ("verified_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hours_worked" ADD CONSTRAINT "hours_worked_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "incident_counters" ADD CONSTRAINT "incident_counters_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 "incidents" ADD CONSTRAINT "incidents_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 "incidents" ADD CONSTRAINT "incidents_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "incidents" ADD CONSTRAINT "incidents_area_id_areas_id_fk" FOREIGN KEY ("area_id") REFERENCES "public"."areas"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "incidents" ADD CONSTRAINT "incidents_reporter_id_users_id_fk" FOREIGN KEY ("reporter_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD CONSTRAINT "injured_persons_incident_id_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "public"."incidents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD CONSTRAINT "injured_persons_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "investigations" ADD CONSTRAINT "investigations_incident_id_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "public"."incidents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "investigations" ADD CONSTRAINT "investigations_lead_id_users_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "root_causes" ADD CONSTRAINT "root_causes_investigation_id_investigations_id_fk" FOREIGN KEY ("investigation_id") REFERENCES "public"."investigations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sites" ADD CONSTRAINT "sites_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 "users" ADD CONSTRAINT "users_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "areas_site_idx" ON "areas" USING btree ("site_id");--> statement-breakpoint
CREATE INDEX "areas_parent_idx" ON "areas" USING btree ("parent_id");--> statement-breakpoint
CREATE INDEX "attachments_incident_idx" ON "attachments" USING btree ("incident_id");--> statement-breakpoint
CREATE INDEX "audit_log_entity_idx" ON "audit_log" USING btree ("organization_id","entity_type","entity_id");--> statement-breakpoint
CREATE INDEX "comments_incident_idx" ON "comments" USING btree ("incident_id");--> statement-breakpoint
CREATE INDEX "corrective_actions_incident_idx" ON "corrective_actions" USING btree ("incident_id");--> statement-breakpoint
CREATE INDEX "corrective_actions_assignee_status_idx" ON "corrective_actions" USING btree ("assignee_id","status");--> statement-breakpoint
CREATE UNIQUE INDEX "hours_worked_site_month_unique" ON "hours_worked" USING btree ("site_id","month");--> statement-breakpoint
CREATE UNIQUE INDEX "incidents_organization_number_unique" ON "incidents" USING btree ("organization_id","number");--> statement-breakpoint
CREATE INDEX "incidents_organization_occurred_idx" ON "incidents" USING btree ("organization_id","occurred_at");--> statement-breakpoint
CREATE INDEX "incidents_organization_status_idx" ON "incidents" USING btree ("organization_id","status");--> statement-breakpoint
CREATE INDEX "incidents_reporter_idx" ON "incidents" USING btree ("reporter_id");--> statement-breakpoint
CREATE INDEX "injured_persons_incident_idx" ON "injured_persons" USING btree ("incident_id");--> statement-breakpoint
CREATE INDEX "root_causes_investigation_idx" ON "root_causes" USING btree ("investigation_id");--> statement-breakpoint
CREATE INDEX "sessions_user_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "sites_organization_name_unique" ON "sites" USING btree ("organization_id","name");--> statement-breakpoint
CREATE UNIQUE INDEX "users_email_unique" ON "users" USING btree ("email");--> statement-breakpoint
CREATE INDEX "users_organization_idx" ON "users" USING btree ("organization_id");
@@ -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;
+14
View File
@@ -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");
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+41
View File
@@ -0,0 +1,41 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"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
}
]
}
+408
View File
@@ -0,0 +1,408 @@
import { bigserial, boolean, date, index, integer, jsonb, numeric, pgEnum, pgTable, text, timestamp, uniqueIndex, uuid, type AnyPgColumn } from 'drizzle-orm/pg-core'
import * as vocab from '../../shared/enums.ts'
export const roleEnum = pgEnum('role', vocab.roles)
export const incidentTypeEnum = pgEnum('incident_type', vocab.incidentTypes)
export const severityEnum = pgEnum('severity', vocab.severities)
export const incidentStatusEnum = pgEnum('incident_status', vocab.incidentStatuses)
export const areaKindEnum = pgEnum('area_kind', vocab.areaKinds)
export const employmentTypeEnum = pgEnum('employment_type', vocab.employmentTypes)
export const treatmentLevelEnum = pgEnum('treatment_level', vocab.treatmentLevels)
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 })
const createdAt = () => timestamptz().notNull().defaultNow()
const updatedAt = () =>
timestamptz()
.notNull()
.defaultNow()
.$onUpdate(() => new Date())
export const organizations = pgTable('organizations', {
id: id(),
name: text().notNull(),
createdAt: createdAt(),
})
export const users = pgTable(
'users',
{
id: id(),
organizationId: uuid()
.notNull()
.references(() => organizations.id),
/** Stored lower-cased. */
email: text().notNull(),
name: text().notNull(),
role: roleEnum().notNull().default('reporter'),
passwordHash: text(),
active: boolean().notNull().default(true),
lastLoginAt: timestamptz(),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [uniqueIndex('users_email_unique').on(t.email), index('users_organization_idx').on(t.organizationId)],
)
export const sessions = pgTable(
'sessions',
{
/** SHA-256 of the session token; the token itself is never stored. */
id: text().primaryKey(),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expiresAt: timestamptz().notNull(),
createdAt: createdAt(),
},
(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',
{
id: id(),
organizationId: uuid()
.notNull()
.references(() => organizations.id),
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(),
},
(t) => [uniqueIndex('sites_organization_name_unique').on(t.organizationId, t.name)],
)
/** Location tree within a site: building → area → line. */
export const areas = pgTable(
'areas',
{
id: id(),
siteId: uuid()
.notNull()
.references(() => sites.id, { onDelete: 'cascade' }),
parentId: uuid().references((): AnyPgColumn => areas.id, { onDelete: 'cascade' }),
name: text().notNull(),
kind: areaKindEnum().notNull().default('area'),
archivedAt: timestamptz(),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(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()
.primaryKey()
.references(() => organizations.id),
lastNumber: integer().notNull().default(0),
})
export const incidents = pgTable(
'incidents',
{
id: id(),
organizationId: uuid()
.notNull()
.references(() => organizations.id),
number: integer().notNull(),
title: text().notNull(),
type: incidentTypeEnum().notNull(),
severity: severityEnum().notNull(),
status: incidentStatusEnum().notNull().default('reported'),
occurredAt: timestamptz().notNull(),
siteId: uuid()
.notNull()
.references(() => sites.id),
areaId: uuid().references(() => areas.id, { onDelete: 'set null' }),
locationDetail: text(),
description: text(),
immediateAction: text(),
/** 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),
],
)
export const injuredPersons = pgTable(
'injured_persons',
{
id: id(),
incidentId: uuid()
.notNull()
.references(() => incidents.id, { onDelete: 'cascade' }),
/** Set when the person has a user account. */
userId: uuid().references(() => users.id, { onDelete: 'set null' }),
name: text().notNull(),
jobTitle: text(),
employmentType: employmentTypeEnum().notNull().default('employee'),
bodyPart: text(),
natureOfInjury: text(),
treatment: treatmentLevelEnum().notNull().default('none'),
daysAway: integer().notNull().default(0),
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(),
},
(t) => [index('injured_persons_incident_idx').on(t.incidentId)],
)
export const attachments = pgTable(
'attachments',
{
id: id(),
incidentId: uuid()
.notNull()
.references(() => incidents.id, { onDelete: 'cascade' }),
fileName: text().notNull(),
contentType: text().notNull(),
sizeBytes: integer().notNull(),
storageKey: text().notNull().unique(),
uploadedById: uuid().references(() => users.id, { onDelete: 'set null' }),
createdAt: createdAt(),
},
(t) => [index('attachments_incident_idx').on(t.incidentId)],
)
export const investigations = pgTable('investigations', {
id: id(),
incidentId: uuid()
.notNull()
.unique()
.references(() => incidents.id, { onDelete: 'cascade' }),
leadId: uuid().references(() => users.id, { onDelete: 'set null' }),
method: rcaMethodEnum().notNull().default('five_whys'),
summary: text(),
startedAt: timestamptz().notNull().defaultNow(),
completedAt: timestamptz(),
createdAt: createdAt(),
updatedAt: updatedAt(),
})
export const rootCauses = pgTable(
'root_causes',
{
id: id(),
investigationId: uuid()
.notNull()
.references(() => investigations.id, { onDelete: 'cascade' }),
category: rootCauseCategoryEnum().notNull(),
description: text().notNull(),
/** 5 Whys chain, first "why" first. */
whys: jsonb().$type<string[]>().notNull().default([]),
createdAt: createdAt(),
},
(t) => [index('root_causes_investigation_idx').on(t.investigationId)],
)
export const correctiveActions = pgTable(
'corrective_actions',
{
id: id(),
incidentId: uuid()
.notNull()
.references(() => incidents.id, { onDelete: 'cascade' }),
rootCauseId: uuid().references(() => rootCauses.id, { onDelete: 'set null' }),
kind: actionKindEnum().notNull().default('corrective'),
title: text().notNull(),
description: text(),
assigneeId: uuid().references(() => users.id, { onDelete: 'set null' }),
dueDate: date({ mode: 'string' }),
status: actionStatusEnum().notNull().default('open'),
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(),
},
(t) => [index('corrective_actions_incident_idx').on(t.incidentId), index('corrective_actions_assignee_status_idx').on(t.assigneeId, t.status)],
)
export const comments = pgTable(
'comments',
{
id: id(),
incidentId: uuid()
.notNull()
.references(() => incidents.id, { onDelete: 'cascade' }),
authorId: uuid().references(() => users.id, { onDelete: 'set null' }),
body: text().notNull(),
createdAt: createdAt(),
},
(t) => [index('comments_incident_idx').on(t.incidentId)],
)
export const auditLog = pgTable(
'audit_log',
{
id: bigserial({ mode: 'number' }).primaryKey(),
organizationId: uuid()
.notNull()
.references(() => organizations.id),
actorId: uuid().references(() => users.id, { onDelete: 'set null' }),
entityType: text().notNull(),
entityId: text().notNull(),
action: text().notNull(),
/** `{ field: [before, after] }` for updates, the created record for creates. */
changes: jsonb(),
createdAt: createdAt(),
},
(t) => [index('audit_log_entity_idx').on(t.organizationId, t.entityType, t.entityId)],
)
/** Monthly hours worked per site, for TRIR / DART rate calculations. */
export const hoursWorked = pgTable(
'hours_worked',
{
id: id(),
siteId: uuid()
.notNull()
.references(() => sites.id, { onDelete: 'cascade' }),
/** 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)],
)
+230
View File
@@ -0,0 +1,230 @@
// Loads demo data into an empty database: `npm run db:seed`. Not for production use.
import { hashPassword } from '../auth/password.ts'
import { loadConfig } from '../config.ts'
import { openDatabase } from './client.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'
const config = loadConfig()
const { db, close } = await openDatabase(config.databaseUrl)
try {
if ((await db.select({ id: users.id }).from(users).limit(1)).length > 0) {
console.error('Database already has users; seeding is only for empty databases.')
process.exitCode = 1
} else {
await db.transaction(async (tx) => {
const [org] = await tx.insert(organizations).values({ name: 'Northstar Co.' }).returning()
const passwordHash = await hashPassword(DEMO_PASSWORD)
const people = await tx
.insert(users)
.values([
{ name: 'Jordan Lee', email: 'admin@example.com', role: 'admin' as const },
{ name: 'Priya Shah', email: 'manager@example.com', role: 'safety_manager' as const },
{ name: 'Sam Rivera', email: 'supervisor@example.com', role: 'supervisor' as const },
{ name: 'Maya Chen', email: 'reporter@example.com', role: 'reporter' as const },
].map((u) => ({ ...u, organizationId: org.id, passwordHash })))
.returning()
const person = (email: string) => people.find((p) => p.email === email)!.id
const layout: Record<string, string[]> = {
'North Campus': ['Building A', 'Warehouse 04', 'Lab 2'],
'South Campus': ['Fabrication 02', 'Yard', 'Office block'],
'West Campus': ['Warehouse 01', 'Loading dock'],
}
const areaIds: Record<string, { siteId: string; areaId: string }> = {}
for (const [siteName, buildings] of Object.entries(layout)) {
const [site] = await tx.insert(sites).values({ organizationId: org.id, name: siteName }).returning()
for (const name of buildings) {
const [area] = await tx.insert(areas).values({ siteId: site.id, name, kind: 'building' }).returning()
areaIds[name] = { siteId: site.id, areaId: area.id }
}
}
const daysAgo = (days: number) => new Date(Date.now() - days * 86_400_000)
const demo = [
{ title: 'Damaged guardrail on mezzanine', type: 'property_damage', severity: 'low', status: 'closed', building: 'Warehouse 01', locationDetail: 'Mezzanine east', reporter: 'manager@example.com', days: 9 },
{ title: 'Hand laceration during material handling', type: 'injury', severity: 'high', status: 'investigation', building: 'Fabrication 02', locationDetail: 'Line 3', reporter: 'supervisor@example.com', days: 7 },
{ 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
const created = await tx
.insert(incidents)
.values(
demo.map((item, index) => ({
organizationId: org.id,
number: index + 1,
title: item.title,
type: item.type,
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,
})),
)
.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
}
+49
View File
@@ -0,0 +1,49 @@
import { serve } from '@hono/node-server'
import { serveStatic } from '@hono/node-server/serve-static'
import { existsSync } from 'node:fs'
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 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'))) {
app.use(
'/*',
serveStatic({
root: config.staticDir,
onFound: (path, c) => {
if (path.includes('/assets/')) c.header('Cache-Control', 'public, max-age=31536000, immutable')
},
}),
)
app.get('*', serveStatic({ path: join(config.staticDir, 'index.html') })) // client-side routes
}
const server = serve({ fetch: app.fetch, port: config.port }, ({ port }) => {
console.log(`Open SafetyLog EHS listening on port ${port}, public URL ${config.publicUrl} (database: ${config.databaseUrl.replace(/\/\/[^@]*@/, '//***@')})`)
})
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)
})
})
}
+252
View File
@@ -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,
}
}
+39
View File
@@ -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
}
}
+29
View File
@@ -0,0 +1,29 @@
import type { Db } from '../db/client.ts'
import { auditLog } from '../db/schema.ts'
interface AuditEntry {
organizationId: string
actorId: string | null
entityType: string
entityId: string
action: string
changes?: Record<string, unknown> | null
}
export async function audit(db: Db, entry: AuditEntry): Promise<void> {
await db.insert(auditLog).values({ ...entry, changes: entry.changes ?? null })
}
const comparable = (value: unknown) => (value instanceof Date ? value.toISOString() : (value ?? null))
/** `{ field: [before, after] }` for each field in `next` whose value differs from `current`. */
export function diff(current: Record<string, unknown>, next: Record<string, unknown>): Record<string, [unknown, unknown]> {
const changes: Record<string, [unknown, unknown]> = {}
for (const [key, value] of Object.entries(next)) {
if (value === undefined) continue
const before = comparable(current[key])
const after = comparable(value)
if (JSON.stringify(before) !== JSON.stringify(after)) changes[key] = [before, after]
}
return changes
}
+12
View File
@@ -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")
})
})
+15
View File
@@ -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'
}
+14
View File
@@ -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)
}
+19
View File
@@ -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()
})
})
+21
View File
@@ -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
}
+37
View File
@@ -0,0 +1,37 @@
import { zValidator } from '@hono/zod-validator'
import type { Context, ValidationTargets } from 'hono'
import { HTTPException } from 'hono/http-exception'
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
}
}
/** Validates part of the request, answering 400 with field-level issues on failure. */
export function validate<Target extends keyof ValidationTargets, Schema extends ZodType>(target: Target, schema: Schema) {
return zValidator(target, schema, (result, c) => {
if (!result.success) {
return c.json({ error: 'Invalid request', issues: result.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })) }, 400)
}
})
}
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 {
const user = c.get('user')
if (!user) throw fail(401, 'Sign in required')
return user
}
+193
View File
@@ -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')
}
+200
View File
@@ -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))
}
+115
View File
@@ -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
}
}
+39
View File
@@ -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)
}
}
+182
View File
@@ -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
}
}
+87
View File
@@ -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) })
})
+130
View File
@@ -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)
})
})
+74
View File
@@ -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)
})
})
+48
View File
@@ -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)
})
+91
View File
@@ -0,0 +1,91 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { ADMIN, createTestContext } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
beforeAll(async () => {
ctx = await createTestContext()
})
afterAll(() => ctx.close())
describe('first-run setup', () => {
it('reports that setup is done and refuses to run again', async () => {
const client = ctx.anonymous()
expect((await client.get('/api/setup')).body).toEqual({ needsSetup: false })
const again = await client.post('/api/setup', { ...ADMIN, email: 'intruder@example.com' })
expect(again.status).toBe(409)
})
it('signed the new admin in', async () => {
const me = await ctx.admin.get('/api/auth/me')
expect(me.status).toBe(200)
expect(me.body.user).toMatchObject({ email: ADMIN.email, role: 'admin' })
expect(me.body.organization.name).toBe(ADMIN.organizationName)
expect(me.body.user.passwordHash).toBeUndefined()
})
})
describe('sign in and out', () => {
it('rejects a wrong password with a generic message', async () => {
const res = await ctx.anonymous().post('/api/auth/login', { email: ADMIN.email, password: 'nope' })
expect(res.status).toBe(401)
expect(res.body.error).toBe('Incorrect email or password')
})
it('treats unknown emails the same as wrong passwords', async () => {
const res = await ctx.anonymous().post('/api/auth/login', { email: 'nobody@example.com', password: 'whatever' })
expect(res.status).toBe(401)
expect(res.body.error).toBe('Incorrect email or password')
})
it('accepts email in any case and sets an http-only cookie', async () => {
const client = ctx.anonymous()
const res = await client.post('/api/auth/login', { email: ADMIN.email.toUpperCase(), password: ADMIN.password })
expect(res.status).toBe(200)
expect(res.headers.get('set-cookie')).toMatch(/HttpOnly/)
expect(res.headers.get('set-cookie')).toMatch(/SameSite=Lax/)
expect((await client.get('/api/auth/me')).status).toBe(200)
expect((await client.post('/api/auth/logout')).status).toBe(204)
expect((await client.get('/api/auth/me')).status).toBe(401)
})
it('invalidates the session server-side on logout, even if the cookie is replayed', async () => {
const client = ctx.anonymous()
await client.post('/api/auth/login', { email: ADMIN.email, password: ADMIN.password })
const stolen = client.cookie
await client.post('/api/auth/logout')
client.cookie = stolen
expect((await client.get('/api/auth/me')).status).toBe(401)
})
it('rate-limits repeated attempts for one email', async () => {
const client = ctx.anonymous()
const email = 'ratelimit@example.com'
for (let i = 0; i < 10; i++) expect((await client.post('/api/auth/login', { email, password: 'x' })).status).toBe(401)
expect((await client.post('/api/auth/login', { email, password: 'x' })).status).toBe(429)
})
it('blocks deactivated users and ends their sessions', async () => {
const worker = await ctx.createUser('reporter', 'leaver@example.com')
expect((await worker.get('/api/auth/me')).status).toBe(200)
await ctx.admin.patch(`/api/users/${worker.user.id}`, { active: false })
expect((await worker.get('/api/auth/me')).status).toBe(401)
expect((await ctx.anonymous().post('/api/auth/login', { email: 'leaver@example.com', password: 'a sufficiently long password' })).status).toBe(401)
})
})
describe('change password', () => {
it('requires the current password and signs out other sessions', async () => {
const user = await ctx.createUser('supervisor', 'changer@example.com')
const otherDevice = ctx.anonymous()
await otherDevice.post('/api/auth/login', { email: 'changer@example.com', password: 'a sufficiently long password' })
expect((await user.post('/api/auth/password', { currentPassword: 'wrong', newPassword: 'a brand new password' })).status).toBe(400)
expect((await user.post('/api/auth/password', { currentPassword: 'a sufficiently long password', newPassword: 'short' })).status).toBe(400)
expect((await user.post('/api/auth/password', { currentPassword: 'a sufficiently long password', newPassword: 'a brand new password' })).status).toBe(204)
expect((await user.get('/api/auth/me')).status).toBe(200)
expect((await otherDevice.get('/api/auth/me')).status).toBe(401)
expect((await ctx.anonymous().post('/api/auth/login', { email: 'changer@example.com', password: 'a brand new password' })).status).toBe(200)
})
})
+113
View File
@@ -0,0 +1,113 @@
import { eq } from 'drizzle-orm'
import { Hono } from 'hono'
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) => {
const { email, password } = c.req.valid('json')
if (!loginLimiter.attempt(email)) throw fail(429, 'Too many sign-in attempts. Try again in 15 minutes.')
const db = c.get('db')
const [user] = await db.select().from(users).where(eq(users.email, email))
const valid = user?.active && user.passwordHash ? await verifyPassword(password, user.passwordHash) : await verifyAgainstDummy(password)
if (!user || !valid) throw fail(401, 'Incorrect email or password')
loginLimiter.reset(email)
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, user.id))
await startSession(c, user.id)
const [result] = await db.select(publicUser).from(users).where(eq(users.id, user.id))
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)
})
.get('/me', requireAuth, async (c) => {
const me = currentUser(c)
const db = c.get('db')
const [user] = await db.select(publicUser).from(users).where(eq(users.id, me.id))
const [organization] = await db.select({ id: organizations.id, name: organizations.name }).from(organizations).where(eq(organizations.id, me.organizationId))
return c.json({ user, organization })
})
.post('/password', requireAuth, validate('json', changePasswordSchema), async (c) => {
const me = currentUser(c)
const { currentPassword, newPassword } = c.req.valid('json')
const db = c.get('db')
const [user] = await db.select({ passwordHash: users.passwordHash }).from(users).where(eq(users.id, me.id))
if (!user.passwordHash || !(await verifyPassword(currentPassword, user.passwordHash))) throw fail(400, 'Current password is incorrect')
await db.update(users).set({ passwordHash: await hashPassword(newPassword) }).where(eq(users.id, me.id))
// Sign out everywhere else
await deleteUserSessions(db, me.id)
await startSession(c, me.id)
return c.body(null, 204)
})
}
+80
View File
@@ -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)
})
})
+169
View File
@@ -0,0 +1,169 @@
import { eq } from 'drizzle-orm'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { auditLog } from '../db/schema.ts'
import { createTestContext, hoursAgo } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
let site: { id: string }
let area: { id: string }
let reporter: Awaited<ReturnType<typeof ctx.createUser>>
let supervisor: Awaited<ReturnType<typeof ctx.createUser>>
const report = (overrides: Record<string, unknown> = {}) => ({
title: 'Forklift reversed into pallet stack',
type: 'near_miss',
severity: 'high',
occurredAt: hoursAgo(2),
siteId: site.id,
...overrides,
})
beforeAll(async () => {
ctx = await createTestContext()
site = await ctx.createSite('North Campus')
area = (await ctx.admin.post(`/api/sites/${site.id}/areas`, { name: 'Loading bay C' })).body.area
reporter = await ctx.createUser('reporter')
supervisor = await ctx.createUser('supervisor')
})
afterAll(() => ctx.close())
describe('reporting', () => {
it('creates an incident with a sequential reference and the reporter recorded', async () => {
const res = await reporter.post('/api/incidents', report({ areaId: area.id, locationDetail: 'Dock door 3' }))
expect(res.status).toBe(201)
expect(res.body.incident).toMatchObject({
number: 1,
reference: 'INC-0001',
status: 'reported',
site: { id: site.id, name: 'North Campus' },
area: { id: area.id, name: 'Loading bay C' },
reporter: { id: reporter.user.id, name: 'reporter user' },
})
})
it('assigns distinct numbers to simultaneous reports', async () => {
const results = await Promise.all(Array.from({ length: 8 }, (_, i) => reporter.post('/api/incidents', report({ title: `Concurrent ${i}` }))))
const numbers = results.map((r) => r.body.incident.number)
expect(new Set(numbers).size).toBe(8)
})
it('keeps anonymous reports anonymous', async () => {
const res = await reporter.post('/api/incidents', report({ title: 'Blocked fire exit', anonymous: true }))
expect(res.body.incident).toMatchObject({ anonymous: true, reporter: null })
})
it('validates location and time', async () => {
const otherSite = await ctx.createSite('South Campus')
expect((await reporter.post('/api/incidents', report({ siteId: otherSite.id, areaId: area.id }))).status).toBe(400)
expect((await reporter.post('/api/incidents', report({ occurredAt: new Date(Date.now() + 86_400_000).toISOString() }))).status).toBe(400)
expect((await reporter.post('/api/incidents', report({ type: 'explosion' }))).status).toBe(400)
})
it('writes an audit entry', async () => {
const res = await reporter.post('/api/incidents', report({ title: 'Audited' }))
const entries = await ctx.db.select().from(auditLog).where(eq(auditLog.entityId, res.body.incident.id))
expect(entries).toHaveLength(1)
expect(entries[0]).toMatchObject({ action: 'create', actorId: reporter.user.id })
})
})
describe('visibility', () => {
it('shows reporters only their own incidents', async () => {
const other = await ctx.createUser('reporter', 'other-reporter@example.com')
const mine = await other.post('/api/incidents', report({ title: 'Only mine' }))
const list = await other.get('/api/incidents')
expect(list.body.items.map((i: { title: string }) => i.title)).toEqual(['Only mine'])
expect((await reporter.get(`/api/incidents/${mine.body.incident.number}`)).status).toBe(404)
})
it('shows supervisors every incident', async () => {
const list = await supervisor.get('/api/incidents?pageSize=100')
expect(list.body.total).toBeGreaterThan(10)
})
})
describe('listing', () => {
it('paginates newest first', async () => {
const page1 = await supervisor.get('/api/incidents?pageSize=3&page=1')
const page2 = await supervisor.get('/api/incidents?pageSize=3&page=2')
expect(page1.body.items).toHaveLength(3)
expect(page1.body.items[0].id).not.toBe(page2.body.items[0].id)
const times = page1.body.items.map((i: { occurredAt: string }) => i.occurredAt)
expect([...times].sort().reverse()).toEqual(times)
})
it('searches title and reference, treating % and _ literally', async () => {
await reporter.post('/api/incidents', report({ title: 'Oil leak under press 4', type: 'hazard', severity: 'medium' }))
expect((await supervisor.get('/api/incidents?q=press')).body.items[0].title).toBe('Oil leak under press 4')
expect((await supervisor.get('/api/incidents?q=INC-0001')).body.items[0].number).toBe(1)
expect((await supervisor.get('/api/incidents?q=%25')).body.total).toBe(0)
})
it('filters by type, severity and status', async () => {
const hazards = await supervisor.get('/api/incidents?type=hazard&severity=medium')
expect(hazards.body.items.every((i: { type: string; severity: string }) => i.type === 'hazard' && i.severity === 'medium')).toBe(true)
expect((await supervisor.get('/api/incidents?status=closed')).body.total).toBe(0)
expect((await supervisor.get('/api/incidents?status=bogus')).status).toBe(400)
})
})
describe('updating', () => {
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}`, { severity: 'low' })).status).toBe(403)
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({ severity: ['high', 'low'], title: ['Wet floor', 'Wet floor by entrance'] })
})
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 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)
})
})
+291
View File
@@ -0,0 +1,291 @@
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 {
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 { 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() })
const EXPORT_LIMIT = 10_000
const escapeLike = (value: string) => value.replace(/[\\%_]/g, '\\$&')
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)}%`
const number = /^(?:inc-?)?(\d+)$/i.exec(q)?.[1]
search = or(
ilike(incidents.title, pattern),
ilike(incidents.description, pattern),
ilike(incidents.locationDetail, pattern),
number ? eq(incidents.number, Number(number)) : undefined,
)
}
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(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 input = c.req.valid('json')
const db = c.get('db')
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) => {
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) => {
const me = currentUser(c)
const db = c.get('db')
const { incident } = await findIncident(db, me, c.req.valid('param').number)
const { occurredAt, ...input } = c.req.valid('json')
const siteId = input.siteId ?? incident.siteId
let areaId = input.areaId !== undefined ? input.areaId : incident.areaId
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 }
const changes = diff(incident, update)
if (Object.keys(changes).length > 0) {
await db.update(incidents).set(update).where(eq(incidents.id, incident.id))
await auditIncident(db, me, me.organizationId, incident.id, 'update', changes)
}
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)
},
)
+124
View File
@@ -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)
})
})
+177
View File
@@ -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)
})
}
+176
View File
@@ -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' } })
})
})
+289
View File
@@ -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"` })
})
+47
View File
@@ -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) })
})
+42
View File
@@ -0,0 +1,42 @@
import { sql } from 'drizzle-orm'
import { Hono } from 'hono'
import { setupSchema } from '../../shared/schemas.ts'
import { startSession } from '../auth/middleware.ts'
import { hashPassword } from '../auth/password.ts'
import type { Db } from '../db/client.ts'
import { organizations, users } from '../db/schema.ts'
import { audit } from '../lib/audit.ts'
import { validate, type AppEnv } from '../lib/http.ts'
import { publicUser } from './users.ts'
const SETUP_LOCK = 717_001
async function hasUsers(db: Db) {
const rows = await db.select({ id: users.id }).from(users).limit(1)
return rows.length > 0
}
/** First-run setup: creates the organization and its first admin. Only works while there are no users. */
export const setupRoutes = new Hono<AppEnv>()
.get('/', async (c) => c.json({ needsSetup: !(await hasUsers(c.get('db'))) }))
.post('/', validate('json', setupSchema), async (c) => {
const input = c.req.valid('json')
const passwordHash = await hashPassword(input.password)
const user = await c.get('db').transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(${SETUP_LOCK})`)
if (await hasUsers(tx)) return null
const [organization] = await tx.insert(organizations).values({ name: input.organizationName }).returning()
const [admin] = await tx
.insert(users)
.values({ organizationId: organization.id, email: input.email, name: input.name, role: 'admin', passwordHash })
.returning(publicUser)
await audit(tx, { organizationId: organization.id, actorId: admin.id, entityType: 'organization', entityId: organization.id, action: 'setup' })
return admin
})
if (!user) return c.json({ error: 'Setup has already been completed' }, 409)
await startSession(c, user.id)
return c.json({ user }, 201)
})
+51
View File
@@ -0,0 +1,51 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createTestContext } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
beforeAll(async () => {
ctx = await createTestContext()
})
afterAll(() => ctx.close())
describe('sites and areas', () => {
it('builds a location tree', async () => {
const site = await ctx.createSite('North Campus')
const building = await ctx.admin.post(`/api/sites/${site.id}/areas`, { name: 'Warehouse 04', kind: 'building' })
expect(building.status).toBe(201)
const bay = await ctx.admin.post(`/api/sites/${site.id}/areas`, { name: 'Loading bay C', parentId: building.body.area.id })
expect(bay.body.area).toMatchObject({ kind: 'area', parentId: building.body.area.id })
const list = await ctx.admin.get('/api/sites')
const north = list.body.sites.find((s: { name: string }) => s.name === 'North Campus')
expect(north.areas.map((a: { name: string }) => a.name)).toEqual(['Loading bay C', 'Warehouse 04'])
})
it('rejects a parent area from another site', async () => {
const a = await ctx.createSite('Site A')
const b = await ctx.createSite('Site B')
const areaInA = await ctx.admin.post(`/api/sites/${a.id}/areas`, { name: 'Line 1' })
const res = await ctx.admin.post(`/api/sites/${b.id}/areas`, { name: 'Line 2', parentId: areaInA.body.area.id })
expect(res.status).toBe(400)
})
it('rejects duplicate site names', async () => {
await ctx.createSite('Duplicate')
expect((await ctx.admin.post('/api/sites', { name: 'Duplicate' })).status).toBe(409)
})
it('hides archived sites unless asked', async () => {
const site = await ctx.createSite('Old Plant')
await ctx.admin.patch(`/api/sites/${site.id}`, { archived: true })
const names = (await ctx.admin.get('/api/sites')).body.sites.map((s: { name: string }) => s.name)
expect(names).not.toContain('Old Plant')
const all = (await ctx.admin.get('/api/sites?includeArchived=true')).body.sites.map((s: { name: string }) => s.name)
expect(all).toContain('Old Plant')
})
it('lets everyone read but only safety managers change locations', async () => {
const reporter = await ctx.createUser('reporter')
const supervisor = await ctx.createUser('supervisor')
expect((await reporter.get('/api/sites')).status).toBe(200)
expect((await supervisor.post('/api/sites', { name: 'Nope' })).status).toBe(403)
})
})
+109
View File
@@ -0,0 +1,109 @@
import { and, asc, eq, inArray, isNull } from 'drizzle-orm'
import { Hono } from 'hono'
import { z } from 'zod'
import { createAreaSchema, createSiteSchema, updateAreaSchema, updateSiteSchema } from '../../shared/schemas.ts'
import { requireAuth, requireRole } from '../auth/middleware.ts'
import { isUniqueViolation, type Db } from '../db/client.ts'
import { areas, sites } from '../db/schema.ts'
import { audit, diff } from '../lib/audit.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
const idParam = z.object({ id: z.uuid() })
const listQuery = z.object({ includeArchived: z.enum(['true', 'false']).optional() })
async function findSite(db: Db, organizationId: string, id: string) {
const [site] = await db.select().from(sites).where(and(eq(sites.id, id), eq(sites.organizationId, organizationId)))
if (!site) throw fail(404, 'Site not found')
return site
}
const archivedAt = (archived: boolean | undefined) => (archived === undefined ? undefined : archived ? new Date() : null)
export const siteRoutes = new Hono<AppEnv>()
.get('/', requireAuth, validate('query', listQuery), async (c) => {
const me = currentUser(c)
const includeArchived = c.req.valid('query').includeArchived === 'true'
const db = c.get('db')
const siteRows = await db
.select()
.from(sites)
.where(and(eq(sites.organizationId, me.organizationId), includeArchived ? undefined : isNull(sites.archivedAt)))
.orderBy(asc(sites.name))
const areaRows = siteRows.length
? await db
.select()
.from(areas)
.where(and(inArray(areas.siteId, siteRows.map((site) => site.id)), includeArchived ? undefined : isNull(areas.archivedAt)))
.orderBy(asc(areas.name))
: []
return c.json({ sites: siteRows.map((site) => ({ ...site, areas: areaRows.filter((area) => area.siteId === site.id) })) })
})
.post('/', requireRole('safety_manager'), validate('json', createSiteSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
try {
const [site] = await db
.insert(sites)
.values({ ...c.req.valid('json'), organizationId: me.organizationId })
.returning()
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'site', entityId: site.id, action: 'create', changes: { name: site.name } })
return c.json({ site: { ...site, areas: [] } }, 201)
} catch (error) {
if (isUniqueViolation(error)) throw fail(409, 'A site with that name already exists')
throw error
}
})
.patch('/:id', requireRole('safety_manager'), validate('param', idParam), validate('json', updateSiteSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const site = await findSite(db, me.organizationId, c.req.valid('param').id)
const { archived, ...fields } = c.req.valid('json')
const update = { ...fields, archivedAt: archivedAt(archived) }
const changes = diff(site, update)
if (Object.keys(changes).length === 0) return c.json({ site })
try {
const [updated] = await db.update(sites).set(update).where(eq(sites.id, site.id)).returning()
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'site', entityId: site.id, action: 'update', changes })
return c.json({ site: updated })
} catch (error) {
if (isUniqueViolation(error)) throw fail(409, 'A site with that name already exists')
throw error
}
})
.post('/:id/areas', requireRole('safety_manager'), validate('param', idParam), validate('json', createAreaSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const site = await findSite(db, me.organizationId, c.req.valid('param').id)
const input = c.req.valid('json')
if (input.parentId) {
const [parent] = await db.select({ id: areas.id }).from(areas).where(and(eq(areas.id, input.parentId), eq(areas.siteId, site.id)))
if (!parent) throw fail(400, 'Parent area must belong to the same site')
}
const [area] = await db
.insert(areas)
.values({ ...input, siteId: site.id })
.returning()
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'area', entityId: area.id, action: 'create', changes: { name: area.name, siteId: site.id } })
return c.json({ area }, 201)
})
export const areaRoutes = new Hono<AppEnv>().patch('/:id', requireRole('safety_manager'), validate('param', idParam), validate('json', updateAreaSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const [row] = await db
.select({ area: areas })
.from(areas)
.innerJoin(sites, eq(sites.id, areas.siteId))
.where(and(eq(areas.id, c.req.valid('param').id), eq(sites.organizationId, me.organizationId)))
if (!row) throw fail(404, 'Area not found')
const { archived, ...fields } = c.req.valid('json')
const update = { ...fields, archivedAt: archivedAt(archived) }
const changes = diff(row.area, update)
if (Object.keys(changes).length === 0) return c.json({ area: row.area })
const [area] = await db.update(areas).set(update).where(eq(areas.id, row.area.id)).returning()
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'area', entityId: area.id, action: 'update', changes })
return c.json({ area })
})
+49
View File
@@ -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)
})
})
+107
View File
@@ -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))
})
+56
View File
@@ -0,0 +1,56 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createTestContext } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
beforeAll(async () => {
ctx = await createTestContext()
})
afterAll(() => ctx.close())
describe('users', () => {
it('lets admins create users and rejects duplicate emails', async () => {
const body = { name: 'Sam Rivera', email: 'Sam@Example.com', role: 'supervisor', password: 'a sufficiently long password' }
const created = await ctx.admin.post('/api/users', body)
expect(created.status).toBe(201)
expect(created.body.user).toMatchObject({ email: 'sam@example.com', role: 'supervisor', active: true })
expect(created.body.user.passwordHash).toBeUndefined()
expect((await ctx.admin.post('/api/users', body)).status).toBe(409)
})
it('validates input', async () => {
const res = await ctx.admin.post('/api/users', { name: '', email: 'not-an-email', role: 'boss', password: 'short' })
expect(res.status).toBe(400)
expect(res.body.issues.map((issue: { path: string }) => issue.path).sort()).toEqual(['email', 'name', 'password', 'role'])
})
it('restricts listing to safety managers and creating to admins', async () => {
const supervisor = await ctx.createUser('supervisor')
const manager = await ctx.createUser('safety_manager')
expect((await supervisor.get('/api/users')).status).toBe(403)
expect((await manager.get('/api/users')).status).toBe(200)
expect((await manager.post('/api/users', { name: 'X', email: 'x@example.com', role: 'admin', password: 'a sufficiently long password' })).status).toBe(403)
expect((await ctx.anonymous().get('/api/users')).status).toBe(401)
})
it('returns 404 for unknown or malformed ids', async () => {
expect((await ctx.admin.patch('/api/users/00000000-0000-4000-8000-000000000000', { name: 'Nobody' })).status).toBe(404)
expect((await ctx.admin.patch('/api/users/not-a-uuid', { name: 'Nobody' })).status).toBe(400)
})
it('keeps at least one active admin', async () => {
const me = await ctx.admin.get('/api/auth/me')
const adminId = me.body.user.id
expect((await ctx.admin.patch(`/api/users/${adminId}`, { role: 'reporter' })).status).toBe(409)
expect((await ctx.admin.patch(`/api/users/${adminId}`, { active: false })).status).toBe(400)
const second = await ctx.createUser('admin', 'second-admin@example.com')
const demoted = await second.patch(`/api/users/${adminId}`, { role: 'safety_manager' })
expect(demoted.status).toBe(200)
expect(demoted.body.user.role).toBe('safety_manager')
// The demoted user stays signed in but loses admin rights immediately
expect((await ctx.admin.get('/api/auth/me')).status).toBe(200)
expect((await ctx.admin.post('/api/users', { name: 'Y', email: 'y@example.com', role: 'reporter', password: 'a sufficiently long password' })).status).toBe(403)
})
})
+104
View File
@@ -0,0 +1,104 @@
import { and, asc, eq, ne } from 'drizzle-orm'
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 { organizations, users } from '../db/schema.ts'
import { audit, diff } from '../lib/audit.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
/** Columns safe to return to clients (never the password hash). */
export const publicUser = {
id: users.id,
email: users.email,
name: users.name,
role: users.role,
active: users.active,
lastLoginAt: users.lastLoginAt,
createdAt: users.createdAt,
}
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) => {
const me = currentUser(c)
const rows = await c.get('db').select(publicUser).from(users).where(eq(users.organizationId, me.organizationId)).orderBy(asc(users.name))
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')
const db = c.get('db')
try {
const [user] = await db
.insert(users)
.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 } })
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
}
})
.patch('/:id', requireRole('admin'), validate('param', idParam), validate('json', updateUserSchema), async (c) => {
const me = currentUser(c)
const { id } = c.req.valid('param')
const input = c.req.valid('json')
const db = c.get('db')
const [target] = await db.select().from(users).where(and(eq(users.id, id), eq(users.organizationId, me.organizationId)))
if (!target) throw fail(404, 'User not found')
if (target.id === me.id && input.active === false) throw fail(400, 'You cannot deactivate your own account')
const losesAdmin = target.role === 'admin' && target.active && ((input.role && input.role !== 'admin') || input.active === false)
if (losesAdmin) {
const otherAdmins = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.organizationId, me.organizationId), eq(users.role, 'admin'), eq(users.active, true), ne(users.id, target.id)))
if (otherAdmins.length === 0) throw fail(409, 'At least one active admin is required')
}
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({ ...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 })
})
+56
View File
@@ -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)
})
+202
View File
@@ -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' })
})
})
+34
View File
@@ -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)
}
}
+97
View File
@@ -0,0 +1,97 @@
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>
/** Minimal API client that keeps its session cookie between requests, like a browser. */
export class Client {
cookie: string | undefined
private readonly app: App
constructor(app: App) {
this.app = app
}
async request(method: string, path: string, body?: unknown) {
const headers: Record<string, string> = {}
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 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 = 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 }
}
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' }
/** 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 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)}`)
async function createUser(role: 'reporter' | 'supervisor' | 'safety_manager' | 'admin', email = `${role}@example.com`) {
const password = 'a sufficiently long password'
const created = await admin.post('/api/users', { name: `${role} user`, email, role, password })
if (created.status !== 201) throw new Error(`createUser failed: ${JSON.stringify(created.body)}`)
const client = new Client(app)
await client.post('/api/auth/login', { email, password })
return Object.assign(client, { user: created.body.user as { id: string; email: string } })
}
async function createSite(name = 'North Campus') {
const res = await admin.post('/api/sites', { name })
return res.body.site as { id: string; name: string }
}
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())}`
}
+350
View File
@@ -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 }
}
Loaded 100 of 185 files, more files were not shown because too many files have changed in this diff. Show more