diff --git a/.env.example b/.env.example index 5c60b20..e468c1f 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,18 @@ # The URL people open in their browser. Use https:// in production so session cookies are marked Secure. # PUBLIC_URL=http://localhost:3000 +# Email (sign-in links, password resets, invitations, notifications). Without it, emails are +# printed to the server log in development and dropped in production. +# SMTP_URL=smtps://user:password@smtp.example.com:465 +# MAIL_FROM=Open SafetyLog EHS + +# 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 diff --git a/.oxlintrc.json b/.oxlintrc.json index 519ce83..b707251 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -4,7 +4,8 @@ "rules": { "react/rules-of-hooks": "error", "react/only-export-components": ["warn", { "allowConstantExport": true }], - "jsx-a11y/control-has-associated-label": "off" + "jsx-a11y/control-has-associated-label": "off", + "jsx-a11y/prefer-tag-over-role": "off" }, "ignorePatterns": ["dist", "playwright-report", "test-results"] } diff --git a/Dockerfile b/Dockerfile index 1b28193..0a5295d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,8 @@ ENV NODE_ENV=production \ PORT=3000 \ DATABASE_URL=pglite:///app/data/pglite WORKDIR /app +# Time-zone data so TZ (e.g. America/Chicago) sets local days for due dates and reminders +RUN apk add --no-cache tzdata COPY package.json package-lock.json ./ RUN npm ci --omit=dev && npm cache clean --force COPY server ./server diff --git a/README.md b/README.md index 94278f5..2d463e3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Free, open-source accident and safety tracking for manufacturing: incident and near-miss reporting, investigations, corrective actions and (planned) OSHA record keeping. -> **Status: early development.** The API and database are in place; the web app still shows demo data until it is connected in Phase 2. See [docs/ROADMAP.md](docs/ROADMAP.md) for the plan and [docs/TODO.md](docs/TODO.md) for open items. +> **Status: early development (Phases 0–2 done).** Reporting, investigation, corrective actions, email notifications, dashboards and CSV export work end to end. OSHA record keeping and offline shop-floor reporting are next. See [docs/ROADMAP.md](docs/ROADMAP.md) for the plan and [docs/TODO.md](docs/TODO.md) for open items. ## Getting started @@ -14,7 +14,9 @@ npm run db:seed # optional: demo organization, sites, users and incidents npm run dev # web app on http://localhost:5173, API on http://localhost:3000 ``` -Demo users (after seeding): `admin@example.com`, `manager@example.com`, `supervisor@example.com` and `reporter@example.com`, all with password `safetylog-demo`. Without seeding, call `POST /api/setup` to create your organization and first admin (see [docs/API.md](docs/API.md)). +Demo users (after seeding): `admin@example.com`, `manager@example.com`, `supervisor@example.com` and `reporter@example.com`, all with password `safetylog-demo`. Without seeding, opening the app takes you to first-run setup. + +In development, emails (sign-in links, invitations, notifications) are printed in the terminal running the server. Settings are read from environment variables or a `.env` file; see [.env.example](.env.example). @@ -25,7 +27,12 @@ cp .env.example .env # set POSTGRES_PASSWORD, and PUBLIC_URL if not localhost docker compose up -d --build ``` -This runs the app and PostgreSQL. The app applies database migrations on startup and serves the web app and API on port 3000. Use HTTPS (for example behind a reverse proxy) and set `PUBLIC_URL` to the `https://` address so session cookies are marked secure. +This runs the app and PostgreSQL. The app applies database migrations on startup and serves the web app and API on port 3000. Uploaded photos are kept in the `app-data` volume. + +- Use HTTPS (for example behind a reverse proxy) and set `PUBLIC_URL` to the `https://` address so session cookies are marked secure and email links point to the right place. +- Set `SMTP_URL` and `MAIL_FROM` so sign-in links, invitations and notifications are delivered. +- Set `TZ` to the plant's time zone (e.g. `America/Chicago`) so due dates and daily reminders follow local days. +- Back up both volumes: `db-data` (PostgreSQL) and `app-data` (uploads). 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 `. diff --git a/docker-compose.yml b/docker-compose.yml index b8294b9..30ba652 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,9 +26,15 @@ services: environment: DATABASE_URL: postgres://safetylog:${POSTGRES_PASSWORD}@db:5432/safetylog PUBLIC_URL: ${PUBLIC_URL:-http://localhost:3000} + SMTP_URL: ${SMTP_URL:-} + MAIL_FROM: ${MAIL_FROM:-Open SafetyLog EHS } + TZ: ${TZ:-UTC} + volumes: + - app-data:/app/data # uploaded photos and files ports: - "${PORT:-3000}:3000" restart: unless-stopped volumes: db-data: + app-data: diff --git a/docs/API.md b/docs/API.md index f2ca3f0..38c7cee 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,11 +1,13 @@ # API reference -JSON over HTTP under `/api`. Authentication uses a session cookie (`safetylog_session`, http-only) set by setup, sign-in and password change. +JSON over HTTP under `/api`. Authentication uses a session cookie (`safetylog_session`, http-only) set by setup, sign-in, emailed links and password changes. -Request bodies are validated against the schemas in [`shared/schemas.ts`](../shared/schemas.ts). Errors look like `{ "error": "message" }`; validation errors (400) also include `issues: [{ path, message }]`. +Request bodies are validated against [`shared/schemas.ts`](../shared/schemas.ts); response shapes are in [`shared/api-types.ts`](../shared/api-types.ts). Errors look like `{ "error": "message" }`; validation errors (400) also include `issues: [{ path, message }]`. Roles, from least to most privileged: `reporter` < `supervisor` < `safety_manager` < `admin`. A role can do everything the roles below it can. +Browsers must send an `Origin` header matching the site on form/multipart and body-less requests (all browsers do); JSON requests are protected by CORS. + ## Setup and authentication | Method | Path | Who | Notes | @@ -14,19 +16,24 @@ Roles, from least to most privileged: `reporter` < `supervisor` < `safety_manage | GET | `/api/setup` | anyone | `{ needsSetup }`: true until the first admin exists | | POST | `/api/setup` | anyone, once | `{ organizationName, name, email, password }`. Creates the organization and an admin, signs them in. 409 afterwards | | POST | `/api/auth/login` | anyone | `{ email, password }`. 401 on bad credentials, 429 after 10 attempts per email in 15 minutes | +| POST | `/api/auth/magic-link` | anyone | `{ email }`. Emails a single-use sign-in link (15 minutes). Always 204 | +| POST | `/api/auth/magic-link/verify` | anyone | `{ token }`. Signs in | +| POST | `/api/auth/password-reset` | anyone | `{ email }`. Emails a reset link (1 hour). Always 204 | +| POST | `/api/auth/password-reset/confirm` | anyone | `{ token, newPassword }`. Sets the password, signs out other sessions, signs in. Also completes invitations | | POST | `/api/auth/logout` | anyone | Ends the session. 204 | | GET | `/api/auth/me` | signed in | `{ user, organization }` | | POST | `/api/auth/password` | signed in | `{ currentPassword, newPassword }`. Signs out all other sessions. 204 | -Passwords need at least 10 characters. +Passwords need at least 10 characters. Email requests are limited to 5 per address per 15 minutes. Emailed links land on web pages (`/auth/magic`, `/auth/reset`) that POST the token, so link scanners in email systems can't use them up. ## Users | Method | Path | Who | Notes | | --- | --- | --- | --- | | GET | `/api/users` | safety_manager | All users in the organization | -| POST | `/api/users` | admin | `{ name, email, role, password }`. 409 if the email exists | -| PATCH | `/api/users/:id` | admin | `{ name?, role?, active? }`. Deactivating signs the user out. The last active admin can't be demoted or deactivated | +| GET | `/api/users/assignable` | supervisor | Active users `{ id, name, role }`, for assigning leads and actions | +| POST | `/api/users` | admin | `{ name, email, role, password? }`. Without `password` an invitation email is sent (link valid 7 days). Returns `{ user, invited }` | +| PATCH | `/api/users/:id` | admin | `{ name?, role?, active?, password? }`. Deactivating or setting a password signs the user out. The last active admin can't be demoted or deactivated | ## Sites and areas @@ -35,21 +42,31 @@ Passwords need at least 10 characters. | GET | `/api/sites` | signed in | Sites with their `areas` (flat list; build the tree from `parentId`). `?includeArchived=true` to include archived | | POST | `/api/sites` | safety_manager | `{ name, address?, timezone? }` | | PATCH | `/api/sites/:id` | safety_manager | `{ name?, address?, timezone?, archived? }` | -| POST | `/api/sites/:id/areas` | safety_manager | `{ name, kind?, parentId? }`. `kind`: `building`, `area` (default), `line`, `other`. Parent must be in the same site | +| POST | `/api/sites/:id/areas` | safety_manager | `{ name, kind?, parentId? }`. `kind`: `building`, `area` (default), `line`, `other` | | PATCH | `/api/areas/:id` | safety_manager | `{ name?, kind?, archived? }` | ## Incidents -Incidents are addressed by their number (`/api/incidents/42` is `INC-0042`). Numbers are sequential per organization with no gaps. +Addressed by number (`/api/incidents/42` is `INC-0042`). Numbers are sequential per organization with no gaps. + +**Visibility:** supervisors and above see every incident. Reporters see incidents they reported plus any where they're assigned a corrective action. Anything else answers 404. | Method | Path | Who | Notes | | --- | --- | --- | --- | -| GET | `/api/incidents` | signed in | Paginated `{ items, page, pageSize, total }`, newest first. Reporters see only their own reports | -| POST | `/api/incidents` | signed in | Create a report (below). 201 | -| GET | `/api/incidents/:number` | signed in | 404 if it doesn't exist or isn't visible to you | -| PATCH | `/api/incidents/:number` | supervisor | Any create field plus `status`. Closing sets `closedAt`; reopening clears it. Moving to another site clears the area unless one is given | +| GET | `/api/incidents` | signed in | `{ items, page, pageSize, total }`, newest first | +| GET | `/api/incidents/export` | signed in | CSV of the same filters (no paging, up to 10,000 rows) | +| POST | `/api/incidents` | signed in | Create a report (below). High/critical reports email safety managers and admins | +| GET | `/api/incidents/:number` | signed in | Full detail: investigation, root causes, actions, attachments, the `transitions` you may perform (with `blockedReason` when not yet possible) and `can` flags | +| PATCH | `/api/incidents/:number` | supervisor | Any create field except `anonymous`. Status changes use transitions | +| POST | `/api/incidents/:number/transitions` | see below | `{ to, note? }`. The note is also posted as a comment | +| GET/POST | `/api/incidents/:number/comments` | can see it | POST `{ body }` | +| GET | `/api/incidents/:number/timeline` | can see it | Audit entries `{ id, at, actor, action, changes }` | +| PUT | `/api/incidents/:number/investigation` | supervisor | `{ leadId?, method?, summary?, completed? }`. Creates the investigation if needed | +| POST | `/api/incidents/:number/root-causes` | supervisor | `{ category, description, whys? }` | +| POST | `/api/incidents/:number/actions` | supervisor | `{ title, description?, kind?, assigneeId?, dueDate?, rootCauseId? }`. Not on closed incidents. Emails the assignee | +| POST | `/api/incidents/:number/attachments` | can see it | Multipart, field `file`. Photos (JPEG, PNG, WebP, GIF, HEIC) and PDFs only, detected from content; `MAX_UPLOAD_MB` each (default 10), 20 per incident | -**List query parameters:** `page` (default 1), `pageSize` (1–100, default 25), `q` (searches title, description and location; `INC-0042` or `42` matches the number), `type`, `severity`, `status`, `siteId`. +**List and export filters:** `page`, `pageSize` (1–100, default 25), `q` (title, description, location; `INC-0042` or `42` matches the number), `type`, `severity`, `status`, `siteId`. **Create body:** @@ -70,8 +87,45 @@ Incidents are addressed by their number (`/api/incidents/42` is `INC-0042`). Num - `type`: `near_miss`, `hazard`, `injury`, `illness`, `property_damage`, `environmental` - `severity`: `low`, `medium`, `high`, `critical` -- `status`: `reported`, `triage`, `investigation`, `actions`, `verification`, `closed` -- `occurredAt` must include a time-zone offset and can't be in the future. -- Anonymous reports don't record the reporter at all, so they won't appear in the reporter's own list. +- `occurredAt` needs a time-zone offset and can't be in the future. Anonymous reports don't record the reporter at all. -Every create and update is recorded in the audit log (`audit_log` table) with who made it and what changed. +### Workflow + +`reported` → `triage` → `investigation` → `actions` → `verification` → `closed`. The rules live in [`shared/workflow.ts`](../shared/workflow.ts) and are enforced by the API. + +| From | To | Who | Condition | +| --- | --- | --- | --- | +| reported | triage | supervisor | | +| triage | investigation | supervisor | | +| triage | actions | supervisor | | +| triage | closed | supervisor | Low or medium severity only | +| investigation | actions | supervisor | At least one root cause | +| actions | investigation | supervisor | | +| actions | verification | supervisor | At least one action, none open or in progress | +| verification | actions | safety_manager | | +| verification | closed | safety_manager | Every (non-cancelled) action verified | +| closed | investigation | safety_manager | Reopen | + +## Root causes, actions, attachments + +| Method | Path | Who | Notes | +| --- | --- | --- | --- | +| PATCH | `/api/root-causes/:id` | supervisor | `{ category?, description?, whys? }` | +| DELETE | `/api/root-causes/:id` | supervisor | | +| GET | `/api/actions` | signed in | `?scope=mine\|all` (`all` needs supervisor) and `?filter=open\|overdue\|done\|all`. Sorted by due date | +| PATCH | `/api/actions/:id` | see notes | Supervisors: any field and status except `verified`. Safety managers: also `verified`. Assignees: only `status` among `open`, `in_progress`, `done` | +| GET | `/api/attachments/:id` | can see the incident | The file, with a sandboxing Content-Security-Policy | +| DELETE | `/api/attachments/:id` | uploader or supervisor | | + +Each action has `overdue: true` when it's open or in progress and its due date has passed (server time zone, `TZ`). Assignees get one email digest per day listing their actions due tomorrow or overdue. + +## Stats + +| Method | Path | Who | Notes | +| --- | --- | --- | --- | +| GET | `/api/stats/overview` | signed in | Open incidents, actions due/overdue, average close time, status counts, weekly trend (12 weeks), incidents by site this year | +| GET | `/api/stats/analytics` | signed in | `?days=` (7–730, default 90): counts by type and severity, top root-cause category, on-time action rate, monthly completions | + +Stats only count what the signed-in user can see. + +Every create and update on an incident, its investigation, actions and files is recorded in the audit log with who made it and what changed. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1a949e3..72052dd 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -15,6 +15,8 @@ This is a living document. Update it as decisions are made or scope changes. | Auth | Email + password (scrypt), server-side sessions in an http-only cookie, four roles: reporter < supervisor < safety manager < admin | Decided | | Deployment | Self-hosted, single `docker compose up` (app + Postgres); file storage added with photo upload | Decided | | Compliance scope | US OSHA first; data model kept extensible for other regions (e.g. RIDDOR) | Proposed | +| Incident PDFs | Browser print-to-PDF from a print stylesheet; server-side PDF deferred (see TODO) | Decided | +| Email | SMTP via `SMTP_URL`; without it, logged in development and dropped in production. Delivery setup deferred (see TODO) | Decided | | CI | Gitea Actions (`.gitea/workflows`): lint, typecheck, unit tests, build, e2e | Decided | | Product name | Open SafetyLog EHS (replaced placeholder "IA Tracker" and the prototype's "Safesite" branding) | Decided | @@ -46,15 +48,16 @@ This is a living document. Update it as decisions are made or scope changes. ## Phase 2: Core workflow (MVP) -- Connect the web app to the API: sign-in and first-run setup screens, incident list and report form using real data -- Photo upload (attachments) -- Magic-link sign-in and password reset by email -- Incident detail page: timeline, investigation, root-cause tools (5 Whys, fishbone), corrective actions (CAPA) -- Status workflow: Reported → Triage → Investigation → Corrective actions → Verification → Closed, with role-based transitions -- "My actions" list and overdue tracking -- Email notifications: assignment, due dates, high-severity alerts -- CSV and PDF export -- Dashboard metrics computed from real data +- [x] Web app connected to the API: sign-in, first-run setup, every page on live data (demo data removed from the frontend) +- [x] Photo and PDF upload, checked by file content (not name), size-limited, served with a sandboxing security policy +- [x] Magic-link sign-in, password reset by email, email invitations for new users (admins can also set passwords when there's no email) +- [x] Incident detail page: details editing, activity log (comments + audit trail), investigation with 5 Whys and fishbone (6M) views, corrective actions (CAPA) +- [x] Status workflow Reported → Triage → Investigation → Corrective actions → Verification → Closed, with role rules and guards (e.g. high severity can't skip investigation; closing needs every action verified), shared between API and UI +- [x] "My actions" and "All actions" lists with overdue tracking; assignees can update progress on their own actions +- [x] Email notifications: high/critical alerts to safety managers, action assignment, daily due/overdue digest +- [x] CSV export of the filtered register (protected against spreadsheet formula injection); print-friendly incident page for PDF via the browser +- [x] Overview dashboard and Analytics computed from real data; Locations and Settings (people, roles, passwords) pages +- [x] End-to-end tests run against the real server: full three-role lifecycle, sign-in, visibility rules, mobile reporting ## Phase 3: Shop-floor reporting @@ -85,8 +88,3 @@ This is a living document. Update it as decisions are made or scope changes. Inspections and audit checklists, job safety analysis (JSA), training and certification records, management of change, SDS links, public API and webhooks. -## Known prototype gaps (to be resolved in Phase 2) - -- The web app still uses in-memory demo data; the API exists but isn't connected yet -- Dashboard, analytics and location figures are hardcoded -- Export, Add location, Settings, Resources, search, notifications and workspace switcher are non-functional diff --git a/docs/TODO.md b/docs/TODO.md index be2b855..90f190b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -14,6 +14,15 @@ Open items that aren't tied to a single roadmap phase. Tick them off (or delete - [ ] **Docker build in CI.** Build the image in CI once a runner with Docker is available. - [ ] **Shared login rate limit.** The login limiter is in memory, so it resets on restart and isn't shared between processes. Fine for one container; move it to the database before running several app instances. +## Phase 2 follow-ups + +- [ ] **Server-side PDF (later).** Decided 2026-10-06 to keep browser-generated PDFs for now: the "Print / PDF" button and print stylesheet are simpler and add no dependencies. Revisit if PDFs need to be produced automatically, e.g. attached to emails or archived on close. +- [ ] **Email delivery (later).** Decided 2026-10-06 that the current behavior is fine for now: without `SMTP_URL`, emails print to the server log in development and are dropped (with a startup warning) in production. Later: configure SMTP for real deployments, add a "send test email" button for admins, and consider showing admins a warning in the app when email isn't set up. +- [ ] **Notification preferences.** Everyone gets every email that applies to them. Add per-user opt-outs, and an alert-recipients setting per site. +- [ ] **Edit corrective actions in the UI.** The API supports changing an action's title, assignee and due date; the incident page only offers status changes and adding new actions. +- [ ] **HEIC thumbnails.** iPhone HEIC photos are stored and downloadable but browsers can't show them inline; convert to JPEG on upload. +- [ ] **Session check console noise.** Signed-out visitors see one 401 in the browser console from the session check; harmless, but a `200 { user: null }` endpoint would be quieter. + ## 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. diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 0000000..447fdc5 --- /dev/null +++ b/e2e/auth.spec.ts @@ -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([]) +}) diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..b198065 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,26 @@ +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' }, +} 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'), +} diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..1c593d5 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,20 @@ +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>): Promise => { + 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' } })) + await ok(await api.post(`/api/sites/${site.id}/areas`, { data: { name: 'Press Shop', kind: 'building' } })) + for (const role of ['supervisor', 'reporter'] as const) { + await ok(await api.post('/api/users', { data: { ...users[role], role, password: PASSWORD } })) + } + await api.dispose() +} diff --git a/e2e/incident-lifecycle.spec.ts b/e2e/incident-lifecycle.spec.ts new file mode 100644 index 0000000..cb2b6b0 --- /dev/null +++ b/e2e/incident-lifecycle.spec.ts @@ -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() +}) diff --git a/e2e/incidents.spec.ts b/e2e/incidents.spec.ts new file mode 100644 index 0000000..7d02b8f --- /dev/null +++ b/e2e/incidents.spec.ts @@ -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() +}) diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts deleted file mode 100644 index ce83868..0000000 --- a/e2e/smoke.spec.ts +++ /dev/null @@ -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([]) -}) diff --git a/package-lock.json b/package-lock.json index bcc6e07..eb4ded9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@hono/zod-validator": "^0.9.1", "drizzle-orm": "^0.45.3", "hono": "^4.13.13", + "nodemailer": "^10.0.15", "pg": "^8.23.1", "zod": "^4.6.5" }, @@ -23,10 +24,12 @@ "@fontsource/dm-mono": "^5.3.0", "@playwright/test": "^1.63.0", "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-query": "^5.104.1", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", "@types/node": "^26.6.4", + "@types/nodemailer": "^8.0.2", "@types/pg": "^8.23.1", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", @@ -2499,6 +2502,34 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tanstack/query-core": { + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz", + "integrity": "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz", + "integrity": "sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.104.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.2", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", @@ -2641,6 +2672,16 @@ "undici-types": "~8.9.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.2.tgz", + "integrity": "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.23.1", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", @@ -4572,6 +4613,15 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nodemailer": { + "version": "10.0.15", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.15.tgz", + "integrity": "sha512-EUqp5PhtcsYXs9Fq/lS7s/8zlTrnBqmzZWzFFROrNikMiz+om/YRKMwqN907t+0A1KKyT8jd39CBUbFZmaZmcA==", + "license": "MIT-0", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/obug": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", diff --git a/package.json b/package.json index ba32edb..1e8a645 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@hono/zod-validator": "^0.9.1", "drizzle-orm": "^0.45.3", "hono": "^4.13.13", + "nodemailer": "^10.0.15", "pg": "^8.23.1", "zod": "^4.6.5" }, @@ -39,10 +40,12 @@ "@fontsource/dm-mono": "^5.3.0", "@playwright/test": "^1.63.0", "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-query": "^5.104.1", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", "@types/node": "^26.6.4", + "@types/nodemailer": "^8.0.2", "@types/pg": "^8.23.1", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", diff --git a/playwright.config.ts b/playwright.config.ts index eac69b3..929601e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,24 +1,28 @@ import { defineConfig, devices } from '@playwright/test' const port = 4173 +export const baseURL = `http://localhost:${port}` export default defineConfig({ testDir: './e2e', + globalSetup: './e2e/global-setup.ts', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', use: { - baseURL: `http://localhost:${port}`, + baseURL, trace: 'on-first-retry', }, projects: [ { name: 'desktop', use: { ...devices['Desktop Chrome'] } }, { name: 'mobile', use: { ...devices['Pixel 7'] } }, ], + // The real server with a throwaway in-memory database, serving the production build webServer: { - command: `npm run build && npm run preview -- --port ${port} --strictPort`, + command: 'npm run build && node server/index.ts', port, reuseExistingServer: !process.env.CI, + env: { DATABASE_URL: 'memory://', PORT: String(port), PUBLIC_URL: baseURL, UPLOAD_DIR: 'test-results/uploads', STATIC_DIR: 'dist' }, }, }) diff --git a/server/app.ts b/server/app.ts index 4611f8e..f8e8877 100644 --- a/server/app.ts +++ b/server/app.ts @@ -6,19 +6,38 @@ 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 { 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 type { FileStorage } from './storage.ts' -export function createApp({ db, config }: { db: Db; config: Config }) { +export interface AppDeps { + db: Db + config: Config + notifier: Notifier + storage: FileStorage +} + +export function createApp({ db, config, notifier, storage }: AppDeps) { const app = new Hono() app.use('*', secureHeaders()) - app.use('/api/*', csrf(), async (c, next) => { + const publicOrigin = new URL(config.publicUrl).origin + // Form/multipart posts must come from our own pages. JSON requests are already protected by CORS preflight. + const sameSite = csrf({ origin: (origin, c) => origin === publicOrigin || origin === new URL(c.req.url).origin }) + + app.use('/api/*', sameSite, async (c, next) => { c.set('db', db) c.set('config', config) + c.set('notifier', notifier) + c.set('storage', storage) c.header('Cache-Control', 'no-store') await next() }) @@ -31,6 +50,10 @@ export function createApp({ db, config }: { db: Db; config: Config }) { app.route('/api/sites', siteRoutes) app.route('/api/areas', areaRoutes) app.route('/api/incidents', incidentRoutes) + app.route('/api/root-causes', rootCauseRoutes) + app.route('/api/actions', actionRoutes) + app.route('/api/attachments', attachmentRoutes) + app.route('/api/stats', statsRoutes) app.all('/api/*', (c) => c.json({ error: 'Not found' }, 404)) app.onError((error, c) => { diff --git a/server/auth/tokens.ts b/server/auth/tokens.ts new file mode 100644 index 0000000..0332ac9 --- /dev/null +++ b/server/auth/tokens.ts @@ -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 { + 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 { + 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 +} diff --git a/server/config.ts b/server/config.ts index 18609f5..5304aa7 100644 --- a/server/config.ts +++ b/server/config.ts @@ -7,6 +7,11 @@ export interface Config { production: boolean /** Built web app to serve, if present. */ staticDir: string + /** e.g. `smtps://user:pass@smtp.example.com:465`. Without it, emails are printed to the log (development only). */ + smtpUrl: string | undefined + mailFrom: string + uploadDir: string + maxUploadBytes: number } export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { @@ -18,5 +23,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { secureCookies: publicUrl.startsWith('https://'), production: env.NODE_ENV === 'production', staticDir: env.STATIC_DIR ?? 'dist', + smtpUrl: env.SMTP_URL || undefined, + mailFrom: env.MAIL_FROM ?? 'Open SafetyLog EHS ', + uploadDir: env.UPLOAD_DIR ?? './data/uploads', + maxUploadBytes: Number(env.MAX_UPLOAD_MB ?? 10) * 1024 * 1024, } } diff --git a/server/db/migrations/0001_workflow_and_email.sql b/server/db/migrations/0001_workflow_and_email.sql new file mode 100644 index 0000000..e929b4f --- /dev/null +++ b/server/db/migrations/0001_workflow_and_email.sql @@ -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"); \ No newline at end of file diff --git a/server/db/migrations/meta/0001_snapshot.json b/server/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..28c9456 --- /dev/null +++ b/server/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,1923 @@ +{ + "id": "06635c3f-6dbf-4fb2-9f0d-54c5f9227434", + "prevId": "d7027c91-94c4-4d75-ad61-9360e917a6b4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.areas": { + "name": "areas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "area_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'area'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "areas_site_idx": { + "name": "areas_site_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "areas_parent_idx": { + "name": "areas_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "areas_site_id_sites_id_fk": { + "name": "areas_site_id_sites_id_fk", + "tableFrom": "areas", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "areas_parent_id_areas_id_fk": { + "name": "areas_parent_id_areas_id_fk", + "tableFrom": "areas", + "tableTo": "areas", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "incident_id": { + "name": "incident_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "attachments_incident_idx": { + "name": "attachments_incident_idx", + "columns": [ + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_incident_id_incidents_id_fk": { + "name": "attachments_incident_id_incidents_id_fk", + "tableFrom": "attachments", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_id_users_id_fk": { + "name": "attachments_uploaded_by_id_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "attachments_storageKey_unique": { + "name": "attachments_storageKey_unique", + "nullsNotDistinct": false, + "columns": [ + "storage_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_entity_idx": { + "name": "audit_log_entity_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_organization_id_organizations_id_fk": { + "name": "audit_log_organization_id_organizations_id_fk", + "tableFrom": "audit_log", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_tokens": { + "name": "auth_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "auth_token_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_tokens_user_idx": { + "name": "auth_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_tokens_user_id_users_id_fk": { + "name": "auth_tokens_user_id_users_id_fk", + "tableFrom": "auth_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "incident_id": { + "name": "incident_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_incident_idx": { + "name": "comments_incident_idx", + "columns": [ + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_incident_id_incidents_id_fk": { + "name": "comments_incident_id_incidents_id_fk", + "tableFrom": "comments", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_author_id_users_id_fk": { + "name": "comments_author_id_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.corrective_actions": { + "name": "corrective_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "incident_id": { + "name": "incident_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "root_cause_id": { + "name": "root_cause_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "action_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'corrective'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "action_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "verified_by_id": { + "name": "verified_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reminder_sent_on": { + "name": "reminder_sent_on", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "corrective_actions_incident_idx": { + "name": "corrective_actions_incident_idx", + "columns": [ + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "corrective_actions_assignee_status_idx": { + "name": "corrective_actions_assignee_status_idx", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "corrective_actions_incident_id_incidents_id_fk": { + "name": "corrective_actions_incident_id_incidents_id_fk", + "tableFrom": "corrective_actions", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "corrective_actions_root_cause_id_root_causes_id_fk": { + "name": "corrective_actions_root_cause_id_root_causes_id_fk", + "tableFrom": "corrective_actions", + "tableTo": "root_causes", + "columnsFrom": [ + "root_cause_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "corrective_actions_assignee_id_users_id_fk": { + "name": "corrective_actions_assignee_id_users_id_fk", + "tableFrom": "corrective_actions", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "corrective_actions_verified_by_id_users_id_fk": { + "name": "corrective_actions_verified_by_id_users_id_fk", + "tableFrom": "corrective_actions", + "tableTo": "users", + "columnsFrom": [ + "verified_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hours_worked": { + "name": "hours_worked", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "hours": { + "name": "hours", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hours_worked_site_month_unique": { + "name": "hours_worked_site_month_unique", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hours_worked_site_id_sites_id_fk": { + "name": "hours_worked_site_id_sites_id_fk", + "tableFrom": "hours_worked", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.incident_counters": { + "name": "incident_counters", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "last_number": { + "name": "last_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "incident_counters_organization_id_organizations_id_fk": { + "name": "incident_counters_organization_id_organizations_id_fk", + "tableFrom": "incident_counters", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.incidents": { + "name": "incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "incident_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "incident_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'reported'" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "area_id": { + "name": "area_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "location_detail": { + "name": "location_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "immediate_action": { + "name": "immediate_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "anonymous": { + "name": "anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "incidents_organization_number_unique": { + "name": "incidents_organization_number_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "incidents_organization_occurred_idx": { + "name": "incidents_organization_occurred_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "incidents_organization_status_idx": { + "name": "incidents_organization_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "incidents_reporter_idx": { + "name": "incidents_reporter_idx", + "columns": [ + { + "expression": "reporter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "incidents_organization_id_organizations_id_fk": { + "name": "incidents_organization_id_organizations_id_fk", + "tableFrom": "incidents", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incidents_site_id_sites_id_fk": { + "name": "incidents_site_id_sites_id_fk", + "tableFrom": "incidents", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incidents_area_id_areas_id_fk": { + "name": "incidents_area_id_areas_id_fk", + "tableFrom": "incidents", + "tableTo": "areas", + "columnsFrom": [ + "area_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "incidents_reporter_id_users_id_fk": { + "name": "incidents_reporter_id_users_id_fk", + "tableFrom": "incidents", + "tableTo": "users", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.injured_persons": { + "name": "injured_persons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "incident_id": { + "name": "incident_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "employment_type": { + "name": "employment_type", + "type": "employment_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'employee'" + }, + "body_part": { + "name": "body_part", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nature_of_injury": { + "name": "nature_of_injury", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "treatment": { + "name": "treatment", + "type": "treatment_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "days_away": { + "name": "days_away", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "days_restricted": { + "name": "days_restricted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "privacy_case": { + "name": "privacy_case", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "injured_persons_incident_idx": { + "name": "injured_persons_incident_idx", + "columns": [ + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "injured_persons_incident_id_incidents_id_fk": { + "name": "injured_persons_incident_id_incidents_id_fk", + "tableFrom": "injured_persons", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "injured_persons_user_id_users_id_fk": { + "name": "injured_persons_user_id_users_id_fk", + "tableFrom": "injured_persons", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.investigations": { + "name": "investigations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "incident_id": { + "name": "incident_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "rca_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'five_whys'" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "investigations_incident_id_incidents_id_fk": { + "name": "investigations_incident_id_incidents_id_fk", + "tableFrom": "investigations", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "investigations_lead_id_users_id_fk": { + "name": "investigations_lead_id_users_id_fk", + "tableFrom": "investigations", + "tableTo": "users", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "investigations_incidentId_unique": { + "name": "investigations_incidentId_unique", + "nullsNotDistinct": false, + "columns": [ + "incident_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.root_causes": { + "name": "root_causes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "investigation_id": { + "name": "investigation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "root_cause_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "whys": { + "name": "whys", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "root_causes_investigation_idx": { + "name": "root_causes_investigation_idx", + "columns": [ + { + "expression": "investigation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "root_causes_investigation_id_investigations_id_fk": { + "name": "root_causes_investigation_id_investigations_id_fk", + "tableFrom": "root_causes", + "tableTo": "investigations", + "columnsFrom": [ + "investigation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sites": { + "name": "sites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sites_organization_name_unique": { + "name": "sites_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sites_organization_id_organizations_id_fk": { + "name": "sites_organization_id_organizations_id_fk", + "tableFrom": "sites", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'reporter'" + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_organization_idx": { + "name": "users_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_organization_id_organizations_id_fk": { + "name": "users_organization_id_organizations_id_fk", + "tableFrom": "users", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.action_kind": { + "name": "action_kind", + "schema": "public", + "values": [ + "corrective", + "preventive" + ] + }, + "public.action_status": { + "name": "action_status", + "schema": "public", + "values": [ + "open", + "in_progress", + "done", + "verified", + "cancelled" + ] + }, + "public.area_kind": { + "name": "area_kind", + "schema": "public", + "values": [ + "building", + "area", + "line", + "other" + ] + }, + "public.auth_token_purpose": { + "name": "auth_token_purpose", + "schema": "public", + "values": [ + "magic_link", + "password_reset" + ] + }, + "public.employment_type": { + "name": "employment_type", + "schema": "public", + "values": [ + "employee", + "temporary", + "contractor", + "visitor" + ] + }, + "public.incident_status": { + "name": "incident_status", + "schema": "public", + "values": [ + "reported", + "triage", + "investigation", + "actions", + "verification", + "closed" + ] + }, + "public.incident_type": { + "name": "incident_type", + "schema": "public", + "values": [ + "near_miss", + "hazard", + "injury", + "illness", + "property_damage", + "environmental" + ] + }, + "public.rca_method": { + "name": "rca_method", + "schema": "public", + "values": [ + "five_whys", + "fishbone", + "other" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "reporter", + "supervisor", + "safety_manager", + "admin" + ] + }, + "public.root_cause_category": { + "name": "root_cause_category", + "schema": "public", + "values": [ + "people", + "process", + "equipment", + "materials", + "environment", + "management" + ] + }, + "public.severity": { + "name": "severity", + "schema": "public", + "values": [ + "low", + "medium", + "high", + "critical" + ] + }, + "public.treatment_level": { + "name": "treatment_level", + "schema": "public", + "values": [ + "none", + "first_aid", + "medical_treatment", + "hospitalized", + "fatality" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/db/migrations/meta/_journal.json b/server/db/migrations/meta/_journal.json index 193ca56..2fa122c 100644 --- a/server/db/migrations/meta/_journal.json +++ b/server/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1791306770507, "tag": "0000_initial", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1791309275942, + "tag": "0001_workflow_and_email", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/db/schema.ts b/server/db/schema.ts index 72e7c8b..2ef99cf 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -12,6 +12,7 @@ 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']) const id = () => uuid().primaryKey().defaultRandom() const timestamptz = () => timestamp({ withTimezone: true }) @@ -62,6 +63,23 @@ export const sessions = pgTable( (t) => [index('sessions_user_idx').on(t.userId)], ) +/** Single-use links sent by email (magic sign-in, password reset/invite). */ +export const authTokens = pgTable( + 'auth_tokens', + { + /** SHA-256 of the token sent in the link. */ + id: text().primaryKey(), + userId: uuid() + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + purpose: authTokenPurposeEnum().notNull(), + expiresAt: timestamptz().notNull(), + usedAt: timestamptz(), + createdAt: createdAt(), + }, + (t) => [index('auth_tokens_user_idx').on(t.userId)], +) + export const sites = pgTable( 'sites', { @@ -230,6 +248,8 @@ export const correctiveActions = pgTable( completedAt: timestamptz(), verifiedById: uuid().references(() => users.id, { onDelete: 'set null' }), verifiedAt: timestamptz(), + /** Date the last due/overdue reminder was emailed, so it goes out at most once a day. */ + reminderSentOn: date({ mode: 'string' }), createdAt: createdAt(), updatedAt: updatedAt(), }, diff --git a/server/db/seed.ts b/server/db/seed.ts index e625dfc..336785b 100644 --- a/server/db/seed.ts +++ b/server/db/seed.ts @@ -2,7 +2,7 @@ import { hashPassword } from '../auth/password.ts' import { loadConfig } from '../config.ts' import { openDatabase } from './client.ts' -import { areas, incidentCounters, incidents, organizations, sites, users } from './schema.ts' +import { auditLog, areas, incidentCounters, incidents, organizations, sites, users } from './schema.ts' const DEMO_PASSWORD = 'safetylog-demo' @@ -49,8 +49,10 @@ try { { title: 'Wet floor near employee entrance', type: 'hazard', severity: 'medium', status: 'actions', building: 'Building A', locationDetail: 'Ground floor lobby', reporter: 'reporter@example.com', days: 6 }, { title: 'Forklift reversed into pallet stack', type: 'near_miss', severity: 'high', status: 'triage', building: 'Warehouse 04', locationDetail: 'Loading bay C', reporter: 'admin@example.com', days: 5 }, ] as const - await tx.insert(incidents).values( - demo.map((item, index) => ({ + const created = await tx + .insert(incidents) + .values( + demo.map((item, index) => ({ organizationId: org.id, number: index + 1, title: item.title, @@ -58,10 +60,23 @@ try { severity: item.severity, status: item.status, occurredAt: daysAgo(item.days), + createdAt: new Date(daysAgo(item.days).getTime() + 2 * 3_600_000), ...areaIds[item.building], locationDetail: item.locationDetail, reporterId: person(item.reporter), closedAt: item.status === 'closed' ? daysAgo(item.days - 3) : null, + })), + ) + .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, })), ) await tx.insert(incidentCounters).values({ organizationId: org.id, lastNumber: demo.length }) diff --git a/server/index.ts b/server/index.ts index 0ef1fc5..e9f2ee7 100644 --- a/server/index.ts +++ b/server/index.ts @@ -5,10 +5,19 @@ import { join } from 'node:path' import { createApp } from './app.ts' import { loadConfig } from './config.ts' import { openDatabase } from './db/client.ts' +import { createMailer } from './mail.ts' +import { Notifier } from './notifications.ts' +import { FileStorage } from './storage.ts' const config = loadConfig() const database = await openDatabase(config.databaseUrl) -const app = createApp({ db: database.db, config }) +const notifier = new Notifier(database.db, createMailer(config), config) +const app = createApp({ db: database.db, config, notifier, storage: new FileStorage(config.uploadDir) }) + +// Hourly check for corrective actions that are due or overdue; each gets at most one reminder a day. +const sendReminders = () => notifier.sendDueReminders().catch((error) => console.error('[reminders] failed:', error)) +const reminderTimer = setInterval(sendReminders, 60 * 60 * 1000) +setTimeout(sendReminders, 30_000).unref() // In production the server also serves the built web app. if (existsSync(join(config.staticDir, 'index.html'))) { @@ -30,7 +39,9 @@ const server = serve({ fetch: app.fetch, port: config.port }, ({ port }) => { for (const signal of ['SIGINT', 'SIGTERM'] as const) { process.on(signal, () => { + clearInterval(reminderTimer) server.close(async () => { + await notifier.flush() await database.close() process.exit(0) }) diff --git a/server/lib/csv.test.ts b/server/lib/csv.test.ts new file mode 100644 index 0000000..046957c --- /dev/null +++ b/server/lib/csv.test.ts @@ -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('a,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("a\r\n'=1+1\r\n'-2\r\n\r\n3\r\n") + }) +}) diff --git a/server/lib/csv.ts b/server/lib/csv.ts new file mode 100644 index 0000000..b725730 --- /dev/null +++ b/server/lib/csv.ts @@ -0,0 +1,14 @@ +type Cell = string | number | null | undefined + +/** + * RFC 4180 CSV with a BOM so Excel opens UTF-8 correctly. Cells that a spreadsheet would treat as a + * formula are prefixed with an apostrophe (CSV injection). + */ +export function toCsv(header: string[], rows: Cell[][]): 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 '' + [header, ...rows].map((row) => row.map(cell).join(',')).join('\r\n') + '\r\n' +} diff --git a/server/lib/dates.ts b/server/lib/dates.ts new file mode 100644 index 0000000..4a0d9b0 --- /dev/null +++ b/server/lib/dates.ts @@ -0,0 +1,14 @@ +/** ISO string for a Date, keeping nulls. */ +export const iso = (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) +} diff --git a/server/lib/file-type.test.ts b/server/lib/file-type.test.ts new file mode 100644 index 0000000..96bda08 --- /dev/null +++ b/server/lib/file-type.test.ts @@ -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(''))).toBeNull() + expect(detectFileType(bytes('PK', 3, 4))).toBeNull() + expect(detectFileType(new Uint8Array())).toBeNull() + }) +}) diff --git a/server/lib/file-type.ts b/server/lib/file-type.ts new file mode 100644 index 0000000..185a66d --- /dev/null +++ b/server/lib/file-type.ts @@ -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 +} diff --git a/server/lib/http.ts b/server/lib/http.ts index cf35904..9940aab 100644 --- a/server/lib/http.ts +++ b/server/lib/http.ts @@ -5,12 +5,16 @@ import type { ZodType } from 'zod' import type { SessionUser } from '../auth/sessions.ts' import type { Config } from '../config.ts' import type { Db } from '../db/client.ts' +import type { Notifier } from '../notifications.ts' +import type { FileStorage } from '../storage.ts' export interface AppEnv { Variables: { db: Db config: Config user: SessionUser | null + notifier: Notifier + storage: FileStorage } } @@ -23,7 +27,7 @@ export function validate new HTTPException(status, { message }) +export const fail = (status: 400 | 401 | 403 | 404 | 409 | 413 | 429, message: string) => new HTTPException(status, { message }) /** The signed-in user; use after `requireAuth`. */ export function currentUser(c: Context): SessionUser { diff --git a/server/lib/incidents.ts b/server/lib/incidents.ts new file mode 100644 index 0000000..ce01d1e --- /dev/null +++ b/server/lib/incidents.ts @@ -0,0 +1,187 @@ +import { and, asc, 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, investigations, 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 }) + .from(incidents) + .innerJoin(sites, eq(sites.id, incidents.siteId)) + .leftJoin(areas, eq(areas.id, incidents.areaId)) + .leftJoin(reporters, eq(reporters.id, incidents.reporterId)) +} + +type IncidentRow = Awaited['execute']>>[number] + +export function toIncident({ incident, siteName, areaName, reporterName }: 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, + } +} + +/** + * 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['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 { + 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') + + 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), + }), + can: { edit: manage, manageInvestigation: manage, manageActions: manage }, + } +} + +/** Records an event on an incident's timeline. */ +export function auditIncident(db: Db, user: SessionUser | null, organizationId: string, incidentId: string, action: string, changes?: Record) { + 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') +} + diff --git a/server/mail.ts b/server/mail.ts new file mode 100644 index 0000000..7ba7326 --- /dev/null +++ b/server/mail.ts @@ -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 +} + +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) + } +} diff --git a/server/notifications.ts b/server/notifications.ts new file mode 100644 index 0000000..a216c0a --- /dev/null +++ b/server/notifications.ts @@ -0,0 +1,156 @@ +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 { correctiveActions, incidents, 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>() + 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 { + await Promise.all([...this.pending]) + } + + private dispatch(task: () => Promise) { + 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}`)}`, + }) + } + }) + } + + 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 { + 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 + } +} diff --git a/server/routes/actions.ts b/server/routes/actions.ts new file mode 100644 index 0000000..8b3ff7b --- /dev/null +++ b/server/routes/actions.ts @@ -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() + .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) }) + }) diff --git a/server/routes/attachments.test.ts b/server/routes/attachments.test.ts new file mode 100644 index 0000000..aab4cf5 --- /dev/null +++ b/server/routes/attachments.test.ts @@ -0,0 +1,74 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createTestContext, hoursAgo } from '../test/helpers.ts' + +let ctx: Awaited> +let reporter: Awaited> +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, 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('') + 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) + }) +}) diff --git a/server/routes/attachments.ts b/server/routes/attachments.ts new file mode 100644 index 0000000..0b1d0dd --- /dev/null +++ b/server/routes/attachments.ts @@ -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() + .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) + }) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 194d5e8..f67f00a 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -1,17 +1,29 @@ import { eq } from 'drizzle-orm' import { Hono } from 'hono' -import { changePasswordSchema, loginSchema } from '../../shared/schemas.ts' +import { changePasswordSchema, emailOnlySchema, loginSchema, resetPasswordSchema, tokenSchema } from '../../shared/schemas.ts' import { endSession, requireAuth, startSession } from '../auth/middleware.ts' import { hashPassword, verifyAgainstDummy, verifyPassword } from '../auth/password.ts' import { RateLimiter } from '../auth/rate-limit.ts' import { deleteUserSessions } from '../auth/sessions.ts' +import { consumeAuthToken, createAuthToken } from '../auth/tokens.ts' +import type { Db } from '../db/client.ts' import { organizations, users } from '../db/schema.ts' import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts' import { publicUser } from './users.ts' +const MAGIC_LINK_TTL_MS = 15 * 60 * 1000 +const RESET_TTL_MS = 60 * 60 * 1000 + +async function activeUserByEmail(db: Db, email: string) { + const [user] = await db.select({ id: users.id, email: users.email, active: users.active }).from(users).where(eq(users.email, email)) + return user?.active ? user : null +} + export function authRoutes() { // 10 failed-or-successful attempts per email per 15 minutes const loginLimiter = new RateLimiter(10, 15 * 60 * 1000) + // Emailed links: 5 per email per 15 minutes + const emailLimiter = new RateLimiter(5, 15 * 60 * 1000) return new Hono() .post('/login', validate('json', loginSchema), async (c) => { @@ -30,6 +42,48 @@ export function authRoutes() { return c.json({ user: result }) }) + // Always 204, so the response never reveals whether an account exists. + .post('/magic-link', validate('json', emailOnlySchema), async (c) => { + const { email } = c.req.valid('json') + if (!emailLimiter.attempt(`magic:${email}`)) throw fail(429, 'Too many requests. Try again in 15 minutes.') + const db = c.get('db') + const user = await activeUserByEmail(db, email) + if (user) c.get('notifier').magicLink(user.email, await createAuthToken(db, user.id, 'magic_link', MAGIC_LINK_TTL_MS)) + return c.body(null, 204) + }) + + // POST rather than GET so email link scanners can't use up the token. + .post('/magic-link/verify', validate('json', tokenSchema), async (c) => { + const db = c.get('db') + const userId = await consumeAuthToken(db, c.req.valid('json').token, 'magic_link') + if (!userId) throw fail(400, 'This sign-in link is invalid or has expired. Request a new one.') + await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, userId)) + await startSession(c, userId) + const [user] = await db.select(publicUser).from(users).where(eq(users.id, userId)) + return c.json({ user }) + }) + + .post('/password-reset', validate('json', emailOnlySchema), async (c) => { + const { email } = c.req.valid('json') + if (!emailLimiter.attempt(`reset:${email}`)) throw fail(429, 'Too many requests. Try again in 15 minutes.') + const db = c.get('db') + const user = await activeUserByEmail(db, email) + if (user) c.get('notifier').passwordReset(user.email, await createAuthToken(db, user.id, 'password_reset', RESET_TTL_MS), null) + return c.body(null, 204) + }) + + .post('/password-reset/confirm', validate('json', resetPasswordSchema), async (c) => { + const { token, newPassword } = c.req.valid('json') + const db = c.get('db') + const userId = await consumeAuthToken(db, token, 'password_reset') + if (!userId) throw fail(400, 'This link is invalid or has expired. Request a new one.') + await db.update(users).set({ passwordHash: await hashPassword(newPassword), lastLoginAt: new Date() }).where(eq(users.id, userId)) + await deleteUserSessions(db, userId) + await startSession(c, userId) + const [user] = await db.select(publicUser).from(users).where(eq(users.id, userId)) + return c.json({ user }) + }) + .post('/logout', async (c) => { await endSession(c) return c.body(null, 204) diff --git a/server/routes/email-auth.test.ts b/server/routes/email-auth.test.ts new file mode 100644 index 0000000..ecf7e5d --- /dev/null +++ b/server/routes/email-auth.test.ts @@ -0,0 +1,80 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createTestContext, tokenFrom } from '../test/helpers.ts' + +let ctx: Awaited> +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) + }) +}) diff --git a/server/routes/incidents.test.ts b/server/routes/incidents.test.ts index 9fcdc36..f7d4ff3 100644 --- a/server/routes/incidents.test.ts +++ b/server/routes/incidents.test.ts @@ -108,27 +108,62 @@ describe('listing', () => { }) describe('updating', () => { - it('lets supervisors update and close incidents, recording what changed', async () => { + it('lets supervisors edit details, recording what changed', async () => { const created = await reporter.post('/api/incidents', report({ title: 'Wet floor' })) const number = created.body.incident.number - expect((await reporter.patch(`/api/incidents/${number}`, { status: 'closed' })).status).toBe(403) + expect((await reporter.patch(`/api/incidents/${number}`, { severity: 'low' })).status).toBe(403) - const closed = await supervisor.patch(`/api/incidents/${number}`, { status: 'closed', severity: 'low' }) - expect(closed.status).toBe(200) - expect(closed.body.incident.status).toBe('closed') - expect(closed.body.incident.closedAt).not.toBeNull() + const edited = await supervisor.patch(`/api/incidents/${number}`, { severity: 'low', title: 'Wet floor by entrance' }) + expect(edited.status).toBe(200) + expect(edited.body.incident).toMatchObject({ severity: 'low', title: 'Wet floor by entrance' }) const [entry] = await ctx.db.select().from(auditLog).where(eq(auditLog.action, 'update')) - expect(entry.changes).toMatchObject({ status: ['reported', 'closed'], severity: ['high', 'low'] }) + expect(entry.changes).toMatchObject({ severity: ['high', 'low'], title: ['Wet floor', 'Wet floor by entrance'] }) + }) - const reopened = await supervisor.patch(`/api/incidents/${number}`, { status: 'investigation' }) - expect(reopened.body.incident.closedAt).toBeNull() + it('does not change status through edits', async () => { + const created = await reporter.post('/api/incidents', report()) + const res = await supervisor.patch(`/api/incidents/${created.body.incident.number}`, { status: 'closed' }) + expect(res.body.incident.status).toBe('reported') }) it('clears the area when moving an incident to another site', async () => { const created = await reporter.post('/api/incidents', report({ areaId: area.id })) - const south = await ctx.createSite('West Campus') - const moved = await supervisor.patch(`/api/incidents/${created.body.incident.number}`, { siteId: south.id }) - expect(moved.body.incident).toMatchObject({ site: { id: south.id }, area: null }) + const west = await ctx.createSite('West Campus') + const moved = await supervisor.patch(`/api/incidents/${created.body.incident.number}`, { siteId: west.id }) + expect(moved.body.incident).toMatchObject({ site: { id: west.id }, area: null }) + }) +}) + +describe('notifications', () => { + it('alerts safety managers about high-severity reports, not low ones', async () => { + const manager = await ctx.createUser('safety_manager', 'alerts@example.com') + await reporter.post('/api/incidents', report({ title: 'Arm caught in conveyor', type: 'injury', severity: 'critical' })) + await reporter.post('/api/incidents', report({ title: 'Scuffed paint', severity: 'low' })) + await ctx.notifier.flush() + const mails = ctx.mailer.sent.filter((mail) => mail.to === manager.user.email) + expect(mails).toHaveLength(1) + expect(mails[0].subject).toMatch(/^\[Critical\] INC-\d{4}: Arm caught in conveyor$/) + expect(mails[0].text).toContain('http://safety.test/incidents/') + }) +}) + +describe('export', () => { + it('exports the filtered list as CSV, neutralising spreadsheet formulas', async () => { + await reporter.post('/api/incidents', report({ title: '=HYPERLINK("http://evil")', type: 'environmental', severity: 'low' })) + const res = await supervisor.get('/api/incidents/export?type=environmental') + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('text/csv') + const csv = res.body.toString('utf8') + const lines = csv.trimEnd().split('\r\n') + expect(lines[0]).toMatch(/^\uFEFFReference,Title,Type/) + expect(lines).toHaveLength(2) + expect(lines[1]).toContain(`"'=HYPERLINK(""http://evil"")"`) + }) + + it('only exports what the user can see', async () => { + const other = await ctx.createUser('reporter', 'csv-reporter@example.com') + const res = await other.get('/api/incidents/export') + expect(res.body.toString('utf8').trimEnd().split('\r\n')).toHaveLength(1) }) }) diff --git a/server/routes/incidents.ts b/server/routes/incidents.ts index 528117b..a231759 100644 --- a/server/routes/incidents.ts +++ b/server/routes/incidents.ts @@ -1,72 +1,38 @@ -import { and, count, desc, eq, ilike, isNull, or, sql, type SQL } from 'drizzle-orm' +import { and, asc, count, desc, eq, ilike, isNull, or, sql, type SQL } from 'drizzle-orm' import { Hono } from 'hono' +import { bodyLimit } from 'hono/body-limit' import { z } from 'zod' +import type { Comment, TimelineEntry } from '../../shared/api-types.ts' import { hasRole } from '../../shared/enums.ts' -import { incidentReference } from '../../shared/format.ts' -import { createIncidentSchema, incidentListQuery, updateIncidentSchema } from '../../shared/schemas.ts' -import type { SessionUser } from '../auth/sessions.ts' +import { + commentSchema, + createActionSchema, + createIncidentSchema, + incidentListQuery, + investigationSchema, + rootCauseSchema, + transitionSchema, + updateIncidentSchema, + type IncidentListQuery, +} from '../../shared/schemas.ts' +import { transitionBlocker, transitions } from '../../shared/workflow.ts' import { requireAuth, requireRole } from '../auth/middleware.ts' +import type { SessionUser } from '../auth/sessions.ts' import type { Db } from '../db/client.ts' -import { areas, incidentCounters, incidents, sites, users } from '../db/schema.ts' -import { audit, diff } from '../lib/audit.ts' +import { areas, attachments, auditLog, comments, correctiveActions, incidentCounters, incidents, investigations, rootCauses, sites, users } from '../db/schema.ts' +import { diff } from '../lib/audit.ts' +import { toCsv } from '../lib/csv.ts' +import { iso } from '../lib/dates.ts' +import { detectFileType } from '../lib/file-type.ts' import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts' +import { assertActiveUser, auditIncident, findIncident, loadDetail, selectIncidents, toAttachment, toIncident, visibleTo } from '../lib/incidents.ts' const numberParam = z.object({ number: z.coerce.number().int().positive() }) /** Reports may be dated slightly ahead to absorb clock differences between devices. */ const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000 - -const columns = { - incident: incidents, - siteName: sites.name, - areaName: areas.name, - reporterName: users.name, -} - -type IncidentRow = { incident: typeof incidents.$inferSelect; siteName: string; areaName: string | null; reporterName: string | null } - -function toDto({ incident, siteName, areaName, reporterName }: IncidentRow) { - return { - id: incident.id, - number: incident.number, - reference: incidentReference(incident.number), - title: incident.title, - type: incident.type, - severity: incident.severity, - status: incident.status, - occurredAt: incident.occurredAt, - reportedAt: incident.createdAt, - updatedAt: incident.updatedAt, - closedAt: incident.closedAt, - site: { id: incident.siteId, name: siteName }, - area: incident.areaId ? { id: incident.areaId, name: areaName } : null, - locationDetail: incident.locationDetail, - description: incident.description, - immediateAction: incident.immediateAction, - anonymous: incident.anonymous, - reporter: incident.reporterId ? { id: incident.reporterId, name: reporterName } : null, - } -} - -function selectIncidents(db: Db) { - return db - .select(columns) - .from(incidents) - .innerJoin(sites, eq(sites.id, incidents.siteId)) - .leftJoin(areas, eq(areas.id, incidents.areaId)) - .leftJoin(users, eq(users.id, incidents.reporterId)) -} - -/** Reporters only see what they reported; supervisors and above see everything in the organization. */ -function visibleTo(user: SessionUser): SQL | undefined { - return and(eq(incidents.organizationId, user.organizationId), hasRole(user.role, 'supervisor') ? undefined : eq(incidents.reporterId, user.id)) -} - -async function findIncident(db: Db, user: SessionUser, number: number) { - const [row] = await selectIncidents(db).where(and(visibleTo(user), eq(incidents.number, number))) - if (!row) throw fail(404, 'Incident not found') - return row -} +const MAX_ATTACHMENTS_PER_INCIDENT = 20 +const EXPORT_LIMIT = 10_000 async function assertLocation(db: Db, organizationId: string, siteId: string, areaId: string | null | undefined) { const [site] = await db @@ -91,39 +57,78 @@ function parseOccurredAt(value: string): Date { const escapeLike = (value: string) => value.replace(/[\\%_]/g, '\\$&') +function listFilter(db: Db, user: SessionUser, { q, type, severity, status, siteId }: Omit) { + 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 { + 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() .get('/', requireAuth, validate('query', incidentListQuery), async (c) => { const me = currentUser(c) - const { page, pageSize, q, type, severity, status, siteId } = c.req.valid('query') + const { page, pageSize, ...filters } = c.req.valid('query') const db = c.get('db') - - 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, - ) - } - const where = and( - visibleTo(me), - 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, - ) - + const where = listFilter(db, me, filters) const [{ total }] = await db.select({ total: count() }).from(incidents).where(where) const rows = await selectIncidents(db) .where(where) .orderBy(desc(incidents.occurredAt), desc(incidents.number)) .limit(pageSize) .offset((page - 1) * pageSize) - return c.json({ items: rows.map(toDto), page, pageSize, total }) + return c.json({ items: rows.map(toIncident), page, pageSize, total }) + }) + + .get('/export', requireAuth, validate('query', incidentListQuery.omit({ page: true, pageSize: true })), async (c) => { + const me = currentUser(c) + const db = c.get('db') + const rows = await selectIncidents(db) + .where(listFilter(db, me, c.req.valid('query'))) + .orderBy(desc(incidents.occurredAt), desc(incidents.number)) + .limit(EXPORT_LIMIT) + const csv = toCsv( + ['Reference', 'Title', 'Type', 'Severity', 'Status', 'Occurred at', 'Reported at', 'Closed at', 'Site', 'Area', 'Location detail', 'Reporter', 'Description', 'Immediate action'], + rows.map(toIncident).map((i) => [ + i.reference, + i.title, + i.type, + i.severity, + i.status, + i.occurredAt, + i.reportedAt, + i.closedAt, + i.site.name, + i.area?.name, + i.locationDetail, + i.anonymous ? 'Anonymous' : i.reporter?.name, + i.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) => { @@ -133,7 +138,7 @@ export const incidentRoutes = new Hono() await assertLocation(db, me.organizationId, input.siteId, input.areaId) const occurred = parseOccurredAt(occurredAt) - const number = await db.transaction(async (tx) => { + const created = await db.transaction(async (tx) => { // Atomic increment: concurrent reports always get distinct, gap-free numbers. const [{ lastNumber }] = await tx .insert(incidentCounters) @@ -143,25 +148,23 @@ export const incidentRoutes = new Hono() const [incident] = await tx .insert(incidents) .values({ ...input, occurredAt: occurred, organizationId: me.organizationId, number: lastNumber, anonymous, reporterId: anonymous ? null : me.id }) - .returning({ id: incidents.id }) - await audit(tx, { - organizationId: me.organizationId, - actorId: anonymous ? null : me.id, - entityType: 'incident', - entityId: incident.id, - action: 'create', - changes: { number: lastNumber, title: input.title, type: input.type, severity: input.severity }, + .returning({ id: incidents.id, number: incidents.number }) + await auditIncident(tx, anonymous ? null : me, me.organizationId, incident.id, 'create', { + number: lastNumber, + title: input.title, + type: input.type, + severity: input.severity, }) - return lastNumber + return incident }) - const [row] = await selectIncidents(db).where(and(eq(incidents.organizationId, me.organizationId), eq(incidents.number, number))) - return c.json({ incident: toDto(row) }, 201) + c.get('notifier').incidentReported(created.id) + const [row] = await selectIncidents(db).where(eq(incidents.id, created.id)) + return c.json({ incident: toIncident(row) }, 201) }) .get('/:number', requireAuth, validate('param', numberParam), async (c) => { - const row = await findIncident(c.get('db'), currentUser(c), c.req.valid('param').number) - return c.json({ incident: toDto(row) }) + return c.json({ incident: await loadDetail(c.get('db'), currentUser(c), c.req.valid('param').number) }) }) .patch('/:number', requireRole('supervisor'), validate('param', numberParam), validate('json', updateIncidentSchema), async (c) => { @@ -175,17 +178,181 @@ export const incidentRoutes = new Hono() if (input.siteId && input.siteId !== incident.siteId && input.areaId === undefined) areaId = null // moving site clears the old area if (input.siteId !== undefined || input.areaId !== undefined) await assertLocation(db, me.organizationId, siteId, areaId) - const update = { - ...input, - areaId, - occurredAt: occurredAt ? parseOccurredAt(occurredAt) : undefined, - closedAt: input.status === undefined ? undefined : input.status === 'closed' ? (incident.closedAt ?? new Date()) : null, - } + const update = { ...input, areaId, occurredAt: occurredAt ? parseOccurredAt(occurredAt) : undefined } const changes = diff(incident, update) if (Object.keys(changes).length > 0) { await db.update(incidents).set(update).where(eq(incidents.id, incident.id)) - await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'incident', entityId: incident.id, action: 'update', changes }) + await auditIncident(db, me, me.organizationId, incident.id, 'update', changes) } - const row = await findIncident(db, me, incident.number) - return c.json({ incident: toDto(row) }) + return c.json({ incident: await loadDetail(db, me, incident.number) }) }) + + .post('/:number/transitions', requireAuth, validate('param', numberParam), validate('json', transitionSchema), async (c) => { + const me = currentUser(c) + const db = c.get('db') + const { to, note } = c.req.valid('json') + const detail = await loadDetail(db, me, c.req.valid('param').number) + + const rule = transitions.find((t) => t.from === detail.status && t.to === to) + if (!rule) throw fail(409, `An incident in "${detail.status}" can't move to "${to}"`) + if (!hasRole(me.role, rule.minRole)) throw fail(403, 'You do not have permission to do that') + const blocker = transitionBlocker(rule, { + severity: detail.severity, + rootCauseCount: detail.rootCauses.length, + actionStatuses: detail.actions.map((action) => action.status), + }) + if (blocker) throw fail(409, blocker) + + // Conditional update guards against two people moving the same incident at once. + const moved = await db + .update(incidents) + .set({ status: to, closedAt: to === 'closed' ? new Date() : null }) + .where(and(eq(incidents.id, detail.id), eq(incidents.status, detail.status))) + .returning({ id: incidents.id }) + if (moved.length === 0) throw fail(409, 'This incident was just changed by someone else. Reload and try again.') + + await auditIncident(db, me, me.organizationId, detail.id, 'status', { status: [detail.status, to], ...(note ? { note } : {}) }) + if (note) await db.insert(comments).values({ incidentId: detail.id, authorId: me.id, body: note }) + return c.json({ incident: await loadDetail(db, me, detail.number) }) + }) + + .get('/:number/comments', requireAuth, validate('param', numberParam), async (c) => { + const db = c.get('db') + const { incident } = await findIncident(db, currentUser(c), c.req.valid('param').number) + const rows = await db + .select({ comment: comments, authorName: users.name }) + .from(comments) + .leftJoin(users, eq(users.id, comments.authorId)) + .where(eq(comments.incidentId, incident.id)) + .orderBy(asc(comments.createdAt)) + const result: Comment[] = rows.map(({ comment, authorName }) => ({ + id: comment.id, + author: comment.authorId ? { id: comment.authorId, name: authorName ?? '' } : null, + body: comment.body, + createdAt: iso(comment.createdAt), + })) + return c.json({ comments: result }) + }) + + .post('/:number/comments', requireAuth, validate('param', numberParam), validate('json', commentSchema), async (c) => { + const me = currentUser(c) + const db = c.get('db') + const { incident } = await findIncident(db, me, c.req.valid('param').number) + const [comment] = await db.insert(comments).values({ incidentId: incident.id, authorId: me.id, body: c.req.valid('json').body }).returning() + const result: Comment = { id: comment.id, author: { id: me.id, name: me.name }, body: comment.body, createdAt: iso(comment.createdAt) } + return c.json({ comment: result }, 201) + }) + + .get('/:number/timeline', requireAuth, validate('param', numberParam), async (c) => { + const me = currentUser(c) + const db = c.get('db') + const { incident } = await findIncident(db, me, c.req.valid('param').number) + const rows = await db + .select({ entry: auditLog, actorName: users.name }) + .from(auditLog) + .leftJoin(users, eq(users.id, auditLog.actorId)) + .where(and(eq(auditLog.organizationId, me.organizationId), eq(auditLog.entityType, 'incident'), eq(auditLog.entityId, incident.id))) + .orderBy(asc(auditLog.id)) + const timeline: TimelineEntry[] = rows.map(({ entry, actorName }) => ({ + id: entry.id, + at: iso(entry.createdAt), + actor: entry.actorId ? { id: entry.actorId, name: actorName ?? '' } : null, + action: entry.action, + changes: (entry.changes as Record | 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 file = body.file + 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, incident.id)) + 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 storage = c.get('storage') + 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: incident.id, fileName, contentType: type.contentType, sizeBytes: data.byteLength, storageKey, uploadedById: me.id }) + .returning() + await auditIncident(db, me, me.organizationId, incident.id, 'attachment.add', { attachmentId: row.id, fileName }) + return c.json({ attachment: toAttachment(row, me.name) }, 201) + } catch (error) { + await storage.remove(storageKey) + throw error + } + }, + ) diff --git a/server/routes/root-causes.ts b/server/routes/root-causes.ts new file mode 100644 index 0000000..723e340 --- /dev/null +++ b/server/routes/root-causes.ts @@ -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() + .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) }) + }) diff --git a/server/routes/stats.test.ts b/server/routes/stats.test.ts new file mode 100644 index 0000000..c7912d0 --- /dev/null +++ b/server/routes/stats.test.ts @@ -0,0 +1,47 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createTestContext, daysFromToday, hoursAgo } from '../test/helpers.ts' + +let ctx: Awaited> +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 analytics = (await supervisor.get('/api/stats/analytics?days=30')).body + expect(analytics).toMatchObject({ periodDays: 30, total: 3, topRootCause: { category: 'equipment', count: 1, share: 1 } }) + expect(analytics.byType).toEqual(expect.arrayContaining([{ type: 'hazard', count: 2 }, { type: 'injury', 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) + }) +}) diff --git a/server/routes/stats.ts b/server/routes/stats.ts new file mode 100644 index 0000000..61f126a --- /dev/null +++ b/server/routes/stats.ts @@ -0,0 +1,145 @@ +import { and, asc, count, desc, eq, gte, inArray, isNotNull, isNull, lt, ne, sql } from 'drizzle-orm' +import { Hono } from 'hono' +import { z } from 'zod' +import type { AnalyticsStats, OverviewStats } from '../../shared/api-types.ts' +import { incidentStatuses, type IncidentStatus } from '../../shared/enums.ts' +import { requireAuth } from '../auth/middleware.ts' +import { correctiveActions, incidents, investigations, rootCauses, sites } from '../db/schema.ts' +import { addDays, localDate } from '../lib/dates.ts' +import { currentUser, validate, type AppEnv } from '../lib/http.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() + .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 + 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`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`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', z.object({ days: z.coerce.number().int().min(7).max(730).default(90) })), async (c) => { + const me = currentUser(c) + const db = c.get('db') + const { days } = c.req.valid('query') + const since = new Date(Date.now() - days * DAY_MS) + const inPeriod = and(visibleTo(db, me), gte(incidents.occurredAt, since)) + + const byType = await db.select({ type: incidents.type, count: count() }).from(incidents).where(inPeriod).groupBy(incidents.type).orderBy(desc(count())) + const bySeverity = await db.select({ severity: incidents.severity, count: count() }).from(incidents).where(inPeriod).groupBy(incidents.severity) + const causes = await db + .select({ category: rootCauses.category, count: count() }) + .from(rootCauses) + .innerJoin(investigations, eq(investigations.id, rootCauses.investigationId)) + .innerJoin(incidents, eq(incidents.id, investigations.incidentId)) + .where(inPeriod) + .groupBy(rootCauses.category) + .orderBy(desc(count())) + const totalCauses = causes.reduce((sum, row) => sum + row.count, 0) + + const monthExpr = sql`to_char(${correctiveActions.completedAt}, 'YYYY-MM')` + const onTimeExpr = sql`(${correctiveActions.completedAt})::date <= ${correctiveActions.dueDate}` + const completed = await db + .select({ month: monthExpr, onTime: onTimeExpr, count: count() }) + .from(correctiveActions) + .innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId)) + .where(and(visibleTo(db, me), isNotNull(correctiveActions.completedAt), isNotNull(correctiveActions.dueDate), gte(correctiveActions.completedAt, since))) + .groupBy(monthExpr, onTimeExpr) + const onTime = completed.filter((row) => row.onTime).reduce((sum, row) => sum + row.count, 0) + const allCompleted = completed.reduce((sum, row) => sum + row.count, 0) + const months = [...new Set(completed.map((row) => row.month))].sort() + + const stats: AnalyticsStats = { + periodDays: days, + total: byType.reduce((sum, row) => sum + row.count, 0), + byType, + bySeverity, + topRootCause: causes[0] ? { category: causes[0].category, count: causes[0].count, share: causes[0].count / totalCauses } : null, + onTimeActionRate: allCompleted ? onTime / allCompleted : null, + monthlyCompletedActions: months.map((month) => ({ + month, + onTime: completed.filter((row) => row.month === month && row.onTime).reduce((sum, row) => sum + row.count, 0), + late: completed.filter((row) => row.month === month && !row.onTime).reduce((sum, row) => sum + row.count, 0), + })), + } + return c.json(stats) + }) diff --git a/server/routes/users.ts b/server/routes/users.ts index fe4b499..5ea76be 100644 --- a/server/routes/users.ts +++ b/server/routes/users.ts @@ -3,10 +3,11 @@ import { Hono } from 'hono' import { z } from 'zod' import { createUserSchema, updateUserSchema } from '../../shared/schemas.ts' import { requireRole } from '../auth/middleware.ts' +import { createAuthToken } from '../auth/tokens.ts' import { hashPassword } from '../auth/password.ts' import { deleteUserSessions } from '../auth/sessions.ts' import { isUniqueViolation } from '../db/client.ts' -import { users } from '../db/schema.ts' +import { organizations, users } from '../db/schema.ts' import { audit, diff } from '../lib/audit.ts' import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts' @@ -22,6 +23,7 @@ export const publicUser = { } const idParam = z.object({ id: z.uuid() }) +const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000 export const userRoutes = new Hono() .get('/', requireRole('safety_manager'), async (c) => { @@ -30,6 +32,18 @@ export const userRoutes = new Hono() return c.json({ users: rows }) }) + /** Active people who can lead investigations or be assigned actions. */ + .get('/assignable', requireRole('supervisor'), async (c) => { + const me = currentUser(c) + const rows = await c + .get('db') + .select({ id: users.id, name: users.name, role: users.role }) + .from(users) + .where(and(eq(users.organizationId, me.organizationId), eq(users.active, true))) + .orderBy(asc(users.name)) + return c.json({ users: rows }) + }) + .post('/', requireRole('admin'), validate('json', createUserSchema), async (c) => { const me = currentUser(c) const { password, ...input } = c.req.valid('json') @@ -37,10 +51,15 @@ export const userRoutes = new Hono() try { const [user] = await db .insert(users) - .values({ ...input, organizationId: me.organizationId, passwordHash: await hashPassword(password) }) + .values({ ...input, organizationId: me.organizationId, passwordHash: password ? await hashPassword(password) : null }) .returning(publicUser) await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'user', entityId: user.id, action: 'create', changes: { email: user.email, role: user.role } }) - return c.json({ user }, 201) + if (!password) { + const [organization] = await db.select({ name: organizations.name }).from(organizations).where(eq(organizations.id, me.organizationId)) + const token = await createAuthToken(db, user.id, 'password_reset', INVITE_TTL_MS) + c.get('notifier').passwordReset(user.email, token, { organization: organization.name, invitedBy: me.name }) + } + return c.json({ user, invited: !password }, 201) } catch (error) { if (isUniqueViolation(error)) throw fail(409, 'A user with that email already exists') throw error @@ -66,14 +85,20 @@ export const userRoutes = new Hono() if (otherAdmins.length === 0) throw fail(409, 'At least one active admin is required') } - const changes = diff(target, input) + const { password, ...fields } = input + const changes: Record = diff(target, fields) + if (password) changes.password = 'reset by admin' if (Object.keys(changes).length === 0) { const [user] = await db.select(publicUser).from(users).where(eq(users.id, id)) return c.json({ user }) } - const [user] = await db.update(users).set(input).where(eq(users.id, id)).returning(publicUser) - // Roles are re-read on every request, so only deactivation needs to end sessions. - if (input.active === false) await deleteUserSessions(db, id) + const [user] = await db + .update(users) + .set({ ...fields, ...(password ? { passwordHash: await hashPassword(password) } : {}) }) + .where(eq(users.id, id)) + .returning(publicUser) + // Roles are re-read on every request, so only deactivation and password resets need to end sessions. + if (input.active === false || (password && id !== me.id)) await deleteUserSessions(db, id) await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'user', entityId: id, action: 'update', changes }) return c.json({ user }) }) diff --git a/server/routes/workflow.test.ts b/server/routes/workflow.test.ts new file mode 100644 index 0000000..b0bdf42 --- /dev/null +++ b/server/routes/workflow.test.ts @@ -0,0 +1,202 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createTestContext, daysFromToday, hoursAgo } from '../test/helpers.ts' + +let ctx: Awaited> +let siteId: string +let reporter: Awaited> +let supervisor: Awaited> +let manager: Awaited> + +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' }) + }) +}) diff --git a/server/storage.ts b/server/storage.ts new file mode 100644 index 0000000..4c83efc --- /dev/null +++ b/server/storage.ts @@ -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 { + await mkdir(this.dir, { recursive: true }) + const key = `${randomUUID()}.${extension}` + await writeFile(this.path(key), data, { flag: 'wx' }) + return key + } + + read(key: string): Promise { + return readFile(this.path(key)) + } + + async remove(key: string): Promise { + 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) + } +} diff --git a/server/test/helpers.ts b/server/test/helpers.ts index a5b48cb..7628e7e 100644 --- a/server/test/helpers.ts +++ b/server/test/helpers.ts @@ -1,6 +1,12 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { createApp } from '../app.ts' import { loadConfig } from '../config.ts' import { openDatabase } from '../db/client.ts' +import { MemoryMailer } from '../mail.ts' +import { Notifier } from '../notifications.ts' +import { FileStorage } from '../storage.ts' type App = ReturnType @@ -15,12 +21,16 @@ export class Client { async request(method: string, path: string, body?: unknown) { const headers: Record = {} - if (body !== undefined) headers['content-type'] = 'application/json' + const isForm = body instanceof FormData + if (body !== undefined && !isForm) headers['content-type'] = 'application/json' + if (method !== 'GET') headers.origin = 'http://safety.test' // browsers send Origin on every non-GET request if (this.cookie) headers.cookie = this.cookie - const res = await this.app.request(path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }) + const payload = body === undefined ? undefined : isForm ? body : JSON.stringify(body) + const res = await this.app.request(path, { method, headers, body: payload }) const setCookie = res.headers.get('set-cookie') if (setCookie) this.cookie = /max-age=0|expires=thu, 01 jan 1970/i.test(setCookie) ? undefined : setCookie.split(';')[0] - const text = await res.text() + const text = res.headers.get('content-type')?.includes('json') || res.status >= 400 ? await res.text() : '' + if (!text && res.ok && res.status !== 204) return { status: res.status, body: Buffer.from(await res.arrayBuffer()), headers: res.headers } // oxlint-disable-next-line typescript/no-explicit-any -- tests read arbitrary JSON shapes const json: any = text ? JSON.parse(text) : null return { status: res.status, body: json, headers: res.headers } @@ -28,7 +38,9 @@ export class Client { get = (path: string) => this.request('GET', path) post = (path: string, body?: unknown) => this.request('POST', path, body ?? {}) + put = (path: string, body: unknown) => this.request('PUT', path, body) patch = (path: string, body: unknown) => this.request('PATCH', path, body) + delete = (path: string) => this.request('DELETE', path) } export const ADMIN = { organizationName: 'Northstar Co.', name: 'Avery Admin', email: 'admin@example.com', password: 'correct horse battery' } @@ -36,7 +48,11 @@ export const ADMIN = { organizationName: 'Northstar Co.', name: 'Avery Admin', e /** Fresh in-memory database and app, with the first-run setup already done and the admin signed in. */ export async function createTestContext() { const database = await openDatabase('memory://') - const app = createApp({ db: database.db, config: { ...loadConfig({}), databaseUrl: 'memory://' } }) + const uploadDir = await mkdtemp(join(tmpdir(), 'safetylog-test-')) + const config = { ...loadConfig({ PUBLIC_URL: 'http://safety.test' }), databaseUrl: 'memory://', uploadDir, maxUploadBytes: 1024 * 1024 } + const mailer = new MemoryMailer() + const notifier = new Notifier(database.db, mailer, config) + const app = createApp({ db: database.db, config, notifier, storage: new FileStorage(uploadDir) }) const admin = new Client(app) const setup = await admin.post('/api/setup', ADMIN) if (setup.status !== 201) throw new Error(`setup failed: ${JSON.stringify(setup.body)}`) @@ -55,7 +71,27 @@ export async function createTestContext() { return res.body.site as { id: string; name: string } } - return { app, db: database.db, admin, anonymous: () => new Client(app), createUser, createSite, close: database.close } + async function close() { + await notifier.flush() + await database.close() + await rm(uploadDir, { recursive: true, force: true }) + } + + return { app, db: database.db, admin, mailer, notifier, config, anonymous: () => new Client(app), createUser, createSite, close } } export const hoursAgo = (hours: number) => new Date(Date.now() - hours * 3_600_000).toISOString() + +/** Pulls the token out of the link in an email body. */ +export function tokenFrom(text: string | undefined): string { + const match = /token=([\w-]+)/.exec(text ?? '') + if (!match) throw new Error(`No token in: ${text}`) + return match[1] +} + +export const daysFromToday = (days: number) => { + const date = new Date() + date.setDate(date.getDate() + days) + const pad = (n: number) => String(n).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +} diff --git a/shared/api-types.ts b/shared/api-types.ts new file mode 100644 index 0000000..d657de7 --- /dev/null +++ b/shared/api-types.ts @@ -0,0 +1,183 @@ +// Response shapes returned by the API. Dates are ISO 8601 strings. +import type { + actionKinds, + actionStatuses, + AreaKind, + IncidentStatus, + 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 Site { + 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 +} + +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[] + /** What the signed-in user may do on this incident. */ + can: { edit: boolean; manageInvestigation: boolean; manageActions: 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 | null +} + +export interface Paginated { + 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 + 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 }[] +} + +export interface AnalyticsStats { + periodDays: number + total: number + byType: { type: IncidentType; count: number }[] + bySeverity: { severity: Severity; count: number }[] + topRootCause: { category: RootCauseCategory; count: number; share: number } | null + /** Share of completed actions finished on or before their due date. */ + onTimeActionRate: number | null + monthlyCompletedActions: { month: string; onTime: number; late: number }[] +} diff --git a/shared/schemas.ts b/shared/schemas.ts index 524afe3..9511ac1 100644 --- a/shared/schemas.ts +++ b/shared/schemas.ts @@ -1,6 +1,6 @@ // Request validation schemas, shared so the web app can validate forms with the same rules as the API. import { z } from 'zod' -import { areaKinds, incidentStatuses, incidentTypes, roles, severities } from './enums.ts' +import { actionKinds, actionStatuses, areaKinds, incidentStatuses, incidentTypes, rcaMethods, roles, rootCauseCategories, severities } from './enums.ts' const email = z.string().trim().toLowerCase().max(254).pipe(z.email()) const password = z.string().min(10, 'Use at least 10 characters').max(256) @@ -14,9 +14,11 @@ export const loginSchema = z.object({ email, password: z.string().min(1).max(256 export const changePasswordSchema = z.object({ currentPassword: z.string().min(1).max(256), newPassword: password }) -export const createUserSchema = z.object({ name, email, role: z.enum(roles), password }) +/** Without a password, the user is emailed an invitation to set one. */ +export const createUserSchema = z.object({ name, email, role: z.enum(roles), password: password.optional() }) -export const updateUserSchema = z.object({ name: name.optional(), role: z.enum(roles).optional(), active: z.boolean().optional() }) +/** `password` lets an admin set a new password when email isn't available. */ +export const updateUserSchema = z.object({ name: name.optional(), role: z.enum(roles).optional(), active: z.boolean().optional(), password: password.optional() }) export const createSiteSchema = z.object({ name, address: optionalText(500), timezone: z.string().trim().max(64).optional() }) @@ -39,10 +41,57 @@ export const createIncidentSchema = z.object({ anonymous: z.boolean().default(false), }) -export const updateIncidentSchema = createIncidentSchema - .omit({ anonymous: true }) - .partial() - .extend({ status: z.enum(incidentStatuses).optional() }) +/** Status changes go through transitions, not updates. */ +export const updateIncidentSchema = createIncidentSchema.omit({ anonymous: true }).partial() + +export const transitionSchema = z.object({ to: z.enum(incidentStatuses), note: optionalText(5_000) }) + +export const commentSchema = z.object({ body: z.string().trim().min(1).max(5_000) }) + +export const investigationSchema = z.object({ + leadId: z.uuid().nullish(), + method: z.enum(rcaMethods).optional(), + summary: optionalText(20_000), + completed: z.boolean().optional(), +}) + +const rootCauseFields = { + category: z.enum(rootCauseCategories), + description: z.string().trim().min(1).max(2_000), + whys: z.array(z.string().trim().min(1).max(1_000)).max(10), +} + +export const rootCauseSchema = z.object({ ...rootCauseFields, whys: rootCauseFields.whys.default([]) }) + +// Update schemas are written out rather than derived with .partial(): in Zod 4, .partial() keeps +// defaults, so a partial update would silently reset omitted fields. +export const updateRootCauseSchema = z.object(rootCauseFields).partial() + +const isoDate = z.iso.date() + +const actionFields = { + title: name, + description: optionalText(5_000), + kind: z.enum(actionKinds), + assigneeId: z.uuid().nullish(), + dueDate: isoDate.nullish(), + rootCauseId: z.uuid().nullish(), +} + +export const createActionSchema = z.object({ ...actionFields, kind: actionFields.kind.default('corrective') }) + +export const updateActionSchema = z.object({ ...actionFields, status: z.enum(actionStatuses) }).partial() + +export const actionListQuery = z.object({ + scope: z.enum(['mine', 'all']).default('mine'), + filter: z.enum(['open', 'overdue', 'done', 'all']).default('open'), +}) + +export const emailOnlySchema = z.object({ email }) + +export const tokenSchema = z.object({ token: z.string().min(20).max(200) }) + +export const resetPasswordSchema = z.object({ token: z.string().min(20).max(200), newPassword: password }) export const incidentListQuery = z.object({ page: z.coerce.number().int().min(1).default(1), @@ -57,4 +106,9 @@ export const incidentListQuery = z.object({ export type SetupInput = z.infer export type CreateIncidentInput = z.input export type UpdateIncidentInput = z.infer +export type CreateActionInput = z.input +export type UpdateActionInput = z.input +export type RootCauseInput = z.input +export type UpdateRootCauseInput = z.input +export type InvestigationInput = z.input export type IncidentListQuery = z.infer diff --git a/shared/workflow.ts b/shared/workflow.ts new file mode 100644 index 0000000..8609eeb --- /dev/null +++ b/shared/workflow.ts @@ -0,0 +1,55 @@ +// Incident status workflow, shared so the web app can show the same options and reasons the API enforces. +import type { ActionStatus } from './api-types.ts' +import { hasRole, type IncidentStatus, type Role, type Severity } from './enums.ts' + +export interface Transition { + from: IncidentStatus + to: IncidentStatus + minRole: Role + label: string +} + +export const transitions: Transition[] = [ + { from: 'reported', to: 'triage', minRole: 'supervisor', label: 'Start triage' }, + { from: 'triage', to: 'investigation', minRole: 'supervisor', label: 'Start investigation' }, + { from: 'triage', to: 'actions', minRole: 'supervisor', label: 'Skip to corrective actions' }, + { from: 'triage', to: 'closed', minRole: 'supervisor', label: 'Close: no further action' }, + { from: 'investigation', to: 'actions', minRole: 'supervisor', label: 'Move to corrective actions' }, + { from: 'actions', to: 'investigation', minRole: 'supervisor', label: 'Back to investigation' }, + { from: 'actions', to: 'verification', minRole: 'supervisor', label: 'Send for verification' }, + { from: 'verification', to: 'actions', minRole: 'safety_manager', label: 'Return to actions' }, + { from: 'verification', to: 'closed', minRole: 'safety_manager', label: 'Verify and close' }, + { from: 'closed', to: 'investigation', minRole: 'safety_manager', label: 'Reopen' }, +] + +export interface WorkflowFacts { + severity: Severity + rootCauseCount: number + actionStatuses: ActionStatus[] +} + +/** Why a transition isn't possible yet, or undefined if it is. */ +export function transitionBlocker(transition: Transition, facts: WorkflowFacts): string | undefined { + const live = facts.actionStatuses.filter((status) => status !== 'cancelled') + if (transition.from === 'triage' && transition.to === 'closed' && (facts.severity === 'high' || facts.severity === 'critical')) { + return 'High and critical incidents must be investigated before closing' + } + if (transition.to === 'actions' && transition.from === 'investigation' && facts.rootCauseCount === 0) { + return 'Record at least one root cause first' + } + if (transition.to === 'verification') { + if (live.length === 0) return 'Add at least one corrective action first' + if (live.some((status) => status === 'open' || status === 'in_progress')) return 'All corrective actions must be done first' + } + if (transition.from === 'verification' && transition.to === 'closed' && live.some((status) => status !== 'verified')) { + return 'Verify every corrective action first' + } + return undefined +} + +/** Transitions from `status` that `role` may perform, with blockers explained. */ +export function availableTransitions(status: IncidentStatus, role: Role, facts: WorkflowFacts) { + return transitions + .filter((t) => t.from === status && hasRole(role, t.minRole)) + .map((t) => ({ to: t.to, label: t.label, blockedReason: transitionBlocker(t, facts) })) +} diff --git a/src/App.test.tsx b/src/App.test.tsx deleted file mode 100644 index 202fa5d..0000000 --- a/src/App.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { MemoryRouter } from 'react-router-dom' -import { describe, expect, it } from 'vitest' -import App from './App' - -function renderAt(path: string) { - return render( - - - , - ) -} - -describe('App', () => { - it('renders the overview with recent incidents', () => { - renderAt('/') - expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(/Jordan/) - expect(screen.getByText('Forklift reversed into pallet stack')).toBeInTheDocument() - }) - - it('adds a submitted report to the incident register', async () => { - const user = userEvent.setup() - renderAt('/report') - - await user.type(screen.getByLabelText('Incident title'), 'Oil leak under press 4') - await user.selectOptions(screen.getByLabelText('Incident type'), 'Hazard') - await user.type(screen.getByLabelText('Specific location'), 'Press line') - await user.click(screen.getByRole('button', { name: /submit report/i })) - - expect(screen.getByRole('heading', { level: 1, name: 'Incidents' })).toBeInTheDocument() - expect(screen.getByText('Oil leak under press 4')).toBeInTheDocument() - expect(screen.getByText(/INC-2409/)).toBeInTheDocument() - }) - - it('filters the incident register by search text', async () => { - const user = userEvent.setup() - renderAt('/incidents') - - await user.type(screen.getByLabelText('Search incidents'), 'guardrail') - - expect(screen.getByText('Damaged guardrail on mezzanine')).toBeInTheDocument() - expect(screen.queryByText('Forklift reversed into pallet stack')).not.toBeInTheDocument() - }) - - it('links to the source code from the about page', () => { - renderAt('/about') - expect(screen.getByRole('heading', { level: 1, name: 'About Open SafetyLog EHS' })).toBeInTheDocument() - expect(screen.getByRole('link', { name: /view source code/i })).toHaveAttribute('href', 'https://www.equineform.com') - }) -}) diff --git a/src/App.tsx b/src/App.tsx index f37eb3d..4c2730f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,30 +1,32 @@ -import { useState } from 'react' import { Navigate, Route, Routes } from 'react-router-dom' import { AppLayout } from './components/AppLayout' -import { createIncident, isOpen } from './lib/incidents' -import { currentUser, seedIncidents } from './lib/seed' -import type { NewIncident } from './lib/types' import { About } from './pages/About' +import { Actions } from './pages/Actions' import { Analytics } from './pages/Analytics' +import { Login, MagicLinkLanding, ResetPassword, Setup } from './pages/Auth' +import { IncidentDetail } from './pages/IncidentDetail' import { Incidents } from './pages/Incidents' import { Locations } from './pages/Locations' import { Overview } from './pages/Overview' import { ReportIncident } from './pages/ReportIncident' +import { Settings } from './pages/Settings' export default function App() { - // In-memory store until the backend lands (Phase 1) - const [incidents, setIncidents] = useState(seedIncidents) - const addIncident = (report: NewIncident) => - setIncidents((current) => [createIncident(report, current, currentUser.name, new Date()), ...current]) - return ( - }> - } /> - } /> + } /> + } /> + } /> + } /> + }> + } /> + } /> + } /> + } /> } /> } /> - } /> + } /> + } /> } /> } /> diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 0f0c223..8cce311 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -1,16 +1,34 @@ import { useState } from 'react' -import { Outlet } from 'react-router-dom' +import { Navigate, Outlet, useLocation } from 'react-router-dom' +import { useActions, useMe, useSetupStatus } from '../lib/queries' import { Header } from './Header' +import { ErrorMessage, Loading } from './QueryState' import { Sidebar } from './Sidebar' -export function AppLayout({ openIncidentCount }: { openIncidentCount: number }) { +/** Signed-in shell. Sends visitors to setup (first run) or sign-in. */ +export function AppLayout() { + const me = useMe() + const setup = useSetupStatus() + const location = useLocation() const [menuOpen, setMenuOpen] = useState(false) + const myOpenActions = useActions('mine', 'open', !!me.data) + + if (me.isPending) return + if (me.isError) return + if (!me.data) { + if (setup.data?.needsSetup) return + if (setup.isPending) return + return + } + + const open = myOpenActions.data ?? [] + const overdue = open.filter((action) => action.overdue).length return (
- setMenuOpen(false)} openIncidentCount={openIncidentCount} /> + setMenuOpen(false)} badges={{ '/actions': open.length }} />
-
setMenuOpen(true)} /> - +
setMenuOpen(true)} overdueActions={overdue} /> +
) diff --git a/src/components/AuthLayout.tsx b/src/components/AuthLayout.tsx new file mode 100644 index 0000000..bd5b65e --- /dev/null +++ b/src/components/AuthLayout.tsx @@ -0,0 +1,24 @@ +import { ShieldCheck } from 'lucide-react' +import type { ReactNode } from 'react' +import { panel } from './styles' + +/** Centered card for sign-in, setup and email-link pages. */ +export function AuthLayout({ title, copy, children }: { title: string; copy?: string; children: ReactNode }) { + return ( +
+
+
+
+ +
+ Open SafetyLog EHS +
+
+

{title}

+ {copy &&

{copy}

} +
{children}
+
+
+
+ ) +} diff --git a/src/components/Badges.tsx b/src/components/Badges.tsx new file mode 100644 index 0000000..8b8c214 --- /dev/null +++ b/src/components/Badges.tsx @@ -0,0 +1,20 @@ +import type { ActionStatus } from '../../shared/api-types.ts' +import type { IncidentStatus, Severity } from '../../shared/enums.ts' +import { actionStatusClass, actionStatusLabel, severityDotClass, severityLabel, statusClass, statusLabel } from '../lib/labels' + +const chip = 'inline-block rounded px-2 py-[5px] text-3xs whitespace-nowrap' + +export const StatusBadge = ({ status }: { status: IncidentStatus }) => {statusLabel[status]} + +export const ActionStatusBadge = ({ status }: { status: ActionStatus }) => {actionStatusLabel[status]} + +export function SeverityLabel({ severity }: { severity: Severity }) { + return ( + + + {severityLabel[severity]} + + ) +} + +export const OverdueBadge = () => Overdue diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 7db1d9e..fc50c81 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,30 +1,55 @@ import { Bell, Menu, Search } from 'lucide-react' -import { useLocation } from 'react-router-dom' -import { pageTitles } from '../lib/navigation' -import { currentUser } from '../lib/seed' +import { useState, type FormEvent } from 'react' +import { Link, useLocation, useNavigate } from 'react-router-dom' +import { initials } from '../lib/initials' +import { pageTitle } from '../lib/navigation' +import type { Me } from '../lib/queries' import { iconButton } from './styles' -export function Header({ onOpenMenu }: { onOpenMenu: () => void }) { +export function Header({ me, onOpenMenu, overdueActions }: { me: Me; onOpenMenu: () => void; overdueActions: number }) { const { pathname } = useLocation() + const navigate = useNavigate() + const [query, setQuery] = useState('') + + const search = (event: FormEvent) => { + event.preventDefault() + navigate(`/incidents${query.trim() ? `?q=${encodeURIComponent(query.trim())}` : ''}`) + setQuery('') + } + return ( -
- -
+
Safety workspace / - {pageTitles[pathname] ?? 'Not found'} + {pageTitle(pathname)}
- - -
{currentUser.initials}
+ {overdueActions > 0 && } + + + {initials(me.user.name)} +
) diff --git a/src/components/IncidentTable.tsx b/src/components/IncidentTable.tsx index 39ec73b..903c53c 100644 --- a/src/components/IncidentTable.tsx +++ b/src/components/IncidentTable.tsx @@ -1,21 +1,16 @@ import { AlertTriangle, MapPin } from 'lucide-react' +import { Link, useNavigate } from 'react-router-dom' +import type { Incident } from '../../shared/api-types.ts' import { formatDate } from '../lib/dates' -import { severityDotClass, statusClass, typeTone } from '../lib/incidents' +import { incidentTypeLabel, incidentTypeTone } from '../lib/labels' import { toneClasses } from '../lib/tones' -import type { Incident } from '../lib/types' -import { iconButton } from './styles' +import { SeverityLabel, StatusBadge } from './Badges' -const th = 'border-b border-line px-2.5 py-3 font-mono text-3xs font-medium tracking-[0.06em] text-faint uppercase first:pl-6 last:pr-[18px]' -const td = 'border-b border-line-soft px-2.5 py-3.5 text-2xs whitespace-nowrap text-body first:pl-6 last:pr-[18px]' +const th = 'border-b border-line px-2.5 py-3 font-mono text-3xs font-medium tracking-[0.06em] text-faint uppercase first:pl-6 last:pr-6' +const td = 'border-b border-line-soft px-2.5 py-3.5 text-2xs whitespace-nowrap text-body first:pl-6 last:pr-6' -interface IncidentTableProps { - incidents: Incident[] - limit?: number - wide?: boolean -} - -export function IncidentTable({ incidents, limit, wide = false }: IncidentTableProps) { - const rows = limit ? incidents.slice(0, limit) : incidents +export function IncidentTable({ incidents, wide = false }: { incidents: Incident[]; wide?: boolean }) { + const navigate = useNavigate() return (
@@ -26,51 +21,50 @@ export function IncidentTable({ incidents, limit, wide = false }: IncidentTableP - - + - {rows.map((incident) => ( - + {incidents.map((incident) => ( + navigate(`/incidents/${incident.number}`)} + > - + - - + ))} diff --git a/src/components/MetricCard.tsx b/src/components/MetricCard.tsx index 9b2ed63..cb57868 100644 --- a/src/components/MetricCard.tsx +++ b/src/components/MetricCard.tsx @@ -4,7 +4,7 @@ import { toneClasses, type Tone } from '../lib/tones' interface MetricCardProps { label: string value: string - delta: string + delta?: string icon: LucideIcon tone: Tone } @@ -17,9 +17,7 @@ export function MetricCard({ label, value, delta, icon: Icon, tone }: MetricCard {label}{value} - - ↗ {delta} - + {delta && {delta}} ) } diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx new file mode 100644 index 0000000..b84f0f2 --- /dev/null +++ b/src/components/Pagination.tsx @@ -0,0 +1,32 @@ +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { iconButton } from './styles' + +interface PaginationProps { + page: number + pageSize: number + total: number + onPage: (page: number) => void +} + +export function Pagination({ page, pageSize, total, onPage }: PaginationProps) { + const pages = Math.max(1, Math.ceil(total / pageSize)) + const first = total === 0 ? 0 : (page - 1) * pageSize + 1 + return ( +
+ + {first}–{Math.min(page * pageSize, total)} of {total} + +
+ + + Page {page} of {pages} + + +
+
+ ) +} diff --git a/src/components/QueryState.tsx b/src/components/QueryState.tsx new file mode 100644 index 0000000..378f798 --- /dev/null +++ b/src/components/QueryState.tsx @@ -0,0 +1,25 @@ +import { AlertTriangle, Loader2 } from 'lucide-react' +import type { ReactNode } from 'react' +import { errorMessage } from '../lib/api' + +export function Loading({ label = 'Loading…' }: { label?: string }) { + return ( +
+ {label} +
+ ) +} + +export function ErrorMessage({ error, className = '' }: { error: unknown; className?: string }) { + if (!error) return null + return ( +
+ + {errorMessage(error)} +
+ ) +} + +export function Empty({ children }: { children: ReactNode }) { + return
{children}
+} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 98ee66d..1bb63e0 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,7 +1,9 @@ -import { ChevronDown, FileText, Info, Plus, Settings, ShieldCheck, X } from 'lucide-react' -import { NavLink } from 'react-router-dom' +import { Info, LogOut, Plus, Settings, ShieldCheck, X } from 'lucide-react' +import { NavLink, useNavigate } from 'react-router-dom' +import { initials } from '../lib/initials' +import { roleLabel } from '../lib/labels' import { primaryNav } from '../lib/navigation' -import { currentUser, workspace } from '../lib/seed' +import { useLogout, type Me } from '../lib/queries' import { Eyebrow } from './Eyebrow' import { iconButton } from './styles' @@ -10,22 +12,25 @@ const navLink = ({ isActive }: { isActive: boolean }) => const navSection = 'mb-6 border-b border-forest-700 pb-[23px]' interface SidebarProps { + me: Me open: boolean onClose: () => void - openIncidentCount: number + badges: Partial> } -export function Sidebar({ open, onClose, openIncidentCount }: SidebarProps) { +export function Sidebar({ me, open, onClose, badges }: SidebarProps) { + const logout = useLogout() + const navigate = useNavigate() return ( diff --git a/src/components/incident/ActionsSection.tsx b/src/components/incident/ActionsSection.tsx new file mode 100644 index 0000000..eb25780 --- /dev/null +++ b/src/components/incident/ActionsSection.tsx @@ -0,0 +1,159 @@ +import { Plus } from 'lucide-react' +import { useState, type FormEvent } from 'react' +import { useOutletContext } from 'react-router-dom' +import type { ActionKind, ActionStatus, CorrectiveAction, IncidentDetail } from '../../../shared/api-types.ts' +import { actionKinds } from '../../../shared/enums.ts' +import { formatDay, toIsoDate } from '../../lib/dates' +import { actionStatusLabel } from '../../lib/labels' +import { statusOptions } from '../../lib/permissions' +import { useAssignableUsers, useIncidentMutations, type Me } from '../../lib/queries' +import { ActionStatusBadge, OverdueBadge } from '../Badges' +import { Field } from '../FormSection' +import { ErrorMessage } from '../QueryState' +import { field, primaryButton, secondaryButton, smallButton } from '../styles' +import { Section } from './Section' + +export function ActionStatusControl({ action, onChange, disabled }: { action: CorrectiveAction; onChange: (status: ActionStatus) => void; disabled?: boolean }) { + const me = useOutletContext().user + const options = statusOptions(action, me) + if (options.length === 0) return + return ( + + ) +} + +export function ActionsSection({ incident }: { incident: IncidentDetail }) { + const { updateAction } = useIncidentMutations(incident.number) + const [adding, setAdding] = useState(false) + const canAdd = incident.can.manageActions && incident.status !== 'closed' + + return ( +
setAdding(true)}> + Add action + + ) + } + > + {adding && setAdding(false)} />} + {incident.actions.length === 0 ? ( + !adding &&

No corrective actions yet.

+ ) : ( +
    + {incident.actions.map((action) => { + const cause = incident.rootCauses.find((c) => c.id === action.rootCauseId) + return ( +
  • +
    +

    + {action.title} + {action.kind === 'preventive' && Preventive} +

    + {action.description &&

    {action.description}

    } +

    + {action.assignee ? `Assigned to ${action.assignee.name}` : 'Unassigned'} + {action.dueDate && ` · Due ${formatDay(action.dueDate)}`} + {cause && ` · Addresses “${cause.description}”`} + {action.verifiedBy && ` · Verified by ${action.verifiedBy.name}`} +

    +
    +
    + {action.overdue && } + updateAction.mutate({ id: action.id, status })} /> +
    +
  • + ) + })} +
+ )} + +
+ ) +} + +function ActionForm({ incident, onDone }: { incident: IncidentDetail; onDone: () => void }) { + const { addAction } = useIncidentMutations(incident.number) + const users = useAssignableUsers() + const [form, setForm] = useState({ title: '', description: '', kind: 'corrective' as ActionKind, assigneeId: '', dueDate: '', rootCauseId: '' }) + + const submit = (event: FormEvent) => { + event.preventDefault() + addAction.mutate( + { ...form, assigneeId: form.assigneeId || null, dueDate: form.dueDate || null, rootCauseId: form.rootCauseId || null, description: form.description || null }, + { onSuccess: onDone }, + ) + } + + return ( +
+ + setForm({ ...form, title: event.target.value })} placeholder="e.g. Add guard refit step to maintenance checklist" /> + + +
Severity Location StatusDate - Actions - Occurred
- + -
- {incident.title} +
+ event.stopPropagation()} + > + {incident.title} + - {incident.id} · {incident.reporter} + {incident.reference} · {incident.anonymous ? 'Anonymous' : (incident.reporter?.name ?? 'Unknown')}
{incident.type}{incidentTypeLabel[incident.type]} - - - {incident.severity} - + -
- - {incident.location} - {incident.building} +
+ + {incident.area?.name ?? incident.site.name} + {incident.locationDetail || incident.site.name}
- {incident.status} - {formatDate(incident.date)} - + {formatDate(incident.occurredAt)}