feat: implement incident reporting and management system
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s

- Add incident reporting routes and logic to handle creation, updating, and retrieval of incidents.
- Implement visibility rules for users based on roles (reporters, supervisors).
- Create tests for incident reporting functionality to ensure correctness and reliability.
- Introduce site and area management routes, including validation for area hierarchy.
- Establish user management routes with role-based access control for creating and updating users.
- Add setup routes for initializing the application with an organization and admin user.
- Implement shared enums and schemas for consistent validation and data handling across the application.
- Create a test helper for setting up an in-memory database and API client for testing.
- Add TypeScript configuration for server-side code with strict type checking and module resolution.
This commit is contained in:
jayjennings committed 2026-10-06 10:51:18 -07:00
1 parent a382fa904a
commit c69eccb5c1
47 files changed
+6297 -45

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
data
.git
.env
test-results
playwright-report
coverage
+15
View File
@@ -0,0 +1,15 @@
# Copy to .env and adjust. All settings are optional for local development.
# Database. Defaults to embedded PostgreSQL (PGlite) stored in ./data/pglite.
# DATABASE_URL=postgres://safetylog:password@localhost:5432/safetylog
# DATABASE_URL=pglite://./data/pglite
# Port for the API server (and the web app in production).
# PORT=3000
# The URL people open in their browser. Use https:// in production so session cookies are marked Secure.
# PUBLIC_URL=http://localhost:3000
# Docker Compose only: password for the bundled PostgreSQL container.
# Use letters and numbers only (it is embedded in a connection URL).
# POSTGRES_PASSWORD=change-me
+4
View File
@@ -31,3 +31,7 @@ coverage
test-results test-results
playwright-report playwright-report
blob-report blob-report
# Local data and config
data
.env
+1 -1
View File
@@ -36,7 +36,7 @@ This Code of Conduct applies within all community spaces, and also applies when
## Enforcement ## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [CONTACT EMAIL TO BE ADDED — see docs/TODO.md]. All complaints will be reviewed and investigated promptly and fairly. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at jay@equineform.com. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident. All community leaders are obligated to respect the privacy and security of the reporter of any incident.
+7 -3
View File
@@ -12,12 +12,13 @@ Everyone taking part is expected to follow our [Code of Conduct](CODE_OF_CONDUCT
## Development setup ## Development setup
Requires Node.js 22.12 or later (see `.nvmrc`). Requires Node.js 22.18 or later (see `.nvmrc`). No database install needed: development uses embedded PostgreSQL in `./data/`.
```sh ```sh
npm install npm install
npx playwright install chromium # once, for end-to-end tests npx playwright install chromium # once, for end-to-end tests
npm run dev npm run db:seed # optional demo data
npm run dev # web app :5173, API :3000
``` ```
Before opening a pull request, run: Before opening a pull request, run:
@@ -32,7 +33,10 @@ CI runs the same checks on every pull request.
## Code guidelines ## Code guidelines
- **TypeScript** everywhere; avoid `any`. - **TypeScript** everywhere; avoid `any`.
- **Structure:** routes go in `src/pages/`, reusable UI in `src/components/`, and types and domain logic in `src/lib/` (no React components there). - **Structure:** routes go in `src/pages/`, reusable UI in `src/components/`, and types and domain logic in `src/lib/` (no React components there). API routes go in `server/routes/`, one file per resource. Enums and request schemas used by both sides live in `shared/`.
- **Server code runs without a build step** (Node strips the types), so use only erasable TypeScript: no `enum`, `namespace` or constructor parameter properties, and include the `.ts` extension in relative imports. `npm run typecheck` enforces this.
- **Database changes:** edit `server/db/schema.ts`, then run `npm run db:generate` and commit the generated migration. Migrations run automatically when the server starts.
- **Permissions:** every API route must check the signed-in user's role and organization. Add a test showing a lower role is refused.
- **Styling:** Tailwind CSS. Use the theme tokens in `src/index.css` (`text-ink`, `bg-coral`, `border-line`, …) rather than new hex values; add a token if a color is reused. - **Styling:** Tailwind CSS. Use the theme tokens in `src/index.css` (`text-ink`, `bg-coral`, `border-line`, …) rather than new hex values; add a token if a color is reused.
- **Tests:** put domain logic in `src/lib/` with a unit test next to it (`*.test.ts`). Cover user-facing flows with a component test or a Playwright test in `e2e/`. - **Tests:** put domain logic in `src/lib/` with a unit test next to it (`*.test.ts`). Cover user-facing flows with a component test or a Playwright test in `e2e/`.
- **Accessibility:** every form control needs a label, and every icon-only button needs an `aria-label`. Shop-floor users may be on gloves-on touchscreens, so keep touch targets generous. - **Accessibility:** every form control needs a label, and every icon-only button needs an `aria-label`. Shop-floor users may be on gloves-on touchscreens, so keep touch targets generous.
+27
View File
@@ -0,0 +1,27 @@
# syntax=docker/dockerfile:1
# --- Build the web app ---
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# --- Runtime: API server + built web app ---
FROM node:24-alpine
ENV NODE_ENV=production \
PORT=3000 \
DATABASE_URL=pglite:///app/data/pglite
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY server ./server
COPY shared ./shared
COPY --from=build /app/dist ./dist
RUN mkdir -p /app/data && chown node:node /app/data
USER node
VOLUME /app/data
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
CMD ["node", "server/index.ts"]
+31 -7
View File
@@ -2,26 +2,45 @@
Free, open-source accident and safety tracking for manufacturing: incident and near-miss reporting, investigations, corrective actions and (planned) OSHA record keeping. Free, open-source accident and safety tracking for manufacturing: incident and near-miss reporting, investigations, corrective actions and (planned) OSHA record keeping.
> **Status: early prototype.** The UI runs on in-memory demo data; nothing is saved yet. See [docs/ROADMAP.md](docs/ROADMAP.md) for the plan and [docs/TODO.md](docs/TODO.md) for open items. > **Status: early development.** 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.
## Getting started ## Getting started
Requires Node.js 22.12 or later (see `.nvmrc`). Requires Node.js 22.18 or later (see `.nvmrc`). No database install is needed for development: the server uses embedded PostgreSQL (PGlite) stored in `./data/`.
```sh ```sh
npm install npm install
npm run dev npm run db:seed # optional: demo organization, sites, users and incidents
npm run dev # web app on http://localhost:5173, API on http://localhost:3000
``` ```
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)).
Settings are read from environment variables or a `.env` file; see [.env.example](.env.example).
## Running in production
```sh
cp .env.example .env # set POSTGRES_PASSWORD, and PUBLIC_URL if not localhost
docker compose up -d --build
```
This runs the app and PostgreSQL. The app applies database migrations on startup and serves the web app and API on port 3000. Use HTTPS (for example behind a reverse proxy) and set `PUBLIC_URL` to the `https://` address so session cookies are marked secure.
For a very small single-machine install, the app image also runs on its own with embedded PostgreSQL: `docker run -p 3000:3000 -v safetylog-data:/app/data <image>`.
## Scripts ## Scripts
| Command | What it does | | Command | What it does |
| --- | --- | | --- | --- |
| `npm run dev` | Start the dev server | | `npm run dev` | Start the web app and API together with auto-reload |
| `npm start` | Start the API server (serves `dist/` too when built) |
| `npm run build` | Production build to `dist/` | | `npm run build` | Production build to `dist/` |
| `npm run lint` | Lint with Oxlint (warnings fail) | | `npm run lint` | Lint with Oxlint (warnings fail) |
| `npm run typecheck` | Type-check with TypeScript | | `npm run typecheck` | Type-check with TypeScript |
| `npm test` | Unit and component tests (Vitest) | | `npm test` | Web and server tests (Vitest; server tests use an in-memory database) |
| `npm run db:generate` | Create a migration after changing `server/db/schema.ts` |
| `npm run db:seed` | Load demo data into an empty database |
| `npm run test:e2e` | End-to-end tests (Playwright, desktop and mobile). Run `npx playwright install chromium` once first | | `npm run test:e2e` | End-to-end tests (Playwright, desktop and mobile). Run `npx playwright install chromium` once first |
| `npm run check` | Lint, typecheck, test and build in one go | | `npm run check` | Lint, typecheck, test and build in one go |
@@ -30,13 +49,18 @@ CI runs the same checks on every push and pull request (`.gitea/workflows/ci.yml
## Project layout ## Project layout
``` ```
src/ src/ Web app (React)
pages/ One component per route pages/ One component per route
components/ Shared UI (layout, tables, form pieces) and shared class strings in styles.ts components/ Shared UI (layout, tables, form pieces) and shared class strings in styles.ts
lib/ Types, domain logic, demo data; no React components lib/ Types, domain logic, demo data; no React components
index.css Tailwind import and design tokens (@theme) index.css Tailwind import and design tokens (@theme)
server/ API (Hono on Node)
routes/ One file per resource, with its tests alongside
auth/ Passwords, sessions, permissions
db/ Drizzle schema, migrations, seed script
shared/ Enums and validation schemas used by both
e2e/ Playwright tests e2e/ Playwright tests
docs/ Roadmap and design notes docs/ Roadmap, to-do list, API reference
``` ```
Styling is Tailwind CSS v4. Colors, fonts and small text sizes are defined as theme tokens in `src/index.css`. Fonts are bundled through Fontsource, so the app makes no third-party requests and works on isolated plant networks. Styling is Tailwind CSS v4. Colors, fonts and small text sizes are defined as theme tokens in `src/index.css`. Fonts are bundled through Fontsource, so the app makes no third-party requests and works on isolated plant networks.
+34
View File
@@ -0,0 +1,34 @@
# Production setup: the app plus PostgreSQL.
# cp .env.example .env # set POSTGRES_PASSWORD (and PUBLIC_URL if not localhost)
# docker compose up -d
# Then open http://localhost:3000 and complete first-run setup.
services:
db:
image: postgres:18-alpine
environment:
POSTGRES_USER: safetylog
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
POSTGRES_DB: safetylog
volumes:
- db-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U safetylog -d safetylog"]
interval: 5s
retries: 10
restart: unless-stopped
app:
build: .
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgres://safetylog:${POSTGRES_PASSWORD}@db:5432/safetylog
PUBLIC_URL: ${PUBLIC_URL:-http://localhost:3000}
ports:
- "${PORT:-3000}:3000"
restart: unless-stopped
volumes:
db-data:
+77
View File
@@ -0,0 +1,77 @@
# API reference
JSON over HTTP under `/api`. Authentication uses a session cookie (`safetylog_session`, http-only) set by setup, sign-in and password change.
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 }]`.
Roles, from least to most privileged: `reporter` < `supervisor` < `safety_manager` < `admin`. A role can do everything the roles below it can.
## Setup and authentication
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| GET | `/api/health` | anyone | `{ ok: true }` |
| GET | `/api/setup` | anyone | `{ needsSetup }`: true until the first admin exists |
| POST | `/api/setup` | anyone, once | `{ organizationName, name, email, password }`. Creates the organization and an admin, signs them in. 409 afterwards |
| POST | `/api/auth/login` | anyone | `{ email, password }`. 401 on bad credentials, 429 after 10 attempts per email in 15 minutes |
| POST | `/api/auth/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.
## 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 |
## Sites and areas
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| GET | `/api/sites` | signed in | Sites with their `areas` (flat list; build the tree from `parentId`). `?includeArchived=true` to include archived |
| POST | `/api/sites` | safety_manager | `{ name, address?, timezone? }` |
| PATCH | `/api/sites/:id` | safety_manager | `{ name?, address?, timezone?, archived? }` |
| POST | `/api/sites/:id/areas` | safety_manager | `{ name, kind?, parentId? }`. `kind`: `building`, `area` (default), `line`, `other`. Parent must be in the same site |
| 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.
| 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 |
**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`.
**Create body:**
```json
{
"title": "Forklift reversed into pallet stack",
"type": "near_miss",
"severity": "high",
"occurredAt": "2026-10-06T08:15:00-05:00",
"siteId": "…",
"areaId": "…",
"locationDetail": "Loading bay C",
"description": "…",
"immediateAction": "Area cordoned off",
"anonymous": false
}
```
- `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.
Every create and update is recorded in the audit log (`audit_log` table) with who made it and what changed.
+18 -8
View File
@@ -10,8 +10,10 @@ This is a living document. Update it as decisions are made or scope changes.
| --- | --- | --- | | --- | --- | --- |
| License | AGPL-3.0-only | Decided | | License | AGPL-3.0-only | Decided |
| Frontend | React + Vite + TypeScript, Tailwind CSS v4 | Decided | | Frontend | React + Vite + TypeScript, Tailwind CSS v4 | Decided |
| Backend | Node (Fastify or Hono) + PostgreSQL + Drizzle ORM, shared Zod schemas | Proposed | | Backend | Node + Hono, PostgreSQL via Drizzle ORM, Zod schemas shared with the web app (`shared/`). Node runs the TypeScript directly (no build step for the server) | Decided |
| Deployment | Self-hosted, single `docker compose up` (app + Postgres + file storage) | Proposed | | Database | PostgreSQL in production; embedded PGlite for local development, tests and very small installs | Decided |
| Auth | Email + password (scrypt), server-side sessions in an http-only cookie, four roles: reporter < supervisor < safety manager < admin | Decided |
| Deployment | Self-hosted, single `docker compose up` (app + Postgres); file storage added with photo upload | Decided |
| Compliance scope | US OSHA first; data model kept extensible for other regions (e.g. RIDDOR) | Proposed | | Compliance scope | US OSHA first; data model kept extensible for other regions (e.g. RIDDOR) | Proposed |
| CI | Gitea Actions (`.gitea/workflows`): lint, typecheck, unit tests, build, e2e | Decided | | CI | Gitea Actions (`.gitea/workflows`): lint, typecheck, unit tests, build, e2e | Decided |
| Product name | Open SafetyLog EHS (replaced placeholder "IA Tracker" and the prototype's "Safesite" branding) | Decided | | Product name | Open SafetyLog EHS (replaced placeholder "IA Tracker" and the prototype's "Safesite" branding) | Decided |
@@ -32,13 +34,21 @@ This is a living document. Update it as decisions are made or scope changes.
## Phase 1: Backend and data model ## Phase 1: Backend and data model
- Entities: Organization, Site → Area (location tree), User, Role, Incident (type, severity, status, timestamps, reporter or anonymous), InjuredPerson and injury details, Attachment, Investigation, RootCause, CorrectiveAction, Comment, AuditLog, HoursWorked - [x] Database schema and first migration for all entities: Organization, Site → Area (location tree), User, Session, Incident, InjuredPerson, Attachment, Investigation, RootCause, CorrectiveAction, Comment, AuditLog, HoursWorked
- Auth: email/password and magic links; SSO/OIDC later; role-based permissions - [x] First-run setup (creates the organization and first admin)
- REST API with pagination, filtering and sequential incident numbering - [x] Email/password sign-in, sessions, change password, login rate limiting
- [x] Role-based permissions (reporters see only their own reports)
- [x] REST API: users, sites/areas, incidents with pagination, search, filters and gap-free sequential numbers (`INC-0001`); see [API.md](API.md)
- [x] Audit log of every change
- [x] Server tests (Vitest + in-memory PostgreSQL), demo seed data
- [x] Dockerfile and `docker-compose.yml`
- Moved to Phase 2: magic-link sign-in and password reset by email (both need the email sending built for notifications). SSO/OIDC stays "later".
## Phase 2: Core workflow (MVP) ## Phase 2: Core workflow (MVP)
- Reporting that saves to the database, with photo upload - 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) - 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 - Status workflow: Reported → Triage → Investigation → Corrective actions → Verification → Closed, with role-based transitions
- "My actions" list and overdue tracking - "My actions" list and overdue tracking
@@ -75,8 +85,8 @@ This is a living document. Update it as decisions are made or scope changes.
Inspections and audit checklists, job safety analysis (JSA), training and certification records, management of change, SDS links, public API and webhooks. Inspections and audit checklists, job safety analysis (JSA), training and certification records, management of change, SDS links, public API and webhooks.
## Known prototype gaps (to be resolved in Phases 1–2) ## Known prototype gaps (to be resolved in Phase 2)
- All data is in memory and resets on refresh - The web app still uses in-memory demo data; the API exists but isn't connected yet
- Dashboard, analytics and location figures are hardcoded - Dashboard, analytics and location figures are hardcoded
- Export, Add location, Settings, Resources, search, notifications and workspace switcher are non-functional - Export, Add location, Settings, Resources, search, notifications and workspace switcher are non-functional
+9 -1
View File
@@ -4,12 +4,20 @@ Open items that aren't tied to a single roadmap phase. Tick them off (or delete
## Project setup ## Project setup
- [ ] **Code of Conduct contact.** Replace the placeholder in [CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md) with a reporting email address, ideally a project alias rather than a personal one. - [ ] **Code of Conduct contact.** [CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md) uses `jay@equineform.com` for now. Change it to a project alias (e.g. `conduct@<project-domain>`) once the project has its own domain.
- [ ] **Source code link.** The About page links to `https://www.equineform.com` for now (default in `src/lib/config.ts`, overridable with `VITE_SOURCE_URL`). Change it to the public git repository once one exists, and use the same URL for issue links in CONTRIBUTING.md. - [ ] **Source code link.** The About page links to `https://www.equineform.com` for now (default in `src/lib/config.ts`, overridable with `VITE_SOURCE_URL`). Change it to the public git repository once one exists, and use the same URL for issue links in CONTRIBUTING.md.
- [ ] **CI on Gitea.** Confirm Actions is enabled for the repository and a runner is registered, then check the first run of `.gitea/workflows/ci.yml` passes. Keep the Playwright container tag in the workflow in sync with `@playwright/test`. - [ ] **CI on Gitea.** Confirm Actions is enabled for the repository and a runner is registered, then check the first run of `.gitea/workflows/ci.yml` passes. Keep the Playwright container tag in the workflow in sync with `@playwright/test`.
## Phase 1 follow-ups
- [ ] **Test against real PostgreSQL.** Server tests use PGlite (PostgreSQL compiled to WebAssembly). Add a CI job with a `postgres` service container that runs the server tests against real PostgreSQL too.
- [ ] **Docker build in CI.** Build the image in CI once a runner with Docker is available.
- [ ] **Shared login rate limit.** The login limiter is in memory, so it resets on restart and isn't shared between processes. Fine for one container; move it to the database before running several app instances.
## Done ## Done
- [x] Docker verified 2026-10-06 (Docker 29.8, Compose v5.5): image builds (~315 MB), `docker compose up` with PostgreSQL 18 passes setup → sign-in → report smoke test; data and sessions survive `down`/`up`; clean shutdown; healthcheck healthy; runs as non-root; standalone PGlite container works; `Secure` cookie with an `https://` PUBLIC_URL; web app loads with no errors.
- [x] Product name: **Open SafetyLog EHS** (decided 2026-10-06). Note: Insight Works sells a similarly named free "Safety Logbook" EHS app for Microsoft Business Central; run a USPTO trademark search before a public launch. - [x] Product name: **Open SafetyLog EHS** (decided 2026-10-06). Note: Insight Works sells a similarly named free "Safety Logbook" EHS app for Microsoft Business Central; run a USPTO trademark search before a public launch.
- [x] CONTRIBUTING.md and CODE_OF_CONDUCT.md (Contributor Covenant 2.1) - [x] CONTRIBUTING.md and CODE_OF_CONDUCT.md (Contributor Covenant 2.1)
- [x] About page with source-code link (AGPL-3.0 section 13) - [x] About page with source-code link (AGPL-3.0 section 13)
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
dialect: 'postgresql',
schema: './server/db/schema.ts',
out: './server/db/migrations',
casing: 'snake_case',
})
+2200 -9
View File
File diff suppressed because it is too large. Load diff
+25 -10
View File
@@ -6,10 +6,13 @@
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.12" "node": ">=22.18"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "concurrently -k -n web,api -c cyan,magenta \"npm:dev:web\" \"npm:dev:server\"",
"dev:web": "vite",
"dev:server": "node --watch --env-file-if-exists=.env server/index.ts",
"start": "node server/index.ts",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "oxlint --deny-warnings", "lint": "oxlint --deny-warnings",
@@ -17,30 +20,42 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"db:generate": "drizzle-kit generate",
"db:seed": "node --env-file-if-exists=.env server/db/seed.ts",
"check": "npm run lint && npm run typecheck && npm test && npm run build" "check": "npm run lint && npm run typecheck && npm test && npm run build"
}, },
"dependencies": { "dependencies": {
"@electric-sql/pglite": "^0.5.8",
"@hono/node-server": "^2.1.3",
"@hono/zod-validator": "^0.9.1",
"drizzle-orm": "^0.45.3",
"hono": "^4.13.13",
"pg": "^8.23.1",
"zod": "^4.6.5"
},
"devDependencies": {
"@fontsource-variable/dm-sans": "^5.3.0", "@fontsource-variable/dm-sans": "^5.3.0",
"@fontsource-variable/space-grotesk": "^5.3.0", "@fontsource-variable/space-grotesk": "^5.3.0",
"@fontsource/dm-mono": "^5.3.0", "@fontsource/dm-mono": "^5.3.0",
"@tailwindcss/vite": "^4.3.3",
"lucide-react": "^1.39.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.3",
"tailwindcss": "^4.3.3"
},
"devDependencies": {
"@playwright/test": "^1.63.0", "@playwright/test": "^1.63.0",
"@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^7.0.1", "@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3", "@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7", "@testing-library/user-event": "^14.6.7",
"@types/node": "^26.6.4", "@types/node": "^26.6.4",
"@types/pg": "^8.23.1",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.1.0", "@vitejs/plugin-react": "^6.1.0",
"concurrently": "^10.0.5",
"drizzle-kit": "^0.31.11",
"jsdom": "^30.1.2", "jsdom": "^30.1.2",
"lucide-react": "^1.39.0",
"oxlint": "^1.79.0", "oxlint": "^1.79.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.3",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vite": "^8.2.2", "vite": "^8.2.2",
"vitest": "^5.0.3" "vitest": "^5.0.3"
+45
View File
@@ -0,0 +1,45 @@
import { Hono } from 'hono'
import { csrf } from 'hono/csrf'
import { HTTPException } from 'hono/http-exception'
import { secureHeaders } from 'hono/secure-headers'
import { loadSession } from './auth/middleware.ts'
import type { Config } from './config.ts'
import type { Db } from './db/client.ts'
import type { AppEnv } from './lib/http.ts'
import { authRoutes } from './routes/auth.ts'
import { incidentRoutes } from './routes/incidents.ts'
import { setupRoutes } from './routes/setup.ts'
import { areaRoutes, siteRoutes } from './routes/sites.ts'
import { userRoutes } from './routes/users.ts'
export function createApp({ db, config }: { db: Db; config: Config }) {
const app = new Hono<AppEnv>()
app.use('*', secureHeaders())
app.use('/api/*', csrf(), async (c, next) => {
c.set('db', db)
c.set('config', config)
c.header('Cache-Control', 'no-store')
await next()
})
app.use('/api/*', loadSession)
app.get('/api/health', (c) => c.json({ ok: true }))
app.route('/api/setup', setupRoutes)
app.route('/api/auth', authRoutes())
app.route('/api/users', userRoutes)
app.route('/api/sites', siteRoutes)
app.route('/api/areas', areaRoutes)
app.route('/api/incidents', incidentRoutes)
app.all('/api/*', (c) => c.json({ error: 'Not found' }, 404))
app.onError((error, c) => {
if (error instanceof HTTPException) return c.json({ error: error.message }, error.status)
console.error(error)
return c.json({ error: 'Internal server error' }, 500)
})
return app
}
export type App = ReturnType<typeof createApp>
+42
View File
@@ -0,0 +1,42 @@
import type { Context, MiddlewareHandler } from 'hono'
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
import { hasRole, type Role } from '../../shared/enums.ts'
import { currentUser, fail, type AppEnv } from '../lib/http.ts'
import { createSession, deleteSession, SESSION_COOKIE, validateSession } from './sessions.ts'
function writeCookie(c: Context<AppEnv>, token: string, expires: Date) {
setCookie(c, SESSION_COOKIE, token, { httpOnly: true, sameSite: 'Lax', secure: c.get('config').secureCookies, path: '/', expires })
}
/** Loads the user from the session cookie, if any. */
export const loadSession: MiddlewareHandler<AppEnv> = async (c, next) => {
const token = getCookie(c, SESSION_COOKIE)
const session = token ? await validateSession(c.get('db'), token) : null
if (token && !session) deleteCookie(c, SESSION_COOKIE, { path: '/' })
if (token && session?.renewedUntil) writeCookie(c, token, session.renewedUntil)
c.set('user', session?.user ?? null)
await next()
}
export const requireAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
currentUser(c)
await next()
}
export function requireRole(minimum: Role): MiddlewareHandler<AppEnv> {
return async (c, next) => {
if (!hasRole(currentUser(c).role, minimum)) throw fail(403, 'You do not have permission to do that')
await next()
}
}
export async function startSession(c: Context<AppEnv>, userId: string): Promise<void> {
const { token, expiresAt } = await createSession(c.get('db'), userId)
writeCookie(c, token, expiresAt)
}
export async function endSession(c: Context<AppEnv>): Promise<void> {
const token = getCookie(c, SESSION_COOKIE)
if (token) await deleteSession(c.get('db'), token)
deleteCookie(c, SESSION_COOKIE, { path: '/' })
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { hashPassword, verifyPassword } from './password.ts'
describe('password hashing', () => {
it('verifies the right password and rejects the wrong one', async () => {
const hash = await hashPassword('correct horse battery')
expect(hash).toMatch(/^scrypt\$32768\$8\$3\$/)
expect(await verifyPassword('correct horse battery', hash)).toBe(true)
expect(await verifyPassword('wrong horse battery', hash)).toBe(false)
})
it('salts each hash', async () => {
expect(await hashPassword('same password')).not.toBe(await hashPassword('same password'))
})
it('rejects malformed hashes', async () => {
expect(await verifyPassword('anything', 'not-a-hash')).toBe(false)
})
})
+34
View File
@@ -0,0 +1,34 @@
import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from 'node:crypto'
// scrypt parameters from the OWASP Password Storage Cheat Sheet (N=2^15, r=8, p=3: ~32 MiB per hash).
const params = { N: 2 ** 15, r: 8, p: 3 }
const keyLength = 64
function derive(password: string, salt: Buffer, options: ScryptOptions): Promise<Buffer> {
return new Promise((resolve, reject) =>
scrypt(password.normalize('NFKC'), salt, keyLength, { ...options, maxmem: 64 * 1024 * 1024 }, (error, key) => (error ? reject(error) : resolve(key))),
)
}
/** Returns `scrypt$N$r$p$salt$hash` so parameters can be raised later without breaking old hashes. */
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16)
const key = await derive(password, salt, params)
return ['scrypt', params.N, params.r, params.p, salt.toString('base64'), key.toString('base64')].join('$')
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const [algorithm, N, r, p, salt, hash] = stored.split('$')
if (algorithm !== 'scrypt' || !salt || !hash) return false
const expected = Buffer.from(hash, 'base64')
const actual = await derive(password, Buffer.from(salt, 'base64'), { N: Number(N), r: Number(r), p: Number(p) })
return actual.length === expected.length && timingSafeEqual(actual, expected)
}
/** Burns comparable time when the user doesn't exist, so login timing doesn't reveal valid emails. */
let dummyHash: Promise<string> | undefined
export async function verifyAgainstDummy(password: string): Promise<false> {
dummyHash ??= hashPassword('dummy-password-for-timing')
await verifyPassword(password, await dummyHash)
return false
}
+31
View File
@@ -0,0 +1,31 @@
/** Fixed-window in-memory limiter. Fine for a single server process; move to the database if we scale out. */
export class RateLimiter {
private hits = new Map<string, { count: number; resetAt: number }>()
private readonly limit: number
private readonly windowMs: number
constructor(limit: number, windowMs: number) {
this.limit = limit
this.windowMs = windowMs
}
/** Records an attempt; returns false once the key has exceeded the limit. */
attempt(key: string, now = Date.now()): boolean {
const entry = this.hits.get(key)
if (!entry || entry.resetAt <= now) {
this.hits.set(key, { count: 1, resetAt: now + this.windowMs })
if (this.hits.size > 10_000) this.prune(now)
return true
}
entry.count += 1
return entry.count <= this.limit
}
reset(key: string): void {
this.hits.delete(key)
}
private prune(now: number) {
for (const [key, entry] of this.hits) if (entry.resetAt <= now) this.hits.delete(key)
}
}
+54
View File
@@ -0,0 +1,54 @@
import { and, eq, gt } from 'drizzle-orm'
import { createHash, randomBytes } from 'node:crypto'
import type { Role } from '../../shared/enums.ts'
import type { Db } from '../db/client.ts'
import { sessions, users } from '../db/schema.ts'
export const SESSION_COOKIE = 'safetylog_session'
export const SESSION_TTL_MS = 14 * 24 * 60 * 60 * 1000
export interface SessionUser {
id: string
organizationId: string
email: string
name: string
role: Role
}
const hashToken = (token: string) => createHash('sha256').update(token).digest('hex')
export async function createSession(db: Db, userId: string): Promise<{ token: string; expiresAt: Date }> {
const token = randomBytes(32).toString('base64url')
const expiresAt = new Date(Date.now() + SESSION_TTL_MS)
await db.insert(sessions).values({ id: hashToken(token), userId, expiresAt })
return { token, expiresAt }
}
/** Returns the session's user, extending the session once it is past half its lifetime. */
export async function validateSession(db: Db, token: string): Promise<{ user: SessionUser; renewedUntil?: Date } | null> {
const id = hashToken(token)
const [row] = await db
.select({
expiresAt: sessions.expiresAt,
user: { id: users.id, organizationId: users.organizationId, email: users.email, name: users.name, role: users.role },
})
.from(sessions)
.innerJoin(users, eq(users.id, sessions.userId))
.where(and(eq(sessions.id, id), gt(sessions.expiresAt, new Date()), eq(users.active, true)))
if (!row) return null
if (row.expiresAt.getTime() - Date.now() < SESSION_TTL_MS / 2) {
const renewedUntil = new Date(Date.now() + SESSION_TTL_MS)
await db.update(sessions).set({ expiresAt: renewedUntil }).where(eq(sessions.id, id))
return { user: row.user, renewedUntil }
}
return { user: row.user }
}
export async function deleteSession(db: Db, token: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.id, hashToken(token)))
}
export async function deleteUserSessions(db: Db, userId: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.userId, userId))
}
+22
View File
@@ -0,0 +1,22 @@
export interface Config {
databaseUrl: string
port: number
/** Public URL users open in their browser; HTTPS here turns on secure cookies. */
publicUrl: string
secureCookies: boolean
production: boolean
/** Built web app to serve, if present. */
staticDir: string
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
const publicUrl = env.PUBLIC_URL ?? `http://localhost:${env.PORT ?? 3000}`
return {
databaseUrl: env.DATABASE_URL ?? 'pglite://./data/pglite',
port: Number(env.PORT ?? 3000),
publicUrl,
secureCookies: publicUrl.startsWith('https://'),
production: env.NODE_ENV === 'production',
staticDir: env.STATIC_DIR ?? 'dist',
}
}
+55
View File
@@ -0,0 +1,55 @@
import { PGlite } from '@electric-sql/pglite'
import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres'
import { migrate as migratePg } from 'drizzle-orm/node-postgres/migrator'
import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core'
import { drizzle as drizzlePglite } from 'drizzle-orm/pglite'
import { migrate as migratePglite } from 'drizzle-orm/pglite/migrator'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import pg from 'pg'
import * as schema from './schema.ts'
export type Db = PgDatabase<PgQueryResultHKT, typeof schema>
export interface Database {
db: Db
close(): Promise<void>
}
const migrationsFolder = join(import.meta.dirname, 'migrations')
/**
* Opens the database and applies pending migrations.
*
* - `postgres://…` connects to PostgreSQL (production).
* - `pglite://<dir>` uses embedded PostgreSQL stored in `<dir>` (local development, very small installs).
* - `memory://` uses a throwaway in-memory database (tests).
*/
export async function openDatabase(url: string): Promise<Database> {
if (/^postgres(ql)?:\/\//.test(url)) {
const pool = new pg.Pool({ connectionString: url })
const db = drizzlePg({ client: pool, schema, casing: 'snake_case' })
await migratePg(db, { migrationsFolder })
return { db: db as unknown as Db, close: () => pool.end() }
}
if (url !== 'memory://' && !url.startsWith('pglite://')) {
throw new Error(`Unsupported DATABASE_URL: expected postgres://, pglite:// or memory://`)
}
const dataDir = url === 'memory://' ? undefined : url.slice('pglite://'.length)
if (dataDir) mkdirSync(dataDir, { recursive: true })
const client = new PGlite(dataDir)
const db = drizzlePglite({ client, schema, casing: 'snake_case' })
await migratePglite(db, { migrationsFolder })
return { db: db as unknown as Db, close: () => client.close() }
}
/** True for a unique-constraint violation from either driver (Drizzle wraps driver errors in `cause`). */
export function isUniqueViolation(error: unknown): boolean {
let current: unknown = error
for (let depth = 0; current && typeof current === 'object' && depth < 5; depth++) {
if ((current as { code?: unknown }).code === '23505') return true
current = (current as { cause?: unknown }).cause
}
return false
}
+225
View File
@@ -0,0 +1,225 @@
CREATE TYPE "public"."action_kind" AS ENUM('corrective', 'preventive');--> statement-breakpoint
CREATE TYPE "public"."action_status" AS ENUM('open', 'in_progress', 'done', 'verified', 'cancelled');--> statement-breakpoint
CREATE TYPE "public"."area_kind" AS ENUM('building', 'area', 'line', 'other');--> statement-breakpoint
CREATE TYPE "public"."employment_type" AS ENUM('employee', 'temporary', 'contractor', 'visitor');--> statement-breakpoint
CREATE TYPE "public"."incident_status" AS ENUM('reported', 'triage', 'investigation', 'actions', 'verification', 'closed');--> statement-breakpoint
CREATE TYPE "public"."incident_type" AS ENUM('near_miss', 'hazard', 'injury', 'illness', 'property_damage', 'environmental');--> statement-breakpoint
CREATE TYPE "public"."rca_method" AS ENUM('five_whys', 'fishbone', 'other');--> statement-breakpoint
CREATE TYPE "public"."role" AS ENUM('reporter', 'supervisor', 'safety_manager', 'admin');--> statement-breakpoint
CREATE TYPE "public"."root_cause_category" AS ENUM('people', 'process', 'equipment', 'materials', 'environment', 'management');--> statement-breakpoint
CREATE TYPE "public"."severity" AS ENUM('low', 'medium', 'high', 'critical');--> statement-breakpoint
CREATE TYPE "public"."treatment_level" AS ENUM('none', 'first_aid', 'medical_treatment', 'hospitalized', 'fatality');--> statement-breakpoint
CREATE TABLE "areas" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"site_id" uuid NOT NULL,
"parent_id" uuid,
"name" text NOT NULL,
"kind" "area_kind" DEFAULT 'area' NOT NULL,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "attachments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"incident_id" uuid NOT NULL,
"file_name" text NOT NULL,
"content_type" text NOT NULL,
"size_bytes" integer NOT NULL,
"storage_key" text NOT NULL,
"uploaded_by_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "attachments_storageKey_unique" UNIQUE("storage_key")
);
--> statement-breakpoint
CREATE TABLE "audit_log" (
"id" bigserial PRIMARY KEY NOT NULL,
"organization_id" uuid NOT NULL,
"actor_id" uuid,
"entity_type" text NOT NULL,
"entity_id" text NOT NULL,
"action" text NOT NULL,
"changes" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "comments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"incident_id" uuid NOT NULL,
"author_id" uuid,
"body" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "corrective_actions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"incident_id" uuid NOT NULL,
"root_cause_id" uuid,
"kind" "action_kind" DEFAULT 'corrective' NOT NULL,
"title" text NOT NULL,
"description" text,
"assignee_id" uuid,
"due_date" date,
"status" "action_status" DEFAULT 'open' NOT NULL,
"completed_at" timestamp with time zone,
"verified_by_id" uuid,
"verified_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "hours_worked" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"site_id" uuid NOT NULL,
"month" date NOT NULL,
"hours" numeric(12, 2) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "incident_counters" (
"organization_id" uuid PRIMARY KEY NOT NULL,
"last_number" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "incidents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"number" integer NOT NULL,
"title" text NOT NULL,
"type" "incident_type" NOT NULL,
"severity" "severity" NOT NULL,
"status" "incident_status" DEFAULT 'reported' NOT NULL,
"occurred_at" timestamp with time zone NOT NULL,
"site_id" uuid NOT NULL,
"area_id" uuid,
"location_detail" text,
"description" text,
"immediate_action" text,
"reporter_id" uuid,
"anonymous" boolean DEFAULT false NOT NULL,
"closed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "injured_persons" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"incident_id" uuid NOT NULL,
"user_id" uuid,
"name" text NOT NULL,
"job_title" text,
"employment_type" "employment_type" DEFAULT 'employee' NOT NULL,
"body_part" text,
"nature_of_injury" text,
"treatment" "treatment_level" DEFAULT 'none' NOT NULL,
"days_away" integer DEFAULT 0 NOT NULL,
"days_restricted" integer DEFAULT 0 NOT NULL,
"privacy_case" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "investigations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"incident_id" uuid NOT NULL,
"lead_id" uuid,
"method" "rca_method" DEFAULT 'five_whys' NOT NULL,
"summary" text,
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "investigations_incidentId_unique" UNIQUE("incident_id")
);
--> statement-breakpoint
CREATE TABLE "organizations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "root_causes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"investigation_id" uuid NOT NULL,
"category" "root_cause_category" NOT NULL,
"description" text NOT NULL,
"whys" jsonb DEFAULT '[]'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" text PRIMARY KEY NOT NULL,
"user_id" uuid NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sites" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"name" text NOT NULL,
"address" text,
"timezone" text DEFAULT 'UTC' NOT NULL,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"email" text NOT NULL,
"name" text NOT NULL,
"role" "role" DEFAULT 'reporter' NOT NULL,
"password_hash" text,
"active" boolean DEFAULT true NOT NULL,
"last_login_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "areas" ADD CONSTRAINT "areas_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "areas" ADD CONSTRAINT "areas_parent_id_areas_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."areas"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_incident_id_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "public"."incidents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_uploaded_by_id_users_id_fk" FOREIGN KEY ("uploaded_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_incident_id_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "public"."incidents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "corrective_actions" ADD CONSTRAINT "corrective_actions_incident_id_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "public"."incidents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "corrective_actions" ADD CONSTRAINT "corrective_actions_root_cause_id_root_causes_id_fk" FOREIGN KEY ("root_cause_id") REFERENCES "public"."root_causes"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "corrective_actions" ADD CONSTRAINT "corrective_actions_assignee_id_users_id_fk" FOREIGN KEY ("assignee_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "corrective_actions" ADD CONSTRAINT "corrective_actions_verified_by_id_users_id_fk" FOREIGN KEY ("verified_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hours_worked" ADD CONSTRAINT "hours_worked_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "incident_counters" ADD CONSTRAINT "incident_counters_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "incidents" ADD CONSTRAINT "incidents_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "incidents" ADD CONSTRAINT "incidents_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "incidents" ADD CONSTRAINT "incidents_area_id_areas_id_fk" FOREIGN KEY ("area_id") REFERENCES "public"."areas"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "incidents" ADD CONSTRAINT "incidents_reporter_id_users_id_fk" FOREIGN KEY ("reporter_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD CONSTRAINT "injured_persons_incident_id_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "public"."incidents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD CONSTRAINT "injured_persons_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "investigations" ADD CONSTRAINT "investigations_incident_id_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "public"."incidents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "investigations" ADD CONSTRAINT "investigations_lead_id_users_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "root_causes" ADD CONSTRAINT "root_causes_investigation_id_investigations_id_fk" FOREIGN KEY ("investigation_id") REFERENCES "public"."investigations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sites" ADD CONSTRAINT "sites_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "users" ADD CONSTRAINT "users_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "areas_site_idx" ON "areas" USING btree ("site_id");--> statement-breakpoint
CREATE INDEX "areas_parent_idx" ON "areas" USING btree ("parent_id");--> statement-breakpoint
CREATE INDEX "attachments_incident_idx" ON "attachments" USING btree ("incident_id");--> statement-breakpoint
CREATE INDEX "audit_log_entity_idx" ON "audit_log" USING btree ("organization_id","entity_type","entity_id");--> statement-breakpoint
CREATE INDEX "comments_incident_idx" ON "comments" USING btree ("incident_id");--> statement-breakpoint
CREATE INDEX "corrective_actions_incident_idx" ON "corrective_actions" USING btree ("incident_id");--> statement-breakpoint
CREATE INDEX "corrective_actions_assignee_status_idx" ON "corrective_actions" USING btree ("assignee_id","status");--> statement-breakpoint
CREATE UNIQUE INDEX "hours_worked_site_month_unique" ON "hours_worked" USING btree ("site_id","month");--> statement-breakpoint
CREATE UNIQUE INDEX "incidents_organization_number_unique" ON "incidents" USING btree ("organization_id","number");--> statement-breakpoint
CREATE INDEX "incidents_organization_occurred_idx" ON "incidents" USING btree ("organization_id","occurred_at");--> statement-breakpoint
CREATE INDEX "incidents_organization_status_idx" ON "incidents" USING btree ("organization_id","status");--> statement-breakpoint
CREATE INDEX "incidents_reporter_idx" ON "incidents" USING btree ("reporter_id");--> statement-breakpoint
CREATE INDEX "injured_persons_incident_idx" ON "injured_persons" USING btree ("incident_id");--> statement-breakpoint
CREATE INDEX "root_causes_investigation_idx" ON "root_causes" USING btree ("investigation_id");--> statement-breakpoint
CREATE INDEX "sessions_user_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "sites_organization_name_unique" ON "sites" USING btree ("organization_id","name");--> statement-breakpoint
CREATE UNIQUE INDEX "users_email_unique" ON "users" USING btree ("email");--> statement-breakpoint
CREATE INDEX "users_organization_idx" ON "users" USING btree ("organization_id");
File diff suppressed because it is too large. Load diff
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1791306770507,
"tag": "0000_initial",
"breakpoints": true
}
]
}
+286
View File
@@ -0,0 +1,286 @@
import { bigserial, boolean, date, index, integer, jsonb, numeric, pgEnum, pgTable, text, timestamp, uniqueIndex, uuid, type AnyPgColumn } from 'drizzle-orm/pg-core'
import * as vocab from '../../shared/enums.ts'
export const roleEnum = pgEnum('role', vocab.roles)
export const incidentTypeEnum = pgEnum('incident_type', vocab.incidentTypes)
export const severityEnum = pgEnum('severity', vocab.severities)
export const incidentStatusEnum = pgEnum('incident_status', vocab.incidentStatuses)
export const areaKindEnum = pgEnum('area_kind', vocab.areaKinds)
export const employmentTypeEnum = pgEnum('employment_type', vocab.employmentTypes)
export const treatmentLevelEnum = pgEnum('treatment_level', vocab.treatmentLevels)
export const rcaMethodEnum = pgEnum('rca_method', vocab.rcaMethods)
export const rootCauseCategoryEnum = pgEnum('root_cause_category', vocab.rootCauseCategories)
export const actionKindEnum = pgEnum('action_kind', vocab.actionKinds)
export const actionStatusEnum = pgEnum('action_status', vocab.actionStatuses)
const id = () => uuid().primaryKey().defaultRandom()
const timestamptz = () => timestamp({ withTimezone: true })
const createdAt = () => timestamptz().notNull().defaultNow()
const updatedAt = () =>
timestamptz()
.notNull()
.defaultNow()
.$onUpdate(() => new Date())
export const organizations = pgTable('organizations', {
id: id(),
name: text().notNull(),
createdAt: createdAt(),
})
export const users = pgTable(
'users',
{
id: id(),
organizationId: uuid()
.notNull()
.references(() => organizations.id),
/** Stored lower-cased. */
email: text().notNull(),
name: text().notNull(),
role: roleEnum().notNull().default('reporter'),
passwordHash: text(),
active: boolean().notNull().default(true),
lastLoginAt: timestamptz(),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [uniqueIndex('users_email_unique').on(t.email), index('users_organization_idx').on(t.organizationId)],
)
export const sessions = pgTable(
'sessions',
{
/** SHA-256 of the session token; the token itself is never stored. */
id: text().primaryKey(),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expiresAt: timestamptz().notNull(),
createdAt: createdAt(),
},
(t) => [index('sessions_user_idx').on(t.userId)],
)
export const sites = pgTable(
'sites',
{
id: id(),
organizationId: uuid()
.notNull()
.references(() => organizations.id),
name: text().notNull(),
address: text(),
timezone: text().notNull().default('UTC'),
archivedAt: timestamptz(),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [uniqueIndex('sites_organization_name_unique').on(t.organizationId, t.name)],
)
/** Location tree within a site: building → area → line. */
export const areas = pgTable(
'areas',
{
id: id(),
siteId: uuid()
.notNull()
.references(() => sites.id, { onDelete: 'cascade' }),
parentId: uuid().references((): AnyPgColumn => areas.id, { onDelete: 'cascade' }),
name: text().notNull(),
kind: areaKindEnum().notNull().default('area'),
archivedAt: timestamptz(),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [index('areas_site_idx').on(t.siteId), index('areas_parent_idx').on(t.parentId)],
)
/** Per-organization counter for gap-free, human-friendly incident numbers. */
export const incidentCounters = pgTable('incident_counters', {
organizationId: uuid()
.primaryKey()
.references(() => organizations.id),
lastNumber: integer().notNull().default(0),
})
export const incidents = pgTable(
'incidents',
{
id: id(),
organizationId: uuid()
.notNull()
.references(() => organizations.id),
number: integer().notNull(),
title: text().notNull(),
type: incidentTypeEnum().notNull(),
severity: severityEnum().notNull(),
status: incidentStatusEnum().notNull().default('reported'),
occurredAt: timestamptz().notNull(),
siteId: uuid()
.notNull()
.references(() => sites.id),
areaId: uuid().references(() => areas.id, { onDelete: 'set null' }),
locationDetail: text(),
description: text(),
immediateAction: text(),
/** Null when reported anonymously. */
reporterId: uuid().references(() => users.id, { onDelete: 'set null' }),
anonymous: boolean().notNull().default(false),
closedAt: timestamptz(),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [
uniqueIndex('incidents_organization_number_unique').on(t.organizationId, t.number),
index('incidents_organization_occurred_idx').on(t.organizationId, t.occurredAt),
index('incidents_organization_status_idx').on(t.organizationId, t.status),
index('incidents_reporter_idx').on(t.reporterId),
],
)
export const injuredPersons = pgTable(
'injured_persons',
{
id: id(),
incidentId: uuid()
.notNull()
.references(() => incidents.id, { onDelete: 'cascade' }),
/** Set when the person has a user account. */
userId: uuid().references(() => users.id, { onDelete: 'set null' }),
name: text().notNull(),
jobTitle: text(),
employmentType: employmentTypeEnum().notNull().default('employee'),
bodyPart: text(),
natureOfInjury: text(),
treatment: treatmentLevelEnum().notNull().default('none'),
daysAway: integer().notNull().default(0),
daysRestricted: integer().notNull().default(0),
/** OSHA privacy case: name withheld from the 300 log. */
privacyCase: boolean().notNull().default(false),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [index('injured_persons_incident_idx').on(t.incidentId)],
)
export const attachments = pgTable(
'attachments',
{
id: id(),
incidentId: uuid()
.notNull()
.references(() => incidents.id, { onDelete: 'cascade' }),
fileName: text().notNull(),
contentType: text().notNull(),
sizeBytes: integer().notNull(),
storageKey: text().notNull().unique(),
uploadedById: uuid().references(() => users.id, { onDelete: 'set null' }),
createdAt: createdAt(),
},
(t) => [index('attachments_incident_idx').on(t.incidentId)],
)
export const investigations = pgTable('investigations', {
id: id(),
incidentId: uuid()
.notNull()
.unique()
.references(() => incidents.id, { onDelete: 'cascade' }),
leadId: uuid().references(() => users.id, { onDelete: 'set null' }),
method: rcaMethodEnum().notNull().default('five_whys'),
summary: text(),
startedAt: timestamptz().notNull().defaultNow(),
completedAt: timestamptz(),
createdAt: createdAt(),
updatedAt: updatedAt(),
})
export const rootCauses = pgTable(
'root_causes',
{
id: id(),
investigationId: uuid()
.notNull()
.references(() => investigations.id, { onDelete: 'cascade' }),
category: rootCauseCategoryEnum().notNull(),
description: text().notNull(),
/** 5 Whys chain, first "why" first. */
whys: jsonb().$type<string[]>().notNull().default([]),
createdAt: createdAt(),
},
(t) => [index('root_causes_investigation_idx').on(t.investigationId)],
)
export const correctiveActions = pgTable(
'corrective_actions',
{
id: id(),
incidentId: uuid()
.notNull()
.references(() => incidents.id, { onDelete: 'cascade' }),
rootCauseId: uuid().references(() => rootCauses.id, { onDelete: 'set null' }),
kind: actionKindEnum().notNull().default('corrective'),
title: text().notNull(),
description: text(),
assigneeId: uuid().references(() => users.id, { onDelete: 'set null' }),
dueDate: date({ mode: 'string' }),
status: actionStatusEnum().notNull().default('open'),
completedAt: timestamptz(),
verifiedById: uuid().references(() => users.id, { onDelete: 'set null' }),
verifiedAt: timestamptz(),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [index('corrective_actions_incident_idx').on(t.incidentId), index('corrective_actions_assignee_status_idx').on(t.assigneeId, t.status)],
)
export const comments = pgTable(
'comments',
{
id: id(),
incidentId: uuid()
.notNull()
.references(() => incidents.id, { onDelete: 'cascade' }),
authorId: uuid().references(() => users.id, { onDelete: 'set null' }),
body: text().notNull(),
createdAt: createdAt(),
},
(t) => [index('comments_incident_idx').on(t.incidentId)],
)
export const auditLog = pgTable(
'audit_log',
{
id: bigserial({ mode: 'number' }).primaryKey(),
organizationId: uuid()
.notNull()
.references(() => organizations.id),
actorId: uuid().references(() => users.id, { onDelete: 'set null' }),
entityType: text().notNull(),
entityId: text().notNull(),
action: text().notNull(),
/** `{ field: [before, after] }` for updates, the created record for creates. */
changes: jsonb(),
createdAt: createdAt(),
},
(t) => [index('audit_log_entity_idx').on(t.organizationId, t.entityType, t.entityId)],
)
/** Monthly hours worked per site, for TRIR / DART rate calculations. */
export const hoursWorked = pgTable(
'hours_worked',
{
id: id(),
siteId: uuid()
.notNull()
.references(() => sites.id, { onDelete: 'cascade' }),
/** First day of the month, e.g. `2026-10-01`. */
month: date({ mode: 'string' }).notNull(),
hours: numeric({ precision: 12, scale: 2 }).notNull(),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [uniqueIndex('hours_worked_site_month_unique').on(t.siteId, t.month)],
)
+73
View File
@@ -0,0 +1,73 @@
// Loads demo data into an empty database: `npm run db:seed`. Not for production use.
import { hashPassword } from '../auth/password.ts'
import { loadConfig } from '../config.ts'
import { openDatabase } from './client.ts'
import { areas, incidentCounters, incidents, organizations, sites, users } from './schema.ts'
const DEMO_PASSWORD = 'safetylog-demo'
const config = loadConfig()
const { db, close } = await openDatabase(config.databaseUrl)
try {
if ((await db.select({ id: users.id }).from(users).limit(1)).length > 0) {
console.error('Database already has users; seeding is only for empty databases.')
process.exitCode = 1
} else {
await db.transaction(async (tx) => {
const [org] = await tx.insert(organizations).values({ name: 'Northstar Co.' }).returning()
const passwordHash = await hashPassword(DEMO_PASSWORD)
const people = await tx
.insert(users)
.values([
{ name: 'Jordan Lee', email: 'admin@example.com', role: 'admin' as const },
{ name: 'Priya Shah', email: 'manager@example.com', role: 'safety_manager' as const },
{ name: 'Sam Rivera', email: 'supervisor@example.com', role: 'supervisor' as const },
{ name: 'Maya Chen', email: 'reporter@example.com', role: 'reporter' as const },
].map((u) => ({ ...u, organizationId: org.id, passwordHash })))
.returning()
const person = (email: string) => people.find((p) => p.email === email)!.id
const layout: Record<string, string[]> = {
'North Campus': ['Building A', 'Warehouse 04', 'Lab 2'],
'South Campus': ['Fabrication 02', 'Yard', 'Office block'],
'West Campus': ['Warehouse 01', 'Loading dock'],
}
const areaIds: Record<string, { siteId: string; areaId: string }> = {}
for (const [siteName, buildings] of Object.entries(layout)) {
const [site] = await tx.insert(sites).values({ organizationId: org.id, name: siteName }).returning()
for (const name of buildings) {
const [area] = await tx.insert(areas).values({ siteId: site.id, name, kind: 'building' }).returning()
areaIds[name] = { siteId: site.id, areaId: area.id }
}
}
const daysAgo = (days: number) => new Date(Date.now() - days * 86_400_000)
const demo = [
{ title: 'Damaged guardrail on mezzanine', type: 'property_damage', severity: 'low', status: 'closed', building: 'Warehouse 01', locationDetail: 'Mezzanine east', reporter: 'manager@example.com', days: 9 },
{ title: 'Hand laceration during material handling', type: 'injury', severity: 'high', status: 'investigation', building: 'Fabrication 02', locationDetail: 'Line 3', reporter: 'supervisor@example.com', days: 7 },
{ title: 'Wet floor near employee entrance', type: 'hazard', severity: 'medium', status: 'actions', building: 'Building A', locationDetail: 'Ground floor lobby', reporter: 'reporter@example.com', days: 6 },
{ title: 'Forklift reversed into pallet stack', type: 'near_miss', severity: 'high', status: 'triage', building: 'Warehouse 04', locationDetail: 'Loading bay C', reporter: 'admin@example.com', days: 5 },
] as const
await tx.insert(incidents).values(
demo.map((item, index) => ({
organizationId: org.id,
number: index + 1,
title: item.title,
type: item.type,
severity: item.severity,
status: item.status,
occurredAt: daysAgo(item.days),
...areaIds[item.building],
locationDetail: item.locationDetail,
reporterId: person(item.reporter),
closedAt: item.status === 'closed' ? daysAgo(item.days - 3) : null,
})),
)
await tx.insert(incidentCounters).values({ organizationId: org.id, lastNumber: demo.length })
})
console.log(`Seeded demo data. Sign in as admin@example.com, manager@example.com, supervisor@example.com or reporter@example.com with password "${DEMO_PASSWORD}".`)
}
} finally {
await close()
}
+38
View File
@@ -0,0 +1,38 @@
import { serve } from '@hono/node-server'
import { serveStatic } from '@hono/node-server/serve-static'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { createApp } from './app.ts'
import { loadConfig } from './config.ts'
import { openDatabase } from './db/client.ts'
const config = loadConfig()
const database = await openDatabase(config.databaseUrl)
const app = createApp({ db: database.db, config })
// In production the server also serves the built web app.
if (existsSync(join(config.staticDir, 'index.html'))) {
app.use(
'/*',
serveStatic({
root: config.staticDir,
onFound: (path, c) => {
if (path.includes('/assets/')) c.header('Cache-Control', 'public, max-age=31536000, immutable')
},
}),
)
app.get('*', serveStatic({ path: join(config.staticDir, 'index.html') })) // client-side routes
}
const server = serve({ fetch: app.fetch, port: config.port }, ({ port }) => {
console.log(`Open SafetyLog EHS listening on port ${port}, public URL ${config.publicUrl} (database: ${config.databaseUrl.replace(/\/\/[^@]*@/, '//***@')})`)
})
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
server.close(async () => {
await database.close()
process.exit(0)
})
})
}
+29
View File
@@ -0,0 +1,29 @@
import type { Db } from '../db/client.ts'
import { auditLog } from '../db/schema.ts'
interface AuditEntry {
organizationId: string
actorId: string | null
entityType: string
entityId: string
action: string
changes?: Record<string, unknown> | null
}
export async function audit(db: Db, entry: AuditEntry): Promise<void> {
await db.insert(auditLog).values({ ...entry, changes: entry.changes ?? null })
}
const comparable = (value: unknown) => (value instanceof Date ? value.toISOString() : (value ?? null))
/** `{ field: [before, after] }` for each field in `next` whose value differs from `current`. */
export function diff(current: Record<string, unknown>, next: Record<string, unknown>): Record<string, [unknown, unknown]> {
const changes: Record<string, [unknown, unknown]> = {}
for (const [key, value] of Object.entries(next)) {
if (value === undefined) continue
const before = comparable(current[key])
const after = comparable(value)
if (JSON.stringify(before) !== JSON.stringify(after)) changes[key] = [before, after]
}
return changes
}
+33
View File
@@ -0,0 +1,33 @@
import { zValidator } from '@hono/zod-validator'
import type { Context, ValidationTargets } from 'hono'
import { HTTPException } from 'hono/http-exception'
import type { ZodType } from 'zod'
import type { SessionUser } from '../auth/sessions.ts'
import type { Config } from '../config.ts'
import type { Db } from '../db/client.ts'
export interface AppEnv {
Variables: {
db: Db
config: Config
user: SessionUser | null
}
}
/** Validates part of the request, answering 400 with field-level issues on failure. */
export function validate<Target extends keyof ValidationTargets, Schema extends ZodType>(target: Target, schema: Schema) {
return zValidator(target, schema, (result, c) => {
if (!result.success) {
return c.json({ error: 'Invalid request', issues: result.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })) }, 400)
}
})
}
export const fail = (status: 400 | 401 | 403 | 404 | 409 | 429, message: string) => new HTTPException(status, { message })
/** The signed-in user; use after `requireAuth`. */
export function currentUser(c: Context<AppEnv>): SessionUser {
const user = c.get('user')
if (!user) throw fail(401, 'Sign in required')
return user
}
+91
View File
@@ -0,0 +1,91 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { ADMIN, createTestContext } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
beforeAll(async () => {
ctx = await createTestContext()
})
afterAll(() => ctx.close())
describe('first-run setup', () => {
it('reports that setup is done and refuses to run again', async () => {
const client = ctx.anonymous()
expect((await client.get('/api/setup')).body).toEqual({ needsSetup: false })
const again = await client.post('/api/setup', { ...ADMIN, email: 'intruder@example.com' })
expect(again.status).toBe(409)
})
it('signed the new admin in', async () => {
const me = await ctx.admin.get('/api/auth/me')
expect(me.status).toBe(200)
expect(me.body.user).toMatchObject({ email: ADMIN.email, role: 'admin' })
expect(me.body.organization.name).toBe(ADMIN.organizationName)
expect(me.body.user.passwordHash).toBeUndefined()
})
})
describe('sign in and out', () => {
it('rejects a wrong password with a generic message', async () => {
const res = await ctx.anonymous().post('/api/auth/login', { email: ADMIN.email, password: 'nope' })
expect(res.status).toBe(401)
expect(res.body.error).toBe('Incorrect email or password')
})
it('treats unknown emails the same as wrong passwords', async () => {
const res = await ctx.anonymous().post('/api/auth/login', { email: 'nobody@example.com', password: 'whatever' })
expect(res.status).toBe(401)
expect(res.body.error).toBe('Incorrect email or password')
})
it('accepts email in any case and sets an http-only cookie', async () => {
const client = ctx.anonymous()
const res = await client.post('/api/auth/login', { email: ADMIN.email.toUpperCase(), password: ADMIN.password })
expect(res.status).toBe(200)
expect(res.headers.get('set-cookie')).toMatch(/HttpOnly/)
expect(res.headers.get('set-cookie')).toMatch(/SameSite=Lax/)
expect((await client.get('/api/auth/me')).status).toBe(200)
expect((await client.post('/api/auth/logout')).status).toBe(204)
expect((await client.get('/api/auth/me')).status).toBe(401)
})
it('invalidates the session server-side on logout, even if the cookie is replayed', async () => {
const client = ctx.anonymous()
await client.post('/api/auth/login', { email: ADMIN.email, password: ADMIN.password })
const stolen = client.cookie
await client.post('/api/auth/logout')
client.cookie = stolen
expect((await client.get('/api/auth/me')).status).toBe(401)
})
it('rate-limits repeated attempts for one email', async () => {
const client = ctx.anonymous()
const email = 'ratelimit@example.com'
for (let i = 0; i < 10; i++) expect((await client.post('/api/auth/login', { email, password: 'x' })).status).toBe(401)
expect((await client.post('/api/auth/login', { email, password: 'x' })).status).toBe(429)
})
it('blocks deactivated users and ends their sessions', async () => {
const worker = await ctx.createUser('reporter', 'leaver@example.com')
expect((await worker.get('/api/auth/me')).status).toBe(200)
await ctx.admin.patch(`/api/users/${worker.user.id}`, { active: false })
expect((await worker.get('/api/auth/me')).status).toBe(401)
expect((await ctx.anonymous().post('/api/auth/login', { email: 'leaver@example.com', password: 'a sufficiently long password' })).status).toBe(401)
})
})
describe('change password', () => {
it('requires the current password and signs out other sessions', async () => {
const user = await ctx.createUser('supervisor', 'changer@example.com')
const otherDevice = ctx.anonymous()
await otherDevice.post('/api/auth/login', { email: 'changer@example.com', password: 'a sufficiently long password' })
expect((await user.post('/api/auth/password', { currentPassword: 'wrong', newPassword: 'a brand new password' })).status).toBe(400)
expect((await user.post('/api/auth/password', { currentPassword: 'a sufficiently long password', newPassword: 'short' })).status).toBe(400)
expect((await user.post('/api/auth/password', { currentPassword: 'a sufficiently long password', newPassword: 'a brand new password' })).status).toBe(204)
expect((await user.get('/api/auth/me')).status).toBe(200)
expect((await otherDevice.get('/api/auth/me')).status).toBe(401)
expect((await ctx.anonymous().post('/api/auth/login', { email: 'changer@example.com', password: 'a brand new password' })).status).toBe(200)
})
})
+59
View File
@@ -0,0 +1,59 @@
import { eq } from 'drizzle-orm'
import { Hono } from 'hono'
import { changePasswordSchema, loginSchema } 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 { organizations, users } from '../db/schema.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
import { publicUser } from './users.ts'
export function authRoutes() {
// 10 failed-or-successful attempts per email per 15 minutes
const loginLimiter = new RateLimiter(10, 15 * 60 * 1000)
return new Hono<AppEnv>()
.post('/login', validate('json', loginSchema), async (c) => {
const { email, password } = c.req.valid('json')
if (!loginLimiter.attempt(email)) throw fail(429, 'Too many sign-in attempts. Try again in 15 minutes.')
const db = c.get('db')
const [user] = await db.select().from(users).where(eq(users.email, email))
const valid = user?.active && user.passwordHash ? await verifyPassword(password, user.passwordHash) : await verifyAgainstDummy(password)
if (!user || !valid) throw fail(401, 'Incorrect email or password')
loginLimiter.reset(email)
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, user.id))
await startSession(c, user.id)
const [result] = await db.select(publicUser).from(users).where(eq(users.id, user.id))
return c.json({ user: result })
})
.post('/logout', async (c) => {
await endSession(c)
return c.body(null, 204)
})
.get('/me', requireAuth, async (c) => {
const me = currentUser(c)
const db = c.get('db')
const [user] = await db.select(publicUser).from(users).where(eq(users.id, me.id))
const [organization] = await db.select({ id: organizations.id, name: organizations.name }).from(organizations).where(eq(organizations.id, me.organizationId))
return c.json({ user, organization })
})
.post('/password', requireAuth, validate('json', changePasswordSchema), async (c) => {
const me = currentUser(c)
const { currentPassword, newPassword } = c.req.valid('json')
const db = c.get('db')
const [user] = await db.select({ passwordHash: users.passwordHash }).from(users).where(eq(users.id, me.id))
if (!user.passwordHash || !(await verifyPassword(currentPassword, user.passwordHash))) throw fail(400, 'Current password is incorrect')
await db.update(users).set({ passwordHash: await hashPassword(newPassword) }).where(eq(users.id, me.id))
// Sign out everywhere else
await deleteUserSessions(db, me.id)
await startSession(c, me.id)
return c.body(null, 204)
})
}
+134
View File
@@ -0,0 +1,134 @@
import { eq } from 'drizzle-orm'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { auditLog } from '../db/schema.ts'
import { createTestContext, hoursAgo } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
let site: { id: string }
let area: { id: string }
let reporter: Awaited<ReturnType<typeof ctx.createUser>>
let supervisor: Awaited<ReturnType<typeof ctx.createUser>>
const report = (overrides: Record<string, unknown> = {}) => ({
title: 'Forklift reversed into pallet stack',
type: 'near_miss',
severity: 'high',
occurredAt: hoursAgo(2),
siteId: site.id,
...overrides,
})
beforeAll(async () => {
ctx = await createTestContext()
site = await ctx.createSite('North Campus')
area = (await ctx.admin.post(`/api/sites/${site.id}/areas`, { name: 'Loading bay C' })).body.area
reporter = await ctx.createUser('reporter')
supervisor = await ctx.createUser('supervisor')
})
afterAll(() => ctx.close())
describe('reporting', () => {
it('creates an incident with a sequential reference and the reporter recorded', async () => {
const res = await reporter.post('/api/incidents', report({ areaId: area.id, locationDetail: 'Dock door 3' }))
expect(res.status).toBe(201)
expect(res.body.incident).toMatchObject({
number: 1,
reference: 'INC-0001',
status: 'reported',
site: { id: site.id, name: 'North Campus' },
area: { id: area.id, name: 'Loading bay C' },
reporter: { id: reporter.user.id, name: 'reporter user' },
})
})
it('assigns distinct numbers to simultaneous reports', async () => {
const results = await Promise.all(Array.from({ length: 8 }, (_, i) => reporter.post('/api/incidents', report({ title: `Concurrent ${i}` }))))
const numbers = results.map((r) => r.body.incident.number)
expect(new Set(numbers).size).toBe(8)
})
it('keeps anonymous reports anonymous', async () => {
const res = await reporter.post('/api/incidents', report({ title: 'Blocked fire exit', anonymous: true }))
expect(res.body.incident).toMatchObject({ anonymous: true, reporter: null })
})
it('validates location and time', async () => {
const otherSite = await ctx.createSite('South Campus')
expect((await reporter.post('/api/incidents', report({ siteId: otherSite.id, areaId: area.id }))).status).toBe(400)
expect((await reporter.post('/api/incidents', report({ occurredAt: new Date(Date.now() + 86_400_000).toISOString() }))).status).toBe(400)
expect((await reporter.post('/api/incidents', report({ type: 'explosion' }))).status).toBe(400)
})
it('writes an audit entry', async () => {
const res = await reporter.post('/api/incidents', report({ title: 'Audited' }))
const entries = await ctx.db.select().from(auditLog).where(eq(auditLog.entityId, res.body.incident.id))
expect(entries).toHaveLength(1)
expect(entries[0]).toMatchObject({ action: 'create', actorId: reporter.user.id })
})
})
describe('visibility', () => {
it('shows reporters only their own incidents', async () => {
const other = await ctx.createUser('reporter', 'other-reporter@example.com')
const mine = await other.post('/api/incidents', report({ title: 'Only mine' }))
const list = await other.get('/api/incidents')
expect(list.body.items.map((i: { title: string }) => i.title)).toEqual(['Only mine'])
expect((await reporter.get(`/api/incidents/${mine.body.incident.number}`)).status).toBe(404)
})
it('shows supervisors every incident', async () => {
const list = await supervisor.get('/api/incidents?pageSize=100')
expect(list.body.total).toBeGreaterThan(10)
})
})
describe('listing', () => {
it('paginates newest first', async () => {
const page1 = await supervisor.get('/api/incidents?pageSize=3&page=1')
const page2 = await supervisor.get('/api/incidents?pageSize=3&page=2')
expect(page1.body.items).toHaveLength(3)
expect(page1.body.items[0].id).not.toBe(page2.body.items[0].id)
const times = page1.body.items.map((i: { occurredAt: string }) => i.occurredAt)
expect([...times].sort().reverse()).toEqual(times)
})
it('searches title and reference, treating % and _ literally', async () => {
await reporter.post('/api/incidents', report({ title: 'Oil leak under press 4', type: 'hazard', severity: 'medium' }))
expect((await supervisor.get('/api/incidents?q=press')).body.items[0].title).toBe('Oil leak under press 4')
expect((await supervisor.get('/api/incidents?q=INC-0001')).body.items[0].number).toBe(1)
expect((await supervisor.get('/api/incidents?q=%25')).body.total).toBe(0)
})
it('filters by type, severity and status', async () => {
const hazards = await supervisor.get('/api/incidents?type=hazard&severity=medium')
expect(hazards.body.items.every((i: { type: string; severity: string }) => i.type === 'hazard' && i.severity === 'medium')).toBe(true)
expect((await supervisor.get('/api/incidents?status=closed')).body.total).toBe(0)
expect((await supervisor.get('/api/incidents?status=bogus')).status).toBe(400)
})
})
describe('updating', () => {
it('lets supervisors update and close incidents, 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)
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 [entry] = await ctx.db.select().from(auditLog).where(eq(auditLog.action, 'update'))
expect(entry.changes).toMatchObject({ status: ['reported', 'closed'], severity: ['high', 'low'] })
const reopened = await supervisor.patch(`/api/incidents/${number}`, { status: 'investigation' })
expect(reopened.body.incident.closedAt).toBeNull()
})
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 })
})
})
+191
View File
@@ -0,0 +1,191 @@
import { and, count, desc, eq, ilike, isNull, or, sql, type SQL } from 'drizzle-orm'
import { Hono } from 'hono'
import { z } from 'zod'
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 { requireAuth, requireRole } from '../auth/middleware.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 { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
const numberParam = z.object({ number: z.coerce.number().int().positive() })
/** Reports may be dated slightly ahead to absorb clock differences between devices. */
const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000
const columns = {
incident: incidents,
siteName: sites.name,
areaName: areas.name,
reporterName: users.name,
}
type IncidentRow = { incident: typeof incidents.$inferSelect; siteName: string; areaName: string | null; reporterName: string | null }
function toDto({ incident, siteName, areaName, reporterName }: IncidentRow) {
return {
id: incident.id,
number: incident.number,
reference: incidentReference(incident.number),
title: incident.title,
type: incident.type,
severity: incident.severity,
status: incident.status,
occurredAt: incident.occurredAt,
reportedAt: incident.createdAt,
updatedAt: incident.updatedAt,
closedAt: incident.closedAt,
site: { id: incident.siteId, name: siteName },
area: incident.areaId ? { id: incident.areaId, name: areaName } : null,
locationDetail: incident.locationDetail,
description: incident.description,
immediateAction: incident.immediateAction,
anonymous: incident.anonymous,
reporter: incident.reporterId ? { id: incident.reporterId, name: reporterName } : null,
}
}
function selectIncidents(db: Db) {
return db
.select(columns)
.from(incidents)
.innerJoin(sites, eq(sites.id, incidents.siteId))
.leftJoin(areas, eq(areas.id, incidents.areaId))
.leftJoin(users, eq(users.id, incidents.reporterId))
}
/** Reporters only see what they reported; supervisors and above see everything in the organization. */
function visibleTo(user: SessionUser): SQL | undefined {
return and(eq(incidents.organizationId, user.organizationId), hasRole(user.role, 'supervisor') ? undefined : eq(incidents.reporterId, user.id))
}
async function findIncident(db: Db, user: SessionUser, number: number) {
const [row] = await selectIncidents(db).where(and(visibleTo(user), eq(incidents.number, number)))
if (!row) throw fail(404, 'Incident not found')
return row
}
async function assertLocation(db: Db, organizationId: string, siteId: string, areaId: string | null | undefined) {
const [site] = await db
.select({ id: sites.id })
.from(sites)
.where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId), isNull(sites.archivedAt)))
if (!site) throw fail(400, 'Unknown site')
if (areaId) {
const [area] = await db
.select({ id: areas.id })
.from(areas)
.where(and(eq(areas.id, areaId), eq(areas.siteId, siteId)))
if (!area) throw fail(400, 'Area must belong to the selected site')
}
}
function parseOccurredAt(value: string): Date {
const date = new Date(value)
if (date.getTime() > Date.now() + MAX_CLOCK_SKEW_MS) throw fail(400, 'Occurrence time cannot be in the future')
return date
}
const escapeLike = (value: string) => value.replace(/[\\%_]/g, '\\$&')
export const incidentRoutes = new Hono<AppEnv>()
.get('/', requireAuth, validate('query', incidentListQuery), async (c) => {
const me = currentUser(c)
const { page, pageSize, q, type, severity, status, siteId } = c.req.valid('query')
const db = c.get('db')
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 [{ 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 })
})
.post('/', requireAuth, validate('json', createIncidentSchema), async (c) => {
const me = currentUser(c)
const { anonymous, occurredAt, ...input } = c.req.valid('json')
const db = c.get('db')
await assertLocation(db, me.organizationId, input.siteId, input.areaId)
const occurred = parseOccurredAt(occurredAt)
const number = await db.transaction(async (tx) => {
// Atomic increment: concurrent reports always get distinct, gap-free numbers.
const [{ lastNumber }] = await tx
.insert(incidentCounters)
.values({ organizationId: me.organizationId, lastNumber: 1 })
.onConflictDoUpdate({ target: incidentCounters.organizationId, set: { lastNumber: sql`${incidentCounters.lastNumber} + 1` } })
.returning({ lastNumber: incidentCounters.lastNumber })
const [incident] = await tx
.insert(incidents)
.values({ ...input, occurredAt: occurred, organizationId: me.organizationId, number: lastNumber, anonymous, reporterId: anonymous ? null : me.id })
.returning({ id: incidents.id })
await audit(tx, {
organizationId: me.organizationId,
actorId: anonymous ? null : me.id,
entityType: 'incident',
entityId: incident.id,
action: 'create',
changes: { number: lastNumber, title: input.title, type: input.type, severity: input.severity },
})
return lastNumber
})
const [row] = await selectIncidents(db).where(and(eq(incidents.organizationId, me.organizationId), eq(incidents.number, number)))
return c.json({ incident: toDto(row) }, 201)
})
.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) })
})
.patch('/:number', requireRole('supervisor'), validate('param', numberParam), validate('json', updateIncidentSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { incident } = await findIncident(db, me, c.req.valid('param').number)
const { occurredAt, ...input } = c.req.valid('json')
const siteId = input.siteId ?? incident.siteId
let areaId = input.areaId !== undefined ? input.areaId : incident.areaId
if (input.siteId && input.siteId !== incident.siteId && input.areaId === undefined) areaId = null // moving site clears the old area
if (input.siteId !== undefined || input.areaId !== undefined) await assertLocation(db, me.organizationId, siteId, areaId)
const update = {
...input,
areaId,
occurredAt: occurredAt ? parseOccurredAt(occurredAt) : undefined,
closedAt: input.status === undefined ? undefined : input.status === 'closed' ? (incident.closedAt ?? new Date()) : null,
}
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 })
}
const row = await findIncident(db, me, incident.number)
return c.json({ incident: toDto(row) })
})
+42
View File
@@ -0,0 +1,42 @@
import { sql } from 'drizzle-orm'
import { Hono } from 'hono'
import { setupSchema } from '../../shared/schemas.ts'
import { startSession } from '../auth/middleware.ts'
import { hashPassword } from '../auth/password.ts'
import type { Db } from '../db/client.ts'
import { organizations, users } from '../db/schema.ts'
import { audit } from '../lib/audit.ts'
import { validate, type AppEnv } from '../lib/http.ts'
import { publicUser } from './users.ts'
const SETUP_LOCK = 717_001
async function hasUsers(db: Db) {
const rows = await db.select({ id: users.id }).from(users).limit(1)
return rows.length > 0
}
/** First-run setup: creates the organization and its first admin. Only works while there are no users. */
export const setupRoutes = new Hono<AppEnv>()
.get('/', async (c) => c.json({ needsSetup: !(await hasUsers(c.get('db'))) }))
.post('/', validate('json', setupSchema), async (c) => {
const input = c.req.valid('json')
const passwordHash = await hashPassword(input.password)
const user = await c.get('db').transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(${SETUP_LOCK})`)
if (await hasUsers(tx)) return null
const [organization] = await tx.insert(organizations).values({ name: input.organizationName }).returning()
const [admin] = await tx
.insert(users)
.values({ organizationId: organization.id, email: input.email, name: input.name, role: 'admin', passwordHash })
.returning(publicUser)
await audit(tx, { organizationId: organization.id, actorId: admin.id, entityType: 'organization', entityId: organization.id, action: 'setup' })
return admin
})
if (!user) return c.json({ error: 'Setup has already been completed' }, 409)
await startSession(c, user.id)
return c.json({ user }, 201)
})
+51
View File
@@ -0,0 +1,51 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createTestContext } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
beforeAll(async () => {
ctx = await createTestContext()
})
afterAll(() => ctx.close())
describe('sites and areas', () => {
it('builds a location tree', async () => {
const site = await ctx.createSite('North Campus')
const building = await ctx.admin.post(`/api/sites/${site.id}/areas`, { name: 'Warehouse 04', kind: 'building' })
expect(building.status).toBe(201)
const bay = await ctx.admin.post(`/api/sites/${site.id}/areas`, { name: 'Loading bay C', parentId: building.body.area.id })
expect(bay.body.area).toMatchObject({ kind: 'area', parentId: building.body.area.id })
const list = await ctx.admin.get('/api/sites')
const north = list.body.sites.find((s: { name: string }) => s.name === 'North Campus')
expect(north.areas.map((a: { name: string }) => a.name)).toEqual(['Loading bay C', 'Warehouse 04'])
})
it('rejects a parent area from another site', async () => {
const a = await ctx.createSite('Site A')
const b = await ctx.createSite('Site B')
const areaInA = await ctx.admin.post(`/api/sites/${a.id}/areas`, { name: 'Line 1' })
const res = await ctx.admin.post(`/api/sites/${b.id}/areas`, { name: 'Line 2', parentId: areaInA.body.area.id })
expect(res.status).toBe(400)
})
it('rejects duplicate site names', async () => {
await ctx.createSite('Duplicate')
expect((await ctx.admin.post('/api/sites', { name: 'Duplicate' })).status).toBe(409)
})
it('hides archived sites unless asked', async () => {
const site = await ctx.createSite('Old Plant')
await ctx.admin.patch(`/api/sites/${site.id}`, { archived: true })
const names = (await ctx.admin.get('/api/sites')).body.sites.map((s: { name: string }) => s.name)
expect(names).not.toContain('Old Plant')
const all = (await ctx.admin.get('/api/sites?includeArchived=true')).body.sites.map((s: { name: string }) => s.name)
expect(all).toContain('Old Plant')
})
it('lets everyone read but only safety managers change locations', async () => {
const reporter = await ctx.createUser('reporter')
const supervisor = await ctx.createUser('supervisor')
expect((await reporter.get('/api/sites')).status).toBe(200)
expect((await supervisor.post('/api/sites', { name: 'Nope' })).status).toBe(403)
})
})
+109
View File
@@ -0,0 +1,109 @@
import { and, asc, eq, inArray, isNull } from 'drizzle-orm'
import { Hono } from 'hono'
import { z } from 'zod'
import { createAreaSchema, createSiteSchema, updateAreaSchema, updateSiteSchema } from '../../shared/schemas.ts'
import { requireAuth, requireRole } from '../auth/middleware.ts'
import { isUniqueViolation, type Db } from '../db/client.ts'
import { areas, sites } from '../db/schema.ts'
import { audit, diff } from '../lib/audit.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
const idParam = z.object({ id: z.uuid() })
const listQuery = z.object({ includeArchived: z.enum(['true', 'false']).optional() })
async function findSite(db: Db, organizationId: string, id: string) {
const [site] = await db.select().from(sites).where(and(eq(sites.id, id), eq(sites.organizationId, organizationId)))
if (!site) throw fail(404, 'Site not found')
return site
}
const archivedAt = (archived: boolean | undefined) => (archived === undefined ? undefined : archived ? new Date() : null)
export const siteRoutes = new Hono<AppEnv>()
.get('/', requireAuth, validate('query', listQuery), async (c) => {
const me = currentUser(c)
const includeArchived = c.req.valid('query').includeArchived === 'true'
const db = c.get('db')
const siteRows = await db
.select()
.from(sites)
.where(and(eq(sites.organizationId, me.organizationId), includeArchived ? undefined : isNull(sites.archivedAt)))
.orderBy(asc(sites.name))
const areaRows = siteRows.length
? await db
.select()
.from(areas)
.where(and(inArray(areas.siteId, siteRows.map((site) => site.id)), includeArchived ? undefined : isNull(areas.archivedAt)))
.orderBy(asc(areas.name))
: []
return c.json({ sites: siteRows.map((site) => ({ ...site, areas: areaRows.filter((area) => area.siteId === site.id) })) })
})
.post('/', requireRole('safety_manager'), validate('json', createSiteSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
try {
const [site] = await db
.insert(sites)
.values({ ...c.req.valid('json'), organizationId: me.organizationId })
.returning()
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'site', entityId: site.id, action: 'create', changes: { name: site.name } })
return c.json({ site: { ...site, areas: [] } }, 201)
} catch (error) {
if (isUniqueViolation(error)) throw fail(409, 'A site with that name already exists')
throw error
}
})
.patch('/:id', requireRole('safety_manager'), validate('param', idParam), validate('json', updateSiteSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const site = await findSite(db, me.organizationId, c.req.valid('param').id)
const { archived, ...fields } = c.req.valid('json')
const update = { ...fields, archivedAt: archivedAt(archived) }
const changes = diff(site, update)
if (Object.keys(changes).length === 0) return c.json({ site })
try {
const [updated] = await db.update(sites).set(update).where(eq(sites.id, site.id)).returning()
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'site', entityId: site.id, action: 'update', changes })
return c.json({ site: updated })
} catch (error) {
if (isUniqueViolation(error)) throw fail(409, 'A site with that name already exists')
throw error
}
})
.post('/:id/areas', requireRole('safety_manager'), validate('param', idParam), validate('json', createAreaSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const site = await findSite(db, me.organizationId, c.req.valid('param').id)
const input = c.req.valid('json')
if (input.parentId) {
const [parent] = await db.select({ id: areas.id }).from(areas).where(and(eq(areas.id, input.parentId), eq(areas.siteId, site.id)))
if (!parent) throw fail(400, 'Parent area must belong to the same site')
}
const [area] = await db
.insert(areas)
.values({ ...input, siteId: site.id })
.returning()
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'area', entityId: area.id, action: 'create', changes: { name: area.name, siteId: site.id } })
return c.json({ area }, 201)
})
export const areaRoutes = new Hono<AppEnv>().patch('/:id', requireRole('safety_manager'), validate('param', idParam), validate('json', updateAreaSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const [row] = await db
.select({ area: areas })
.from(areas)
.innerJoin(sites, eq(sites.id, areas.siteId))
.where(and(eq(areas.id, c.req.valid('param').id), eq(sites.organizationId, me.organizationId)))
if (!row) throw fail(404, 'Area not found')
const { archived, ...fields } = c.req.valid('json')
const update = { ...fields, archivedAt: archivedAt(archived) }
const changes = diff(row.area, update)
if (Object.keys(changes).length === 0) return c.json({ area: row.area })
const [area] = await db.update(areas).set(update).where(eq(areas.id, row.area.id)).returning()
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'area', entityId: area.id, action: 'update', changes })
return c.json({ area })
})
+56
View File
@@ -0,0 +1,56 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createTestContext } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
beforeAll(async () => {
ctx = await createTestContext()
})
afterAll(() => ctx.close())
describe('users', () => {
it('lets admins create users and rejects duplicate emails', async () => {
const body = { name: 'Sam Rivera', email: 'Sam@Example.com', role: 'supervisor', password: 'a sufficiently long password' }
const created = await ctx.admin.post('/api/users', body)
expect(created.status).toBe(201)
expect(created.body.user).toMatchObject({ email: 'sam@example.com', role: 'supervisor', active: true })
expect(created.body.user.passwordHash).toBeUndefined()
expect((await ctx.admin.post('/api/users', body)).status).toBe(409)
})
it('validates input', async () => {
const res = await ctx.admin.post('/api/users', { name: '', email: 'not-an-email', role: 'boss', password: 'short' })
expect(res.status).toBe(400)
expect(res.body.issues.map((issue: { path: string }) => issue.path).sort()).toEqual(['email', 'name', 'password', 'role'])
})
it('restricts listing to safety managers and creating to admins', async () => {
const supervisor = await ctx.createUser('supervisor')
const manager = await ctx.createUser('safety_manager')
expect((await supervisor.get('/api/users')).status).toBe(403)
expect((await manager.get('/api/users')).status).toBe(200)
expect((await manager.post('/api/users', { name: 'X', email: 'x@example.com', role: 'admin', password: 'a sufficiently long password' })).status).toBe(403)
expect((await ctx.anonymous().get('/api/users')).status).toBe(401)
})
it('returns 404 for unknown or malformed ids', async () => {
expect((await ctx.admin.patch('/api/users/00000000-0000-4000-8000-000000000000', { name: 'Nobody' })).status).toBe(404)
expect((await ctx.admin.patch('/api/users/not-a-uuid', { name: 'Nobody' })).status).toBe(400)
})
it('keeps at least one active admin', async () => {
const me = await ctx.admin.get('/api/auth/me')
const adminId = me.body.user.id
expect((await ctx.admin.patch(`/api/users/${adminId}`, { role: 'reporter' })).status).toBe(409)
expect((await ctx.admin.patch(`/api/users/${adminId}`, { active: false })).status).toBe(400)
const second = await ctx.createUser('admin', 'second-admin@example.com')
const demoted = await second.patch(`/api/users/${adminId}`, { role: 'safety_manager' })
expect(demoted.status).toBe(200)
expect(demoted.body.user.role).toBe('safety_manager')
// The demoted user stays signed in but loses admin rights immediately
expect((await ctx.admin.get('/api/auth/me')).status).toBe(200)
expect((await ctx.admin.post('/api/users', { name: 'Y', email: 'y@example.com', role: 'reporter', password: 'a sufficiently long password' })).status).toBe(403)
})
})
+79
View File
@@ -0,0 +1,79 @@
import { and, asc, eq, ne } from 'drizzle-orm'
import { Hono } from 'hono'
import { z } from 'zod'
import { createUserSchema, updateUserSchema } from '../../shared/schemas.ts'
import { requireRole } from '../auth/middleware.ts'
import { hashPassword } from '../auth/password.ts'
import { deleteUserSessions } from '../auth/sessions.ts'
import { isUniqueViolation } from '../db/client.ts'
import { users } from '../db/schema.ts'
import { audit, diff } from '../lib/audit.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
/** Columns safe to return to clients (never the password hash). */
export const publicUser = {
id: users.id,
email: users.email,
name: users.name,
role: users.role,
active: users.active,
lastLoginAt: users.lastLoginAt,
createdAt: users.createdAt,
}
const idParam = z.object({ id: z.uuid() })
export const userRoutes = new Hono<AppEnv>()
.get('/', requireRole('safety_manager'), async (c) => {
const me = currentUser(c)
const rows = await c.get('db').select(publicUser).from(users).where(eq(users.organizationId, me.organizationId)).orderBy(asc(users.name))
return c.json({ users: rows })
})
.post('/', requireRole('admin'), validate('json', createUserSchema), async (c) => {
const me = currentUser(c)
const { password, ...input } = c.req.valid('json')
const db = c.get('db')
try {
const [user] = await db
.insert(users)
.values({ ...input, organizationId: me.organizationId, passwordHash: await hashPassword(password) })
.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)
} catch (error) {
if (isUniqueViolation(error)) throw fail(409, 'A user with that email already exists')
throw error
}
})
.patch('/:id', requireRole('admin'), validate('param', idParam), validate('json', updateUserSchema), async (c) => {
const me = currentUser(c)
const { id } = c.req.valid('param')
const input = c.req.valid('json')
const db = c.get('db')
const [target] = await db.select().from(users).where(and(eq(users.id, id), eq(users.organizationId, me.organizationId)))
if (!target) throw fail(404, 'User not found')
if (target.id === me.id && input.active === false) throw fail(400, 'You cannot deactivate your own account')
const losesAdmin = target.role === 'admin' && target.active && ((input.role && input.role !== 'admin') || input.active === false)
if (losesAdmin) {
const otherAdmins = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.organizationId, me.organizationId), eq(users.role, 'admin'), eq(users.active, true), ne(users.id, target.id)))
if (otherAdmins.length === 0) throw fail(409, 'At least one active admin is required')
}
const changes = diff(target, input)
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)
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'user', entityId: id, action: 'update', changes })
return c.json({ user })
})
+61
View File
@@ -0,0 +1,61 @@
import { createApp } from '../app.ts'
import { loadConfig } from '../config.ts'
import { openDatabase } from '../db/client.ts'
type App = ReturnType<typeof createApp>
/** Minimal API client that keeps its session cookie between requests, like a browser. */
export class Client {
cookie: string | undefined
private readonly app: App
constructor(app: App) {
this.app = app
}
async request(method: string, path: string, body?: unknown) {
const headers: Record<string, string> = {}
if (body !== undefined) headers['content-type'] = 'application/json'
if (this.cookie) headers.cookie = this.cookie
const res = await this.app.request(path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) })
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()
// oxlint-disable-next-line typescript/no-explicit-any -- tests read arbitrary JSON shapes
const json: any = text ? JSON.parse(text) : null
return { status: res.status, body: json, headers: res.headers }
}
get = (path: string) => this.request('GET', path)
post = (path: string, body?: unknown) => this.request('POST', path, body ?? {})
patch = (path: string, body: unknown) => this.request('PATCH', path, body)
}
export const ADMIN = { organizationName: 'Northstar Co.', name: 'Avery Admin', email: 'admin@example.com', password: 'correct horse battery' }
/** Fresh in-memory database and app, with the first-run setup already done and the admin signed in. */
export async function createTestContext() {
const database = await openDatabase('memory://')
const app = createApp({ db: database.db, config: { ...loadConfig({}), databaseUrl: 'memory://' } })
const admin = new Client(app)
const setup = await admin.post('/api/setup', ADMIN)
if (setup.status !== 201) throw new Error(`setup failed: ${JSON.stringify(setup.body)}`)
async function createUser(role: 'reporter' | 'supervisor' | 'safety_manager' | 'admin', email = `${role}@example.com`) {
const password = 'a sufficiently long password'
const created = await admin.post('/api/users', { name: `${role} user`, email, role, password })
if (created.status !== 201) throw new Error(`createUser failed: ${JSON.stringify(created.body)}`)
const client = new Client(app)
await client.post('/api/auth/login', { email, password })
return Object.assign(client, { user: created.body.user as { id: string; email: string } })
}
async function createSite(name = 'North Campus') {
const res = await admin.post('/api/sites', { name })
return res.body.site as { id: string; name: string }
}
return { app, db: database.db, admin, anonymous: () => new Client(app), createUser, createSite, close: database.close }
}
export const hoursAgo = (hours: number) => new Date(Date.now() - hours * 3_600_000).toISOString()
+35
View File
@@ -0,0 +1,35 @@
// Domain vocabularies shared by the server (database enums, validation) and the web app.
/** Ordered from least to most privileged. */
export const roles = ['reporter', 'supervisor', 'safety_manager', 'admin'] as const
export type Role = (typeof roles)[number]
export function hasRole(role: Role, minimum: Role): boolean {
return roles.indexOf(role) >= roles.indexOf(minimum)
}
export const incidentTypes = ['near_miss', 'hazard', 'injury', 'illness', 'property_damage', 'environmental'] as const
export type IncidentType = (typeof incidentTypes)[number]
export const severities = ['low', 'medium', 'high', 'critical'] as const
export type Severity = (typeof severities)[number]
/** Workflow: Reported → Triage → Investigation → Corrective actions → Verification → Closed. */
export const incidentStatuses = ['reported', 'triage', 'investigation', 'actions', 'verification', 'closed'] as const
export type IncidentStatus = (typeof incidentStatuses)[number]
export const areaKinds = ['building', 'area', 'line', 'other'] as const
export type AreaKind = (typeof areaKinds)[number]
export const employmentTypes = ['employee', 'temporary', 'contractor', 'visitor'] as const
export const treatmentLevels = ['none', 'first_aid', 'medical_treatment', 'hospitalized', 'fatality'] as const
export const rcaMethods = ['five_whys', 'fishbone', 'other'] as const
/** Fishbone (6M) categories. */
export const rootCauseCategories = ['people', 'process', 'equipment', 'materials', 'environment', 'management'] as const
export const actionKinds = ['corrective', 'preventive'] as const
export const actionStatuses = ['open', 'in_progress', 'done', 'verified', 'cancelled'] as const
+4
View File
@@ -0,0 +1,4 @@
/** Human-facing incident reference, e.g. `INC-0042`. */
export function incidentReference(number: number): string {
return `INC-${String(number).padStart(4, '0')}`
}
+60
View File
@@ -0,0 +1,60 @@
// 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'
const email = z.string().trim().toLowerCase().max(254).pipe(z.email())
const password = z.string().min(10, 'Use at least 10 characters').max(256)
const name = z.string().trim().min(1).max(200)
const optionalText = (max: number) => z.string().trim().max(max).nullish()
const isoDateTime = z.iso.datetime({ offset: true })
export const setupSchema = z.object({ organizationName: name, name, email, password })
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 })
export const updateUserSchema = z.object({ name: name.optional(), role: z.enum(roles).optional(), active: z.boolean().optional() })
export const createSiteSchema = z.object({ name, address: optionalText(500), timezone: z.string().trim().max(64).optional() })
export const updateSiteSchema = createSiteSchema.partial().extend({ archived: z.boolean().optional() })
export const createAreaSchema = z.object({ name, kind: z.enum(areaKinds).default('area'), parentId: z.uuid().nullish() })
export const updateAreaSchema = z.object({ name: name.optional(), kind: z.enum(areaKinds).optional(), archived: z.boolean().optional() })
export const createIncidentSchema = z.object({
title: name,
type: z.enum(incidentTypes),
severity: z.enum(severities),
occurredAt: isoDateTime,
siteId: z.uuid(),
areaId: z.uuid().nullish(),
locationDetail: optionalText(500),
description: optionalText(10_000),
immediateAction: optionalText(10_000),
anonymous: z.boolean().default(false),
})
export const updateIncidentSchema = createIncidentSchema
.omit({ anonymous: true })
.partial()
.extend({ status: z.enum(incidentStatuses).optional() })
export const incidentListQuery = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(25),
q: z.string().trim().max(200).optional(),
type: z.enum(incidentTypes).optional(),
severity: z.enum(severities).optional(),
status: z.enum(incidentStatuses).optional(),
siteId: z.uuid().optional(),
})
export type SetupInput = z.infer<typeof setupSchema>
export type CreateIncidentInput = z.input<typeof createIncidentSchema>
export type UpdateIncidentInput = z.infer<typeof updateIncidentSchema>
export type IncidentListQuery = z.infer<typeof incidentListQuery>
+1 -1
View File
@@ -18,5 +18,5 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["src"] "include": ["src", "shared"]
} }
+1 -1
View File
@@ -1,4 +1,4 @@
{ {
"files": [], "files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }, { "path": "./tsconfig.server.json" }]
} }
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node", "vitest/globals"],
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["server", "shared", "drizzle.config.ts"]
}
+12 -4
View File
@@ -10,10 +10,18 @@ const { version } = JSON.parse(readFileSync(new URL('./package.json', import.met
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
define: { __APP_VERSION__: JSON.stringify(version) }, define: { __APP_VERSION__: JSON.stringify(version) },
server: {
proxy: { '/api': 'http://localhost:3000' },
},
test: { test: {
environment: 'jsdom', projects: [
globals: true, {
setupFiles: ['./src/test/setup.ts'], extends: true,
include: ['src/**/*.test.{ts,tsx}'], test: { name: 'web', environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], include: ['src/**/*.test.{ts,tsx}'] },
},
{
test: { name: 'server', environment: 'node', globals: true, include: ['server/**/*.test.ts', 'shared/**/*.test.ts'] },
},
],
}, },
}) })