feat: implement incident reporting and management system
- 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:
1 parent
a382fa904a
commit
c69eccb5c1
47 files changed
+6297
-45
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
data
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
coverage
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
Generated
+2200
-9
File diff suppressed because it is too large.
Load diff
+25
-10
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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: '/' })
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791306770507,
|
||||||
|
"tag": "0000_initial",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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)],
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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) })
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 })
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
@@ -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 })
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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')}`
|
||||||
|
}
|
||||||
@@ -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
@@ -18,5 +18,5 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "shared"]
|
||||||
}
|
}
|
||||||
+1
-1
@@ -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" }]
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
Reference in new issue
Block a user