diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..756d82d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +data +.git +.env +test-results +playwright-report +coverage diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5c60b20 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 987e2be..a979a8b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ coverage test-results playwright-report blob-report + +# Local data and config +data +.env diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index b37e6c9..8daf21e 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -36,7 +36,7 @@ This Code of Conduct applies within all community spaces, and also applies when ## 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f44ebb..1c8acf8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,12 +12,13 @@ Everyone taking part is expected to follow our [Code of Conduct](CODE_OF_CONDUCT ## 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 npm install 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: @@ -32,7 +33,10 @@ CI runs the same checks on every pull request. ## Code guidelines - **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. - **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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1b28193 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 06ade25..94278f5 100644 --- a/README.md +++ b/README.md @@ -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. -> **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 -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 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 `. + ## Scripts | 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 lint` | Lint with Oxlint (warnings fail) | | `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 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 ``` -src/ +src/ Web app (React) pages/ One component per route components/ Shared UI (layout, tables, form pieces) and shared class strings in styles.ts lib/ Types, domain logic, demo data; no React components 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 -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. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b8294b9 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..f2ca3f0 --- /dev/null +++ b/docs/API.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 08fdecf..1a949e3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -10,8 +10,10 @@ This is a living document. Update it as decisions are made or scope changes. | --- | --- | --- | | License | AGPL-3.0-only | Decided | | Frontend | React + Vite + TypeScript, Tailwind CSS v4 | Decided | -| Backend | Node (Fastify or Hono) + PostgreSQL + Drizzle ORM, shared Zod schemas | Proposed | -| Deployment | Self-hosted, single `docker compose up` (app + Postgres + file storage) | 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 | +| 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 | | 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 | @@ -32,13 +34,21 @@ This is a living document. Update it as decisions are made or scope changes. ## 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 -- Auth: email/password and magic links; SSO/OIDC later; role-based permissions -- REST API with pagination, filtering and sequential incident numbering +- [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 +- [x] First-run setup (creates the organization and first admin) +- [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) -- 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) - Status workflow: Reported → Triage → Investigation → Corrective actions → Verification → Closed, with role-based transitions - "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. -## 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 - Export, Add location, Settings, Resources, search, notifications and workspace switcher are non-functional diff --git a/docs/TODO.md b/docs/TODO.md index f970cc3..be2b855 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -4,12 +4,20 @@ Open items that aren't tied to a single roadmap phase. Tick them off (or delete ## 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@`) 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. - [ ] **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 +- [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] CONTRIBUTING.md and CODE_OF_CONDUCT.md (Contributor Covenant 2.1) - [x] About page with source-code link (AGPL-3.0 section 13) diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..f4fddb4 --- /dev/null +++ b/drizzle.config.ts @@ -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', +}) diff --git a/package-lock.json b/package-lock.json index 8abfbff..bcc6e07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,33 +9,43 @@ "version": "0.1.0", "license": "AGPL-3.0-only", "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/space-grotesk": "^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", + "@tailwindcss/vite": "^4.3.3", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", "@types/node": "^26.6.4", + "@types/pg": "^8.23.1", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.1.0", + "concurrently": "^10.0.5", + "drizzle-kit": "^0.31.11", "jsdom": "^30.1.2", + "lucide-react": "^1.39.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", "vite": "^8.2.2", "vitest": "^5.0.3" }, "engines": { - "node": ">=22.12" + "node": ">=22.18" } }, "node_modules/@adobe/css-tools": { @@ -268,6 +278,897 @@ "node": ">=20.19.0" } }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz", + "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==", + "license": "Apache-2.0" + }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@exodus/bytes": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", @@ -290,6 +1191,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource-variable/dm-sans/-/dm-sans-5.3.0.tgz", "integrity": "sha512-BpUG5bqePiDFMBM4ZtLSlPdAIOM990uB1dlXzIf4aw21yQR7BmykedLXXXMqGWsR18aMLOsbWccwJNh3ztTQaA==", + "dev": true, "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -299,6 +1201,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource-variable/space-grotesk/-/space-grotesk-5.3.0.tgz", "integrity": "sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw==", + "dev": true, "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -308,15 +1211,39 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource/dm-mono/-/dm-mono-5.3.0.tgz", "integrity": "sha512-OINjI8C1S/wpchhQxl7njZdMn4+hnDCpQ4YtvvOpKNARo+0J8O1x1IcrChxNjHOhfVv1by8C/FQoy3hXK+C1Ug==", + "dev": true, "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@hono/node-server": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.3.tgz", + "integrity": "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@hono/zod-validator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@hono/zod-validator/-/zod-validator-0.9.1.tgz", + "integrity": "sha512-iiv6w0qrIc0arfvCtUqBWsvl4fXjzaTcQcCJTTtCnkawF9HHGE+KjEl0ox3gQJ6rKZEgE3mLExlQCF72M+mNuw==", + "license": "MIT", + "peerDependencies": { + "hono": ">=4.11.2", + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -327,6 +1254,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -337,6 +1265,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -346,12 +1275,14 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -362,6 +1293,7 @@ "version": "0.148.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/oxc-project" @@ -737,6 +1669,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -753,6 +1686,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -769,6 +1703,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -785,6 +1720,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -801,6 +1737,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -817,6 +1754,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -833,6 +1771,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "glibc" ], @@ -852,6 +1791,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "musl" ], @@ -871,6 +1811,7 @@ "cpu": [ "ppc64" ], + "dev": true, "libc": [ "glibc" ], @@ -890,6 +1831,7 @@ "cpu": [ "s390x" ], + "dev": true, "libc": [ "glibc" ], @@ -909,6 +1851,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "glibc" ], @@ -928,6 +1871,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "musl" ], @@ -947,6 +1891,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -963,6 +1908,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -979,6 +1925,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -992,12 +1939,14 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, "license": "MIT" }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", @@ -1013,6 +1962,7 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -1045,6 +1995,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1065,6 +2016,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1085,6 +2037,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1105,6 +2058,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1125,6 +2079,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1145,6 +2100,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "glibc" ], @@ -1168,6 +2124,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "musl" ], @@ -1191,6 +2148,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "glibc" ], @@ -1214,6 +2172,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "musl" ], @@ -1237,6 +2196,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1257,6 +2217,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -1274,6 +2235,7 @@ "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 20" @@ -1300,6 +2262,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1316,6 +2279,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1332,6 +2296,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1348,6 +2313,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1364,6 +2330,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1380,6 +2347,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "glibc" ], @@ -1399,6 +2367,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "musl" ], @@ -1418,6 +2387,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "glibc" ], @@ -1437,6 +2407,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "musl" ], @@ -1464,6 +2435,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1485,6 +2457,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1501,6 +2474,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1514,6 +2488,7 @@ "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, "license": "MIT", "dependencies": { "@tailwindcss/node": "4.3.3", @@ -1666,6 +2641,18 @@ "undici-types": "~8.9.0" } }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -2159,6 +3146,13 @@ "require-from-string": "^2.0.2" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/chai": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", @@ -2169,10 +3163,64 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/concurrently": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz", + "integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.9.0", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -2245,6 +3293,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -2258,10 +3307,647 @@ "license": "MIT", "peer": true }, + "node_modules/drizzle-kit": { + "version": "0.31.11", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.11.tgz", + "integrity": "sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "tsx": "^4.21.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.3", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.3.tgz", + "integrity": "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@netlify/db": ">=0.4.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@netlify/db": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.24.5", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -2291,6 +3977,58 @@ "dev": true, "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -2315,6 +4053,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -2332,6 +4071,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2342,12 +4082,58 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, + "node_modules/hono": { + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", @@ -2382,6 +4168,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -2439,6 +4226,7 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -2471,6 +4259,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2491,6 +4280,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2511,6 +4301,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2531,6 +4322,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2551,6 +4343,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2571,6 +4364,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "glibc" ], @@ -2594,6 +4388,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "musl" ], @@ -2617,6 +4412,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "glibc" ], @@ -2640,6 +4436,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "musl" ], @@ -2663,6 +4460,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2683,6 +4481,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2710,6 +4509,7 @@ "version": "1.39.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.39.0.tgz", "integrity": "sha512-y8nXoEwvqqIsF927NBWXODa4bfMrcUeEb/9sgpwFqg0gUjgn3j5Hznk+v7STmPgZ2iQ11JKlbQGdFRuTOwvYkA==", + "dev": true, "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -2730,6 +4530,7 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -2756,6 +4557,7 @@ "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, "funding": [ { "type": "github", @@ -2846,16 +4648,107 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2897,6 +4790,7 @@ "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -2921,6 +4815,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -2951,6 +4884,7 @@ "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -2960,6 +4894,7 @@ "version": "19.2.8", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -2980,6 +4915,7 @@ "version": "7.18.3", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", + "dev": true, "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -3002,6 +4938,7 @@ "version": "7.18.3", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz", "integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==", + "dev": true, "license": "MIT", "dependencies": { "react-router": "7.18.3" @@ -3038,10 +4975,21 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/rolldown": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "dev": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.148.0", @@ -3071,6 +5019,16 @@ "@rolldown/binding-win32-x64-msvc": "1.2.7" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -3088,23 +5046,69 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, "license": "MIT" }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "dev": true, "license": "MIT" }, + "node_modules/shell-quote": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/std-env": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", @@ -3112,6 +5116,53 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -3125,16 +5176,31 @@ "node": ">=8" } }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, "license": "MIT" }, "node_modules/tapable": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3168,6 +5234,7 @@ "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -3226,6 +5293,42 @@ "node": "^22.14.0 || >=24.0.0" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -3282,6 +5385,7 @@ "version": "8.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", @@ -3509,6 +5613,37 @@ "node": ">=20.11" } }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -3525,6 +5660,62 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index e38d6dd..ba32edb 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,13 @@ "private": true, "type": "module", "engines": { - "node": ">=22.12" + "node": ">=22.18" }, "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", "preview": "vite preview", "lint": "oxlint --deny-warnings", @@ -17,30 +20,42 @@ "test": "vitest run", "test:watch": "vitest", "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" }, "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/space-grotesk": "^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", + "@tailwindcss/vite": "^4.3.3", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", "@types/node": "^26.6.4", + "@types/pg": "^8.23.1", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.1.0", + "concurrently": "^10.0.5", + "drizzle-kit": "^0.31.11", "jsdom": "^30.1.2", + "lucide-react": "^1.39.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", "vite": "^8.2.2", "vitest": "^5.0.3" diff --git a/server/app.ts b/server/app.ts new file mode 100644 index 0000000..4611f8e --- /dev/null +++ b/server/app.ts @@ -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() + + 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 diff --git a/server/auth/middleware.ts b/server/auth/middleware.ts new file mode 100644 index 0000000..a4657ab --- /dev/null +++ b/server/auth/middleware.ts @@ -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, 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 = 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 = async (c, next) => { + currentUser(c) + await next() +} + +export function requireRole(minimum: Role): MiddlewareHandler { + 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, userId: string): Promise { + const { token, expiresAt } = await createSession(c.get('db'), userId) + writeCookie(c, token, expiresAt) +} + +export async function endSession(c: Context): Promise { + const token = getCookie(c, SESSION_COOKIE) + if (token) await deleteSession(c.get('db'), token) + deleteCookie(c, SESSION_COOKIE, { path: '/' }) +} diff --git a/server/auth/password.test.ts b/server/auth/password.test.ts new file mode 100644 index 0000000..cb9381c --- /dev/null +++ b/server/auth/password.test.ts @@ -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) + }) +}) diff --git a/server/auth/password.ts b/server/auth/password.ts new file mode 100644 index 0000000..6b89739 --- /dev/null +++ b/server/auth/password.ts @@ -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 { + 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 { + 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 { + 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 | undefined +export async function verifyAgainstDummy(password: string): Promise { + dummyHash ??= hashPassword('dummy-password-for-timing') + await verifyPassword(password, await dummyHash) + return false +} diff --git a/server/auth/rate-limit.ts b/server/auth/rate-limit.ts new file mode 100644 index 0000000..f8bd22d --- /dev/null +++ b/server/auth/rate-limit.ts @@ -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() + 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) + } +} diff --git a/server/auth/sessions.ts b/server/auth/sessions.ts new file mode 100644 index 0000000..214c7b7 --- /dev/null +++ b/server/auth/sessions.ts @@ -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 { + await db.delete(sessions).where(eq(sessions.id, hashToken(token))) +} + +export async function deleteUserSessions(db: Db, userId: string): Promise { + await db.delete(sessions).where(eq(sessions.userId, userId)) +} diff --git a/server/config.ts b/server/config.ts new file mode 100644 index 0000000..18609f5 --- /dev/null +++ b/server/config.ts @@ -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', + } +} diff --git a/server/db/client.ts b/server/db/client.ts new file mode 100644 index 0000000..1a941d1 --- /dev/null +++ b/server/db/client.ts @@ -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 + +export interface Database { + db: Db + close(): Promise +} + +const migrationsFolder = join(import.meta.dirname, 'migrations') + +/** + * Opens the database and applies pending migrations. + * + * - `postgres://…` connects to PostgreSQL (production). + * - `pglite://` uses embedded PostgreSQL stored in `` (local development, very small installs). + * - `memory://` uses a throwaway in-memory database (tests). + */ +export async function openDatabase(url: string): Promise { + 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 +} diff --git a/server/db/migrations/0000_initial.sql b/server/db/migrations/0000_initial.sql new file mode 100644 index 0000000..2b04888 --- /dev/null +++ b/server/db/migrations/0000_initial.sql @@ -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"); \ No newline at end of file diff --git a/server/db/migrations/meta/0000_snapshot.json b/server/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..4f2f579 --- /dev/null +++ b/server/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,1828 @@ +{ + "id": "d7027c91-94c4-4d75-ad61-9360e917a6b4", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.areas": { + "name": "areas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "area_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'area'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "areas_site_idx": { + "name": "areas_site_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "areas_parent_idx": { + "name": "areas_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "areas_site_id_sites_id_fk": { + "name": "areas_site_id_sites_id_fk", + "tableFrom": "areas", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "areas_parent_id_areas_id_fk": { + "name": "areas_parent_id_areas_id_fk", + "tableFrom": "areas", + "tableTo": "areas", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "incident_id": { + "name": "incident_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "attachments_incident_idx": { + "name": "attachments_incident_idx", + "columns": [ + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_incident_id_incidents_id_fk": { + "name": "attachments_incident_id_incidents_id_fk", + "tableFrom": "attachments", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_id_users_id_fk": { + "name": "attachments_uploaded_by_id_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "attachments_storageKey_unique": { + "name": "attachments_storageKey_unique", + "nullsNotDistinct": false, + "columns": [ + "storage_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_entity_idx": { + "name": "audit_log_entity_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_organization_id_organizations_id_fk": { + "name": "audit_log_organization_id_organizations_id_fk", + "tableFrom": "audit_log", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "incident_id": { + "name": "incident_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_incident_idx": { + "name": "comments_incident_idx", + "columns": [ + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_incident_id_incidents_id_fk": { + "name": "comments_incident_id_incidents_id_fk", + "tableFrom": "comments", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_author_id_users_id_fk": { + "name": "comments_author_id_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.corrective_actions": { + "name": "corrective_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "incident_id": { + "name": "incident_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "root_cause_id": { + "name": "root_cause_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "action_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'corrective'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "action_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "verified_by_id": { + "name": "verified_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "corrective_actions_incident_idx": { + "name": "corrective_actions_incident_idx", + "columns": [ + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "corrective_actions_assignee_status_idx": { + "name": "corrective_actions_assignee_status_idx", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "corrective_actions_incident_id_incidents_id_fk": { + "name": "corrective_actions_incident_id_incidents_id_fk", + "tableFrom": "corrective_actions", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "corrective_actions_root_cause_id_root_causes_id_fk": { + "name": "corrective_actions_root_cause_id_root_causes_id_fk", + "tableFrom": "corrective_actions", + "tableTo": "root_causes", + "columnsFrom": [ + "root_cause_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "corrective_actions_assignee_id_users_id_fk": { + "name": "corrective_actions_assignee_id_users_id_fk", + "tableFrom": "corrective_actions", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "corrective_actions_verified_by_id_users_id_fk": { + "name": "corrective_actions_verified_by_id_users_id_fk", + "tableFrom": "corrective_actions", + "tableTo": "users", + "columnsFrom": [ + "verified_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hours_worked": { + "name": "hours_worked", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "hours": { + "name": "hours", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hours_worked_site_month_unique": { + "name": "hours_worked_site_month_unique", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hours_worked_site_id_sites_id_fk": { + "name": "hours_worked_site_id_sites_id_fk", + "tableFrom": "hours_worked", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.incident_counters": { + "name": "incident_counters", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "last_number": { + "name": "last_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "incident_counters_organization_id_organizations_id_fk": { + "name": "incident_counters_organization_id_organizations_id_fk", + "tableFrom": "incident_counters", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.incidents": { + "name": "incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "incident_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "incident_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'reported'" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "area_id": { + "name": "area_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "location_detail": { + "name": "location_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "immediate_action": { + "name": "immediate_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "anonymous": { + "name": "anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "incidents_organization_number_unique": { + "name": "incidents_organization_number_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "incidents_organization_occurred_idx": { + "name": "incidents_organization_occurred_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "incidents_organization_status_idx": { + "name": "incidents_organization_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "incidents_reporter_idx": { + "name": "incidents_reporter_idx", + "columns": [ + { + "expression": "reporter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "incidents_organization_id_organizations_id_fk": { + "name": "incidents_organization_id_organizations_id_fk", + "tableFrom": "incidents", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incidents_site_id_sites_id_fk": { + "name": "incidents_site_id_sites_id_fk", + "tableFrom": "incidents", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incidents_area_id_areas_id_fk": { + "name": "incidents_area_id_areas_id_fk", + "tableFrom": "incidents", + "tableTo": "areas", + "columnsFrom": [ + "area_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "incidents_reporter_id_users_id_fk": { + "name": "incidents_reporter_id_users_id_fk", + "tableFrom": "incidents", + "tableTo": "users", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.injured_persons": { + "name": "injured_persons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "incident_id": { + "name": "incident_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "employment_type": { + "name": "employment_type", + "type": "employment_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'employee'" + }, + "body_part": { + "name": "body_part", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nature_of_injury": { + "name": "nature_of_injury", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "treatment": { + "name": "treatment", + "type": "treatment_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "days_away": { + "name": "days_away", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "days_restricted": { + "name": "days_restricted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "privacy_case": { + "name": "privacy_case", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "injured_persons_incident_idx": { + "name": "injured_persons_incident_idx", + "columns": [ + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "injured_persons_incident_id_incidents_id_fk": { + "name": "injured_persons_incident_id_incidents_id_fk", + "tableFrom": "injured_persons", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "injured_persons_user_id_users_id_fk": { + "name": "injured_persons_user_id_users_id_fk", + "tableFrom": "injured_persons", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.investigations": { + "name": "investigations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "incident_id": { + "name": "incident_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "rca_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'five_whys'" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "investigations_incident_id_incidents_id_fk": { + "name": "investigations_incident_id_incidents_id_fk", + "tableFrom": "investigations", + "tableTo": "incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "investigations_lead_id_users_id_fk": { + "name": "investigations_lead_id_users_id_fk", + "tableFrom": "investigations", + "tableTo": "users", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "investigations_incidentId_unique": { + "name": "investigations_incidentId_unique", + "nullsNotDistinct": false, + "columns": [ + "incident_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.root_causes": { + "name": "root_causes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "investigation_id": { + "name": "investigation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "root_cause_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "whys": { + "name": "whys", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "root_causes_investigation_idx": { + "name": "root_causes_investigation_idx", + "columns": [ + { + "expression": "investigation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "root_causes_investigation_id_investigations_id_fk": { + "name": "root_causes_investigation_id_investigations_id_fk", + "tableFrom": "root_causes", + "tableTo": "investigations", + "columnsFrom": [ + "investigation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sites": { + "name": "sites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sites_organization_name_unique": { + "name": "sites_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sites_organization_id_organizations_id_fk": { + "name": "sites_organization_id_organizations_id_fk", + "tableFrom": "sites", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'reporter'" + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_organization_idx": { + "name": "users_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_organization_id_organizations_id_fk": { + "name": "users_organization_id_organizations_id_fk", + "tableFrom": "users", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.action_kind": { + "name": "action_kind", + "schema": "public", + "values": [ + "corrective", + "preventive" + ] + }, + "public.action_status": { + "name": "action_status", + "schema": "public", + "values": [ + "open", + "in_progress", + "done", + "verified", + "cancelled" + ] + }, + "public.area_kind": { + "name": "area_kind", + "schema": "public", + "values": [ + "building", + "area", + "line", + "other" + ] + }, + "public.employment_type": { + "name": "employment_type", + "schema": "public", + "values": [ + "employee", + "temporary", + "contractor", + "visitor" + ] + }, + "public.incident_status": { + "name": "incident_status", + "schema": "public", + "values": [ + "reported", + "triage", + "investigation", + "actions", + "verification", + "closed" + ] + }, + "public.incident_type": { + "name": "incident_type", + "schema": "public", + "values": [ + "near_miss", + "hazard", + "injury", + "illness", + "property_damage", + "environmental" + ] + }, + "public.rca_method": { + "name": "rca_method", + "schema": "public", + "values": [ + "five_whys", + "fishbone", + "other" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "reporter", + "supervisor", + "safety_manager", + "admin" + ] + }, + "public.root_cause_category": { + "name": "root_cause_category", + "schema": "public", + "values": [ + "people", + "process", + "equipment", + "materials", + "environment", + "management" + ] + }, + "public.severity": { + "name": "severity", + "schema": "public", + "values": [ + "low", + "medium", + "high", + "critical" + ] + }, + "public.treatment_level": { + "name": "treatment_level", + "schema": "public", + "values": [ + "none", + "first_aid", + "medical_treatment", + "hospitalized", + "fatality" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/db/migrations/meta/_journal.json b/server/db/migrations/meta/_journal.json new file mode 100644 index 0000000..193ca56 --- /dev/null +++ b/server/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1791306770507, + "tag": "0000_initial", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/server/db/schema.ts b/server/db/schema.ts new file mode 100644 index 0000000..72e7c8b --- /dev/null +++ b/server/db/schema.ts @@ -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().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)], +) diff --git a/server/db/seed.ts b/server/db/seed.ts new file mode 100644 index 0000000..e625dfc --- /dev/null +++ b/server/db/seed.ts @@ -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 = { + 'North Campus': ['Building A', 'Warehouse 04', 'Lab 2'], + 'South Campus': ['Fabrication 02', 'Yard', 'Office block'], + 'West Campus': ['Warehouse 01', 'Loading dock'], + } + const areaIds: Record = {} + 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() +} diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..0ef1fc5 --- /dev/null +++ b/server/index.ts @@ -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) + }) + }) +} diff --git a/server/lib/audit.ts b/server/lib/audit.ts new file mode 100644 index 0000000..02a943b --- /dev/null +++ b/server/lib/audit.ts @@ -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 | null +} + +export async function audit(db: Db, entry: AuditEntry): Promise { + 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, next: Record): Record { + const changes: Record = {} + 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 +} diff --git a/server/lib/http.ts b/server/lib/http.ts new file mode 100644 index 0000000..cf35904 --- /dev/null +++ b/server/lib/http.ts @@ -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: 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): SessionUser { + const user = c.get('user') + if (!user) throw fail(401, 'Sign in required') + return user +} diff --git a/server/routes/auth.test.ts b/server/routes/auth.test.ts new file mode 100644 index 0000000..75f5fc6 --- /dev/null +++ b/server/routes/auth.test.ts @@ -0,0 +1,91 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { ADMIN, createTestContext } from '../test/helpers.ts' + +let ctx: Awaited> +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) + }) +}) diff --git a/server/routes/auth.ts b/server/routes/auth.ts new file mode 100644 index 0000000..194d5e8 --- /dev/null +++ b/server/routes/auth.ts @@ -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() + .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) + }) +} diff --git a/server/routes/incidents.test.ts b/server/routes/incidents.test.ts new file mode 100644 index 0000000..9fcdc36 --- /dev/null +++ b/server/routes/incidents.test.ts @@ -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> +let site: { id: string } +let area: { id: string } +let reporter: Awaited> +let supervisor: Awaited> + +const report = (overrides: Record = {}) => ({ + 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 }) + }) +}) diff --git a/server/routes/incidents.ts b/server/routes/incidents.ts new file mode 100644 index 0000000..528117b --- /dev/null +++ b/server/routes/incidents.ts @@ -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() + .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) }) + }) diff --git a/server/routes/setup.ts b/server/routes/setup.ts new file mode 100644 index 0000000..1e74106 --- /dev/null +++ b/server/routes/setup.ts @@ -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() + .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) + }) diff --git a/server/routes/sites.test.ts b/server/routes/sites.test.ts new file mode 100644 index 0000000..9cbe297 --- /dev/null +++ b/server/routes/sites.test.ts @@ -0,0 +1,51 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createTestContext } from '../test/helpers.ts' + +let ctx: Awaited> +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) + }) +}) diff --git a/server/routes/sites.ts b/server/routes/sites.ts new file mode 100644 index 0000000..934953e --- /dev/null +++ b/server/routes/sites.ts @@ -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() + .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().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 }) +}) diff --git a/server/routes/users.test.ts b/server/routes/users.test.ts new file mode 100644 index 0000000..c666161 --- /dev/null +++ b/server/routes/users.test.ts @@ -0,0 +1,56 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createTestContext } from '../test/helpers.ts' + +let ctx: Awaited> +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) + }) + +}) diff --git a/server/routes/users.ts b/server/routes/users.ts new file mode 100644 index 0000000..fe4b499 --- /dev/null +++ b/server/routes/users.ts @@ -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() + .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 }) + }) diff --git a/server/test/helpers.ts b/server/test/helpers.ts new file mode 100644 index 0000000..a5b48cb --- /dev/null +++ b/server/test/helpers.ts @@ -0,0 +1,61 @@ +import { createApp } from '../app.ts' +import { loadConfig } from '../config.ts' +import { openDatabase } from '../db/client.ts' + +type App = ReturnType + +/** 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 = {} + 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() diff --git a/shared/enums.ts b/shared/enums.ts new file mode 100644 index 0000000..9b65f64 --- /dev/null +++ b/shared/enums.ts @@ -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 diff --git a/shared/format.ts b/shared/format.ts new file mode 100644 index 0000000..37f43c5 --- /dev/null +++ b/shared/format.ts @@ -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')}` +} diff --git a/shared/schemas.ts b/shared/schemas.ts new file mode 100644 index 0000000..524afe3 --- /dev/null +++ b/shared/schemas.ts @@ -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 +export type CreateIncidentInput = z.input +export type UpdateIncidentInput = z.infer +export type IncidentListQuery = z.infer diff --git a/tsconfig.app.json b/tsconfig.app.json index 8f7dc61..59cee9b 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -18,5 +18,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] + "include": ["src", "shared"] } diff --git a/tsconfig.json b/tsconfig.json index d32ff68..bb7d1f7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,4 @@ { "files": [], - "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }, { "path": "./tsconfig.server.json" }] } diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..725de0d --- /dev/null +++ b/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"] +} diff --git a/vite.config.ts b/vite.config.ts index 1ef62e4..59d840e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,10 +10,18 @@ const { version } = JSON.parse(readFileSync(new URL('./package.json', import.met export default defineConfig({ plugins: [react(), tailwindcss()], define: { __APP_VERSION__: JSON.stringify(version) }, + server: { + proxy: { '/api': 'http://localhost:3000' }, + }, test: { - environment: 'jsdom', - globals: true, - setupFiles: ['./src/test/setup.ts'], - include: ['src/**/*.test.{ts,tsx}'], + projects: [ + { + extends: true, + 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'] }, + }, + ], }, })