diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs
index 669f3da..cd7fe68 100644
--- a/.dependency-cruiser.cjs
+++ b/.dependency-cruiser.cjs
@@ -29,7 +29,14 @@ module.exports = {
severity: "error",
comment: "Feature modules never open connections or run migrations: createDatabase()/runMigrations() belong to the composition roots (src/app/_server, src/*.ts entrypoints). Types and table definitions (src/db/schema) are fine.",
from: { path: "^src/features/" },
- to: { path: "^src/db/(index|client|migrate)\\.ts$", dependencyTypesNot: ["type-only"] },
+ to: { path: "^src/db/(index|client|migrate|job-queue-client)\\.ts$", dependencyTypesNot: ["type-only"] },
+ },
+ {
+ name: "pg-boss-client-only-in-db",
+ severity: "error",
+ comment: "pg-boss opens its own pool: construct it only in src/db (job-queue.ts); features use injected JobSender types.",
+ from: { path: "^src/", pathNot: "^src/db/" },
+ to: { path: "(^|/)node_modules/pg-boss(/|$)", dependencyTypesNot: ["type-only"] },
},
{
name: "not-to-unresolvable",
diff --git a/.env.example b/.env.example
index 5e27cb4..a134d7a 100644
--- a/.env.example
+++ b/.env.example
@@ -29,6 +29,31 @@ S3_FORCE_PATH_STYLE=true
# Host port of the local S3 gateway.
S3_PORT=8333
+# --- Environment -------------------------------------------------------------------------------
+# local | showcase | production. Only `local` accepts the committed local-default auth secret.
+APP_ENV=local
+
+# --- Authentication (Better Auth) ---------------------------------------------------------------
+# Signs sessions and cookies; at least 32 characters. Generate a real one: `openssl rand -base64 32`.
+BETTER_AUTH_SECRET=local-dev-only-secret-change-me-0123456789
+# Public base URL of the web app (cookies, redirects, trusted origin).
+BETTER_AUTH_URL=http://localhost:3000
+# Client IP for the login rate limit. Set to what YOUR reverse proxy writes and list the proxy
+# addresses; without trusted proxies only a single-value header is trusted. If the web container is
+# reachable without a proxy, clients can forge this header – put a proxy in front (see operations.md).
+AUTH_IP_HEADERS=x-forwarded-for
+AUTH_TRUSTED_PROXIES=
+
+# Demo seed only (`pnpm seed:demo`): password of the synthetic demo accounts, local use only.
+SEED_PASSWORD=demo-password-local-only
+
+# --- Upload limits (intake) ---------------------------------------------------------------------
+# Maximum size per file in bytes (default 20 MiB) and files per request (default 10).
+UPLOAD_MAX_FILE_BYTES=20971520
+UPLOAD_MAX_FILES=10
+# Cap of one whole upload request (all files + form overhead); requests without Content-Length are refused.
+UPLOAD_MAX_REQUEST_BYTES=41943040
+
# --- Web ---------------------------------------------------------------------------------------
# Host port of the web container.
WEB_PORT=3000
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 20f4a7d..3e9f1bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,14 @@ This file records what changes **in the product** – process and session state
## [Unreleased]
### Added
+- Upload of a quote request (`/requests`): .eml, .msg, .pdf, .xlsx, .docx up to a configured size;
+ originals stored privately, download only for the own company. Request, documents, audit entry and
+ the processing job are created in one step; exact duplicates are flagged and linked.
+- Invite-only login (e-mail + password): admins invite staff into their own company and hand over
+ an invitation link; sign-up without a valid invitation link creates no account. Roles `admin` and `clerk` per company.
+- Tenant isolation: every company-owned table has forced row-level security; data access runs
+ inside `withTenant()`.
+- Login rate limit (stored in the database) and `pnpm seed:demo` with two synthetic companies.
- Runnable local stack: `docker compose up` starts PostgreSQL 17, SeaweedFS (S3), a one-shot `setup`
step (migrations + private bucket), the web app and a no-op worker.
- `GET /api/health` reports database and storage status (200 / 503, no connection details).
diff --git a/compose.yaml b/compose.yaml
index bbe54fb..fd11f01 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -54,6 +54,9 @@ services:
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-local-access-key}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-local-secret-key}
S3_FORCE_PATH_STYLE: "true"
+ BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-local-dev-only-secret-change-me-0123456789}
+ BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
+ APP_ENV: ${APP_ENV:-local}
depends_on:
postgres:
condition: service_healthy
diff --git a/docs/technical/api.md b/docs/technical/api.md
index 2742ae7..86bd935 100644
--- a/docs/technical/api.md
+++ b/docs/technical/api.md
@@ -17,3 +17,28 @@
(showcase epic #19).
- Checks are added additively (queue backlog and AI service follow with #28); clients must ignore
unknown check names.
+
+## `POST /api/requests` (session required)
+
+Multipart form, field `files` (1–`UPLOAD_MAX_FILES` files, each ≤ `UPLOAD_MAX_FILE_BYTES`, whole request
+≤ `UPLOAD_MAX_REQUEST_BYTES`, `Content-Length` required). Allowed: `.eml .msg .pdf .xlsx .docx`, checked
+by extension **and** content: signature for all; for `.xlsx/.docx` also the package structure (no
+macros, no foreign ZIPs, declared size/entry limits). `.msg` is checked by its OLE signature only.
+
+| Status | Body |
+|---|---|
+| 201 | `{"requestId": uuid, "possibleDuplicate": bool, "duplicateOfId": uuid \| null}` |
+| 401 / 403 | `{"error":{"title":"…"}}` – not signed in / role |
+| 411 | no numeric `Content-Length` (the body is bounded before it is read) |
+| 413 | declared body larger than `UPLOAD_MAX_REQUEST_BYTES` |
+| 422 | `{"error":{"title":"Dateityp nicht erlaubt: …"}}` – user-facing reason |
+| 500 | generic message; details only as IDs in the log |
+
+Request (NEW), documents, audit event and the `request-process` job commit in one transaction.
+Duplicates: same `Message-ID` (read from `.eml` only – `.msg` Message-ID extraction is a follow-up)
+or the same set of file hashes within the company; checks are serialised per company.
+
+## `GET /api/documents/:id` (session required)
+
+Streams the original as `attachment` with `x-content-type-options: nosniff` and `cache-control: private,
+no-store`. A document of another company answers 404 like a missing one; no session → 401.
diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md
index 53f74f4..49b2913 100644
--- a/docs/technical/architecture.md
+++ b/docs/technical/architecture.md
@@ -12,8 +12,9 @@ approve them, and exports each approved request exactly once to an ERP (mock in
It is a TypeScript modular monolith (`web` + `worker` from one codebase) on PostgreSQL, plus the
AI service. Decisions and rationale: [ADR-0001](../decisions/ADR-0001-pilot-architecture.md).
-**Current state (2026-09-22): app skeleton (#3)** – runnable stack, health endpoint, database roles and
-schema `app`, module skeletons with enforced boundaries. Status per module below (`skeleton` = public
+**Current state (2026-09-23): #3 skeleton, #4 identity/tenancy, #5 intake** – runnable stack, health
+endpoint, invite-only login, companies, `withTenant()` with forced RLS, upload with atomic enqueue
+and duplicate flags, module boundaries enforced. Tables: [data-model.md](data-model.md). Status per module below (`skeleton` = public
`index.ts` only).
## Modules
@@ -22,22 +23,22 @@ Every new file belongs to one of these modules – otherwise add the module here
| Module | Location | Task | Exposure | Data class | Protection | Status |
|---|---|---|---|---|---|---|
-| `intake` | `src/features/intake/` | upload, duplicate fingerprint, creates request + documents | authenticated UI/route | confidential + personal | session, tenant context, size/type limits | skeleton |
-| `documents` | `src/features/documents/` | document records, storage references, hashes | internal | confidential | tenant context | skeleton |
+| `intake` | `src/features/intake/` | upload, duplicate fingerprint, creates request + documents | authenticated UI/route | confidential + personal | session, tenant context, size/type limits | built: upload validation (extension + signature, size), fingerprint, atomic submit |
+| `documents` | `src/features/documents/` | document records, storage references, hashes | internal | confidential | tenant context | built: records, SHA-256, storage keys |
| `extraction` | `src/features/extraction/` | AI-service client, persists runs/fields/evidence | internal | confidential + personal | tenant context, contract validation | skeleton |
-| `requests` | `src/features/requests/` | request aggregate, status machine | internal | confidential | tenant context | skeleton |
+| `requests` | `src/features/requests/` | request aggregate, status machine | internal | confidential | tenant context | partial: `app.requests` + repository (status machine: #7) |
| `review` | `src/features/review/` | review UI, corrections, approve/reject | authenticated UI | confidential + personal | session, role check, audit | skeleton |
| `export` | `src/features/export/` | ERP port + REST adapter, idempotency | outbound HTTP | confidential | idempotency key, unique export, timeout | skeleton |
| `erp-mock` | `src/features/erp-mock/` | simulated ERP REST API | route behind flag | synthetic | disabled unless `ERP_MOCK_ENABLED` | skeleton |
-| `identity` | `src/features/identity/` | Better Auth, users, companies, roles | public login route | personal (staff) | rate limit, invite-only | skeleton |
-| `tenancy` | `src/features/tenancy/` | `withTenant()`, RLS policies | internal | – | forced RLS, `app_rw` without BYPASSRLS | skeleton |
-| `audit` | `src/features/audit/` | append-only audit events | internal | personal (staff) | INSERT/SELECT only | skeleton |
-| `jobs` | `src/features/jobs/`, entrypoint `src/worker.ts` | pg-boss, job handlers, `drain()`, worker entrypoint | internal | IDs only | transactional enqueue | skeleton (no-op worker) |
-| `storage` | `src/features/storage/` | `BlobStore` port + S3 adapter | internal | confidential | private bucket, access via app routes | partial: S3 adapter, bucket setup, health ping |
+| `identity` | `src/features/identity/` | Better Auth, users, companies, roles | public login route | personal (staff) | rate limit, invite-only | partial: Better Auth (invite-only, organization + admin plugins), `authorize()`, invite, seed |
+| `tenancy` | `src/features/tenancy/` | `withTenant()`, RLS policies | internal | – | forced RLS, `app_rw` without BYPASSRLS | built: `withTenant()`, forced RLS on `app.*` |
+| `audit` | `src/features/audit/` | append-only audit events | internal | personal (staff) | INSERT/SELECT only | partial: `recordAudit()` (append-only enforced by grants) |
+| `jobs` | `src/features/jobs/`, entrypoint `src/worker.ts` | pg-boss, job handlers, `drain()`, worker entrypoint | internal | IDs only | transactional enqueue | partial: queues + transactional enqueue; worker no-op until #7 |
+| `storage` | `src/features/storage/` | `BlobStore` port + S3 adapter | internal | confidential | private bucket, access via app routes | built: S3 adapter (put/get/delete, bucket setup, ping) |
| `observability` | `src/features/observability/` | logger, health, request-list ops data | `/api/health` | IDs only | no PII in logs | partial: health aggregation (database, storage) |
| `db` | `src/db/`, deploy step `src/setup.ts` | Drizzle schema, migrations, DB roles | internal | – | migrations as owner role | built: roles check, schema `app`, default grants for `app_rw` |
| `config` | `src/config/` | typed runtime configuration, validated at start (zod) | internal | secrets (in memory only) | errors name variables, never values | built |
-| `app` | `src/app/` | Next.js routes and pages; composition root `src/app/_server/` (pool, storage client) | `/`, `/api/health` | – | calls module APIs only (dependency-cruiser) | skeleton: placeholder page, health route |
+| `app` | `src/app/` | Next.js routes and pages; composition root `src/app/_server/` (pool, storage client) | `/`, `/login`, `/signup`, `/invite`, `/requests`, `/api/requests`, `/api/documents/:id`, `/api/auth/*`, `/api/health` | – | calls module APIs only (dependency-cruiser) | partial: login, sign-up, invite, home |
| AI service | `services/ai/` | docling parsing, extraction, grounding, evals | internal HTTP | confidential + personal (transient) | bearer token, stateless, no DB/storage access | planned |
| Contracts | `contracts/` | OpenAPI: AI service, ERP export | – | – | contract tests | planned |
@@ -49,6 +50,9 @@ Deliberately accepted risks – without an entry here a deviation counts as a de
|---|---|---|---|
| No RLS on the `auth` and `pgboss` schemas | Not company-owned business data; reachable only by server code (ADR-0001 D7) | Fluory | 2026-12-31 (review at M3) |
| Showcase without unattended retries (Vercel Hobby cron once/day) | Showcase only; production runs a worker (D2) | Fluory | when a production-like demo is needed |
+| Better Auth admin plugin mounted without any holder of its admin role | ADR-0001 D6 names the plugin; nobody holds `platform-admin`, so `/api/auth/admin/*` rejects every caller (tested); user management runs through `identity` | Fluory | with #30 (decide: keep for ban/deactivate or remove) |
+| Upload endpoint without a per-user rate limit | Authenticated staff only; body bounded by `Content-Length` + `UPLOAD_MAX_REQUEST_BYTES` before reading | Fluory | before any public deployment (#19) |
+| `.msg` uploads checked by OLE signature only | Structure check of Outlook messages needs a CFB parser; files are served only as attachments with `nosniff` and parsed later by the stateless AI service | Fluory | with #23 (MSG parsing) |
| Gemini API free tier for local development | Synthetic data only; never showcase or customer data (D8) | Fluory | when a Vertex development budget exists |
## Data flow
diff --git a/docs/technical/data-model.md b/docs/technical/data-model.md
new file mode 100644
index 0000000..64b1127
--- /dev/null
+++ b/docs/technical/data-model.md
@@ -0,0 +1,87 @@
+# Data model – RequestFlow
+
+> Living document: whoever adds or changes a table updates this file **in the same PR**.
+> Source of truth: `src/db/schema/` + `src/db/migrations/`. Classification per the `datenschutz` add-on:
+> public / internal / confidential / personal.
+
+## Schemas
+
+| Schema | Owner | Runtime access (`app_rw`) | Tenant isolation |
+|---|---|---|---|
+| `app` | `app_owner` | DML via default privileges, no CREATE | every table: `company_id` + RLS **enabled and forced**, policy `
_tenant_isolation` |
+| `auth` | `app_owner` | DML on all tables, no CREATE | none – Better Auth data, server code only (exceptions register) |
+| `pgboss` | `app_owner` (deploy step installs schema + queues) | DML only | none – job queue, IDs only (exceptions register) |
+| `drizzle` | `app_owner` | none | migration journal |
+
+Tenant policy (all `app` tables): `company_id = nullif(current_setting('app.company_id', true), '')::uuid`
+for `USING` and `WITH CHECK`. `withTenant()` sets `app.company_id` transaction-locally; without it a
+query sees zero rows and every write fails.
+
+## Tables
+
+### `app.requests` – request aggregate (#4, extended by #5/#7)
+
+| Column | Type | Notes | Class |
+|---|---|---|---|
+| `id` | uuid PK | `gen_random_uuid()` | internal |
+| `company_id` | uuid FK → `auth.organization.id` | tenant key, `ON DELETE RESTRICT` | internal |
+| `status` | text | `NEW · PROCESSING · REVIEW · APPROVED · EXPORTED · REJECTED · ERROR` (check constraint) | internal |
+| `created_at` | timestamptz | | internal |
+| `source` | text | `upload` (mailbox later) | internal |
+| `created_by` | uuid | uploading user | personal (staff) |
+| `subject` | text | mail subject or first file name, max 300 chars | confidential |
+| `message_id` | text | `Message-ID` of an uploaded mail – duplicate key | personal |
+| `fingerprint` | text | SHA-256 over the sorted file hashes – duplicate key | internal |
+| `possible_duplicate` / `duplicate_of_id` | boolean / uuid | exact duplicate within the company; composite FK `(duplicate_of_id, company_id)` | internal |
+
+Unique `(id, company_id)` so child tables can pin the company with composite foreign keys (FK checks
+bypass RLS). Purpose: one quote request per row. Retention: open question for the customer (ADR-0001 open points).
+
+### `app.documents` – originals of a request (#5)
+
+| Column | Type | Notes | Class |
+|---|---|---|---|
+| `id`, `company_id`, `request_id` | uuid | composite FK `(request_id, company_id)` → `requests` | internal |
+| `filename`, `content_type`, `kind` | text | kind `eml · msg · pdf · xlsx · docx` | confidential |
+| `size_bytes`, `sha256` | bigint, text | SHA-256 of the raw bytes (integrity, duplicates) | internal |
+| `storage_key` | text | `{companyId}/{requestId}/{documentId}` in the private bucket | internal |
+
+The bytes (confidential + personal) live only in object storage; served via `GET /api/documents/:id`.
+
+### `app.audit_events` – append-only business audit (#5, ADR-0001 D10)
+
+| Column | Type | Notes | Class |
+|---|---|---|---|
+| `company_id`, `entity_type`, `entity_id` | | what changed | internal |
+| `actor_user_id` | uuid | who | personal (staff) |
+| `action`, `data` | text, jsonb | e.g. `request.uploaded`; old/new values later – no document content | internal/confidential |
+
+`app_rw` has INSERT and SELECT only (UPDATE/DELETE/TRUNCATE revoked). Written in the same transaction
+as the change.
+
+### `auth.*` – Better Auth 1.7.5 (generated with the Better Auth CLI, timestamps with time zone)
+
+| Table | Content | Class | Purpose |
+|---|---|---|---|
+| `user` | name, e-mail, global role (`user`), ban fields | personal (staff) | login identity |
+| `account` | password hash (credential provider) | confidential | authentication |
+| `session` | token, expiry, IP, user agent, `active_organization_id` | personal (staff) | session; carries the active company |
+| `verification` | verification tokens | confidential | e-mail verification (unused in the pilot) |
+| `organization` | company name, slug | internal | company = tenant |
+| `member` | user ↔ company, company role `admin`/`clerk`; unique `user_id` (one company per user) | internal | membership + role |
+| `invitation` | e-mail, company, role, status, expiry, inviter; the random `id` is the sign-up token (link) | personal (staff) | invite-only sign-up |
+| `rate_limit` | key (IP + path), counter | personal (IP) | built-in rate limit, database storage |
+
+A system user `system@requestflow.invalid` (no password account, no membership) is the inviter of each
+company's first admin; it can never obtain a session.
+
+## Relations
+
+```text
+auth.organization 1─n auth.member n─1 auth.user 1─n auth.session / auth.account
+auth.organization 1─n auth.invitation
+auth.organization 1─n app.requests (company_id)
+app.requests 1─n app.documents (request_id, company_id)
+app.requests 0─1 app.requests (duplicate_of_id, company_id)
+app.* 1─n app.audit_events (entity_type, entity_id – no FK, append-only)
+```
diff --git a/docs/technical/operations.md b/docs/technical/operations.md
index 83834ec..9e5888b 100644
--- a/docs/technical/operations.md
+++ b/docs/technical/operations.md
@@ -25,6 +25,20 @@ migration aborts if `app_owner`/`app_rw` are missing or could bypass RLS – fix
customer) an operator creates `app_owner` and `app_rw` once with the same statements (passwords from
the secret manager), before the first `setup` run; the first migration refuses to run otherwise.
+## Login rate limit and client IP
+
+Better Auth limits `/api/auth/*` per client IP (5 sign-ins/sign-ups per minute, counters in
+`auth.rate_limit`). The IP comes from `AUTH_IP_HEADERS`; that header is only trustworthy when a
+reverse proxy sets it and clients cannot reach the web container directly. Any deployment beyond the
+local machine puts a proxy in front and lists it in `AUTH_TRUSTED_PROXIES`. A per-account limit is a
+follow-up (not in the pilot).
+
+## Invitations and account recovery
+
+Invite-only: an admin creates an invitation on `/invite` and hands over the link
+(`/signup?invitation=`, 7 days valid); e-mail delivery is not part of the pilot. There is no
+self-service password reset yet – recovery is an operator task (delete the user row, invite again).
+
## Frequent failures
| Symptom | Cause | Action |
diff --git a/drizzle.config.ts b/drizzle.config.ts
index f46881d..cf0bfdf 100644
--- a/drizzle.config.ts
+++ b/drizzle.config.ts
@@ -3,9 +3,9 @@ import { defineConfig } from "drizzle-kit";
// drizzle-kit runs as the owner role; the app itself connects as `app_rw` (ADR-0001 D7).
export default defineConfig({
dialect: "postgresql",
- schema: "./src/db/schema.ts",
+ schema: "./src/db/schema/index.ts",
out: "./src/db/migrations",
- schemaFilter: ["app"],
+ schemaFilter: ["app", "auth"],
migrations: { schema: "drizzle" },
dbCredentials: { url: process.env.MIGRATION_DATABASE_URL ?? "" },
});
diff --git a/package.json b/package.json
index 1750a8a..61017b9 100644
--- a/package.json
+++ b/package.json
@@ -22,13 +22,17 @@
"verify:changed": "bash scripts/verify-changed.sh",
"verify": "pnpm lint && pnpm typecheck && pnpm test && pnpm test:integration && pnpm depcruise && pnpm build && pnpm audit --audit-level high",
"verify:full": "pnpm verify",
- "setup:deploy": "tsx src/setup.ts"
+ "setup:deploy": "tsx src/setup.ts",
+ "seed:demo": "tsx src/seed.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1138.0",
+ "@better-auth/drizzle-adapter": "1.7.5",
+ "better-auth": "1.7.5",
"drizzle-orm": "0.45.3",
"next": "16.3.6",
"pg": "8.23.0",
+ "pg-boss": "12.33.6",
"react": "19.3.0",
"react-dom": "19.3.0",
"tsx": "4.23.15",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7d01764..b8d5979 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,15 +11,24 @@ importers:
'@aws-sdk/client-s3':
specifier: 3.1138.0
version: 3.1138.0
+ '@better-auth/drizzle-adapter':
+ specifier: 1.7.5
+ version: 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0))
+ better-auth:
+ specifier: 1.7.5
+ version: 1.7.5(drizzle-kit@0.31.11)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0))(next@16.3.6(@babel/core@7.29.7)(@types/node@24.13.6)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@5.0.1(@types/node@24.13.6)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15)))
drizzle-orm:
specifier: 0.45.3
- version: 0.45.3(@types/pg@8.23.1)(pg@8.23.0)
+ version: 0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0)
next:
specifier: 16.3.6
version: 16.3.6(@babel/core@7.29.7)(@types/node@24.13.6)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
pg:
specifier: 8.23.0
version: 8.23.0
+ pg-boss:
+ specifier: 12.33.6
+ version: 12.33.6
react:
specifier: 19.3.0
version: 19.3.0
@@ -205,6 +214,85 @@ packages:
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
engines: {node: '>=6.9.0'}
+ '@better-auth/core@1.7.5':
+ resolution: {integrity: sha512-kVlSu4H8OKQfjg4b/Zj5MOaospt83N0JbX38wsDzE58Yw95jzovFkU3pxzB1eUFYc4mkuhUMZD8iT1gpUjNMcQ==}
+ peerDependencies:
+ '@better-auth/utils': 0.4.2
+ '@better-fetch/fetch': 1.3.2
+ '@opentelemetry/api': ^1.9.0
+ better-call: 1.4.0
+ jose: ^6.1.0
+ kysely: ^0.28.5 || ^0.29.0
+ nanostores: ^1.0.1
+ peerDependenciesMeta:
+ '@opentelemetry/api':
+ optional: true
+
+ '@better-auth/drizzle-adapter@1.7.5':
+ resolution: {integrity: sha512-9SM7v1735SoaedRDcDbHc5ULgXEd2vUlEJkvRHpMF2Q9qf59TRh1b5A9hryyecyi56bm/0CNUDU3nY0uVWj5/Q==}
+ peerDependencies:
+ '@better-auth/core': ^1.7.5
+ '@better-auth/utils': 0.4.2
+ drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0
+ peerDependenciesMeta:
+ drizzle-orm:
+ optional: true
+
+ '@better-auth/kysely-adapter@1.7.5':
+ resolution: {integrity: sha512-1wE5gvnjW+c1i4GrLtL9HLn3s0Xrq4YneCDan1NO1dpz0mEguLFNlzfnUGykTFrDwvFh2X5rkIbA8ALCj6WzXQ==}
+ peerDependencies:
+ '@better-auth/core': ^1.7.5
+ '@better-auth/utils': 0.4.2
+ kysely: ^0.28.17 || ^0.29.0
+ peerDependenciesMeta:
+ kysely:
+ optional: true
+
+ '@better-auth/memory-adapter@1.7.5':
+ resolution: {integrity: sha512-YDmnfR9zOXbn5SNYYwfHBPuc19hg1d1C1vUXb+Hm8Q91pTsstFNX5OTlZbo7f28q06FpogAEfGGCN/2QV39cig==}
+ peerDependencies:
+ '@better-auth/core': ^1.7.5
+ '@better-auth/utils': 0.4.2
+
+ '@better-auth/mongo-adapter@1.7.5':
+ resolution: {integrity: sha512-Yq0LfF0VlA9Kfjcjp/v43MSsChv7vKd1ct0Mt15px4zlqaCPIGfPbjw9YNM6jTo0fGpqLR0n15PllSWmLLRp9g==}
+ peerDependencies:
+ '@better-auth/core': ^1.7.5
+ '@better-auth/utils': 0.4.2
+ mongodb: ^6.0.0 || ^7.0.0
+ peerDependenciesMeta:
+ mongodb:
+ optional: true
+
+ '@better-auth/prisma-adapter@1.7.5':
+ resolution: {integrity: sha512-QfW6HS9vK0FMcbI/GsQLdplICxOz0EPzYWGONZT4ovL3cSItd4YH/09USbPPVl+VUQCdznAQIk+n+NKvHdmSag==}
+ peerDependencies:
+ '@better-auth/core': ^1.7.5
+ '@better-auth/utils': 0.4.2
+ '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
+ prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
+ peerDependenciesMeta:
+ '@prisma/client':
+ optional: true
+ prisma:
+ optional: true
+
+ '@better-auth/telemetry@1.7.5':
+ resolution: {integrity: sha512-e/REPqMy9Em+gC6G0xWBikiMLRuy532Er7jqdoNkPBa65FbywgWcm1cZbgdW5CnHsrM3OLZsmKn14yeGoDISeg==}
+ peerDependencies:
+ '@better-auth/core': ^1.7.5
+ '@better-auth/utils': 0.4.2
+ '@better-fetch/fetch': 1.3.2
+
+ '@better-auth/utils@0.4.2':
+ resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==}
+
+ '@better-auth/utils@0.5.0':
+ resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==}
+
+ '@better-fetch/fetch@1.3.2':
+ resolution: {integrity: sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==}
+
'@drizzle-team/brocli@0.10.2':
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
@@ -959,6 +1047,14 @@ packages:
cpu: [x64]
os: [win32]
+ '@noble/ciphers@2.4.0':
+ resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==}
+ engines: {node: '>= 20.19.0'}
+
+ '@noble/hashes@2.4.0':
+ resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==}
+ engines: {node: '>= 20.19.0'}
+
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -975,6 +1071,10 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
+ '@opentelemetry/semantic-conventions@1.43.0':
+ resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==}
+ engines: {node: '>=14'}
+
'@oxc-project/types@0.150.0':
resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==}
@@ -1098,6 +1198,9 @@ packages:
resolution: {integrity: sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==}
engines: {node: '>=18.0.0'}
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
'@swc/helpers@0.5.23':
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
@@ -1418,6 +1521,73 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ better-auth@1.7.5:
+ resolution: {integrity: sha512-aKE0Zt2EPTpFvmq4/oATNyG/mAfc6JUqWkW9pzGlrVnzUb0lso7GJ9BPxD6JPhLqYV1a9zOAb0uAz1Q5fm+eHA==}
+ peerDependencies:
+ '@lynx-js/react': '*'
+ '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
+ '@sveltejs/kit': ^2.0.0
+ '@tanstack/react-start': ^1.0.0
+ '@tanstack/solid-start': ^1.0.0
+ drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1'
+ drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0
+ mongodb: ^6.0.0 || ^7.0.0
+ mysql2: ^3.0.0
+ next: ^14.0.0 || ^15.0.0 || ^16.0.0
+ pg: ^8.0.0
+ prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ solid-js: ^1.0.0
+ svelte: ^4.0.0 || ^5.0.0
+ vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0
+ vue: ^3.0.0
+ peerDependenciesMeta:
+ '@lynx-js/react':
+ optional: true
+ '@prisma/client':
+ optional: true
+ '@sveltejs/kit':
+ optional: true
+ '@tanstack/react-start':
+ optional: true
+ '@tanstack/solid-start':
+ optional: true
+ drizzle-kit:
+ optional: true
+ drizzle-orm:
+ optional: true
+ mongodb:
+ optional: true
+ mysql2:
+ optional: true
+ next:
+ optional: true
+ pg:
+ optional: true
+ prisma:
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+ solid-js:
+ optional: true
+ svelte:
+ optional: true
+ vitest:
+ optional: true
+ vue:
+ optional: true
+
+ better-call@1.4.0:
+ resolution: {integrity: sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==}
+ peerDependencies:
+ zod: ^4.0.0
+ peerDependenciesMeta:
+ zod:
+ optional: true
+
bowser@2.14.1:
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
@@ -1487,6 +1657,10 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+ cron-parser@5.10.1:
+ resolution: {integrity: sha512-pKRrRagItwk9rGIStcUyMxFX34x56zoX3KDf9HJ4ttwkXIK1qxK63EvXvEDmlxI9AdPJ+Y7TEKRftRlW5eSS7A==}
+ engines: {node: '>=18'}
+
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -1537,6 +1711,9 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
+ defu@6.1.7:
+ resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
+
dependency-cruiser@18.4.0:
resolution: {integrity: sha512-LLBYQ2XYmOCMG+liUWI2ReRYnnFXIbnzvCGHTohXAViSkawXr3T35fF/FV2cxpmB661pfkJ3ZXn95jhcEQ9GqA==}
engines: {node: ^22||^24||>=26}
@@ -2160,6 +2337,9 @@ packages:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'}
+ jose@6.2.12:
+ resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2201,6 +2381,10 @@ packages:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
+ kysely@0.29.6:
+ resolution: {integrity: sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==}
+ engines: {node: '>=22.0.0'}
+
language-subtag-registry@0.3.23:
resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
@@ -2296,6 +2480,10 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+ luxon@3.7.2:
+ resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
+ engines: {node: '>=12'}
+
magic-string@1.4.1:
resolution: {integrity: sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==}
@@ -2329,6 +2517,10 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ nanostores@1.5.3:
+ resolution: {integrity: sha512-rQLB6eV4f2AW/n3L0JmwCROpaisYy9EDEADvEFSd1C/qG8hB6O5TPlh9A791JRbJr4CnMQBzptDcvD9OR1+6WA==}
+ engines: {node: ^20.0.0 || >=22.0.0}
+
napi-postinstall@0.3.4:
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -2366,6 +2558,10 @@ packages:
resolution: {integrity: sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==}
engines: {node: '>=18'}
+ non-error@0.1.0:
+ resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
+ engines: {node: '>=20'}
+
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -2433,6 +2629,11 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+ pg-boss@12.33.6:
+ resolution: {integrity: sha512-kVGyVSFyG2xa5hn0TN1286PeTtkIVwTbIvGeVo2kme/vSkXrdcuaiXfcggID1cSHY7QG6k2CwNYRsbP7bVJeHg==}
+ engines: {node: '>=22.12.0'}
+ hasBin: true
+
pg-cloudflare@1.4.0:
resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
@@ -2578,6 +2779,12 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
+ rou3@0.9.2:
+ resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==}
+
+ rrule-temporal@2.2.6:
+ resolution: {integrity: sha512-izTnMn8pAFNLDuyRfF+H53YnWOF7tsA5+RcGrha4onVzOnXn8YXQ7tJE5StNZvAZXvxz0In3R/IQhbQJpi7EYQ==}
+
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -2608,6 +2815,13 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ serialize-error@13.0.1:
+ resolution: {integrity: sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA==}
+ engines: {node: '>=20'}
+
+ set-cookie-parser@3.1.2:
+ resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==}
+
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -2739,10 +2953,17 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
+ tagged-tag@1.0.0:
+ resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
+ engines: {node: '>=20'}
+
tapable@2.3.3:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
+ temporal-spec@1.0.1:
+ resolution: {integrity: sha512-wxVoanmDeavXie1vu2JaQ3WIc3JZnWAOYFBsJyATaVsXsycKYUflGsyBmrRSnoCpZJpwPyr38VpgSUlQ8CbFxg==}
+
tinybench@6.1.4:
resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==}
engines: {node: '>=20.0.0'}
@@ -2788,6 +3009,10 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
+ type-fest@5.10.0:
+ resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==}
+ engines: {node: '>=20'}
+
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -3241,6 +3466,63 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
+ '@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3)':
+ dependencies:
+ '@better-auth/utils': 0.4.2
+ '@better-fetch/fetch': 1.3.2
+ '@opentelemetry/semantic-conventions': 1.43.0
+ '@standard-schema/spec': 1.1.0
+ better-call: 1.4.0(zod@4.6.5)
+ jose: 6.2.12
+ kysely: 0.29.6
+ nanostores: 1.5.3
+ zod: 4.6.5
+
+ '@better-auth/drizzle-adapter@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0))':
+ dependencies:
+ '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3)
+ '@better-auth/utils': 0.4.2
+ optionalDependencies:
+ drizzle-orm: 0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0)
+
+ '@better-auth/kysely-adapter@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.6)':
+ dependencies:
+ '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3)
+ '@better-auth/utils': 0.4.2
+ optionalDependencies:
+ kysely: 0.29.6
+
+ '@better-auth/memory-adapter@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)':
+ dependencies:
+ '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3)
+ '@better-auth/utils': 0.4.2
+
+ '@better-auth/mongo-adapter@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)':
+ dependencies:
+ '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3)
+ '@better-auth/utils': 0.4.2
+
+ '@better-auth/prisma-adapter@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)':
+ dependencies:
+ '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3)
+ '@better-auth/utils': 0.4.2
+
+ '@better-auth/telemetry@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)':
+ dependencies:
+ '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3)
+ '@better-auth/utils': 0.4.2
+ '@better-fetch/fetch': 1.3.2
+
+ '@better-auth/utils@0.4.2':
+ dependencies:
+ '@noble/hashes': 2.4.0
+
+ '@better-auth/utils@0.5.0':
+ dependencies:
+ '@noble/hashes': 2.4.0
+
+ '@better-fetch/fetch@1.3.2': {}
+
'@drizzle-team/brocli@0.10.2': {}
'@emnapi/core@1.10.0':
@@ -3729,6 +4011,10 @@ snapshots:
'@next/swc-win32-x64-msvc@16.3.6':
optional: true
+ '@noble/ciphers@2.4.0': {}
+
+ '@noble/hashes@2.4.0': {}
+
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -3743,6 +4029,8 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
+ '@opentelemetry/semantic-conventions@1.43.0': {}
+
'@oxc-project/types@0.150.0': {}
'@rolldown/binding-android-arm-eabi@1.2.9':
@@ -3827,6 +4115,8 @@ snapshots:
dependencies:
tslib: 2.8.1
+ '@standard-schema/spec@1.1.0': {}
+
'@swc/helpers@0.5.23':
dependencies:
tslib: 2.8.1
@@ -4157,6 +4447,45 @@ snapshots:
baseline-browser-mapping@2.11.25: {}
+ better-auth@1.7.5(drizzle-kit@0.31.11)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0))(next@16.3.6(@babel/core@7.29.7)(@types/node@24.13.6)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@5.0.1(@types/node@24.13.6)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15))):
+ dependencies:
+ '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3)
+ '@better-auth/drizzle-adapter': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0))
+ '@better-auth/kysely-adapter': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.6)
+ '@better-auth/memory-adapter': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)
+ '@better-auth/mongo-adapter': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)
+ '@better-auth/prisma-adapter': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)
+ '@better-auth/telemetry': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)
+ '@better-auth/utils': 0.4.2
+ '@better-fetch/fetch': 1.3.2
+ '@noble/ciphers': 2.4.0
+ '@noble/hashes': 2.4.0
+ better-call: 1.4.0(zod@4.6.5)
+ defu: 6.1.7
+ jose: 6.2.12
+ kysely: 0.29.6
+ nanostores: 1.5.3
+ zod: 4.6.5
+ optionalDependencies:
+ drizzle-kit: 0.31.11
+ drizzle-orm: 0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0)
+ next: 16.3.6(@babel/core@7.29.7)(@types/node@24.13.6)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ pg: 8.23.0
+ react: 19.3.0
+ react-dom: 19.3.0(react@19.3.0)
+ vitest: 5.0.1(@types/node@24.13.6)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15))
+ transitivePeerDependencies:
+ - '@opentelemetry/api'
+
+ better-call@1.4.0(zod@4.6.5):
+ dependencies:
+ '@better-auth/utils': 0.5.0
+ '@better-fetch/fetch': 1.3.2
+ rou3: 0.9.2
+ set-cookie-parser: 3.1.2
+ optionalDependencies:
+ zod: 4.6.5
+
bowser@2.14.1: {}
brace-expansion@1.1.21:
@@ -4224,6 +4553,10 @@ snapshots:
convert-source-map@2.0.0: {}
+ cron-parser@5.10.1:
+ dependencies:
+ luxon: 3.7.2
+
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -4274,6 +4607,8 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
+ defu@6.1.7: {}
+
dependency-cruiser@18.4.0:
dependencies:
acorn: 8.18.0
@@ -4308,9 +4643,10 @@ snapshots:
esbuild: 0.25.12
tsx: 4.23.15
- drizzle-orm@0.45.3(@types/pg@8.23.1)(pg@8.23.0):
+ drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0):
optionalDependencies:
'@types/pg': 8.23.1
+ kysely: 0.29.6
pg: 8.23.0
dunder-proto@1.0.1:
@@ -5046,6 +5382,8 @@ snapshots:
has-symbols: 1.1.0
set-function-name: 2.0.2
+ jose@6.2.12: {}
+
js-tokens@4.0.0: {}
js-yaml@4.3.2:
@@ -5079,6 +5417,8 @@ snapshots:
kleur@3.0.3: {}
+ kysely@0.29.6: {}
+
language-subtag-registry@0.3.23: {}
language-tags@1.0.9:
@@ -5153,6 +5493,8 @@ snapshots:
dependencies:
yallist: 3.1.1
+ luxon@3.7.2: {}
+
magic-string@1.4.1:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
@@ -5180,6 +5522,8 @@ snapshots:
nanoid@3.3.19: {}
+ nanostores@1.5.3: {}
+
napi-postinstall@0.3.4: {}
natural-compare@1.4.0: {}
@@ -5218,6 +5562,8 @@ snapshots:
node-releases@2.0.56: {}
+ non-error@0.1.0: {}
+
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -5296,6 +5642,15 @@ snapshots:
path-parse@1.0.7: {}
+ pg-boss@12.33.6:
+ dependencies:
+ cron-parser: 5.10.1
+ pg: 8.23.0
+ rrule-temporal: 2.2.6
+ serialize-error: 13.0.1
+ transitivePeerDependencies:
+ - pg-native
+
pg-cloudflare@1.4.0:
optional: true
@@ -5456,6 +5811,12 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.2.9
'@rolldown/binding-win32-x64-msvc': 1.2.9
+ rou3@0.9.2: {}
+
+ rrule-temporal@2.2.6:
+ dependencies:
+ temporal-spec: 1.0.1
+
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -5489,6 +5850,13 @@ snapshots:
semver@7.8.5: {}
+ serialize-error@13.0.1:
+ dependencies:
+ non-error: 0.1.0
+ type-fest: 5.10.0
+
+ set-cookie-parser@3.1.2: {}
+
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -5673,8 +6041,12 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
+ tagged-tag@1.0.0: {}
+
tapable@2.3.3: {}
+ temporal-spec@1.0.1: {}
+
tinybench@6.1.4: {}
tinyexec@1.3.0: {}
@@ -5724,6 +6096,10 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
+ type-fest@5.10.0:
+ dependencies:
+ tagged-tag: 1.0.0
+
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
diff --git a/src/app/_components/auth-form.tsx b/src/app/_components/auth-form.tsx
new file mode 100644
index 0000000..06df4af
--- /dev/null
+++ b/src/app/_components/auth-form.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useState, type FormEvent } from "react";
+
+// Posts JSON straight to Better Auth (/api/auth/*); cookies are set by that response, so no extra
+// auth plugin is needed for server actions.
+export function AuthForm({ mode, invitationId }: { mode: "sign-in" | "sign-up"; invitationId?: string }) {
+ const router = useRouter();
+ const [message, setMessage] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ async function submit(event: FormEvent) {
+ event.preventDefault();
+ setBusy(true);
+ setMessage(null);
+ const form = new FormData(event.currentTarget);
+ const body = {
+ email: String(form.get("email") ?? ""),
+ password: String(form.get("password") ?? ""),
+ ...(mode === "sign-up" ? { name: String(form.get("name") ?? ""), invitationId } : {}),
+ };
+ const response = await fetch(`/api/auth/${mode}/email`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ setBusy(false);
+ if (mode === "sign-in") {
+ if (response.ok) {
+ router.push("/");
+ router.refresh();
+ }
+ else if (response.status === 429) setMessage("Zu viele Versuche – bitte später erneut versuchen.");
+ else setMessage("Anmeldung fehlgeschlagen. Bitte E-Mail und Passwort prüfen.");
+ return;
+ }
+ // Same answer for invited and uninvited addresses (no enumeration).
+ setMessage(
+ response.ok
+ ? "Falls für diese Adresse eine Einladung vorliegt, ist das Konto jetzt angelegt. Bitte anmelden."
+ : "Registrierung fehlgeschlagen. Passwort mindestens 12 Zeichen.",
+ );
+ }
+
+ return (
+
+ );
+}
+
+export function SignOutButton() {
+ const router = useRouter();
+ async function signOut() {
+ await fetch("/api/auth/sign-out", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
+ router.push("/login");
+ router.refresh();
+ }
+ return (
+
+ );
+}
diff --git a/src/app/_server/runtime.ts b/src/app/_server/runtime.ts
index 6b212fa..a66ad60 100644
--- a/src/app/_server/runtime.ts
+++ b/src/app/_server/runtime.ts
@@ -1,13 +1,18 @@
import { loadConfig, type AppConfig } from "@/config/env";
import { createDatabase, type DatabaseHandle } from "@/db";
+import { createJobQueue } from "@/db/job-queue-client";
+import { createAuth, getActor, type Actor, type Auth } from "@/features/identity";
import { S3BlobStore } from "@/features/storage";
+import { createTenancy, type Tenancy } from "@/features/tenancy";
-// Composition root of the web process: one pool and one storage client per process, created on
-// first use (never at import time, so `next build` needs no environment).
+// Composition root of the web process: one pool, one storage client and one auth instance per
+// process, created on first use (never at import time, so `next build` needs no environment).
export interface Runtime {
config: AppConfig;
database: DatabaseHandle;
storage: S3BlobStore;
+ auth: Auth;
+ tenancy: Tenancy;
}
let runtime: Runtime | undefined;
@@ -15,7 +20,31 @@ let runtime: Runtime | undefined;
export function getRuntime(): Runtime {
if (!runtime) {
const config = loadConfig();
- runtime = { config, database: createDatabase(config.databaseUrl), storage: new S3BlobStore(config.storage) };
+ const database = createDatabase(config.databaseUrl);
+ runtime = {
+ config,
+ database,
+ storage: new S3BlobStore(config.storage),
+ auth: createAuth(database.db, config.auth),
+ tenancy: createTenancy(database.db),
+ };
}
return runtime;
}
+
+let jobClient: ReturnType | undefined;
+
+/** pg-boss client of the web process (send only, no supervision). */
+export function getJobClient(): ReturnType {
+ jobClient ??= createJobQueue(getRuntime().config.databaseUrl).catch((error: unknown) => {
+ jobClient = undefined;
+ throw error;
+ });
+ return jobClient;
+}
+
+/** The signed-in actor for the current request, or null. */
+export async function currentActor(headers: Headers): Promise {
+ const { auth, database } = getRuntime();
+ return getActor(auth, database.db, headers);
+}
diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts
new file mode 100644
index 0000000..0f223ec
--- /dev/null
+++ b/src/app/api/auth/[...all]/route.ts
@@ -0,0 +1,10 @@
+import { getRuntime } from "@/app/_server/runtime";
+
+export const dynamic = "force-dynamic";
+
+// Better Auth endpoints (/api/auth/*): sign-in, sign-up (invite-only), session, organization.
+// Resolved per request so the build needs no environment.
+const handle = (request: Request) => getRuntime().auth.handler(request);
+
+export const GET = handle;
+export const POST = handle;
diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts
new file mode 100644
index 0000000..16ebeea
--- /dev/null
+++ b/src/app/api/documents/[id]/route.ts
@@ -0,0 +1,30 @@
+import { currentActor, getRuntime } from "@/app/_server/runtime";
+import { getDocument } from "@/features/documents";
+
+export const dynamic = "force-dynamic";
+
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+// GET /api/documents/:id – the only way to read an original (private bucket, no public URLs;
+// ADR-0001 D5). A document of another company is indistinguishable from a missing one (404).
+export async function GET(request: Request, context: { params: Promise<{ id: string }> }): Promise {
+ const actor = await currentActor(request.headers);
+ if (!actor) return Response.json({ error: { title: "Nicht angemeldet." } }, { status: 401 });
+ const { id } = await context.params;
+ if (!UUID.test(id)) return Response.json({ error: { title: "Nicht gefunden." } }, { status: 404 });
+
+ const { tenancy, storage } = getRuntime();
+ const document = await tenancy.withTenant(actor.companyId, (tx) => getDocument(tx, id));
+ if (!document) return Response.json({ error: { title: "Nicht gefunden." } }, { status: 404 });
+
+ const { body, length } = await storage.stream(document.storageKey);
+ return new Response(body, {
+ headers: {
+ "content-type": document.contentType,
+ ...(length === undefined ? {} : { "content-length": String(length) }),
+ "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(document.filename)}`,
+ "x-content-type-options": "nosniff",
+ "cache-control": "private, no-store",
+ },
+ });
+}
diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts
new file mode 100644
index 0000000..2bbee90
--- /dev/null
+++ b/src/app/api/requests/route.ts
@@ -0,0 +1,47 @@
+import { currentActor, getJobClient, getRuntime } from "@/app/_server/runtime";
+import { AuthorizationError } from "@/features/identity";
+import { submitUpload, UploadRejected } from "@/features/intake";
+
+export const dynamic = "force-dynamic";
+
+const problem = (status: number, title: string) => Response.json({ error: { title } }, { status });
+
+// POST /api/requests – multipart upload of one request (field `files`, 1..n files).
+// Company and user come from the session, never from the form (ADR-0001 D7).
+export async function POST(request: Request): Promise {
+ const actor = await currentActor(request.headers);
+ if (!actor) return problem(401, "Nicht angemeldet.");
+ const { config, tenancy, storage } = getRuntime();
+
+ // The body is buffered by formData(): bound it BEFORE reading. Node enforces the declared length,
+ // so a request without a numeric Content-Length (chunked) is refused.
+ const declared = request.headers.get("content-length");
+ if (!declared || !/^\d+$/.test(declared)) return problem(411, "Upload ohne Längenangabe wird nicht angenommen.");
+ if (Number(declared) > config.upload.maxRequestBytes) return problem(413, "Upload zu groß.");
+
+ let form: FormData;
+ try {
+ form = await request.formData();
+ } catch {
+ return problem(400, "Ungültiger Upload.");
+ }
+ const files = await Promise.all(
+ form
+ .getAll("files")
+ .filter((entry): entry is File => typeof entry !== "string")
+ .map(async (file) => ({ name: file.name, bytes: new Uint8Array(await file.arrayBuffer()) })),
+ );
+
+ try {
+ const boss = await getJobClient();
+ const result = await submitUpload({ tenancy, storage, boss, limits: config.upload }, actor, files);
+ return Response.json(result, { status: 201 });
+ } catch (error) {
+ if (error instanceof UploadRejected) return problem(422, error.message);
+ if (error instanceof AuthorizationError) return problem(403, "Keine Berechtigung.");
+ const kind = error instanceof Error ? error.name : "unknown";
+ const code = (error as { code?: unknown } | null)?.code;
+ console.error(JSON.stringify({ level: "error", route: "POST /api/requests", companyId: actor.companyId, userId: actor.userId, error: kind, code: typeof code === "string" ? code : undefined }));
+ return problem(500, "Upload fehlgeschlagen. Bitte erneut versuchen.");
+ }
+}
diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx
new file mode 100644
index 0000000..00ffdb9
--- /dev/null
+++ b/src/app/invite/page.tsx
@@ -0,0 +1,65 @@
+import { headers } from "next/headers";
+import { notFound, redirect } from "next/navigation";
+import { z } from "zod";
+import { currentActor, getRuntime } from "@/app/_server/runtime";
+import { authorize, AuthorizationError, inviteUser, COMPANY_ROLES, type Actor } from "@/features/identity";
+
+export const dynamic = "force-dynamic";
+
+const inviteInput = z.object({ email: z.email().max(254), role: z.enum(COMPANY_ROLES) });
+
+async function adminOrNotFound(): Promise {
+ const actor = await currentActor(await headers());
+ if (!actor) redirect("/login");
+ try {
+ authorize(actor, "users.invite");
+ } catch (error) {
+ if (error instanceof AuthorizationError) notFound();
+ throw error;
+ }
+ return actor;
+}
+
+// Invite form (pilot: no role-admin UI; e-mail delivery is out of scope). Authorization runs
+// server-side on render AND in the action. The admin hands the link over to the invited person.
+async function invite(formData: FormData) {
+ "use server";
+ const actor = await adminOrNotFound();
+ const input = inviteInput.safeParse({ email: formData.get("email"), role: formData.get("role") });
+ if (!input.success) redirect("/invite?error=input");
+ const { invitationId } = await inviteUser(getRuntime().database.db, actor, input.data);
+ redirect(`/invite?invitation=${invitationId}`);
+}
+
+export default async function InvitePage({ searchParams }: { searchParams: Promise> }) {
+ await adminOrNotFound();
+ const params = await searchParams;
+ const link = params.invitation ? `${getRuntime().config.auth.baseURL}/signup?invitation=${encodeURIComponent(params.invitation)}` : null;
+ return (
+
+ Mitarbeitende einladen
+ {link && (
+
+ Einladung angelegt (7 Tage gültig). Diesen Link an die eingeladene Person weitergeben: {link}
+
+ )}
+ {params.error && Bitte eine gültige E-Mail-Adresse und Rolle angeben.
}
+
+
+ );
+}
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
new file mode 100644
index 0000000..ddb9ef5
--- /dev/null
+++ b/src/app/login/page.tsx
@@ -0,0 +1,14 @@
+import Link from "next/link";
+import { AuthForm } from "@/app/_components/auth-form";
+
+export default function LoginPage() {
+ return (
+
+ Anmelden
+
+
+ Eingeladen worden? Konto anlegen
+
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index d408995..e0ed0b6 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,9 +1,41 @@
-export default function HomePage() {
+import Link from "next/link";
+import { headers } from "next/headers";
+import { SignOutButton } from "@/app/_components/auth-form";
+import { currentActor, getRuntime } from "@/app/_server/runtime";
+import { getCompany } from "@/features/identity";
+
+export const dynamic = "force-dynamic";
+
+export default async function HomePage() {
+ const actor = await currentActor(await headers());
+ if (!actor) {
+ return (
+
+ RequestFlow
+ Angebotsanfragen erfassen, neben der Quelle prüfen und genau einmal ans ERP übergeben.
+
+ Anmelden · Konto mit Einladung anlegen
+
+ Pilot – alle Daten sind synthetisch.
+
+ );
+ }
+ const company = await getCompany(getRuntime().database.db, actor.companyId);
return (
RequestFlow
- Angebotsanfragen erfassen, neben der Quelle prüfen und genau einmal ans ERP übergeben.
- Pilot im Aufbau – alle Daten sind synthetisch.
+
+ Firma: {company?.name} · Rolle: {actor.role === "admin" ? "Administration" : "Sachbearbeitung"}
+
+
+ Anfragen
+
+ {actor.role === "admin" && (
+
+ Mitarbeitende einladen
+
+ )}
+
);
}
diff --git a/src/app/requests/[id]/page.tsx b/src/app/requests/[id]/page.tsx
new file mode 100644
index 0000000..d28e4fc
--- /dev/null
+++ b/src/app/requests/[id]/page.tsx
@@ -0,0 +1,45 @@
+import Link from "next/link";
+import { headers } from "next/headers";
+import { notFound, redirect } from "next/navigation";
+import { currentActor, getRuntime } from "@/app/_server/runtime";
+import { listDocuments } from "@/features/documents";
+import { getRequest } from "@/features/requests";
+
+export const dynamic = "force-dynamic";
+
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+export default async function RequestPage({ params }: { params: Promise<{ id: string }> }) {
+ const actor = await currentActor(await headers());
+ if (!actor) redirect("/login");
+ const { id } = await params;
+ if (!UUID.test(id)) notFound();
+ const data = await getRuntime().tenancy.withTenant(actor.companyId, async (tx) => {
+ const request = await getRequest(tx, id);
+ return request ? { request, documents: await listDocuments(tx, id) } : null;
+ });
+ if (!data) notFound();
+ const { request, documents } = data;
+ return (
+
+
+ ← Anfragen
+
+ {request.subject ?? "(ohne Betreff)"}
+ Status: {request.status}
+ {request.possibleDuplicate && request.duplicateOfId && (
+
+ Mögliches Duplikat von dieser Anfrage.
+
+ )}
+ Dokumente
+
+ {documents.map((document) => (
+ -
+ {document.filename} ({Math.ceil(document.sizeBytes / 1024)} KB)
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/requests/page.tsx b/src/app/requests/page.tsx
new file mode 100644
index 0000000..e36ed59
--- /dev/null
+++ b/src/app/requests/page.tsx
@@ -0,0 +1,48 @@
+import Link from "next/link";
+import { headers } from "next/headers";
+import { redirect } from "next/navigation";
+import { currentActor, getRuntime } from "@/app/_server/runtime";
+import { listRequests } from "@/features/requests";
+import { UploadForm } from "./upload-form";
+
+export const dynamic = "force-dynamic";
+
+const dateFormat = new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short", timeZone: "Europe/Berlin" });
+
+export default async function RequestsPage() {
+ const actor = await currentActor(await headers());
+ if (!actor) redirect("/login");
+ const requests = await getRuntime().tenancy.withTenant(actor.companyId, (tx) => listRequests(tx));
+ return (
+
+ Anfragen
+
+ {requests.length === 0 ? (
+ Noch keine Anfragen.
+ ) : (
+
+
+
+ | Eingang |
+ Betreff |
+ Status |
+ Hinweis |
+
+
+
+ {requests.map((request) => (
+
+ | {dateFormat.format(request.createdAt)} |
+
+ {request.subject ?? "(ohne Betreff)"}
+ |
+ {request.status} |
+ {request.possibleDuplicate ? "Mögliches Duplikat" : ""} |
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/src/app/requests/upload-form.tsx b/src/app/requests/upload-form.tsx
new file mode 100644
index 0000000..3fe7e1e
--- /dev/null
+++ b/src/app/requests/upload-form.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useState, type FormEvent } from "react";
+
+// Posts the selected files to POST /api/requests; errors from the server are shown verbatim
+// (they are written for users, e.g. "Dateityp nicht erlaubt").
+export function UploadForm() {
+ const router = useRouter();
+ const [message, setMessage] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ async function submit(event: FormEvent) {
+ event.preventDefault();
+ const formElement = event.currentTarget;
+ setBusy(true);
+ setMessage(null);
+ const response = await fetch("/api/requests", { method: "POST", body: new FormData(formElement) });
+ setBusy(false);
+ if (response.ok) {
+ const result = (await response.json()) as { possibleDuplicate: boolean };
+ setMessage(result.possibleDuplicate ? "Anfrage angelegt – möglicherweise ein Duplikat, bitte prüfen." : "Anfrage angelegt.");
+ formElement.reset();
+ router.refresh();
+ return;
+ }
+ const body = (await response.json().catch(() => null)) as { error?: { title?: string } } | null;
+ setMessage(body?.error?.title ?? "Upload fehlgeschlagen.");
+ }
+
+ return (
+
+ );
+}
diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx
new file mode 100644
index 0000000..9f049f1
--- /dev/null
+++ b/src/app/signup/page.tsx
@@ -0,0 +1,23 @@
+import Link from "next/link";
+import { AuthForm } from "@/app/_components/auth-form";
+
+// Invite-only: the link from the invitation carries its id (`/signup?invitation=`).
+export default async function SignupPage({ searchParams }: { searchParams: Promise> }) {
+ const { invitation } = await searchParams;
+ return (
+
+ Konto anlegen
+ {invitation ? (
+ <>
+ Verwenden Sie die E-Mail-Adresse, an die die Einladung ging.
+
+ >
+ ) : (
+ Ein Konto kann nur über einen Einladungslink angelegt werden. Bitte wenden Sie sich an Ihre Administration.
+ )}
+
+ Zur Anmeldung
+
+
+ );
+}
diff --git a/src/config/env.test.ts b/src/config/env.test.ts
index d12af79..9cf6a92 100644
--- a/src/config/env.test.ts
+++ b/src/config/env.test.ts
@@ -8,6 +8,9 @@ const valid = {
S3_BUCKET: "requestflow-documents",
S3_ACCESS_KEY_ID: "local-key",
S3_SECRET_ACCESS_KEY: "s3-secret-value",
+ BETTER_AUTH_SECRET: "test-only-secret-with-at-least-32-characters",
+ BETTER_AUTH_URL: "http://localhost:3000",
+ APP_ENV: "local",
};
describe("loadConfig", () => {
@@ -50,4 +53,25 @@ describe("loadConfig", () => {
it("reads S3_FORCE_PATH_STYLE=false as false", () => {
expect(loadConfig({ ...valid, S3_FORCE_PATH_STYLE: "false" }).storage.forcePathStyle).toBe(false);
});
+
+ it("refuses the committed local auth secret outside APP_ENV=local", () => {
+ const local = { ...valid, BETTER_AUTH_SECRET: "local-dev-only-secret-change-me-0123456789" };
+
+ expect(() => loadConfig({ ...local, APP_ENV: "production" })).toThrow(/BETTER_AUTH_SECRET/);
+ expect(() => loadConfig({ ...local, APP_ENV: "showcase" })).toThrow(/BETTER_AUTH_SECRET/);
+ expect(() => loadConfig(local)).not.toThrow();
+ });
+
+ it("requires an explicit APP_ENV", () => {
+ const { APP_ENV: _env, ...withoutEnv } = valid;
+
+ expect(() => loadConfig(withoutEnv)).toThrow(/APP_ENV/);
+ });
+
+ it("reads the client-IP headers and trusted proxies for the auth rate limit as lists", () => {
+ const config = loadConfig({ ...valid, AUTH_IP_HEADERS: "x-real-ip, x-forwarded-for", AUTH_TRUSTED_PROXIES: "10.0.0.2" });
+
+ expect(config.auth.ipAddressHeaders).toEqual(["x-real-ip", "x-forwarded-for"]);
+ expect(config.auth.trustedProxies).toEqual(["10.0.0.2"]);
+ });
});
diff --git a/src/config/env.ts b/src/config/env.ts
index 4605fdf..855ceda 100644
--- a/src/config/env.ts
+++ b/src/config/env.ts
@@ -8,8 +8,21 @@ const schema = z.object({
S3_ACCESS_KEY_ID: z.string().min(1),
S3_SECRET_ACCESS_KEY: z.string().min(1),
S3_FORCE_PATH_STYLE: z.enum(["true", "false"]).default("true"),
+ BETTER_AUTH_SECRET: z.string().min(32),
+ BETTER_AUTH_URL: z.url(),
+ AUTH_IP_HEADERS: z.string().default("x-forwarded-for"),
+ AUTH_TRUSTED_PROXIES: z.string().default(""),
+ // Deployment environment – set explicitly everywhere (compose, CI, .env). Only `local` may use the
+ // committed local-default secret.
+ APP_ENV: z.enum(["local", "showcase", "production"]),
+ UPLOAD_MAX_FILE_BYTES: z.coerce.number().int().positive().default(20 * 1024 * 1024),
+ UPLOAD_MAX_FILES: z.coerce.number().int().positive().max(50).default(10),
+ UPLOAD_MAX_REQUEST_BYTES: z.coerce.number().int().positive().default(40 * 1024 * 1024),
});
+const LOCAL_PLACEHOLDER_SECRETS = new Set(["local-dev-only-secret-change-me-0123456789"]);
+const list = (value: string) => value.split(",").map((item) => item.trim()).filter(Boolean);
+
export interface AppConfig {
databaseUrl: string;
storage: {
@@ -20,6 +33,17 @@ export interface AppConfig {
secretAccessKey: string;
forcePathStyle: boolean;
};
+ auth: {
+ secret: string;
+ baseURL: string;
+ ipAddressHeaders: string[];
+ trustedProxies: string[];
+ };
+ upload: {
+ maxFileBytes: number;
+ maxFiles: number;
+ maxRequestBytes: number;
+ };
}
// Errors list variable names only – values may be secrets and end up in logs.
@@ -30,6 +54,10 @@ export function loadConfig(source: Record = process.
throw new Error(`Invalid or missing configuration: ${names.join(", ")}`);
}
const env = parsed.data;
+ // The committed local default must never sign sessions of a real deployment.
+ if (env.APP_ENV !== "local" && LOCAL_PLACEHOLDER_SECRETS.has(env.BETTER_AUTH_SECRET)) {
+ throw new Error("Invalid or missing configuration: BETTER_AUTH_SECRET");
+ }
return {
databaseUrl: env.DATABASE_URL,
storage: {
@@ -40,5 +68,12 @@ export function loadConfig(source: Record = process.
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
forcePathStyle: env.S3_FORCE_PATH_STYLE === "true",
},
+ auth: {
+ secret: env.BETTER_AUTH_SECRET,
+ baseURL: env.BETTER_AUTH_URL,
+ ipAddressHeaders: list(env.AUTH_IP_HEADERS),
+ trustedProxies: list(env.AUTH_TRUSTED_PROXIES),
+ },
+ upload: { maxFileBytes: env.UPLOAD_MAX_FILE_BYTES, maxFiles: env.UPLOAD_MAX_FILES, maxRequestBytes: env.UPLOAD_MAX_REQUEST_BYTES },
};
}
diff --git a/src/db/job-queue-client.ts b/src/db/job-queue-client.ts
new file mode 100644
index 0000000..d0de6b0
--- /dev/null
+++ b/src/db/job-queue-client.ts
@@ -0,0 +1,64 @@
+import { PgBoss, type Queue } from "pg-boss";
+
+// pg-boss opens its own pool, so its construction lives in src/db with the other database clients
+// (AGENTS.md: no raw DB client outside src/db and tenancy) and is called only by composition roots
+// (web runtime, worker, deploy step, tests). Feature modules get the instance injected.
+export const PGBOSS_SCHEMA = "pgboss";
+
+/** Runtime client (`app_rw`): no schema creation or migration; polling, no LISTEN/NOTIFY. */
+export async function createJobQueue(connectionString: string, options: { supervise?: boolean } = {}): Promise {
+ const boss = new PgBoss({
+ connectionString,
+ schema: PGBOSS_SCHEMA,
+ max: 4,
+ migrate: false,
+ createSchema: false,
+ supervise: options.supervise ?? false,
+ schedule: false,
+ });
+ boss.on("error", (error: Error) => console.error(JSON.stringify({ level: "error", module: "jobs", message: error.message })));
+ await boss.start();
+ return boss;
+}
+
+/**
+ * Deploy step, owner role: install/upgrade the pg-boss schema and queues, then grant the runtime role
+ * the least it needs – job rows read/write, queue and version read, queue statistics maintenance.
+ */
+export async function installJobQueues(ownerConnectionString: string, definitions: Queue[]): Promise {
+ const boss = new PgBoss({
+ connectionString: ownerConnectionString,
+ schema: PGBOSS_SCHEMA,
+ max: 2,
+ migrate: true,
+ supervise: false,
+ schedule: false,
+ });
+ boss.on("error", () => {});
+ await boss.start();
+ try {
+ for (const { name, policy, ...options } of definitions) {
+ // The policy is fixed at creation; retries, expiry and dead letter can be updated in place.
+ if (await boss.getQueue(name)) await boss.updateQueue(name, options);
+ else await boss.createQueue(name, { policy, ...options });
+ }
+ const db = boss.getDb();
+ const s = PGBOSS_SCHEMA;
+ for (const statement of [
+ `REVOKE ALL ON ALL TABLES IN SCHEMA ${s} FROM app_rw`,
+ `GRANT USAGE ON SCHEMA ${s} TO app_rw`,
+ `GRANT SELECT, INSERT, UPDATE, DELETE ON ${s}.job, ${s}.job_common, ${s}.job_dependency, ${s}.warning, ${s}.bam TO app_rw`,
+ `GRANT SELECT, INSERT, UPDATE, DELETE ON ${s}.queue_stats TO app_rw`,
+ `GRANT SELECT, UPDATE ON ${s}.queue TO app_rw`,
+ `GRANT SELECT ON ${s}.version, ${s}.schedule, ${s}.subscription TO app_rw`,
+ `GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${s} TO app_rw`,
+ `GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA ${s} TO app_rw`,
+ // Partitions of job/queue_stats created later by the owner inherit the table privileges.
+ `ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw`,
+ ]) {
+ await db.executeSql(statement);
+ }
+ } finally {
+ await boss.stop({ graceful: false });
+ }
+}
diff --git a/src/db/job-queue.ts b/src/db/job-queue.ts
new file mode 100644
index 0000000..04a6f7d
--- /dev/null
+++ b/src/db/job-queue.ts
@@ -0,0 +1,25 @@
+import { sql } from "drizzle-orm";
+import { fromDrizzle, type PgBoss, type SendOptions } from "pg-boss";
+import type { Database } from "./client";
+
+// Transactional enqueue helper (ADR-0001 D4). Opening pg-boss pools lives in ./job-queue-client.ts
+// (composition roots only – dependency-cruiser rule `no-db-connection-in-features`).
+
+type Transaction = Parameters[0]>[0];
+export type JobSender = Pick;
+
+/**
+ * Enqueues IN the caller's transaction (transactional outbox without an outbox table): the job
+ * exists exactly when the transaction commits. A job refused by the queue policy is an error.
+ */
+export async function sendInTransaction(
+ queue: JobSender,
+ tx: Transaction,
+ name: string,
+ data: object,
+ options: Omit = {},
+): Promise {
+ const id = await queue.send(name, data, { ...options, db: fromDrizzle(tx, sql) });
+ if (!id) throw new Error(`job refused by queue policy: ${name}`);
+ return id;
+}
diff --git a/src/db/migrations/0001_identity_tenancy.sql b/src/db/migrations/0001_identity_tenancy.sql
new file mode 100644
index 0000000..e87aff5
--- /dev/null
+++ b/src/db/migrations/0001_identity_tenancy.sql
@@ -0,0 +1,119 @@
+-- Generated by drizzle-kit from src/db/schema/*; edited: schema `app` already exists (0000).
+CREATE SCHEMA "auth";
+--> statement-breakpoint
+CREATE TABLE "auth"."account" (
+ "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
+ "account_id" text NOT NULL,
+ "provider_id" text NOT NULL,
+ "user_id" uuid NOT NULL,
+ "access_token" text,
+ "refresh_token" text,
+ "id_token" text,
+ "access_token_expires_at" timestamp with time zone,
+ "refresh_token_expires_at" timestamp with time zone,
+ "scope" text,
+ "password" text,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "auth"."invitation" (
+ "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
+ "organization_id" uuid NOT NULL,
+ "email" text NOT NULL,
+ "role" text,
+ "status" text DEFAULT 'pending' NOT NULL,
+ "expires_at" timestamp with time zone NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "inviter_id" uuid NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "auth"."member" (
+ "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
+ "organization_id" uuid NOT NULL,
+ "user_id" uuid NOT NULL,
+ "role" text DEFAULT 'member' NOT NULL,
+ "created_at" timestamp with time zone NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "auth"."organization" (
+ "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
+ "name" text NOT NULL,
+ "slug" text NOT NULL,
+ "logo" text,
+ "created_at" timestamp with time zone NOT NULL,
+ "metadata" text,
+ CONSTRAINT "organization_slug_unique" UNIQUE("slug")
+);
+--> statement-breakpoint
+CREATE TABLE "auth"."rate_limit" (
+ "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
+ "key" text NOT NULL,
+ "count" integer NOT NULL,
+ "last_request" bigint NOT NULL,
+ CONSTRAINT "rate_limit_key_unique" UNIQUE("key")
+);
+--> statement-breakpoint
+CREATE TABLE "auth"."session" (
+ "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
+ "expires_at" timestamp with time zone NOT NULL,
+ "token" text NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone NOT NULL,
+ "ip_address" text,
+ "user_agent" text,
+ "user_id" uuid NOT NULL,
+ "active_organization_id" text,
+ "impersonated_by" text,
+ CONSTRAINT "session_token_unique" UNIQUE("token")
+);
+--> statement-breakpoint
+CREATE TABLE "auth"."user" (
+ "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
+ "name" text NOT NULL,
+ "email" text NOT NULL,
+ "email_verified" boolean DEFAULT false NOT NULL,
+ "image" text,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "role" text,
+ "banned" boolean DEFAULT false,
+ "ban_reason" text,
+ "ban_expires" timestamp with time zone,
+ CONSTRAINT "user_email_unique" UNIQUE("email")
+);
+--> statement-breakpoint
+CREATE TABLE "auth"."verification" (
+ "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
+ "identifier" text NOT NULL,
+ "value" text NOT NULL,
+ "expires_at" timestamp with time zone 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 "app"."requests" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "company_id" uuid NOT NULL,
+ "status" text DEFAULT 'NEW' NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT "requests_status_check" CHECK (status in ('NEW', 'PROCESSING', 'REVIEW', 'APPROVED', 'EXPORTED', 'REJECTED', 'ERROR'))
+);
+--> statement-breakpoint
+ALTER TABLE "app"."requests" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
+ALTER TABLE "auth"."account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "auth"."invitation" ADD CONSTRAINT "invitation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "auth"."invitation" ADD CONSTRAINT "invitation_inviter_id_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "auth"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "auth"."member" ADD CONSTRAINT "member_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "auth"."member" ADD CONSTRAINT "member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "auth"."session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "app"."requests" ADD CONSTRAINT "requests_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "auth"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "account_userId_idx" ON "auth"."account" USING btree ("user_id");--> statement-breakpoint
+CREATE INDEX "invitation_organizationId_idx" ON "auth"."invitation" USING btree ("organization_id");--> statement-breakpoint
+CREATE INDEX "invitation_email_idx" ON "auth"."invitation" USING btree ("email");--> statement-breakpoint
+CREATE INDEX "member_organizationId_idx" ON "auth"."member" USING btree ("organization_id");--> statement-breakpoint
+CREATE INDEX "member_userId_idx" ON "auth"."member" USING btree ("user_id");--> statement-breakpoint
+CREATE INDEX "session_userId_idx" ON "auth"."session" USING btree ("user_id");--> statement-breakpoint
+CREATE INDEX "verification_identifier_idx" ON "auth"."verification" USING btree ("identifier");--> statement-breakpoint
+CREATE INDEX "requests_company_id_idx" ON "app"."requests" USING btree ("company_id");--> statement-breakpoint
+CREATE POLICY "requests_tenant_isolation" ON "app"."requests" AS PERMISSIVE FOR ALL TO public USING (company_id = nullif(current_setting('app.company_id', true), '')::uuid) WITH CHECK (company_id = nullif(current_setting('app.company_id', true), '')::uuid);
\ No newline at end of file
diff --git a/src/db/migrations/0002_auth_grants_force_rls.sql b/src/db/migrations/0002_auth_grants_force_rls.sql
new file mode 100644
index 0000000..e7373f5
--- /dev/null
+++ b/src/db/migrations/0002_auth_grants_force_rls.sql
@@ -0,0 +1,17 @@
+-- Hand-written (ADR-0001 D7).
+-- 1) The runtime role may use the Better Auth tables (schema `auth`, no RLS – exceptions register),
+-- but never create objects there.
+GRANT USAGE ON SCHEMA auth TO app_rw;
+--> statement-breakpoint
+REVOKE ALL ON SCHEMA auth FROM PUBLIC;
+--> statement-breakpoint
+GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA auth TO app_rw;
+--> statement-breakpoint
+ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA auth GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw;
+--> statement-breakpoint
+-- 2) Forced RLS: drizzle-kit emits only ENABLE. FORCE makes the policy apply to the table owner too,
+-- so no role except a superuser ever reads company data without `app.company_id`.
+ALTER TABLE app.requests FORCE ROW LEVEL SECURITY;
+--> statement-breakpoint
+-- 3) One company per user in the pilot: the session hook and getActor resolve THE membership.
+CREATE UNIQUE INDEX member_one_company_per_user ON auth.member (user_id);
diff --git a/src/db/migrations/0003_intake.sql b/src/db/migrations/0003_intake.sql
new file mode 100644
index 0000000..46d180f
--- /dev/null
+++ b/src/db/migrations/0003_intake.sql
@@ -0,0 +1,45 @@
+CREATE TABLE "app"."audit_events" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "company_id" uuid NOT NULL,
+ "actor_user_id" uuid,
+ "action" text NOT NULL,
+ "entity_type" text NOT NULL,
+ "entity_id" uuid NOT NULL,
+ "data" jsonb DEFAULT '{}'::jsonb NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "app"."audit_events" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
+CREATE TABLE "app"."documents" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "company_id" uuid NOT NULL,
+ "request_id" uuid NOT NULL,
+ "filename" text NOT NULL,
+ "content_type" text NOT NULL,
+ "kind" text NOT NULL,
+ "size_bytes" bigint NOT NULL,
+ "sha256" text NOT NULL,
+ "storage_key" text NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT "documents_kind_check" CHECK (kind in ('eml', 'msg', 'pdf', 'xlsx', 'docx'))
+);
+--> statement-breakpoint
+ALTER TABLE "app"."documents" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
+ALTER TABLE "app"."requests" ADD COLUMN "source" text DEFAULT 'upload' NOT NULL;--> statement-breakpoint
+ALTER TABLE "app"."requests" ADD COLUMN "created_by" uuid;--> statement-breakpoint
+ALTER TABLE "app"."requests" ADD COLUMN "subject" text;--> statement-breakpoint
+ALTER TABLE "app"."requests" ADD COLUMN "message_id" text;--> statement-breakpoint
+ALTER TABLE "app"."requests" ADD COLUMN "fingerprint" text;--> statement-breakpoint
+ALTER TABLE "app"."requests" ADD COLUMN "possible_duplicate" boolean DEFAULT false NOT NULL;--> statement-breakpoint
+ALTER TABLE "app"."requests" ADD COLUMN "duplicate_of_id" uuid;--> statement-breakpoint
+ALTER TABLE "app"."audit_events" ADD CONSTRAINT "audit_events_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "auth"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "app"."documents" ADD CONSTRAINT "documents_company_id_organization_id_fk" FOREIGN KEY ("company_id") REFERENCES "auth"."organization"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "app"."documents" ADD CONSTRAINT "documents_request_id_requests_id_fk" FOREIGN KEY ("request_id") REFERENCES "app"."requests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "audit_events_entity_idx" ON "app"."audit_events" USING btree ("company_id","entity_type","entity_id");--> statement-breakpoint
+CREATE INDEX "documents_company_id_idx" ON "app"."documents" USING btree ("company_id");--> statement-breakpoint
+CREATE INDEX "documents_request_id_idx" ON "app"."documents" USING btree ("request_id");--> statement-breakpoint
+ALTER TABLE "app"."requests" ADD CONSTRAINT "requests_duplicate_of_id_requests_id_fk" FOREIGN KEY ("duplicate_of_id") REFERENCES "app"."requests"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "requests_company_message_id_idx" ON "app"."requests" USING btree ("company_id","message_id");--> statement-breakpoint
+CREATE INDEX "requests_company_fingerprint_idx" ON "app"."requests" USING btree ("company_id","fingerprint");--> statement-breakpoint
+CREATE POLICY "audit_events_tenant_isolation" ON "app"."audit_events" AS PERMISSIVE FOR ALL TO public USING (company_id = nullif(current_setting('app.company_id', true), '')::uuid) WITH CHECK (company_id = nullif(current_setting('app.company_id', true), '')::uuid);--> statement-breakpoint
+CREATE POLICY "documents_tenant_isolation" ON "app"."documents" AS PERMISSIVE FOR ALL TO public USING (company_id = nullif(current_setting('app.company_id', true), '')::uuid) WITH CHECK (company_id = nullif(current_setting('app.company_id', true), '')::uuid);
\ No newline at end of file
diff --git a/src/db/migrations/0004_intake_force_rls_audit.sql b/src/db/migrations/0004_intake_force_rls_audit.sql
new file mode 100644
index 0000000..ef6fcfb
--- /dev/null
+++ b/src/db/migrations/0004_intake_force_rls_audit.sql
@@ -0,0 +1,8 @@
+-- Hand-written (ADR-0001 D7, D10).
+-- 1) Forced RLS on the new company-owned tables (drizzle-kit emits only ENABLE).
+ALTER TABLE app.documents FORCE ROW LEVEL SECURITY;
+--> statement-breakpoint
+ALTER TABLE app.audit_events FORCE ROW LEVEL SECURITY;
+--> statement-breakpoint
+-- 2) Append-only audit: the runtime role may insert and read, never change or delete.
+REVOKE UPDATE, DELETE, TRUNCATE ON app.audit_events FROM app_rw;
diff --git a/src/db/migrations/0005_intake_same_company_fks.sql b/src/db/migrations/0005_intake_same_company_fks.sql
new file mode 100644
index 0000000..dd278bf
--- /dev/null
+++ b/src/db/migrations/0005_intake_same_company_fks.sql
@@ -0,0 +1,8 @@
+-- Generated by drizzle-kit; edited: the UNIQUE (id, company_id) must exist before the FKs that use it.
+ALTER TABLE "app"."documents" DROP CONSTRAINT "documents_request_id_requests_id_fk";
+--> statement-breakpoint
+ALTER TABLE "app"."requests" DROP CONSTRAINT "requests_duplicate_of_id_requests_id_fk";
+--> statement-breakpoint
+ALTER TABLE "app"."requests" ADD CONSTRAINT "requests_id_company_unique" UNIQUE("id","company_id");--> statement-breakpoint
+ALTER TABLE "app"."documents" ADD CONSTRAINT "documents_request_same_company_fk" FOREIGN KEY ("request_id","company_id") REFERENCES "app"."requests"("id","company_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "app"."requests" ADD CONSTRAINT "requests_duplicate_same_company_fk" FOREIGN KEY ("duplicate_of_id","company_id") REFERENCES "app"."requests"("id","company_id") ON DELETE no action ON UPDATE no action;
diff --git a/src/db/migrations/meta/0001_snapshot.json b/src/db/migrations/meta/0001_snapshot.json
new file mode 100644
index 0000000..ca20ee7
--- /dev/null
+++ b/src/db/migrations/meta/0001_snapshot.json
@@ -0,0 +1,840 @@
+{
+ "id": "1a5f1fcb-ba40-4aed-b2cc-a9acb1e65375",
+ "prevId": "bb3fbb0f-c63b-429b-bdde-dd62b4f1e186",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "auth.account": {
+ "name": "account",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "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
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.invitation": {
+ "name": "invitation",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "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()"
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "invitation_organizationId_idx": {
+ "name": "invitation_organizationId_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "invitation_email_idx": {
+ "name": "invitation_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "invitation_organization_id_organization_id_fk": {
+ "name": "invitation_organization_id_organization_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invitation_inviter_id_user_id_fk": {
+ "name": "invitation_inviter_id_user_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "inviter_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.member": {
+ "name": "member",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "member_organizationId_idx": {
+ "name": "member_organizationId_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "member_userId_idx": {
+ "name": "member_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "member_organization_id_organization_id_fk": {
+ "name": "member_organization_id_organization_id_fk",
+ "tableFrom": "member",
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "member_user_id_user_id_fk": {
+ "name": "member_user_id_user_id_fk",
+ "tableFrom": "member",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.organization": {
+ "name": "organization",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "organization_slug_unique": {
+ "name": "organization_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.rate_limit": {
+ "name": "rate_limit",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "count": {
+ "name": "count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_request": {
+ "name": "last_request",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "rate_limit_key_unique": {
+ "name": "rate_limit_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.session": {
+ "name": "session",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "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
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "active_organization_id": {
+ "name": "active_organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.user": {
+ "name": "user",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "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()"
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.verification": {
+ "name": "verification",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "app.requests": {
+ "name": "requests",
+ "schema": "app",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'NEW'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "requests_company_id_idx": {
+ "name": "requests_company_id_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "requests_company_id_organization_id_fk": {
+ "name": "requests_company_id_organization_id_fk",
+ "tableFrom": "requests",
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {
+ "requests_tenant_isolation": {
+ "name": "requests_tenant_isolation",
+ "as": "PERMISSIVE",
+ "for": "ALL",
+ "to": [
+ "public"
+ ],
+ "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid",
+ "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid"
+ }
+ },
+ "checkConstraints": {
+ "requests_status_check": {
+ "name": "requests_status_check",
+ "value": "status in ('NEW', 'PROCESSING', 'REVIEW', 'APPROVED', 'EXPORTED', 'REJECTED', 'ERROR')"
+ }
+ },
+ "isRLSEnabled": true
+ }
+ },
+ "enums": {},
+ "schemas": {
+ "auth": "auth",
+ "app": "app"
+ },
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/src/db/migrations/meta/0002_snapshot.json b/src/db/migrations/meta/0002_snapshot.json
new file mode 100644
index 0000000..0d13d41
--- /dev/null
+++ b/src/db/migrations/meta/0002_snapshot.json
@@ -0,0 +1,840 @@
+{
+ "id": "3fa69086-6743-434f-a277-58a30576189a",
+ "prevId": "1a5f1fcb-ba40-4aed-b2cc-a9acb1e65375",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "auth.account": {
+ "name": "account",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "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
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.invitation": {
+ "name": "invitation",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "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()"
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "invitation_organizationId_idx": {
+ "name": "invitation_organizationId_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ },
+ "invitation_email_idx": {
+ "name": "invitation_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "invitation_organization_id_organization_id_fk": {
+ "name": "invitation_organization_id_organization_id_fk",
+ "tableFrom": "invitation",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ },
+ "invitation_inviter_id_user_id_fk": {
+ "name": "invitation_inviter_id_user_id_fk",
+ "tableFrom": "invitation",
+ "columnsFrom": [
+ "inviter_id"
+ ],
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.member": {
+ "name": "member",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "member_organizationId_idx": {
+ "name": "member_organizationId_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ },
+ "member_userId_idx": {
+ "name": "member_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "member_organization_id_organization_id_fk": {
+ "name": "member_organization_id_organization_id_fk",
+ "tableFrom": "member",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ },
+ "member_user_id_user_id_fk": {
+ "name": "member_user_id_user_id_fk",
+ "tableFrom": "member",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.organization": {
+ "name": "organization",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "organization_slug_unique": {
+ "name": "organization_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "nullsNotDistinct": false
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.rate_limit": {
+ "name": "rate_limit",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "count": {
+ "name": "count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_request": {
+ "name": "last_request",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "rate_limit_key_unique": {
+ "name": "rate_limit_key_unique",
+ "columns": [
+ "key"
+ ],
+ "nullsNotDistinct": false
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.session": {
+ "name": "session",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "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
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "active_organization_id": {
+ "name": "active_organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "columns": [
+ "token"
+ ],
+ "nullsNotDistinct": false
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.user": {
+ "name": "user",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "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()"
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "nullsNotDistinct": false
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.verification": {
+ "name": "verification",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "app.requests": {
+ "name": "requests",
+ "schema": "app",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'NEW'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "requests_company_id_idx": {
+ "name": "requests_company_id_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "requests_company_id_organization_id_fk": {
+ "name": "requests_company_id_organization_id_fk",
+ "tableFrom": "requests",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "restrict"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {
+ "requests_tenant_isolation": {
+ "name": "requests_tenant_isolation",
+ "as": "PERMISSIVE",
+ "for": "ALL",
+ "to": [
+ "public"
+ ],
+ "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid",
+ "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid"
+ }
+ },
+ "checkConstraints": {
+ "requests_status_check": {
+ "name": "requests_status_check",
+ "value": "status in ('NEW', 'PROCESSING', 'REVIEW', 'APPROVED', 'EXPORTED', 'REJECTED', 'ERROR')"
+ }
+ },
+ "isRLSEnabled": true
+ }
+ },
+ "enums": {},
+ "schemas": {
+ "auth": "auth",
+ "app": "app"
+ },
+ "views": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/src/db/migrations/meta/0003_snapshot.json b/src/db/migrations/meta/0003_snapshot.json
new file mode 100644
index 0000000..678a936
--- /dev/null
+++ b/src/db/migrations/meta/0003_snapshot.json
@@ -0,0 +1,1209 @@
+{
+ "id": "e4bbf32f-aa85-4b73-a166-d485a537b7b3",
+ "prevId": "3fa69086-6743-434f-a277-58a30576189a",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "auth.account": {
+ "name": "account",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "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
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.invitation": {
+ "name": "invitation",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "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()"
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "invitation_organizationId_idx": {
+ "name": "invitation_organizationId_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "invitation_email_idx": {
+ "name": "invitation_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "invitation_organization_id_organization_id_fk": {
+ "name": "invitation_organization_id_organization_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invitation_inviter_id_user_id_fk": {
+ "name": "invitation_inviter_id_user_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "inviter_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.member": {
+ "name": "member",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "member_organizationId_idx": {
+ "name": "member_organizationId_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "member_userId_idx": {
+ "name": "member_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "member_organization_id_organization_id_fk": {
+ "name": "member_organization_id_organization_id_fk",
+ "tableFrom": "member",
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "member_user_id_user_id_fk": {
+ "name": "member_user_id_user_id_fk",
+ "tableFrom": "member",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.organization": {
+ "name": "organization",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "organization_slug_unique": {
+ "name": "organization_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.rate_limit": {
+ "name": "rate_limit",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "count": {
+ "name": "count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_request": {
+ "name": "last_request",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "rate_limit_key_unique": {
+ "name": "rate_limit_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.session": {
+ "name": "session",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "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
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "active_organization_id": {
+ "name": "active_organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.user": {
+ "name": "user",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "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()"
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.verification": {
+ "name": "verification",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "app.audit_events": {
+ "name": "audit_events",
+ "schema": "app",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actor_user_id": {
+ "name": "actor_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "data": {
+ "name": "data",
+ "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": {
+ "audit_events_entity_idx": {
+ "name": "audit_events_entity_idx",
+ "columns": [
+ {
+ "expression": "company_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_events_company_id_organization_id_fk": {
+ "name": "audit_events_company_id_organization_id_fk",
+ "tableFrom": "audit_events",
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {
+ "audit_events_tenant_isolation": {
+ "name": "audit_events_tenant_isolation",
+ "as": "PERMISSIVE",
+ "for": "ALL",
+ "to": [
+ "public"
+ ],
+ "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid",
+ "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid"
+ }
+ },
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "app.documents": {
+ "name": "documents",
+ "schema": "app",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "filename": {
+ "name": "filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sha256": {
+ "name": "sha256",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "storage_key": {
+ "name": "storage_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "documents_company_id_idx": {
+ "name": "documents_company_id_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "documents_request_id_idx": {
+ "name": "documents_request_id_idx",
+ "columns": [
+ {
+ "expression": "request_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "documents_company_id_organization_id_fk": {
+ "name": "documents_company_id_organization_id_fk",
+ "tableFrom": "documents",
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "documents_request_id_requests_id_fk": {
+ "name": "documents_request_id_requests_id_fk",
+ "tableFrom": "documents",
+ "tableTo": "requests",
+ "schemaTo": "app",
+ "columnsFrom": [
+ "request_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {
+ "documents_tenant_isolation": {
+ "name": "documents_tenant_isolation",
+ "as": "PERMISSIVE",
+ "for": "ALL",
+ "to": [
+ "public"
+ ],
+ "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid",
+ "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid"
+ }
+ },
+ "checkConstraints": {
+ "documents_kind_check": {
+ "name": "documents_kind_check",
+ "value": "kind in ('eml', 'msg', 'pdf', 'xlsx', 'docx')"
+ }
+ },
+ "isRLSEnabled": true
+ },
+ "app.requests": {
+ "name": "requests",
+ "schema": "app",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'NEW'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'upload'"
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "message_id": {
+ "name": "message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fingerprint": {
+ "name": "fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "possible_duplicate": {
+ "name": "possible_duplicate",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "duplicate_of_id": {
+ "name": "duplicate_of_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "requests_company_id_idx": {
+ "name": "requests_company_id_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "requests_company_message_id_idx": {
+ "name": "requests_company_message_id_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "message_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "requests_company_fingerprint_idx": {
+ "name": "requests_company_fingerprint_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "fingerprint",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "requests_company_id_organization_id_fk": {
+ "name": "requests_company_id_organization_id_fk",
+ "tableFrom": "requests",
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "requests_duplicate_of_id_requests_id_fk": {
+ "name": "requests_duplicate_of_id_requests_id_fk",
+ "tableFrom": "requests",
+ "tableTo": "requests",
+ "schemaTo": "app",
+ "columnsFrom": [
+ "duplicate_of_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {
+ "requests_tenant_isolation": {
+ "name": "requests_tenant_isolation",
+ "as": "PERMISSIVE",
+ "for": "ALL",
+ "to": [
+ "public"
+ ],
+ "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid",
+ "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid"
+ }
+ },
+ "checkConstraints": {
+ "requests_status_check": {
+ "name": "requests_status_check",
+ "value": "status in ('NEW', 'PROCESSING', 'REVIEW', 'APPROVED', 'EXPORTED', 'REJECTED', 'ERROR')"
+ }
+ },
+ "isRLSEnabled": true
+ }
+ },
+ "enums": {},
+ "schemas": {
+ "auth": "auth",
+ "app": "app"
+ },
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/src/db/migrations/meta/0004_snapshot.json b/src/db/migrations/meta/0004_snapshot.json
new file mode 100644
index 0000000..a4277cd
--- /dev/null
+++ b/src/db/migrations/meta/0004_snapshot.json
@@ -0,0 +1,1209 @@
+{
+ "id": "d257ca2a-1a2c-482f-a651-21d7d15408fa",
+ "prevId": "e4bbf32f-aa85-4b73-a166-d485a537b7b3",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "auth.account": {
+ "name": "account",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "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
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.invitation": {
+ "name": "invitation",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "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()"
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "invitation_organizationId_idx": {
+ "name": "invitation_organizationId_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ },
+ "invitation_email_idx": {
+ "name": "invitation_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "invitation_organization_id_organization_id_fk": {
+ "name": "invitation_organization_id_organization_id_fk",
+ "tableFrom": "invitation",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ },
+ "invitation_inviter_id_user_id_fk": {
+ "name": "invitation_inviter_id_user_id_fk",
+ "tableFrom": "invitation",
+ "columnsFrom": [
+ "inviter_id"
+ ],
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.member": {
+ "name": "member",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "member_organizationId_idx": {
+ "name": "member_organizationId_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ },
+ "member_userId_idx": {
+ "name": "member_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "member_organization_id_organization_id_fk": {
+ "name": "member_organization_id_organization_id_fk",
+ "tableFrom": "member",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ },
+ "member_user_id_user_id_fk": {
+ "name": "member_user_id_user_id_fk",
+ "tableFrom": "member",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.organization": {
+ "name": "organization",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "organization_slug_unique": {
+ "name": "organization_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "nullsNotDistinct": false
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.rate_limit": {
+ "name": "rate_limit",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "count": {
+ "name": "count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_request": {
+ "name": "last_request",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "rate_limit_key_unique": {
+ "name": "rate_limit_key_unique",
+ "columns": [
+ "key"
+ ],
+ "nullsNotDistinct": false
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.session": {
+ "name": "session",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "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
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "active_organization_id": {
+ "name": "active_organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "columns": [
+ "token"
+ ],
+ "nullsNotDistinct": false
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.user": {
+ "name": "user",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "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()"
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "nullsNotDistinct": false
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.verification": {
+ "name": "verification",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "app.audit_events": {
+ "name": "audit_events",
+ "schema": "app",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actor_user_id": {
+ "name": "actor_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "data": {
+ "name": "data",
+ "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": {
+ "audit_events_entity_idx": {
+ "name": "audit_events_entity_idx",
+ "columns": [
+ {
+ "expression": "company_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,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "audit_events_company_id_organization_id_fk": {
+ "name": "audit_events_company_id_organization_id_fk",
+ "tableFrom": "audit_events",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "restrict"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {
+ "audit_events_tenant_isolation": {
+ "name": "audit_events_tenant_isolation",
+ "as": "PERMISSIVE",
+ "for": "ALL",
+ "to": [
+ "public"
+ ],
+ "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid",
+ "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid"
+ }
+ },
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "app.documents": {
+ "name": "documents",
+ "schema": "app",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "filename": {
+ "name": "filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sha256": {
+ "name": "sha256",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "storage_key": {
+ "name": "storage_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "documents_company_id_idx": {
+ "name": "documents_company_id_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ },
+ "documents_request_id_idx": {
+ "name": "documents_request_id_idx",
+ "columns": [
+ {
+ "expression": "request_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "documents_company_id_organization_id_fk": {
+ "name": "documents_company_id_organization_id_fk",
+ "tableFrom": "documents",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "restrict"
+ },
+ "documents_request_id_requests_id_fk": {
+ "name": "documents_request_id_requests_id_fk",
+ "tableFrom": "documents",
+ "columnsFrom": [
+ "request_id"
+ ],
+ "tableTo": "requests",
+ "schemaTo": "app",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {
+ "documents_tenant_isolation": {
+ "name": "documents_tenant_isolation",
+ "as": "PERMISSIVE",
+ "for": "ALL",
+ "to": [
+ "public"
+ ],
+ "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid",
+ "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid"
+ }
+ },
+ "checkConstraints": {
+ "documents_kind_check": {
+ "name": "documents_kind_check",
+ "value": "kind in ('eml', 'msg', 'pdf', 'xlsx', 'docx')"
+ }
+ },
+ "isRLSEnabled": true
+ },
+ "app.requests": {
+ "name": "requests",
+ "schema": "app",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'NEW'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'upload'"
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "message_id": {
+ "name": "message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fingerprint": {
+ "name": "fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "possible_duplicate": {
+ "name": "possible_duplicate",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "duplicate_of_id": {
+ "name": "duplicate_of_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "requests_company_id_idx": {
+ "name": "requests_company_id_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ },
+ "requests_company_message_id_idx": {
+ "name": "requests_company_message_id_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "message_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ },
+ "requests_company_fingerprint_idx": {
+ "name": "requests_company_fingerprint_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "fingerprint",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "requests_company_id_organization_id_fk": {
+ "name": "requests_company_id_organization_id_fk",
+ "tableFrom": "requests",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "restrict"
+ },
+ "requests_duplicate_of_id_requests_id_fk": {
+ "name": "requests_duplicate_of_id_requests_id_fk",
+ "tableFrom": "requests",
+ "columnsFrom": [
+ "duplicate_of_id"
+ ],
+ "tableTo": "requests",
+ "schemaTo": "app",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "set null"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {
+ "requests_tenant_isolation": {
+ "name": "requests_tenant_isolation",
+ "as": "PERMISSIVE",
+ "for": "ALL",
+ "to": [
+ "public"
+ ],
+ "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid",
+ "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid"
+ }
+ },
+ "checkConstraints": {
+ "requests_status_check": {
+ "name": "requests_status_check",
+ "value": "status in ('NEW', 'PROCESSING', 'REVIEW', 'APPROVED', 'EXPORTED', 'REJECTED', 'ERROR')"
+ }
+ },
+ "isRLSEnabled": true
+ }
+ },
+ "enums": {},
+ "schemas": {
+ "auth": "auth",
+ "app": "app"
+ },
+ "views": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/src/db/migrations/meta/0005_snapshot.json b/src/db/migrations/meta/0005_snapshot.json
new file mode 100644
index 0000000..b460bd5
--- /dev/null
+++ b/src/db/migrations/meta/0005_snapshot.json
@@ -0,0 +1,1222 @@
+{
+ "id": "6dd7be4b-8830-4b08-8aeb-333d5afc540b",
+ "prevId": "d257ca2a-1a2c-482f-a651-21d7d15408fa",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "auth.account": {
+ "name": "account",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "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
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.invitation": {
+ "name": "invitation",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "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()"
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "invitation_organizationId_idx": {
+ "name": "invitation_organizationId_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "invitation_email_idx": {
+ "name": "invitation_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "invitation_organization_id_organization_id_fk": {
+ "name": "invitation_organization_id_organization_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invitation_inviter_id_user_id_fk": {
+ "name": "invitation_inviter_id_user_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "inviter_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.member": {
+ "name": "member",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "member_organizationId_idx": {
+ "name": "member_organizationId_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "member_userId_idx": {
+ "name": "member_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "member_organization_id_organization_id_fk": {
+ "name": "member_organization_id_organization_id_fk",
+ "tableFrom": "member",
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "member_user_id_user_id_fk": {
+ "name": "member_user_id_user_id_fk",
+ "tableFrom": "member",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.organization": {
+ "name": "organization",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "organization_slug_unique": {
+ "name": "organization_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.rate_limit": {
+ "name": "rate_limit",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "count": {
+ "name": "count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_request": {
+ "name": "last_request",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "rate_limit_key_unique": {
+ "name": "rate_limit_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.session": {
+ "name": "session",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "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
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "active_organization_id": {
+ "name": "active_organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.user": {
+ "name": "user",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "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()"
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "auth.verification": {
+ "name": "verification",
+ "schema": "auth",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "pg_catalog.gen_random_uuid()"
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "app.audit_events": {
+ "name": "audit_events",
+ "schema": "app",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actor_user_id": {
+ "name": "actor_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "data": {
+ "name": "data",
+ "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": {
+ "audit_events_entity_idx": {
+ "name": "audit_events_entity_idx",
+ "columns": [
+ {
+ "expression": "company_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_events_company_id_organization_id_fk": {
+ "name": "audit_events_company_id_organization_id_fk",
+ "tableFrom": "audit_events",
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {
+ "audit_events_tenant_isolation": {
+ "name": "audit_events_tenant_isolation",
+ "as": "PERMISSIVE",
+ "for": "ALL",
+ "to": [
+ "public"
+ ],
+ "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid",
+ "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid"
+ }
+ },
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "app.documents": {
+ "name": "documents",
+ "schema": "app",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "filename": {
+ "name": "filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sha256": {
+ "name": "sha256",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "storage_key": {
+ "name": "storage_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "documents_company_id_idx": {
+ "name": "documents_company_id_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "documents_request_id_idx": {
+ "name": "documents_request_id_idx",
+ "columns": [
+ {
+ "expression": "request_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "documents_company_id_organization_id_fk": {
+ "name": "documents_company_id_organization_id_fk",
+ "tableFrom": "documents",
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "documents_request_same_company_fk": {
+ "name": "documents_request_same_company_fk",
+ "tableFrom": "documents",
+ "tableTo": "requests",
+ "schemaTo": "app",
+ "columnsFrom": [
+ "request_id",
+ "company_id"
+ ],
+ "columnsTo": [
+ "id",
+ "company_id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {
+ "documents_tenant_isolation": {
+ "name": "documents_tenant_isolation",
+ "as": "PERMISSIVE",
+ "for": "ALL",
+ "to": [
+ "public"
+ ],
+ "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid",
+ "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid"
+ }
+ },
+ "checkConstraints": {
+ "documents_kind_check": {
+ "name": "documents_kind_check",
+ "value": "kind in ('eml', 'msg', 'pdf', 'xlsx', 'docx')"
+ }
+ },
+ "isRLSEnabled": true
+ },
+ "app.requests": {
+ "name": "requests",
+ "schema": "app",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'NEW'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'upload'"
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "message_id": {
+ "name": "message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fingerprint": {
+ "name": "fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "possible_duplicate": {
+ "name": "possible_duplicate",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "duplicate_of_id": {
+ "name": "duplicate_of_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "requests_company_id_idx": {
+ "name": "requests_company_id_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "requests_company_message_id_idx": {
+ "name": "requests_company_message_id_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "message_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "requests_company_fingerprint_idx": {
+ "name": "requests_company_fingerprint_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "fingerprint",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "requests_company_id_organization_id_fk": {
+ "name": "requests_company_id_organization_id_fk",
+ "tableFrom": "requests",
+ "tableTo": "organization",
+ "schemaTo": "auth",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "requests_duplicate_same_company_fk": {
+ "name": "requests_duplicate_same_company_fk",
+ "tableFrom": "requests",
+ "tableTo": "requests",
+ "schemaTo": "app",
+ "columnsFrom": [
+ "duplicate_of_id",
+ "company_id"
+ ],
+ "columnsTo": [
+ "id",
+ "company_id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "requests_id_company_unique": {
+ "name": "requests_id_company_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "id",
+ "company_id"
+ ]
+ }
+ },
+ "policies": {
+ "requests_tenant_isolation": {
+ "name": "requests_tenant_isolation",
+ "as": "PERMISSIVE",
+ "for": "ALL",
+ "to": [
+ "public"
+ ],
+ "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid",
+ "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid"
+ }
+ },
+ "checkConstraints": {
+ "requests_status_check": {
+ "name": "requests_status_check",
+ "value": "status in ('NEW', 'PROCESSING', 'REVIEW', 'APPROVED', 'EXPORTED', 'REJECTED', 'ERROR')"
+ }
+ },
+ "isRLSEnabled": true
+ }
+ },
+ "enums": {},
+ "schemas": {
+ "auth": "auth",
+ "app": "app"
+ },
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json
index 1709b0c..4d58b3c 100644
--- a/src/db/migrations/meta/_journal.json
+++ b/src/db/migrations/meta/_journal.json
@@ -8,6 +8,41 @@
"when": 1790113205929,
"tag": "0000_app_schema",
"breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "7",
+ "when": 1790142267590,
+ "tag": "0001_identity_tenancy",
+ "breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "7",
+ "when": 1790142279511,
+ "tag": "0002_auth_grants_force_rls",
+ "breakpoints": true
+ },
+ {
+ "idx": 3,
+ "version": "7",
+ "when": 1790142953338,
+ "tag": "0003_intake",
+ "breakpoints": true
+ },
+ {
+ "idx": 4,
+ "version": "7",
+ "when": 1790142958057,
+ "tag": "0004_intake_force_rls_audit",
+ "breakpoints": true
+ },
+ {
+ "idx": 5,
+ "version": "7",
+ "when": 1790144232796,
+ "tag": "0005_intake_same_company_fks",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/db/schema.ts b/src/db/schema.ts
deleted file mode 100644
index 2e17b2c..0000000
--- a/src/db/schema.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-// Drizzle schema of the `app` schema. Tables arrive with the feature issues (#4 onwards);
-// every company-owned table gets `company_id` + forced RLS (ADR-0001 D7).
-export {};
diff --git a/src/db/schema/app.ts b/src/db/schema/app.ts
new file mode 100644
index 0000000..76a053d
--- /dev/null
+++ b/src/db/schema/app.ts
@@ -0,0 +1,117 @@
+// Company-owned business data (schema `app`). Every table has `company_id` + RLS enabled AND forced
+// (FORCE is added by a hand-written migration – drizzle-kit only emits ENABLE) with the policy
+// `tenant_isolation` (ADR-0001 D7). Access only via `withTenant()` as `app_rw`.
+import { sql } from "drizzle-orm";
+import { bigint, boolean, check, foreignKey, index, jsonb, pgPolicy, pgSchema, text, timestamp, unique, uuid } from "drizzle-orm/pg-core";
+import { organization } from "./auth";
+
+export const appSchema = pgSchema("app");
+
+/** `app.company_id` is set transaction-locally by `withTenant()`; unset → NULL → no row matches. */
+export const currentCompany = sql`nullif(current_setting('app.company_id', true), '')::uuid`;
+
+export const tenantPolicy = (table: string) =>
+ pgPolicy(`${table}_tenant_isolation`, {
+ as: "permissive",
+ for: "all",
+ to: "public",
+ using: sql`company_id = ${currentCompany}`,
+ withCheck: sql`company_id = ${currentCompany}`,
+ });
+
+export const REQUEST_STATUSES = ["NEW", "PROCESSING", "REVIEW", "APPROVED", "EXPORTED", "REJECTED", "ERROR"] as const;
+export type RequestStatus = (typeof REQUEST_STATUSES)[number];
+
+// Minimal request aggregate – the first tenant table (#4). #5 and #7 extend it additively.
+export const requests = appSchema
+ .table(
+ "requests",
+ {
+ id: uuid("id").default(sql`gen_random_uuid()`).primaryKey(),
+ companyId: uuid("company_id")
+ .notNull()
+ .references(() => organization.id, { onDelete: "restrict" }),
+ status: text("status").$type().default("NEW").notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ // Intake (#5): who uploaded, what arrived, duplicate fingerprint (ADR-0001 D9).
+ source: text("source").$type<"upload">().default("upload").notNull(),
+ createdBy: uuid("created_by"),
+ subject: text("subject"),
+ messageId: text("message_id"),
+ fingerprint: text("fingerprint"),
+ possibleDuplicate: boolean("possible_duplicate").default(false).notNull(),
+ duplicateOfId: uuid("duplicate_of_id"),
+ },
+ (table) => [
+ index("requests_company_id_idx").on(table.companyId),
+ index("requests_company_message_id_idx").on(table.companyId, table.messageId),
+ index("requests_company_fingerprint_idx").on(table.companyId, table.fingerprint),
+ // FK checks bypass RLS: child rows pin company_id through composite keys.
+ unique("requests_id_company_unique").on(table.id, table.companyId),
+ foreignKey({
+ name: "requests_duplicate_same_company_fk",
+ columns: [table.duplicateOfId, table.companyId],
+ foreignColumns: [table.id, table.companyId],
+ }),
+ check("requests_status_check", sql.raw(`status in (${REQUEST_STATUSES.map((s) => `'${s}'`).join(", ")})`)),
+ tenantPolicy("requests"),
+ ],
+ )
+ .enableRLS();
+
+// Originals of a request (mail or loose files). Bytes live in private object storage under
+// `{companyId}/{requestId}/{documentId}`; the row keeps the reference and the SHA-256.
+export const documents = appSchema
+ .table(
+ "documents",
+ {
+ id: uuid("id").default(sql`gen_random_uuid()`).primaryKey(),
+ companyId: uuid("company_id")
+ .notNull()
+ .references(() => organization.id, { onDelete: "restrict" }),
+ requestId: uuid("request_id").notNull(),
+ filename: text("filename").notNull(),
+ contentType: text("content_type").notNull(),
+ kind: text("kind").$type().notNull(),
+ sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
+ sha256: text("sha256").notNull(),
+ storageKey: text("storage_key").notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ },
+ (table) => [
+ index("documents_company_id_idx").on(table.companyId),
+ index("documents_request_id_idx").on(table.requestId),
+ foreignKey({
+ name: "documents_request_same_company_fk",
+ columns: [table.requestId, table.companyId],
+ foreignColumns: [requests.id, requests.companyId],
+ }).onDelete("cascade"),
+ check("documents_kind_check", sql.raw(`kind in (${DOCUMENT_KINDS.map((k) => `'${k}'`).join(", ")})`)),
+ tenantPolicy("documents"),
+ ],
+ )
+ .enableRLS();
+
+export const DOCUMENT_KINDS = ["eml", "msg", "pdf", "xlsx", "docx"] as const;
+export type DocumentKind = (typeof DOCUMENT_KINDS)[number];
+
+// Append-only business audit (ADR-0001 D10): written in the same transaction as the change;
+// app_rw has INSERT and SELECT only (hand-written migration revokes UPDATE/DELETE).
+export const auditEvents = appSchema
+ .table(
+ "audit_events",
+ {
+ id: uuid("id").default(sql`gen_random_uuid()`).primaryKey(),
+ companyId: uuid("company_id")
+ .notNull()
+ .references(() => organization.id, { onDelete: "restrict" }),
+ actorUserId: uuid("actor_user_id"),
+ action: text("action").notNull(),
+ entityType: text("entity_type").notNull(),
+ entityId: uuid("entity_id").notNull(),
+ data: jsonb("data").$type>().default({}).notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ },
+ (table) => [index("audit_events_entity_idx").on(table.companyId, table.entityType, table.entityId), tenantPolicy("audit_events")],
+ )
+ .enableRLS();
diff --git a/src/db/schema/auth.ts b/src/db/schema/auth.ts
new file mode 100644
index 0000000..7c972e0
--- /dev/null
+++ b/src/db/schema/auth.ts
@@ -0,0 +1,215 @@
+// Better Auth tables (schema `auth`), generated with `pnpm dlx auth@1.7.5 generate` (Better Auth CLI)
+// and adapted: timestamps with time zone. Not company-owned business data → no RLS; reachable only
+// by server code (ADR-0001 D7, exceptions register). Regenerate on a Better Auth upgrade.
+import { relations, sql } from "drizzle-orm";
+import {
+ pgSchema,
+ text,
+ bigint,
+ timestamp,
+ boolean,
+ integer,
+ uuid,
+ index,
+} from "drizzle-orm/pg-core";
+
+export const authSchema = pgSchema("auth");
+
+export const user = authSchema.table("user", {
+ id: uuid("id")
+ .default(sql`pg_catalog.gen_random_uuid()`)
+ .primaryKey(),
+ name: text("name").notNull(),
+ email: text("email").notNull().unique(),
+ emailVerified: boolean("email_verified").default(false).notNull(),
+ image: text("image"),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true })
+ .defaultNow()
+ .$onUpdate(() => /* @__PURE__ */ new Date())
+ .notNull(),
+ role: text("role"),
+ banned: boolean("banned").default(false),
+ banReason: text("ban_reason"),
+ banExpires: timestamp("ban_expires", { withTimezone: true }),
+});
+
+export const session = authSchema.table(
+ "session",
+ {
+ id: uuid("id")
+ .default(sql`pg_catalog.gen_random_uuid()`)
+ .primaryKey(),
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
+ token: text("token").notNull().unique(),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true })
+ .$onUpdate(() => /* @__PURE__ */ new Date())
+ .notNull(),
+ ipAddress: text("ip_address"),
+ userAgent: text("user_agent"),
+ userId: uuid("user_id")
+ .notNull()
+ .references(() => user.id, { onDelete: "cascade" }),
+ activeOrganizationId: text("active_organization_id"),
+ impersonatedBy: text("impersonated_by"),
+ },
+ (table) => [index("session_userId_idx").on(table.userId)],
+);
+
+export const account = authSchema.table(
+ "account",
+ {
+ id: uuid("id")
+ .default(sql`pg_catalog.gen_random_uuid()`)
+ .primaryKey(),
+ accountId: text("account_id").notNull(),
+ providerId: text("provider_id").notNull(),
+ userId: uuid("user_id")
+ .notNull()
+ .references(() => user.id, { onDelete: "cascade" }),
+ accessToken: text("access_token"),
+ refreshToken: text("refresh_token"),
+ idToken: text("id_token"),
+ accessTokenExpiresAt: timestamp("access_token_expires_at", { withTimezone: true }),
+ refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { withTimezone: true }),
+ scope: text("scope"),
+ password: text("password"),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true })
+ .$onUpdate(() => /* @__PURE__ */ new Date())
+ .notNull(),
+ },
+ (table) => [index("account_userId_idx").on(table.userId)],
+);
+
+export const verification = authSchema.table(
+ "verification",
+ {
+ id: uuid("id")
+ .default(sql`pg_catalog.gen_random_uuid()`)
+ .primaryKey(),
+ identifier: text("identifier").notNull(),
+ value: text("value").notNull(),
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true })
+ .defaultNow()
+ .$onUpdate(() => /* @__PURE__ */ new Date())
+ .notNull(),
+ },
+ (table) => [index("verification_identifier_idx").on(table.identifier)],
+);
+
+export const organization = authSchema.table("organization", {
+ id: uuid("id")
+ .default(sql`pg_catalog.gen_random_uuid()`)
+ .primaryKey(),
+ name: text("name").notNull(),
+ slug: text("slug").notNull().unique(),
+ logo: text("logo"),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
+ metadata: text("metadata"),
+});
+
+export const member = authSchema.table(
+ "member",
+ {
+ id: uuid("id")
+ .default(sql`pg_catalog.gen_random_uuid()`)
+ .primaryKey(),
+ organizationId: uuid("organization_id")
+ .notNull()
+ .references(() => organization.id, { onDelete: "cascade" }),
+ userId: uuid("user_id")
+ .notNull()
+ .references(() => user.id, { onDelete: "cascade" }),
+ role: text("role").default("member").notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
+ },
+ (table) => [
+ index("member_organizationId_idx").on(table.organizationId),
+ index("member_userId_idx").on(table.userId),
+ ],
+);
+
+export const invitation = authSchema.table(
+ "invitation",
+ {
+ id: uuid("id")
+ .default(sql`pg_catalog.gen_random_uuid()`)
+ .primaryKey(),
+ organizationId: uuid("organization_id")
+ .notNull()
+ .references(() => organization.id, { onDelete: "cascade" }),
+ email: text("email").notNull(),
+ role: text("role"),
+ status: text("status").default("pending").notNull(),
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
+ inviterId: uuid("inviter_id")
+ .notNull()
+ .references(() => user.id, { onDelete: "cascade" }),
+ },
+ (table) => [
+ index("invitation_organizationId_idx").on(table.organizationId),
+ index("invitation_email_idx").on(table.email),
+ ],
+);
+
+export const rateLimit = authSchema.table("rate_limit", {
+ id: uuid("id")
+ .default(sql`pg_catalog.gen_random_uuid()`)
+ .primaryKey(),
+ key: text("key").notNull().unique(),
+ count: integer("count").notNull(),
+ lastRequest: bigint("last_request", { mode: "number" }).notNull(),
+});
+
+export const userRelations = relations(user, ({ many }) => ({
+ sessions: many(session),
+ accounts: many(account),
+ members: many(member),
+ invitations: many(invitation),
+}));
+
+export const sessionRelations = relations(session, ({ one }) => ({
+ user: one(user, {
+ fields: [session.userId],
+ references: [user.id],
+ }),
+}));
+
+export const accountRelations = relations(account, ({ one }) => ({
+ user: one(user, {
+ fields: [account.userId],
+ references: [user.id],
+ }),
+}));
+
+export const organizationRelations = relations(organization, ({ many }) => ({
+ members: many(member),
+ invitations: many(invitation),
+}));
+
+export const memberRelations = relations(member, ({ one }) => ({
+ organization: one(organization, {
+ fields: [member.organizationId],
+ references: [organization.id],
+ }),
+ user: one(user, {
+ fields: [member.userId],
+ references: [user.id],
+ }),
+}));
+
+export const invitationRelations = relations(invitation, ({ one }) => ({
+ organization: one(organization, {
+ fields: [invitation.organizationId],
+ references: [organization.id],
+ }),
+ user: one(user, {
+ fields: [invitation.inviterId],
+ references: [user.id],
+ }),
+}));
diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts
new file mode 100644
index 0000000..378ac7f
--- /dev/null
+++ b/src/db/schema/index.ts
@@ -0,0 +1,4 @@
+// Drizzle schema: `auth` (Better Auth, no RLS – exceptions register) and `app` (company-owned,
+// forced RLS). Migrations in ../migrations are generated from here plus hand-written SQL.
+export * from "./auth";
+export * from "./app";
diff --git a/src/features/audit/audit.ts b/src/features/audit/audit.ts
new file mode 100644
index 0000000..08274d0
--- /dev/null
+++ b/src/features/audit/audit.ts
@@ -0,0 +1,26 @@
+import { and, asc, eq } from "drizzle-orm";
+import { auditEvents } from "@/db/schema";
+import { tenantOf, type TenantTx } from "@/features/tenancy";
+
+// Business audit (ADR-0001 D10, DR6): written in the caller's transaction, so the change and its
+// audit event commit or roll back together. The database allows INSERT/SELECT only.
+export interface AuditEventInput {
+ actorUserId: string | null;
+ action: string;
+ entityType: "request" | "document" | "user";
+ entityId: string;
+ data?: Record;
+}
+
+export async function recordAudit(tx: TenantTx, event: AuditEventInput): Promise {
+ await tx.insert(auditEvents).values({ companyId: tenantOf(tx), ...event, data: event.data ?? {} });
+}
+
+export async function listAuditEvents(tx: TenantTx, entityType: AuditEventInput["entityType"], entityId: string) {
+ tenantOf(tx);
+ return tx
+ .select()
+ .from(auditEvents)
+ .where(and(eq(auditEvents.entityType, entityType), eq(auditEvents.entityId, entityId)))
+ .orderBy(asc(auditEvents.createdAt));
+}
diff --git a/src/features/audit/index.ts b/src/features/audit/index.ts
index eee41bb..6de75b1 100644
--- a/src/features/audit/index.ts
+++ b/src/features/audit/index.ts
@@ -1,3 +1,2 @@
// Public API of the `audit` module: append-only audit events.
-// Other modules import only from this file (dependency-cruiser, ADR-0001 D1). Planned – see docs/technical/architecture.md.
-export {};
+export { recordAudit, listAuditEvents, type AuditEventInput } from "./audit";
diff --git a/src/features/documents/index.ts b/src/features/documents/index.ts
index bd349e9..4f24efe 100644
--- a/src/features/documents/index.ts
+++ b/src/features/documents/index.ts
@@ -1,3 +1,2 @@
// Public API of the `documents` module: document records, storage references, hashes.
-// Other modules import only from this file (dependency-cruiser, ADR-0001 D1). Planned – see docs/technical/architecture.md.
-export {};
+export { insertDocuments, getDocument, listDocuments, type DocumentRow, type NewDocument } from "./repository";
diff --git a/src/features/documents/repository.ts b/src/features/documents/repository.ts
new file mode 100644
index 0000000..8b2073f
--- /dev/null
+++ b/src/features/documents/repository.ts
@@ -0,0 +1,33 @@
+import { asc, eq } from "drizzle-orm";
+import { documents, type DocumentKind } from "@/db/schema";
+import { tenantOf, type TenantTx } from "@/features/tenancy";
+
+export interface NewDocument {
+ id: string;
+ requestId: string;
+ filename: string;
+ contentType: string;
+ kind: DocumentKind;
+ sizeBytes: number;
+ sha256: string;
+ storageKey: string;
+}
+
+export type DocumentRow = typeof documents.$inferSelect;
+
+export async function insertDocuments(tx: TenantTx, rows: NewDocument[]): Promise {
+ const companyId = tenantOf(tx);
+ if (rows.length) await tx.insert(documents).values(rows.map((row) => ({ ...row, companyId })));
+}
+
+/** RLS hides other companies' documents: a foreign id simply returns null. */
+export async function getDocument(tx: TenantTx, id: string): Promise {
+ tenantOf(tx);
+ const [row] = await tx.select().from(documents).where(eq(documents.id, id));
+ return row ?? null;
+}
+
+export async function listDocuments(tx: TenantTx, requestId: string): Promise {
+ tenantOf(tx);
+ return tx.select().from(documents).where(eq(documents.requestId, requestId)).orderBy(asc(documents.createdAt), asc(documents.filename));
+}
diff --git a/src/features/identity/access.ts b/src/features/identity/access.ts
new file mode 100644
index 0000000..93a0fe8
--- /dev/null
+++ b/src/features/identity/access.ts
@@ -0,0 +1,30 @@
+import { createAccessControl } from "better-auth/plugins/access";
+import { adminAc, userAc } from "better-auth/plugins/admin/access";
+import { defaultStatements } from "better-auth/plugins/organization/access";
+
+// Permissions of the Better Auth organization plugin's own HTTP endpoints (/api/auth/organization/*).
+// They mirror `authorize()`: only company admins invite or change members; clerks get nothing.
+// Deleting the organization (= company) is nobody's right in the pilot.
+export const organizationAc = createAccessControl(defaultStatements);
+
+export const organizationRoles = {
+ admin: organizationAc.newRole({
+ organization: ["update"],
+ member: ["create", "update", "delete"],
+ invitation: ["create", "cancel"],
+ team: [],
+ ac: ["read"],
+ }),
+ clerk: organizationAc.newRole({
+ organization: [],
+ member: [],
+ invitation: [],
+ team: [],
+ ac: ["read"],
+ }),
+};
+
+// Global roles of the Better Auth admin plugin. Every app user is `user` (no admin-plugin rights);
+// `platform-admin` exists only so the plugin has an admin role – nobody holds it in the pilot.
+export const PLATFORM_ADMIN_ROLE = "platform-admin";
+export const platformRoles = { user: userAc, [PLATFORM_ADMIN_ROLE]: adminAc };
diff --git a/src/features/identity/actor.ts b/src/features/identity/actor.ts
new file mode 100644
index 0000000..97332b6
--- /dev/null
+++ b/src/features/identity/actor.ts
@@ -0,0 +1,26 @@
+import { eq } from "drizzle-orm";
+import type { Database } from "@/db";
+import * as schema from "@/db/schema";
+import type { Auth } from "./auth";
+import { isCompanyRole, type Actor } from "./authorize";
+
+/**
+ * The signed-in actor of a request: user, company and company role (ADR-0001 D7). The company is
+ * the user's live membership (one company per user, unique index) – re-read on every call, so a
+ * removed membership takes effect immediately; never from client input. A session whose active
+ * organization disagrees with the membership fails closed. Better Auth clears the active
+ * organization after a refused switch; the membership still identifies the company then.
+ */
+export async function getActor(auth: Auth, db: Database, headers: Headers): Promise {
+ const session = await auth.api.getSession({ headers });
+ if (!session) return null;
+ const [membership] = await db
+ .select({ companyId: schema.member.organizationId, role: schema.member.role })
+ .from(schema.member)
+ .where(eq(schema.member.userId, session.user.id))
+ .limit(1);
+ if (!membership || !isCompanyRole(membership.role)) return null;
+ const active = session.session.activeOrganizationId;
+ if (active && active !== membership.companyId) return null;
+ return { userId: session.user.id, companyId: membership.companyId, role: membership.role };
+}
diff --git a/src/features/identity/auth.ts b/src/features/identity/auth.ts
new file mode 100644
index 0000000..f5a5188
--- /dev/null
+++ b/src/features/identity/auth.ts
@@ -0,0 +1,155 @@
+import { drizzleAdapter } from "@better-auth/drizzle-adapter";
+import { betterAuth } from "better-auth";
+import { APIError } from "better-auth/api";
+import { admin, organization } from "better-auth/plugins";
+import { and, asc, eq, gt, sql } from "drizzle-orm";
+import type { Database } from "@/db";
+import * as schema from "@/db/schema";
+import { organizationAc, organizationRoles, PLATFORM_ADMIN_ROLE, platformRoles } from "./access";
+import { isCompanyRole } from "./authorize";
+
+export interface AuthSettings {
+ secret: string;
+ baseURL: string;
+ /** Client-IP source for rate limiting; must match the deployment's proxy setup. */
+ ipAddressHeaders?: string[];
+ trustedProxies?: string[];
+ /** Rate limit on /api/auth/* (built-in, database storage). Tests may tighten it. */
+ rateLimit?: { window: number; max: number };
+}
+
+const lower = (value: string) => value.trim().toLowerCase();
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+const invitationIdOf = (context: { body?: unknown } | null) =>
+ (context?.body as { invitationId?: unknown } | undefined)?.invitationId;
+
+/**
+ * Better Auth for RequestFlow (ADR-0001 D6): e-mail + password, invite-only, organization = company,
+ * admin plugin without any global admin, rate limit stored in the database.
+ */
+export function createAuth(db: Database, settings: AuthSettings) {
+ // Invite-only: the sign-up must present the invitation id (a random UUID handed over as a link)
+ // AND the invited e-mail. An e-mail alone proves nothing – addresses are guessable and unverified.
+ const pendingInvitation = async (email: string, invitationId: unknown) => {
+ if (typeof invitationId !== "string" || !UUID.test(invitationId)) return undefined;
+ const [row] = await db
+ .select()
+ .from(schema.invitation)
+ .where(
+ and(
+ eq(schema.invitation.id, invitationId),
+ sql`lower(${schema.invitation.email}) = ${lower(email)}`,
+ eq(schema.invitation.status, "pending"),
+ gt(schema.invitation.expiresAt, new Date()),
+ ),
+ )
+ .limit(1);
+ return row;
+ };
+
+ const membershipOf = async (userId: string) => {
+ // One company per user (unique index on member.user_id); ordered for determinism anyway.
+ const [row] = await db.select().from(schema.member).where(eq(schema.member.userId, userId)).orderBy(asc(schema.member.createdAt)).limit(1);
+ return row;
+ };
+
+ return betterAuth({
+ secret: settings.secret,
+ baseURL: settings.baseURL,
+ basePath: "/api/auth",
+ database: drizzleAdapter(db, { provider: "pg", schema, transaction: true }),
+ advanced: {
+ database: { generateId: "uuid" },
+ ipAddress: {
+ ipAddressHeaders: settings.ipAddressHeaders ?? ["x-forwarded-for"],
+ ...(settings.trustedProxies?.length ? { trustedProxies: settings.trustedProxies } : {}),
+ },
+ },
+ emailAndPassword: { enabled: true, minPasswordLength: 12, autoSignIn: false },
+ session: { expiresIn: 60 * 60 * 8, updateAge: 60 * 60 },
+ rateLimit: {
+ enabled: true,
+ storage: "database",
+ window: settings.rateLimit?.window ?? 60,
+ max: settings.rateLimit?.max ?? 30,
+ customRules: {
+ "/sign-in/email": { window: 60, max: settings.rateLimit?.max ?? 5 },
+ "/sign-up/email": { window: 60, max: settings.rateLimit?.max ?? 5 },
+ },
+ },
+ plugins: [
+ organization({
+ allowUserToCreateOrganization: false,
+ creatorRole: "admin",
+ ac: organizationAc,
+ roles: organizationRoles,
+ disableOrganizationDeletion: true,
+ invitationExpiresIn: 60 * 60 * 24 * 7,
+ cancelPendingInvitationsOnReInvite: true,
+ // Only the pilot's two company roles – no plugin defaults (owner/member), no role lists.
+ organizationHooks: {
+ beforeCreateInvitation: async ({ invitation }) => {
+ if (!isCompanyRole(invitation.role)) throw new APIError("BAD_REQUEST", { message: "Unknown role." });
+ },
+ beforeUpdateMemberRole: async ({ newRole }) => {
+ if (!isCompanyRole(newRole)) throw new APIError("BAD_REQUEST", { message: "Unknown role." });
+ },
+ beforeAddMember: async ({ member }) => {
+ if (!isCompanyRole(member.role)) throw new APIError("BAD_REQUEST", { message: "Unknown role." });
+ },
+ },
+ }),
+ // Company admins are `member.role = admin`, never a global admin-plugin role, so they cannot use
+ // the plugin's cross-company endpoints (list/ban/impersonate users).
+ admin({
+ defaultRole: "user",
+ adminRoles: [PLATFORM_ADMIN_ROLE],
+ roles: platformRoles,
+ allowImpersonatingAdmins: false,
+ }),
+ ],
+ databaseHooks: {
+ user: {
+ create: {
+ // Invite-only: an account is created only for an e-mail with a pending, unexpired invitation.
+ before: async (user, context) => {
+ const invitation = await pendingInvitation(user.email, invitationIdOf(context));
+ if (!invitation) {
+ throw new APIError("FORBIDDEN", { message: "Registration requires an invitation." });
+ }
+ return { data: { ...user, email: lower(user.email), role: "user" } };
+ },
+ // The invitation becomes the membership: company and company role come from it.
+ after: async (user, context) => {
+ const invitation = await pendingInvitation(user.email, invitationIdOf(context));
+ if (!invitation || !isCompanyRole(invitation.role)) return;
+ await db.transaction(async (tx) => {
+ await tx.insert(schema.member).values({
+ organizationId: invitation.organizationId,
+ userId: user.id,
+ role: invitation.role as string,
+ createdAt: new Date(),
+ });
+ await tx
+ .update(schema.invitation)
+ .set({ status: "accepted" })
+ .where(eq(schema.invitation.id, invitation.id));
+ });
+ },
+ },
+ },
+ session: {
+ create: {
+ // A session always carries the user's company; no membership → no session (fail closed).
+ before: async (session) => {
+ const membership = await membershipOf(session.userId);
+ if (!membership || !isCompanyRole(membership.role)) return false;
+ return { data: { ...session, activeOrganizationId: membership.organizationId } };
+ },
+ },
+ },
+ },
+ });
+}
+
+export type Auth = ReturnType;
diff --git a/src/features/identity/authorize.test.ts b/src/features/identity/authorize.test.ts
new file mode 100644
index 0000000..111e087
--- /dev/null
+++ b/src/features/identity/authorize.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+import { authorize, AuthorizationError, isCompanyRole, type Actor } from "./authorize";
+
+const companyId = "6f1f3f4e-0000-4000-8000-000000000001";
+const admin: Actor = { userId: "u-admin", companyId, role: "admin" };
+const clerk: Actor = { userId: "u-clerk", companyId, role: "clerk" };
+
+describe("authorize", () => {
+ it("allows admins to manage users", () => {
+ expect(() => authorize(admin, "users.manage")).not.toThrow();
+ });
+
+ it("denies admin-only actions to clerks", () => {
+ expect(() => authorize(clerk, "users.manage")).toThrow(AuthorizationError);
+ expect(() => authorize(clerk, "users.invite")).toThrow(AuthorizationError);
+ });
+
+ it("allows both roles to process requests", () => {
+ expect(() => authorize(admin, "requests.process")).not.toThrow();
+ expect(() => authorize(clerk, "requests.process")).not.toThrow();
+ });
+
+ it("denies everything to an actor with an unknown role (fail closed)", () => {
+ const stranger = { userId: "u-x", companyId, role: "owner" } as unknown as Actor;
+
+ expect(() => authorize(stranger, "requests.process")).toThrow(AuthorizationError);
+ });
+
+ it("denies everything to an actor without a company", () => {
+ const orphan = { userId: "u-y", companyId: "", role: "admin" } as Actor;
+
+ expect(() => authorize(orphan, "requests.process")).toThrow(AuthorizationError);
+ });
+
+ it("recognises only the two pilot roles", () => {
+ expect(isCompanyRole("admin")).toBe(true);
+ expect(isCompanyRole("clerk")).toBe(true);
+ expect(isCompanyRole("owner")).toBe(false);
+ expect(isCompanyRole("member")).toBe(false);
+ });
+});
diff --git a/src/features/identity/authorize.ts b/src/features/identity/authorize.ts
new file mode 100644
index 0000000..d817c61
--- /dev/null
+++ b/src/features/identity/authorize.ts
@@ -0,0 +1,36 @@
+// Company roles and the single authorization decision (ADR-0001 D7). The role lives on the
+// membership (Better Auth `member.role`), never on the global user – a company admin has no rights
+// outside their own company.
+export const COMPANY_ROLES = ["admin", "clerk"] as const;
+export type CompanyRole = (typeof COMPANY_ROLES)[number];
+
+export interface Actor {
+ userId: string;
+ companyId: string;
+ role: CompanyRole;
+}
+
+export type Action = "requests.process" | "users.invite" | "users.manage";
+
+const PERMISSIONS: Record> = {
+ admin: new Set(["requests.process", "users.invite", "users.manage"]),
+ clerk: new Set(["requests.process"]),
+};
+
+export class AuthorizationError extends Error {
+ constructor(readonly action: Action) {
+ super(`not allowed: ${action}`);
+ this.name = "AuthorizationError";
+ }
+}
+
+export function isCompanyRole(value: unknown): value is CompanyRole {
+ return typeof value === "string" && (COMPANY_ROLES as readonly string[]).includes(value);
+}
+
+/** Throws unless the actor may perform the action. Unknown roles and missing companies fail closed. */
+export function authorize(actor: Actor, action: Action): void {
+ if (!actor.companyId || !isCompanyRole(actor.role) || !PERMISSIONS[actor.role].has(action)) {
+ throw new AuthorizationError(action);
+ }
+}
diff --git a/src/features/identity/companies.ts b/src/features/identity/companies.ts
new file mode 100644
index 0000000..3e516d8
--- /dev/null
+++ b/src/features/identity/companies.ts
@@ -0,0 +1,87 @@
+import { and, eq, sql } from "drizzle-orm";
+import type { Database } from "@/db";
+import * as schema from "@/db/schema";
+import { authorize, isCompanyRole, type Actor, type CompanyRole } from "./authorize";
+
+const INVITATION_DAYS = 7;
+// Invitations need an inviter (Better Auth schema). The first admin of a company is invited by this
+// system user: it has no password account and no membership, so it can never obtain a session.
+const SYSTEM_INVITER_EMAIL = "system@requestflow.invalid";
+
+const lower = (value: string) => value.trim().toLowerCase();
+const expiry = () => new Date(Date.now() + INVITATION_DAYS * 24 * 60 * 60 * 1000);
+
+export interface Company {
+ id: string;
+ name: string;
+ slug: string;
+}
+
+async function systemInviterId(db: Database): Promise {
+ await db
+ .insert(schema.user)
+ .values({ name: "RequestFlow system", email: SYSTEM_INVITER_EMAIL, role: "user" })
+ .onConflictDoNothing({ target: schema.user.email });
+ const [row] = await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.email, SYSTEM_INVITER_EMAIL));
+ if (!row) throw new Error("system inviter missing");
+ return row.id;
+}
+
+/** Operator action (seed script): a new company plus the invitation for its first admin. */
+export async function bootstrapCompany(
+ db: Database,
+ input: { name: string; slug: string; adminEmail: string },
+): Promise<{ company: Company; invitationId: string }> {
+ const inviterId = await systemInviterId(db);
+ return db.transaction(async (tx) => {
+ const [company] = await tx
+ .insert(schema.organization)
+ .values({ name: input.name, slug: input.slug, createdAt: new Date() })
+ .returning({ id: schema.organization.id, name: schema.organization.name, slug: schema.organization.slug });
+ if (!company) throw new Error("company insert returned no row");
+ const [invitation] = await tx
+ .insert(schema.invitation)
+ .values({ organizationId: company.id, email: lower(input.adminEmail), role: "admin", status: "pending", expiresAt: expiry(), inviterId })
+ .returning({ id: schema.invitation.id });
+ if (!invitation) throw new Error("invitation insert returned no row");
+ return { company, invitationId: invitation.id };
+ });
+}
+
+/** Company admins invite staff into their own company only – the company comes from the actor. */
+export async function inviteUser(
+ db: Database,
+ actor: Actor,
+ input: { email: string; role: CompanyRole },
+): Promise<{ invitationId: string }> {
+ authorize(actor, "users.invite");
+ if (!isCompanyRole(input.role)) throw new Error("unknown role");
+ const email = lower(input.email);
+ return db.transaction(async (tx) => {
+ // Re-inviting replaces a pending invitation of the same company.
+ await tx
+ .update(schema.invitation)
+ .set({ status: "canceled" })
+ .where(
+ and(
+ eq(schema.invitation.organizationId, actor.companyId),
+ sql`lower(${schema.invitation.email}) = ${email}`,
+ eq(schema.invitation.status, "pending"),
+ ),
+ );
+ const [row] = await tx
+ .insert(schema.invitation)
+ .values({ organizationId: actor.companyId, email, role: input.role, status: "pending", expiresAt: expiry(), inviterId: actor.userId })
+ .returning({ id: schema.invitation.id });
+ if (!row) throw new Error("invitation insert returned no row");
+ return { invitationId: row.id };
+ });
+}
+
+export async function getCompany(db: Database, companyId: string): Promise {
+ const [row] = await db
+ .select({ id: schema.organization.id, name: schema.organization.name, slug: schema.organization.slug })
+ .from(schema.organization)
+ .where(eq(schema.organization.id, companyId));
+ return row ?? null;
+}
diff --git a/src/features/identity/index.ts b/src/features/identity/index.ts
index 7ee9838..5ae0520 100644
--- a/src/features/identity/index.ts
+++ b/src/features/identity/index.ts
@@ -1,3 +1,5 @@
-// Public API of the `identity` module: Better Auth, users, companies, roles.
-// Other modules import only from this file (dependency-cruiser, ADR-0001 D1). Planned – see docs/technical/architecture.md.
-export {};
+// Public API of the `identity` module: Better Auth, companies, company roles (ADR-0001 D6/D7).
+export { createAuth, type Auth, type AuthSettings } from "./auth";
+export { getActor } from "./actor";
+export { bootstrapCompany, getCompany, inviteUser, type Company } from "./companies";
+export { authorize, AuthorizationError, isCompanyRole, COMPANY_ROLES, type Action, type Actor, type CompanyRole } from "./authorize";
diff --git a/src/features/intake/files.test.ts b/src/features/intake/files.test.ts
new file mode 100644
index 0000000..c08049d
--- /dev/null
+++ b/src/features/intake/files.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+import { classifyUpload, UploadRejected } from "./files";
+import { makeZip, MINIMAL_DOCX, MINIMAL_XLSX } from "./zip-fixture";
+
+const bytes = (text: string) => new TextEncoder().encode(text);
+const PDF = bytes("%PDF-1.7\n%synthetic\n");
+const XLSX = makeZip(MINIMAL_XLSX);
+const DOCX = makeZip(MINIMAL_DOCX);
+const OLE = new Uint8Array([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, 0, 0]);
+const EML = bytes("From: Einkauf \r\nSubject: Anfrage\r\nMessage-ID: \r\n\r\nHallo");
+const limits = { maxFileBytes: 1024 };
+
+describe("classifyUpload", () => {
+ it.each([
+ ["anfrage.pdf", PDF, "pdf"],
+ ["positionen.xlsx", XLSX, "xlsx"],
+ ["spezifikation.docx", DOCX, "docx"],
+ ["weitergeleitet.msg", OLE, "msg"],
+ ["anfrage.eml", EML, "eml"],
+ ["ANFRAGE.PDF", PDF, "pdf"],
+ ] as const)("accepts %s as %s", (name, content, kind) => {
+ expect(classifyUpload(name, content, limits).kind).toBe(kind);
+ });
+
+ it("rejects an unsupported extension with a clear message", () => {
+ expect(() => classifyUpload("makro.xlsm", XLSX, limits)).toThrow(/Dateityp nicht erlaubt/);
+ expect(() => classifyUpload("programm.exe", bytes("MZ"), limits)).toThrow(UploadRejected);
+ });
+
+ it("rejects content that does not match the extension (renamed files)", () => {
+ expect(() => classifyUpload("anfrage.pdf", XLSX, limits)).toThrow(/passt nicht zum Dateityp/);
+ expect(() => classifyUpload("tabelle.xlsx", PDF, limits)).toThrow(/passt nicht zum Dateityp/);
+ expect(() => classifyUpload("mail.eml", new Uint8Array([0x00, 0x01, 0x02]), limits)).toThrow(/passt nicht zum Dateityp/);
+ });
+
+ it("rejects a macro workbook renamed to .xlsx and a workbook renamed to .docx", () => {
+ expect(() => classifyUpload("umbenannt.xlsx", makeZip([...MINIMAL_XLSX, ["xl/vbaProject.bin", 10]]), limits)).toThrow(/Makros/);
+ expect(() => classifyUpload("tabelle.docx", XLSX, limits)).toThrow(/passt nicht zum Dateityp/);
+ });
+
+ it("rejects files above the size limit and empty files", () => {
+ expect(() => classifyUpload("gross.pdf", new Uint8Array(2048).fill(0x25), limits)).toThrow(/zu groß/);
+ expect(() => classifyUpload("leer.pdf", new Uint8Array(0), limits)).toThrow(/leer/);
+ });
+
+ it("strips path components and control characters from the stored file name", () => {
+ expect(classifyUpload("../../etc/anfrage\u0000.pdf", PDF, limits).filename).toBe("anfrage.pdf");
+ expect(classifyUpload("C:\\temp\\anfrage.pdf", PDF, limits).filename).toBe("anfrage.pdf");
+ });
+});
diff --git a/src/features/intake/files.ts b/src/features/intake/files.ts
new file mode 100644
index 0000000..a6213ea
--- /dev/null
+++ b/src/features/intake/files.ts
@@ -0,0 +1,80 @@
+import type { DocumentKind } from "@/db/schema";
+import { inspectOoxml } from "./ooxml";
+
+// Upload validation (security rule: untrusted files). Allow-list by extension AND content: signature
+// for all types, OOXML package structure for .xlsx/.docx (no macros, no foreign ZIPs, no zip bombs).
+// Known limit: .msg is checked by its OLE signature only (registered risk). Messages are user-facing.
+export class UploadRejected extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "UploadRejected";
+ }
+}
+
+export interface UploadLimits {
+ maxFileBytes: number;
+}
+
+export interface ClassifiedFile {
+ kind: DocumentKind;
+ filename: string;
+ contentType: string;
+}
+
+const CONTENT_TYPES: Record = {
+ eml: "message/rfc822",
+ msg: "application/vnd.ms-outlook",
+ pdf: "application/pdf",
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+};
+
+const startsWith = (bytes: Uint8Array, signature: number[]) => signature.every((value, index) => bytes[index] === value);
+const ZIP = [0x50, 0x4b, 0x03, 0x04];
+const OLE = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
+const PDF = [0x25, 0x50, 0x44, 0x46, 0x2d]; // %PDF-
+
+function looksLikeMail(bytes: Uint8Array): boolean {
+ const head = bytes.subarray(0, 8192);
+ if (head.includes(0)) return false;
+ const text = new TextDecoder("latin1").decode(head);
+ return /^[\x21-\x39\x3b-\x7e]+:/m.test(text) && /^(from|message-id|subject|date|to|received|return-path|mime-version):/im.test(text);
+}
+
+const SIGNATURE_CHECK: Record boolean> = {
+ pdf: (bytes) => startsWith(bytes, PDF),
+ xlsx: (bytes) => startsWith(bytes, ZIP),
+ docx: (bytes) => startsWith(bytes, ZIP),
+ msg: (bytes) => startsWith(bytes, OLE),
+ eml: looksLikeMail,
+};
+
+/** Base name only, no control characters, bounded length. */
+export function safeFilename(name: string): string {
+ const base = name.split(/[\\/]/).pop() ?? "";
+ const cleaned = base.replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(-200);
+ return cleaned || "datei";
+}
+
+export function classifyUpload(name: string, bytes: Uint8Array, limits: UploadLimits): ClassifiedFile {
+ const filename = safeFilename(name);
+ const extension = filename.includes(".") ? filename.split(".").pop()!.toLowerCase() : "";
+ if (!(extension in CONTENT_TYPES)) {
+ throw new UploadRejected(`Dateityp nicht erlaubt: ${filename}. Erlaubt sind .eml, .msg, .pdf, .xlsx und .docx.`);
+ }
+ const kind = extension as DocumentKind;
+ if (bytes.byteLength === 0) throw new UploadRejected(`Die Datei ${filename} ist leer.`);
+ if (bytes.byteLength > limits.maxFileBytes) {
+ throw new UploadRejected(`Die Datei ${filename} ist zu groß (maximal ${Math.floor(limits.maxFileBytes / (1024 * 1024)) || 1} MB).`);
+ }
+ if (!SIGNATURE_CHECK[kind](bytes)) {
+ throw new UploadRejected(`Der Inhalt von ${filename} passt nicht zum Dateityp .${kind}.`);
+ }
+ if (kind === "xlsx" || kind === "docx") {
+ const check = inspectOoxml(bytes, kind);
+ if (!check.ok && check.reason === "macros") throw new UploadRejected(`Die Datei ${filename} enthält Makros – nicht erlaubt.`);
+ if (!check.ok && check.reason === "too-large") throw new UploadRejected(`Die Datei ${filename} ist entpackt zu groß.`);
+ if (!check.ok) throw new UploadRejected(`Der Inhalt von ${filename} passt nicht zum Dateityp .${kind}.`);
+ }
+ return { kind, filename, contentType: CONTENT_TYPES[kind] };
+}
diff --git a/src/features/intake/fingerprint.test.ts b/src/features/intake/fingerprint.test.ts
new file mode 100644
index 0000000..229fb44
--- /dev/null
+++ b/src/features/intake/fingerprint.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+import { requestFingerprint, sha256Hex } from "./fingerprint";
+import { parseMailHeaders } from "./mail-headers";
+
+const enc = (text: string) => new TextEncoder().encode(text);
+
+describe("requestFingerprint", () => {
+ it("is independent of the order of the files", () => {
+ const a = sha256Hex(enc("a"));
+ const b = sha256Hex(enc("b"));
+
+ expect(requestFingerprint([a, b])).toBe(requestFingerprint([b, a]));
+ });
+
+ it("differs when one file differs", () => {
+ expect(requestFingerprint([sha256Hex(enc("a"))])).not.toBe(requestFingerprint([sha256Hex(enc("a2"))]));
+ });
+
+ it("treats the same file uploaded twice in one request as one", () => {
+ const a = sha256Hex(enc("a"));
+
+ expect(requestFingerprint([a, a])).toBe(requestFingerprint([a]));
+ });
+
+ it("hashes raw bytes – CRLF and LF variants of a mail are different files", () => {
+ expect(sha256Hex(enc("x\r\ny"))).not.toBe(sha256Hex(enc("x\ny")));
+ });
+});
+
+describe("parseMailHeaders", () => {
+ it("reads Message-ID and Subject from the header block only", () => {
+ const mail = enc("From: a@example.com\r\nMessage-ID: \r\nSubject: Anfrage Flansche\r\n\r\nMessage-ID: ");
+
+ expect(parseMailHeaders(mail)).toEqual({ messageId: "", subject: "Anfrage Flansche" });
+ });
+
+ it("unfolds folded header lines and decodes RFC 2047 encoded words", () => {
+ const mail = enc("Subject: =?UTF-8?B?QW5mcmFnZSBEcmVoc3TDvGNr?=\r\n =?utf-8?Q?_f=C3=BCr_KW_42?=\r\nMessage-Id:\r\n \r\n\r\nbody");
+
+ expect(parseMailHeaders(mail)).toEqual({ messageId: "", subject: "Anfrage Drehstück für KW 42" });
+ });
+
+ it("returns nulls when the headers are missing", () => {
+ expect(parseMailHeaders(enc("From: a@example.com\n\nbody"))).toEqual({ messageId: null, subject: null });
+ });
+
+ it("caps the subject length", () => {
+ const long = "x".repeat(1000);
+
+ expect(parseMailHeaders(enc(`Subject: ${long}\n\n`)).subject).toHaveLength(300);
+ });
+});
diff --git a/src/features/intake/fingerprint.ts b/src/features/intake/fingerprint.ts
new file mode 100644
index 0000000..80e5a76
--- /dev/null
+++ b/src/features/intake/fingerprint.ts
@@ -0,0 +1,12 @@
+import { createHash } from "node:crypto";
+
+// Exact-duplicate detection (ADR-0001 D9, DR7): SHA-256 over the raw bytes of every file, and a
+// request fingerprint over the set of file hashes (order-independent, duplicates collapsed).
+export function sha256Hex(bytes: Uint8Array): string {
+ return createHash("sha256").update(bytes).digest("hex");
+}
+
+export function requestFingerprint(fileHashes: string[]): string {
+ const set = [...new Set(fileHashes)].sort();
+ return sha256Hex(new TextEncoder().encode(set.join("\n")));
+}
diff --git a/src/features/intake/index.ts b/src/features/intake/index.ts
index 94dee61..ffada73 100644
--- a/src/features/intake/index.ts
+++ b/src/features/intake/index.ts
@@ -1,3 +1,3 @@
-// Public API of the `intake` module: upload, duplicate fingerprint, creates request + documents.
-// Other modules import only from this file (dependency-cruiser, ADR-0001 D1). Planned – see docs/technical/architecture.md.
-export {};
+// Public API of the `intake` module: upload, validation, duplicate fingerprint.
+export { submitUpload, type IntakeDeps, type SubmittedRequest, type UploadedFile } from "./submit";
+export { UploadRejected, type UploadLimits } from "./files";
diff --git a/src/features/intake/mail-headers.ts b/src/features/intake/mail-headers.ts
new file mode 100644
index 0000000..07fd5be
--- /dev/null
+++ b/src/features/intake/mail-headers.ts
@@ -0,0 +1,54 @@
+// Minimal RFC 5322 header reader for intake: Message-ID (duplicates) and Subject (list display).
+// Full parsing of bodies and attachments is the AI service's job (docling / stdlib email).
+const MAX_HEADER_BYTES = 64 * 1024;
+const MAX_SUBJECT = 300;
+
+export interface MailHeaders {
+ messageId: string | null;
+ subject: string | null;
+}
+
+function headerBlock(bytes: Uint8Array): string {
+ const text = new TextDecoder("latin1").decode(bytes.subarray(0, MAX_HEADER_BYTES));
+ const end = text.search(/\r?\n\r?\n/);
+ return (end === -1 ? text : text.slice(0, end)).replace(/\r?\n[ \t]+/g, " ");
+}
+
+function latin1ToUtf8(text: string): string {
+ return new TextDecoder("utf-8").decode(Uint8Array.from(text, (char) => char.charCodeAt(0) & 0xff));
+}
+
+function decodeEncodedWords(value: string): string {
+ const decoded = value.replace(/\?=\s+=\?/g, "?==?").replace(/=\?([^?]+)\?([bqBQ])\?([^?]*)\?=/g, (_all, charset: string, encoding: string, data: string) => {
+ let raw: Uint8Array;
+ if (encoding.toUpperCase() === "B") {
+ raw = Uint8Array.from(Buffer.from(data, "base64"));
+ } else {
+ const text = data.replace(/_/g, " ").replace(/=([0-9A-Fa-f]{2})/g, (_m, hex: string) => String.fromCharCode(parseInt(hex, 16)));
+ raw = Uint8Array.from(text, (char) => char.charCodeAt(0) & 0xff);
+ }
+ try {
+ return new TextDecoder(charset.toLowerCase()).decode(raw);
+ } catch {
+ return new TextDecoder("utf-8").decode(raw);
+ }
+ });
+ return decoded;
+}
+
+function header(block: string, name: string): string | null {
+ const match = block.match(new RegExp(`^${name}:[ \\t]*(.*)$`, "im"));
+ const value = match?.[1]?.trim();
+ return value ? value : null;
+}
+
+export function parseMailHeaders(bytes: Uint8Array): MailHeaders {
+ const block = headerBlock(bytes);
+ const messageId = header(block, "Message-ID");
+ const rawSubject = header(block, "Subject");
+ const subject = rawSubject === null ? null : decodeEncodedWords(/[^\x00-\x7f]/.test(rawSubject) ? latin1ToUtf8(rawSubject) : rawSubject);
+ return {
+ messageId: messageId ? messageId.slice(0, 998) : null,
+ subject: subject ? subject.slice(0, MAX_SUBJECT) : null,
+ };
+}
diff --git a/src/features/intake/ooxml.test.ts b/src/features/intake/ooxml.test.ts
new file mode 100644
index 0000000..7ba61d9
--- /dev/null
+++ b/src/features/intake/ooxml.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+import { inspectOoxml } from "./ooxml";
+import { makeZip } from "./zip-fixture";
+
+const XLSX: Array<[string, number]> = [
+ ["[Content_Types].xml", 1000],
+ ["xl/workbook.xml", 800],
+ ["xl/worksheets/sheet1.xml", 5000],
+];
+const DOCX: Array<[string, number]> = [
+ ["[Content_Types].xml", 1000],
+ ["word/document.xml", 9000],
+];
+
+describe("inspectOoxml", () => {
+ it("accepts a well-formed workbook and document", () => {
+ expect(inspectOoxml(makeZip(XLSX), "xlsx")).toEqual({ ok: true });
+ expect(inspectOoxml(makeZip(DOCX), "docx")).toEqual({ ok: true });
+ });
+
+ it("rejects a macro-enabled package renamed to .xlsx / .docx", () => {
+ expect(inspectOoxml(makeZip([...XLSX, ["xl/vbaProject.bin", 4000]]), "xlsx")).toMatchObject({ ok: false, reason: "macros" });
+ expect(inspectOoxml(makeZip([...DOCX, ["word/vbaProject.bin", 4000]]), "docx")).toMatchObject({ ok: false, reason: "macros" });
+ expect(inspectOoxml(makeZip([...XLSX, ["xl/macrosheets/sheet1.xml", 10]]), "xlsx")).toMatchObject({ ok: false, reason: "macros" });
+ });
+
+ it("rejects a ZIP that is not the claimed OOXML type (e.g. a .jar or a docx renamed to .xlsx)", () => {
+ expect(inspectOoxml(makeZip([["META-INF/MANIFEST.MF", 10], ["a.class", 10]]), "xlsx")).toMatchObject({ ok: false, reason: "structure" });
+ expect(inspectOoxml(makeZip(DOCX), "xlsx")).toMatchObject({ ok: false, reason: "structure" });
+ });
+
+ it("rejects zip bombs by declared uncompressed size and by entry count", () => {
+ expect(inspectOoxml(makeZip([...XLSX, ["xl/media/huge.bin", 0xfffffff0]]), "xlsx")).toMatchObject({ ok: false, reason: "too-large" });
+ const many: Array<[string, number]> = Array.from({ length: 3000 }, (_, i) => [`xl/x${i}.xml`, 1]);
+ expect(inspectOoxml(makeZip([...XLSX, ...many]), "xlsx")).toMatchObject({ ok: false, reason: "too-large" });
+ });
+
+ it("rejects truncated or garbage archives", () => {
+ expect(inspectOoxml(new Uint8Array([0x50, 0x4b, 3, 4, 0, 0]), "xlsx")).toMatchObject({ ok: false, reason: "structure" });
+ });
+});
diff --git a/src/features/intake/ooxml.ts b/src/features/intake/ooxml.ts
new file mode 100644
index 0000000..5f5b721
--- /dev/null
+++ b/src/features/intake/ooxml.ts
@@ -0,0 +1,47 @@
+// Structure check of Office Open XML uploads (.xlsx/.docx) from the ZIP central directory only –
+// nothing is decompressed. Rejects macro packages, foreign ZIPs (.jar, renamed types) and zip bombs
+// by declared size/entry count, before the file reaches storage or the AI service.
+export type OoxmlCheck = { ok: true } | { ok: false; reason: "structure" | "macros" | "too-large" };
+
+const MAX_ENTRIES = 2000;
+const MAX_UNCOMPRESSED_BYTES = 200 * 1024 * 1024;
+const MAIN_PART = { xlsx: "xl/workbook.xml", docx: "word/document.xml" } as const;
+const MACRO_PARTS = /(^|\/)vbaProject\.bin$|^xl\/macrosheets\/|(^|\/)activeX\//i;
+
+export function inspectOoxml(bytes: Uint8Array, kind: "xlsx" | "docx"): OoxmlCheck {
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ // End of central directory: last 22 bytes plus up to 64 KiB comment.
+ let eocd = -1;
+ for (let at = bytes.length - 22; at >= Math.max(0, bytes.length - 22 - 0xffff); at--) {
+ if (view.getUint32(at, true) === 0x06054b50) {
+ eocd = at;
+ break;
+ }
+ }
+ if (eocd < 0) return { ok: false, reason: "structure" };
+ const entries = view.getUint16(eocd + 10, true);
+ const cdSize = view.getUint32(eocd + 12, true);
+ const cdOffset = view.getUint32(eocd + 16, true);
+ if (entries === 0xffff || cdOffset === 0xffffffff) return { ok: false, reason: "structure" }; // ZIP64: not expected here
+ if (entries > MAX_ENTRIES) return { ok: false, reason: "too-large" };
+ if (cdOffset + cdSize > eocd) return { ok: false, reason: "structure" };
+
+ const decoder = new TextDecoder("utf-8", { fatal: false });
+ const names = new Set();
+ let total = 0;
+ let at = cdOffset;
+ for (let index = 0; index < entries; index++) {
+ if (at + 46 > eocd || view.getUint32(at, true) !== 0x02014b50) return { ok: false, reason: "structure" };
+ total += view.getUint32(at + 24, true);
+ const nameLength = view.getUint16(at + 28, true);
+ const extraLength = view.getUint16(at + 30, true);
+ const commentLength = view.getUint16(at + 32, true);
+ if (at + 46 + nameLength > eocd) return { ok: false, reason: "structure" };
+ names.add(decoder.decode(bytes.subarray(at + 46, at + 46 + nameLength)));
+ at += 46 + nameLength + extraLength + commentLength;
+ }
+ if (total > MAX_UNCOMPRESSED_BYTES) return { ok: false, reason: "too-large" };
+ for (const name of names) if (MACRO_PARTS.test(name)) return { ok: false, reason: "macros" };
+ if (!names.has("[Content_Types].xml") || !names.has(MAIN_PART[kind])) return { ok: false, reason: "structure" };
+ return { ok: true };
+}
diff --git a/src/features/intake/submit.ts b/src/features/intake/submit.ts
new file mode 100644
index 0000000..769d9f1
--- /dev/null
+++ b/src/features/intake/submit.ts
@@ -0,0 +1,101 @@
+import { randomUUID } from "node:crypto";
+import { recordAudit } from "@/features/audit";
+import { insertDocuments, type NewDocument } from "@/features/documents";
+import { authorize, type Actor } from "@/features/identity";
+import { enqueueRequestProcessing, type JobSender } from "@/features/jobs";
+import { createRequest, findDuplicate, lockDuplicateDetection } from "@/features/requests";
+import { S3BlobStore } from "@/features/storage";
+import type { Tenancy } from "@/features/tenancy";
+import { classifyUpload, UploadRejected, type UploadLimits } from "./files";
+import { requestFingerprint, sha256Hex } from "./fingerprint";
+import { parseMailHeaders } from "./mail-headers";
+
+export interface IntakeDeps {
+ tenancy: Tenancy;
+ storage: S3BlobStore;
+ boss: JobSender;
+ limits: UploadLimits & { maxFiles: number };
+}
+
+export interface UploadedFile {
+ name: string;
+ bytes: Uint8Array;
+}
+
+export interface SubmittedRequest {
+ requestId: string;
+ possibleDuplicate: boolean;
+ duplicateOfId: string | null;
+}
+
+/**
+ * Upload intake (ADR-0001 D9): validate every file, store the originals privately, then create the
+ * request (NEW), its documents, the audit event and the processing job in ONE tenant transaction.
+ * If the transaction fails, nothing of it exists and the stored objects are removed again.
+ * Exact duplicates are flagged and linked, never discarded.
+ */
+export async function submitUpload(deps: IntakeDeps, actor: Actor, files: UploadedFile[]): Promise {
+ authorize(actor, "requests.process");
+ if (files.length === 0) throw new UploadRejected("Bitte mindestens eine Datei auswählen.");
+ if (files.length > deps.limits.maxFiles) throw new UploadRejected(`Höchstens ${deps.limits.maxFiles} Dateien pro Anfrage.`);
+
+ const requestId = randomUUID();
+ const classified = files.map((file) => ({ file, meta: classifyUpload(file.name, file.bytes, deps.limits) }));
+ const documents: NewDocument[] = classified.map(({ file, meta }) => {
+ const id = randomUUID();
+ return {
+ id,
+ requestId,
+ filename: meta.filename,
+ contentType: meta.contentType,
+ kind: meta.kind,
+ sizeBytes: file.bytes.byteLength,
+ sha256: sha256Hex(file.bytes),
+ storageKey: S3BlobStore.documentKey(actor.companyId, requestId, id),
+ };
+ });
+ const mail = classified.find(({ meta }) => meta.kind === "eml");
+ const headers = mail ? parseMailHeaders(mail.file.bytes) : { messageId: null, subject: null };
+ const fingerprint = requestFingerprint(documents.map((document) => document.sha256));
+
+ const stored: string[] = [];
+ try {
+ for (const [index, document] of documents.entries()) {
+ await deps.storage.put(document.storageKey, classified[index]!.file.bytes, document.contentType);
+ stored.push(document.storageKey);
+ }
+ return await deps.tenancy.withTenant(actor.companyId, async (tx) => {
+ await lockDuplicateDetection(tx);
+ const duplicate = await findDuplicate(tx, { messageId: headers.messageId, fingerprint });
+ await createRequest(tx, {
+ id: requestId,
+ createdBy: actor.userId,
+ subject: headers.subject ?? documents[0]?.filename ?? null,
+ messageId: headers.messageId,
+ fingerprint,
+ possibleDuplicate: duplicate !== null,
+ duplicateOfId: duplicate?.id ?? null,
+ });
+ await insertDocuments(tx, documents);
+ await recordAudit(tx, {
+ actorUserId: actor.userId,
+ action: "request.uploaded",
+ entityType: "request",
+ entityId: requestId,
+ data: { documents: documents.length, possibleDuplicate: duplicate !== null, duplicateOfId: duplicate?.id ?? null },
+ });
+ await enqueueRequestProcessing(deps.boss, tx, requestId);
+ return { requestId, possibleDuplicate: duplicate !== null, duplicateOfId: duplicate?.id ?? null };
+ });
+ } catch (error) {
+ // Nothing references these objects: remove them. A failed delete leaves an orphan without any
+ // row pointing to it – logged by key (IDs only) for a later cleanup sweep.
+ const results = await Promise.allSettled(stored.map((key) => deps.storage.delete(key)));
+ results.forEach((result, index) => {
+ if (result.status === "rejected") {
+ console.error(JSON.stringify({ level: "error", module: "intake", message: "orphaned object", key: stored[index] }));
+ }
+ });
+ throw error;
+ }
+}
diff --git a/src/features/intake/zip-fixture.ts b/src/features/intake/zip-fixture.ts
new file mode 100644
index 0000000..144443b
--- /dev/null
+++ b/src/features/intake/zip-fixture.ts
@@ -0,0 +1,53 @@
+// Test fixture helper (used by *.test.ts only): builds synthetic ZIP archives.
+// Minimal ZIP writer (stored entries, CRC not checked by the inspector) – synthetic fixtures only.
+export function makeZip(entries: Array<[string, number]>): Uint8Array {
+ const enc = new TextEncoder();
+ const locals: Uint8Array[] = [];
+ const centrals: Uint8Array[] = [];
+ let offset = 0;
+ for (const [name, size] of entries) {
+ const nameBytes = enc.encode(name);
+ const local = new Uint8Array(30 + nameBytes.length);
+ const lv = new DataView(local.buffer);
+ lv.setUint32(0, 0x04034b50, true);
+ lv.setUint32(18, 0, true);
+ lv.setUint32(22, size, true);
+ lv.setUint16(26, nameBytes.length, true);
+ local.set(nameBytes, 30);
+ const central = new Uint8Array(46 + nameBytes.length);
+ const cv = new DataView(central.buffer);
+ cv.setUint32(0, 0x02014b50, true);
+ cv.setUint32(20, 0, true);
+ cv.setUint32(24, size, true);
+ cv.setUint16(28, nameBytes.length, true);
+ cv.setUint32(42, offset, true);
+ central.set(nameBytes, 46);
+ locals.push(local);
+ centrals.push(central);
+ offset += local.length;
+ }
+ const cdSize = centrals.reduce((sum, c) => sum + c.length, 0);
+ const eocd = new Uint8Array(22);
+ const ev = new DataView(eocd.buffer);
+ ev.setUint32(0, 0x06054b50, true);
+ ev.setUint16(8, entries.length, true);
+ ev.setUint16(10, entries.length, true);
+ ev.setUint32(12, cdSize, true);
+ ev.setUint32(16, offset, true);
+ const out = new Uint8Array(offset + cdSize + 22);
+ let at = 0;
+ for (const part of [...locals, ...centrals, eocd]) {
+ out.set(part, at);
+ at += part.length;
+ }
+ return out;
+}
+
+export const MINIMAL_XLSX: Array<[string, number]> = [
+ ["[Content_Types].xml", 1000],
+ ["xl/workbook.xml", 800],
+];
+export const MINIMAL_DOCX: Array<[string, number]> = [
+ ["[Content_Types].xml", 1000],
+ ["word/document.xml", 900],
+];
diff --git a/src/features/jobs/boss.ts b/src/features/jobs/boss.ts
new file mode 100644
index 0000000..ccb42fb
--- /dev/null
+++ b/src/features/jobs/boss.ts
@@ -0,0 +1,15 @@
+import { sendInTransaction, type JobSender } from "@/db/job-queue";
+import { tenantOf, type TenantTx } from "@/features/tenancy";
+import { QUEUES, type RequestJob } from "./queues";
+
+export type { JobSender };
+
+/**
+ * Enqueues processing of a request IN the caller's tenant transaction: the job exists exactly when
+ * the request row commits. Payload: IDs only. `singletonKey = requestId` + queue policy `exclusive`
+ * → at most one queued-or-active job per request (ADR-0001 D4).
+ */
+export async function enqueueRequestProcessing(queue: JobSender, tx: TenantTx, requestId: string): Promise {
+ const job: RequestJob = { requestId, companyId: tenantOf(tx) };
+ return sendInTransaction(queue, tx, QUEUES.processRequest, job, { singletonKey: requestId });
+}
diff --git a/src/features/jobs/index.ts b/src/features/jobs/index.ts
index 9655842..3db3dce 100644
--- a/src/features/jobs/index.ts
+++ b/src/features/jobs/index.ts
@@ -1,3 +1,4 @@
-// Public API of the `jobs` module: pg-boss, job handlers, drain(), worker entrypoint.
-// Other modules import only from this file (dependency-cruiser, ADR-0001 D1). Planned – see docs/technical/architecture.md.
-export {};
+// Public API of the `jobs` module: queue definitions, transactional enqueue (worker + drain(): #7).
+// The pg-boss client itself is created in src/db (it opens a pool) and injected.
+export { enqueueRequestProcessing, type JobSender } from "./boss";
+export { QUEUE_DEFINITIONS, QUEUES, type QueueName, type RequestJob } from "./queues";
diff --git a/src/features/jobs/queues.ts b/src/features/jobs/queues.ts
new file mode 100644
index 0000000..b92aad4
--- /dev/null
+++ b/src/features/jobs/queues.ts
@@ -0,0 +1,30 @@
+import type { Queue } from "pg-boss";
+
+// Queue definitions (ADR-0001 D4). Installed by the deploy step as the owner role; the runtime role
+// only sends, fetches and completes jobs. Payloads carry IDs only – never document content.
+export const QUEUES = {
+ processRequest: "request-process",
+ processRequestDead: "request-process-dead",
+} as const;
+
+export type QueueName = (typeof QUEUES)[keyof typeof QUEUES];
+
+export interface RequestJob {
+ requestId: string;
+ companyId: string;
+}
+
+// `exclusive` + singletonKey = requestId: at most one queued-or-active job per request.
+export const QUEUE_DEFINITIONS: Array = [
+ { name: QUEUES.processRequestDead, policy: "standard", retentionSeconds: 60 * 60 * 24 * 14 },
+ {
+ name: QUEUES.processRequest,
+ policy: "exclusive",
+ retryLimit: 5,
+ retryDelay: 30,
+ retryBackoff: true,
+ retryDelayMax: 60 * 30,
+ expireInSeconds: 60 * 10,
+ deadLetter: QUEUES.processRequestDead,
+ },
+];
diff --git a/src/features/requests/index.ts b/src/features/requests/index.ts
index 9ef6966..84fa3ff 100644
--- a/src/features/requests/index.ts
+++ b/src/features/requests/index.ts
@@ -1,3 +1,2 @@
-// Public API of the `requests` module: request aggregate, status machine.
-// Other modules import only from this file (dependency-cruiser, ADR-0001 D1). Planned – see docs/technical/architecture.md.
-export {};
+// Public API of the `requests` module: request aggregate (status machine follows with #7).
+export { createRequest, findDuplicate, lockDuplicateDetection, getRequest, listRequests, type NewRequest, type RequestRow } from "./repository";
diff --git a/src/features/requests/repository.ts b/src/features/requests/repository.ts
new file mode 100644
index 0000000..171bced
--- /dev/null
+++ b/src/features/requests/repository.ts
@@ -0,0 +1,64 @@
+import { and, asc, desc, eq, or, sql, type SQL } from "drizzle-orm";
+import { requests, type RequestStatus } from "@/db/schema";
+import { tenantOf, type TenantTx } from "@/features/tenancy";
+
+export type RequestRow = typeof requests.$inferSelect;
+
+export interface NewRequest {
+ id?: string;
+ createdBy?: string | null;
+ subject?: string | null;
+ messageId?: string | null;
+ fingerprint?: string | null;
+ possibleDuplicate?: boolean;
+ duplicateOfId?: string | null;
+}
+
+// Repository of the request aggregate. Every function needs a tenant transaction; the company id is
+// taken from it, never from the caller – RLS enforces the same rule in the database.
+export async function listRequests(tx: TenantTx): Promise {
+ tenantOf(tx);
+ return tx.select().from(requests).orderBy(desc(requests.createdAt));
+}
+
+export async function getRequest(tx: TenantTx, id: string): Promise {
+ tenantOf(tx);
+ const [row] = await tx.select().from(requests).where(eq(requests.id, id));
+ return row ?? null;
+}
+
+export async function createRequest(tx: TenantTx, input: NewRequest = {}): Promise {
+ const companyId = tenantOf(tx);
+ const [row] = await tx.insert(requests).values({ ...input, companyId, status: "NEW" satisfies RequestStatus }).returning();
+ if (!row) throw new Error("insert returned no row");
+ return row;
+}
+
+/**
+ * Serialises duplicate detection per company for the rest of the transaction, so two identical
+ * uploads at the same moment cannot both miss each other.
+ */
+export async function lockDuplicateDetection(tx: TenantTx): Promise {
+ const companyId = tenantOf(tx);
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`intake-duplicates:${companyId}`}, 0))`);
+}
+
+/**
+ * The earliest request of the same company with the same Message-ID or the same file fingerprint
+ * (exact duplicate, ADR-0001 D9). Tenant-scoped by RLS: other companies' requests never match.
+ */
+export async function findDuplicate(
+ tx: TenantTx,
+ keys: { messageId: string | null; fingerprint: string },
+): Promise {
+ tenantOf(tx);
+ const conditions: SQL[] = [eq(requests.fingerprint, keys.fingerprint)];
+ if (keys.messageId) conditions.push(eq(requests.messageId, keys.messageId));
+ const [row] = await tx
+ .select()
+ .from(requests)
+ .where(and(or(...conditions)))
+ .orderBy(asc(requests.createdAt))
+ .limit(1);
+ return row ?? null;
+}
diff --git a/src/features/storage/s3-blob-store.ts b/src/features/storage/s3-blob-store.ts
index 1339683..33b5700 100644
--- a/src/features/storage/s3-blob-store.ts
+++ b/src/features/storage/s3-blob-store.ts
@@ -1,6 +1,9 @@
import {
CreateBucketCommand,
+ DeleteObjectCommand,
+ GetObjectCommand,
HeadBucketCommand,
+ PutObjectCommand,
S3Client,
S3ServiceException,
} from "@aws-sdk/client-s3";
@@ -52,6 +55,32 @@ export class S3BlobStore {
}
}
+ /** Object key convention (ADR-0001 D5): `{companyId}/{requestId}/{documentId}`. */
+ static documentKey(companyId: string, requestId: string, documentId: string): string {
+ return `${companyId}/${requestId}/${documentId}`;
+ }
+
+ async put(key: string, bytes: Uint8Array, contentType: string): Promise {
+ await this.client.send(new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: bytes, ContentType: contentType }));
+ }
+
+ async get(key: string): Promise {
+ const response = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
+ if (!response.Body) throw new Error("empty object body");
+ return response.Body.transformToByteArray();
+ }
+
+ /** Streams an object (downloads never buffer the whole file). */
+ async stream(key: string): Promise<{ body: ReadableStream; length: number | undefined }> {
+ const response = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
+ if (!response.Body) throw new Error("empty object body");
+ return { body: response.Body.transformToWebStream() as ReadableStream, length: response.ContentLength };
+ }
+
+ async delete(key: string): Promise {
+ await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
+ }
+
destroy(): void {
this.client.destroy();
}
diff --git a/src/features/tenancy/index.ts b/src/features/tenancy/index.ts
index 8ff0367..9b7a7d1 100644
--- a/src/features/tenancy/index.ts
+++ b/src/features/tenancy/index.ts
@@ -1,3 +1,2 @@
-// Public API of the `tenancy` module: withTenant(), RLS policies.
-// Other modules import only from this file (dependency-cruiser, ADR-0001 D1). Planned – see docs/technical/architecture.md.
-export {};
+// Public API of the `tenancy` module: tenant context and forced RLS (ADR-0001 D7).
+export { createTenancy, tenantOf, MissingTenantError, type Tenancy, type TenantTx } from "./with-tenant";
diff --git a/src/features/tenancy/with-tenant.ts b/src/features/tenancy/with-tenant.ts
new file mode 100644
index 0000000..f6e0a47
--- /dev/null
+++ b/src/features/tenancy/with-tenant.ts
@@ -0,0 +1,44 @@
+import { sql } from "drizzle-orm";
+import type { Database } from "@/db";
+
+// Tenant context (ADR-0001 D7). `withTenant` opens a transaction, sets `app.company_id`
+// transaction-locally (safe with poolers: gone at COMMIT/ROLLBACK) and hands out a branded
+// transaction. Repositories accept only that brand, so a query without tenant context does not
+// compile – and `tenantOf()` re-checks at runtime.
+const TENANT = Symbol("tenant");
+
+type Transaction = Parameters[0]>[0];
+export type TenantTx = Transaction & { readonly [TENANT]: string };
+
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+export class MissingTenantError extends Error {
+ constructor() {
+ super("repository called without tenant context – use withTenant()");
+ this.name = "MissingTenantError";
+ }
+}
+
+export interface Tenancy {
+ withTenant(companyId: string, fn: (tx: TenantTx) => Promise): Promise;
+}
+
+export function createTenancy(db: Database): Tenancy {
+ return {
+ async withTenant(companyId, fn) {
+ if (!UUID.test(companyId)) throw new MissingTenantError();
+ return db.transaction(async (tx) => {
+ await tx.execute(sql`select set_config('app.company_id', ${companyId}, true)`);
+ Object.defineProperty(tx, TENANT, { value: companyId, enumerable: false });
+ return fn(tx as TenantTx);
+ });
+ },
+ };
+}
+
+/** The company of the current tenant transaction; throws if the transaction has no tenant. */
+export function tenantOf(tx: TenantTx): string {
+ const companyId = (tx as Partial>)[TENANT];
+ if (!companyId) throw new MissingTenantError();
+ return companyId;
+}
diff --git a/src/seed.ts b/src/seed.ts
new file mode 100644
index 0000000..5bfd5a6
--- /dev/null
+++ b/src/seed.ts
@@ -0,0 +1,50 @@
+// Demo seed (`pnpm seed:demo`, local only): two synthetic companies, each with an admin, and a clerk
+// in the first one. It uses the real path – company + invitation, then sign-up – no bypass.
+// All names and addresses are synthetic (example.com). Passwords come from SEED_PASSWORD.
+import { eq } from "drizzle-orm";
+import { loadConfig } from "@/config/env";
+import { createDatabase } from "@/db";
+import * as schema from "@/db/schema";
+import { bootstrapCompany, createAuth, getActor, inviteUser } from "@/features/identity";
+
+const COMPANIES = [
+ { name: "Musterbau Beispiel GmbH", slug: "musterbau", admin: "admin@musterbau.example.com", clerk: "sachbearbeitung@musterbau.example.com" },
+ { name: "Beispielwerk Nord AG", slug: "beispielwerk", admin: "admin@beispielwerk.example.com" },
+];
+
+async function main(): Promise {
+ const password = process.env.SEED_PASSWORD;
+ if (!password || password.length < 12) throw new Error("SEED_PASSWORD (at least 12 characters) is required");
+ const config = loadConfig();
+ const database = createDatabase(config.databaseUrl, { max: 2 });
+ const auth = createAuth(database.db, config.auth);
+ const signUp = (email: string, name: string, invitationId: string) =>
+ auth.api.signUpEmail({ body: { email, password, name, invitationId } as { email: string; password: string; name: string } });
+ try {
+ for (const company of COMPANIES) {
+ const [existing] = await database.db.select().from(schema.organization).where(eq(schema.organization.slug, company.slug));
+ if (existing) {
+ console.log(`skip ${company.slug}: exists`);
+ continue;
+ }
+ const { invitationId } = await bootstrapCompany(database.db, { name: company.name, slug: company.slug, adminEmail: company.admin });
+ await signUp(company.admin, "Demo Admin", invitationId);
+ if (company.clerk) {
+ const login = await auth.api.signInEmail({ body: { email: company.admin, password }, returnHeaders: true });
+ const cookie = login.headers.getSetCookie().map((line) => line.split(";")[0]).join("; ");
+ const admin = await getActor(auth, database.db, new Headers({ cookie }));
+ if (!admin) throw new Error("seeded admin has no company");
+ const invitation = await inviteUser(database.db, admin, { email: company.clerk, role: "clerk" });
+ await signUp(company.clerk, "Demo Sachbearbeitung", invitation.invitationId);
+ }
+ console.log(`seeded ${company.slug}`);
+ }
+ } finally {
+ await database.pool.end();
+ }
+}
+
+main().catch((error: unknown) => {
+ console.error(error instanceof Error ? error.message : "seed failed");
+ process.exit(1);
+});
diff --git a/src/setup.ts b/src/setup.ts
index bdc0c0c..00e9618 100644
--- a/src/setup.ts
+++ b/src/setup.ts
@@ -4,6 +4,8 @@
import { setTimeout as sleep } from "node:timers/promises";
import { loadConfig } from "@/config/env";
import { runMigrations } from "@/db/migrate";
+import { installJobQueues } from "@/db/job-queue-client";
+import { QUEUE_DEFINITIONS } from "@/features/jobs";
import { S3BlobStore } from "@/features/storage";
const ATTEMPTS = 30;
@@ -42,13 +44,14 @@ async function main(): Promise {
const config = loadConfig();
await withRetry("migrations", () => runMigrations(migrationUrl));
+ await withRetry("job queues", () => installJobQueues(migrationUrl, QUEUE_DEFINITIONS));
const store = new S3BlobStore(config.storage);
try {
await withRetry("bucket", () => store.ensureBucket());
} finally {
store.destroy();
}
- log("info", "migrations applied, bucket ready");
+ log("info", "migrations applied, job queues installed, bucket ready");
}
main().catch((error: unknown) => {
diff --git a/tests/architecture/dependency-rules.test.ts b/tests/architecture/dependency-rules.test.ts
index fff9700..cb0a494 100644
--- a/tests/architecture/dependency-rules.test.ts
+++ b/tests/architecture/dependency-rules.test.ts
@@ -46,6 +46,13 @@ describe("module boundary rules", () => {
expect(output).toContain("no-db-connection-in-features");
});
+ it("rejects a feature module opening its own pg-boss pool", () => {
+ const { status, output } = cruise("src/features/eta");
+
+ expect(status).not.toBe(0);
+ expect(output).toContain("no-db-connection-in-features");
+ });
+
it("accepts an import through the other module's index.ts", () => {
const { status, output } = cruise("src/features/gamma");
diff --git a/tests/fixtures/depcruise/src/db/job-queue-client.ts b/tests/fixtures/depcruise/src/db/job-queue-client.ts
new file mode 100644
index 0000000..9ad28c4
--- /dev/null
+++ b/tests/fixtures/depcruise/src/db/job-queue-client.ts
@@ -0,0 +1,3 @@
+export function createJobQueue() {
+ return {};
+}
diff --git a/tests/fixtures/depcruise/src/features/eta/index.ts b/tests/fixtures/depcruise/src/features/eta/index.ts
new file mode 100644
index 0000000..f73b531
--- /dev/null
+++ b/tests/fixtures/depcruise/src/features/eta/index.ts
@@ -0,0 +1,4 @@
+// Fixture: a feature opening its own pg-boss pool (forbidden).
+import { createJobQueue } from "../../db/job-queue-client";
+
+export const queue = createJobQueue();
diff --git a/tests/integration/helpers/stack.ts b/tests/integration/helpers/stack.ts
new file mode 100644
index 0000000..d668cd2
--- /dev/null
+++ b/tests/integration/helpers/stack.ts
@@ -0,0 +1,92 @@
+import { randomUUID } from "node:crypto";
+import { loadConfig } from "@/config/env";
+import { createDatabase, type DatabaseHandle } from "@/db";
+import { bootstrapCompany, createAuth, type Auth, type AuthSettings } from "@/features/identity";
+
+// Shared wiring for integration tests: the real app_rw pool, Better Auth over HTTP (auth.handler),
+// unique synthetic companies and e-mail addresses per run (the database persists between runs).
+export interface Stack {
+ database: DatabaseHandle;
+ auth: Auth;
+ close(): Promise;
+}
+
+export function createStack(overrides: Partial = {}): Stack {
+ const config = loadConfig();
+ const database = createDatabase(config.databaseUrl, { max: 4 });
+ const auth = createAuth(database.db, { ...config.auth, ...overrides });
+ return { database, auth, close: () => database.pool.end() };
+}
+
+export const unique = (prefix: string) => `${prefix}-${randomUUID().slice(0, 8)}`;
+export const syntheticEmail = (prefix: string) => `${unique(prefix)}@example.com`;
+export const PASSWORD = "synthetic-password-123";
+
+/**
+ * A fresh client IP per call, so the per-IP rate limit of one test never bleeds into another –
+ * across test files too. Random /64 prefixes in the IPv6 documentation range (Better Auth collapses
+ * IPv6 to /64 before keying).
+ */
+const group = () => Math.floor(Math.random() * 0x10000).toString(16).padStart(4, "0");
+export const freshIp = () => `2001:db8:${group()}:${group()}::1`;
+/** How Better Auth keys an IPv6 client in `auth.rate_limit` (expanded /64 prefix). */
+export const rateLimitKeyPrefix = (ip: string) => `2001:0db8:${ip.split(":")[2]}:${ip.split(":")[3]}:`;
+
+export async function call(
+ auth: Auth,
+ path: string,
+ init: { body?: unknown; cookie?: string; ip?: string; method?: string } = {},
+): Promise<{ status: number; body: unknown; cookie: string }> {
+ const headers = new Headers({
+ "content-type": "application/json",
+ origin: "http://localhost:3000",
+ "x-forwarded-for": init.ip ?? freshIp(),
+ });
+ if (init.cookie) headers.set("cookie", init.cookie);
+ const response = await auth.handler(
+ new Request(`http://localhost:3000/api/auth${path}`, {
+ method: init.method ?? (init.body === undefined ? "GET" : "POST"),
+ headers,
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
+ }),
+ );
+ const text = await response.text();
+ const cookie = response.headers
+ .getSetCookie()
+ .map((line) => line.split(";")[0])
+ .join("; ");
+ return { status: response.status, body: text ? JSON.parse(text) : null, cookie };
+}
+
+export async function signUp(auth: Auth, email: string, invitationId?: string) {
+ return call(auth, "/sign-up/email", { body: { email, password: PASSWORD, name: "Synthetic User", invitationId } });
+}
+
+export async function signIn(auth: Auth, email: string, ip?: string) {
+ return call(auth, "/sign-in/email", { body: { email, password: PASSWORD }, ip });
+}
+
+/** A company with a signed-in first admin. */
+export async function companyWithAdmin(stack: Stack) {
+ const adminEmail = syntheticEmail("admin");
+ const { company, invitationId } = await bootstrapCompany(stack.database.db, {
+ name: `Beispiel Maschinenbau ${unique("co")}`,
+ slug: unique("beispiel"),
+ adminEmail,
+ });
+ await signUp(stack.auth, adminEmail, invitationId);
+ const login = await signIn(stack.auth, adminEmail);
+ if (login.status !== 200) throw new Error(`sign-in failed: ${login.status}`);
+ return { company, adminEmail, cookie: login.cookie };
+}
+
+/** Invite a user into the actor's company and sign them in; returns the new user's cookie. */
+export async function invitedUser(stack: Stack, admin: import("@/features/identity").Actor, role: "admin" | "clerk") {
+ const { inviteUser } = await import("@/features/identity");
+ const email = syntheticEmail(role);
+ const { invitationId } = await inviteUser(stack.database.db, admin, { email, role });
+ await signUp(stack.auth, email, invitationId);
+ const login = await signIn(stack.auth, email);
+ if (login.status !== 200) throw new Error(`sign-in failed: ${login.status}`);
+ return { email, cookie: login.cookie };
+}
diff --git a/tests/integration/identity.test.ts b/tests/integration/identity.test.ts
new file mode 100644
index 0000000..6984ef1
--- /dev/null
+++ b/tests/integration/identity.test.ts
@@ -0,0 +1,203 @@
+import { eq, sql } from "drizzle-orm";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import * as schema from "@/db/schema";
+import { AuthorizationError, getActor, inviteUser, type Actor } from "@/features/identity";
+import {
+ call,
+ companyWithAdmin,
+ createStack,
+ freshIp,
+ invitedUser,
+ rateLimitKeyPrefix,
+ signIn,
+ signUp,
+ syntheticEmail,
+ type Stack,
+} from "./helpers/stack";
+
+describe("identity: invite-only login and companies", () => {
+ let stack: Stack;
+ const actorOf = async (cookie: string) => (await getActor(stack.auth, stack.database.db, new Headers({ cookie }))) as Actor;
+ const userCount = async (email: string) =>
+ (await stack.database.db.select().from(schema.user).where(eq(schema.user.email, email.toLowerCase()))).length;
+
+ beforeAll(() => {
+ stack = createStack();
+ });
+
+ afterAll(async () => {
+ await stack.close();
+ });
+
+ // Better Auth answers a refused sign-up with the same generic response as a successful one
+ // (anti-enumeration: nobody learns which addresses are invited). "Rejected" is therefore proven by
+ // its effect: no user, no session token, no login.
+ it("rejects a sign-up without an invitation: no user, no token, no login", async () => {
+ const email = syntheticEmail("uninvited");
+
+ const result = await signUp(stack.auth, email);
+
+ expect((result.body as { token: unknown }).token).toBeNull();
+ expect(result.cookie).not.toMatch(/session_token/);
+ expect(await userCount(email)).toBe(0);
+ expect((await signIn(stack.auth, email)).status).toBe(401);
+ });
+
+ it("rejects an invited address without the invitation id, or with a wrong one (no takeover by e-mail alone)", async () => {
+ const { cookie } = await companyWithAdmin(stack);
+ const email = syntheticEmail("target");
+ await inviteUser(stack.database.db, await actorOf(cookie), { email, role: "clerk" });
+
+ await signUp(stack.auth, email);
+ await signUp(stack.auth, email, crypto.randomUUID());
+ await signUp(stack.auth, email, "not-a-uuid");
+
+ expect(await userCount(email)).toBe(0);
+ });
+
+ it("rejects the invitation id of one address for another address", async () => {
+ const { cookie } = await companyWithAdmin(stack);
+ const { invitationId } = await inviteUser(stack.database.db, await actorOf(cookie), { email: syntheticEmail("real"), role: "clerk" });
+ const attacker = syntheticEmail("attacker");
+
+ await signUp(stack.auth, attacker, invitationId);
+
+ expect(await userCount(attacker)).toBe(0);
+ });
+
+ it("gives an invited user a session that carries their active company and role", async () => {
+ const { company, cookie } = await companyWithAdmin(stack);
+
+ const session = await call(stack.auth, "/get-session", { cookie });
+ const actor = await actorOf(cookie);
+
+ expect((session.body as { session: { activeOrganizationId: string } }).session.activeOrganizationId).toBe(company.id);
+ expect(actor).toMatchObject({ companyId: company.id, role: "admin" });
+ });
+
+ it("uses UUIDs for companies, so company_id columns and the RLS cast match", async () => {
+ const { company } = await companyWithAdmin(stack);
+
+ expect(company.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
+ });
+
+ it("matches the invitation e-mail case-insensitively and consumes the invitation", async () => {
+ const { company, cookie } = await companyWithAdmin(stack);
+ const email = syntheticEmail("Clerk").toUpperCase();
+ const { invitationId } = await inviteUser(stack.database.db, await actorOf(cookie), { email: email.toLowerCase(), role: "clerk" });
+
+ await signUp(stack.auth, email, invitationId);
+ const login = await signIn(stack.auth, email.toLowerCase());
+
+ expect(await actorOf(login.cookie)).toMatchObject({ companyId: company.id, role: "clerk" });
+ const [invitation] = await stack.database.db.select().from(schema.invitation).where(eq(schema.invitation.id, invitationId));
+ expect(invitation?.status).toBe("accepted");
+ });
+
+ it("denies inviting to clerks – server-side function and the plugin's HTTP endpoint", async () => {
+ const { company, cookie } = await companyWithAdmin(stack);
+ const clerk = await invitedUser(stack, await actorOf(cookie), "clerk");
+
+ await expect(inviteUser(stack.database.db, await actorOf(clerk.cookie), { email: syntheticEmail("x"), role: "admin" })).rejects.toThrow(
+ AuthorizationError,
+ );
+ const viaPlugin = await call(stack.auth, "/organization/invite-member", {
+ cookie: clerk.cookie,
+ body: { email: syntheticEmail("y"), role: "admin", organizationId: company.id },
+ });
+ expect(viaPlugin.status).toBe(403);
+ });
+
+ it("accepts only the pilot roles through the plugin's HTTP endpoints", async () => {
+ const { company, cookie } = await companyWithAdmin(stack);
+
+ const owner = await call(stack.auth, "/organization/invite-member", {
+ cookie,
+ body: { email: syntheticEmail("o"), role: "owner", organizationId: company.id },
+ });
+
+ expect(owner.status).toBeGreaterThanOrEqual(400);
+ const pending = await stack.database.db.select().from(schema.invitation).where(sql`${schema.invitation.role} = 'owner'`);
+ expect(pending).toHaveLength(0);
+ });
+
+ it("gives a company admin no access to the global admin plugin (no cross-company user list)", async () => {
+ const { cookie } = await companyWithAdmin(stack);
+
+ const listUsers = await call(stack.auth, "/admin/list-users", { cookie });
+
+ expect([401, 403]).toContain(listUsers.status);
+ });
+
+ it("does not let a user create another company", async () => {
+ const { cookie } = await companyWithAdmin(stack);
+
+ const created = await call(stack.auth, "/organization/create", { cookie, body: { name: "Fremdfirma", slug: syntheticEmail("s") } });
+
+ expect(created.status).toBe(403);
+ });
+
+ it("refuses switching the active company to a foreign one and listing its members", async () => {
+ const a = await companyWithAdmin(stack);
+ const b = await companyWithAdmin(stack);
+
+ const switched = await call(stack.auth, "/organization/set-active", { cookie: a.cookie, body: { organizationId: b.company.id } });
+ const members = await call(stack.auth, `/organization/list-members?organizationId=${b.company.id}`, { cookie: a.cookie });
+
+ expect(switched.status).toBeGreaterThanOrEqual(400);
+ expect(members.status).toBeGreaterThanOrEqual(400);
+ expect((await actorOf(a.cookie)).companyId).toBe(a.company.id);
+ });
+
+ it("refuses removing the last admin of a company", async () => {
+ const { company, cookie } = await companyWithAdmin(stack);
+ const admin = await actorOf(cookie);
+
+ const removed = await call(stack.auth, "/organization/remove-member", {
+ cookie,
+ body: { memberIdOrEmail: admin.userId, organizationId: company.id },
+ });
+
+ expect(removed.status).toBeGreaterThanOrEqual(400);
+ expect(await actorOf(cookie)).not.toBeNull();
+ });
+
+ it("allows one company per user: a second membership is refused by the database", async () => {
+ const a = await companyWithAdmin(stack);
+ const b = await companyWithAdmin(stack);
+ const admin = await actorOf(a.cookie);
+
+ const second = stack.database.db
+ .insert(schema.member)
+ .values({ organizationId: b.company.id, userId: admin.userId, role: "clerk", createdAt: new Date() });
+
+ await expect(second).rejects.toThrow();
+ });
+
+ it("rejects a login without membership (fail closed)", async () => {
+ const { cookie, adminEmail } = await companyWithAdmin(stack);
+ const admin = await actorOf(cookie);
+ await stack.database.db.delete(schema.member).where(eq(schema.member.userId, admin.userId));
+
+ expect(await getActor(stack.auth, stack.database.db, new Headers({ cookie }))).toBeNull();
+ expect((await signIn(stack.auth, adminEmail)).status).not.toBe(200);
+ });
+
+ // Proves the limiter and its database storage. The client IP comes from the configured header,
+ // which only a trusted reverse proxy may set in a real deployment (see operations.md).
+ it("rate-limits repeated sign-ins over HTTP and stores the counter in the database", async () => {
+ const limited = createStack({ rateLimit: { window: 60, max: 3 } });
+ const ip = freshIp();
+ const email = syntheticEmail("brute");
+ try {
+ const statuses: number[] = [];
+ for (let attempt = 0; attempt < 5; attempt++) statuses.push((await signIn(limited.auth, email, ip)).status);
+
+ expect(statuses).toContain(429);
+ const rows = await limited.database.db.select().from(schema.rateLimit).where(sql`${schema.rateLimit.key} like ${`${rateLimitKeyPrefix(ip)}%`}`);
+ expect(rows.length).toBeGreaterThan(0);
+ } finally {
+ await limited.close();
+ }
+ });
+});
diff --git a/tests/integration/intake.test.ts b/tests/integration/intake.test.ts
new file mode 100644
index 0000000..cd0b204
--- /dev/null
+++ b/tests/integration/intake.test.ts
@@ -0,0 +1,200 @@
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import type { PgBoss } from "pg-boss";
+import { loadConfig } from "@/config/env";
+import { listAuditEvents } from "@/features/audit";
+import { listDocuments } from "@/features/documents";
+import { getActor, type Actor } from "@/features/identity";
+import { submitUpload, UploadRejected, type IntakeDeps } from "@/features/intake";
+import { createJobQueue } from "@/db/job-queue-client";
+import { QUEUES } from "@/features/jobs";
+import { getRequest } from "@/features/requests";
+import { S3BlobStore } from "@/features/storage";
+import { createTenancy } from "@/features/tenancy";
+import { companyWithAdmin, createStack, invitedUser, unique, type Stack } from "./helpers/stack";
+
+const enc = (text: string) => new TextEncoder().encode(text);
+const mail = (messageId: string) =>
+ enc(`From: Einkauf \r\nTo: vertrieb@example.org\r\nSubject: Anfrage Flansche\r\nMessage-ID: ${messageId}\r\n\r\nBitte um Angebot.\r\n`);
+const pdf = (marker: string) => enc(`%PDF-1.7\n% synthetic ${marker}\n`);
+
+describe("intake: upload a request and enqueue processing atomically", () => {
+ let stack: Stack;
+ let storage: S3BlobStore;
+ let boss: PgBoss;
+ let deps: IntakeDeps;
+ let clerkA: Actor;
+ let adminB: Actor;
+
+ const jobCount = async (requestId: string) => {
+ const { rows } = await stack.database.pool.query(
+ "select count(*)::int as n from pgboss.job where name = $1 and singleton_key = $2",
+ [QUEUES.processRequest, requestId],
+ );
+ return rows[0].n as number;
+ };
+ const requestExists = async (actor: Actor, id: string) =>
+ (await deps.tenancy.withTenant(actor.companyId, (tx) => getRequest(tx, id))) !== null;
+ const objectExists = (key: string) => storage.get(key).then(() => true, () => false);
+
+ beforeAll(async () => {
+ stack = createStack();
+ const config = loadConfig();
+ storage = new S3BlobStore(config.storage);
+ boss = await createJobQueue(config.databaseUrl);
+ deps = { tenancy: createTenancy(stack.database.db), storage, boss, limits: { maxFileBytes: 1024 * 1024, maxFiles: 5 } };
+
+ const a = await companyWithAdmin(stack);
+ const adminA = (await getActor(stack.auth, stack.database.db, new Headers({ cookie: a.cookie })))!;
+ const clerk = await invitedUser(stack, adminA, "clerk");
+ clerkA = (await getActor(stack.auth, stack.database.db, new Headers({ cookie: clerk.cookie })))!;
+ const b = await companyWithAdmin(stack);
+ adminB = (await getActor(stack.auth, stack.database.db, new Headers({ cookie: b.cookie })))!;
+ });
+
+ afterAll(async () => {
+ await boss.stop({ graceful: false });
+ storage.destroy();
+ await stack.close();
+ });
+
+ it("commits request (NEW), documents, audit event and one job together; originals are stored privately", async () => {
+ const result = await submitUpload(deps, clerkA, [
+ { name: "anfrage.eml", bytes: mail(`<${unique("m")}@example.com>`) },
+ { name: "zeichnung.pdf", bytes: pdf(unique("p")) },
+ ]);
+
+ const { request, documents, audit } = await deps.tenancy.withTenant(clerkA.companyId, async (tx) => ({
+ request: await getRequest(tx, result.requestId),
+ documents: await listDocuments(tx, result.requestId),
+ audit: await listAuditEvents(tx, "request", result.requestId),
+ }));
+ expect(request).toMatchObject({ status: "NEW", companyId: clerkA.companyId, createdBy: clerkA.userId, subject: "Anfrage Flansche" });
+ expect(documents.map((d) => d.kind).sort()).toEqual(["eml", "pdf"]);
+ for (const document of documents) {
+ expect(document.storageKey).toBe(`${clerkA.companyId}/${result.requestId}/${document.id}`);
+ expect(await objectExists(document.storageKey)).toBe(true);
+ expect(document.sha256).toMatch(/^[0-9a-f]{64}$/);
+ }
+ expect(audit).toHaveLength(1);
+ expect(audit[0]).toMatchObject({ action: "request.uploaded", actorUserId: clerkA.userId });
+ expect(await jobCount(result.requestId)).toBe(1);
+ });
+
+ it("rolls back request, documents and job when a failure happens after the inserts, and removes the stored objects", async () => {
+ let requestId = "";
+ let jobsInsideTransaction = -1;
+ const keys: string[] = [];
+ const failingBoss: Pick = {
+ send: (async (...args: Parameters) => {
+ const [, data, options] = args as [string, { requestId: string }, { db: { executeSql(t: string, v: unknown[]): Promise<{ rows: Array<{ n: number }> }> } }];
+ requestId = data.requestId;
+ await (boss.send as (...a: unknown[]) => Promise)(...args); // the job row is really inserted …
+ const seen = await options.db.executeSql("select count(*)::int as n from pgboss.job where singleton_key = $1", [requestId]);
+ jobsInsideTransaction = seen.rows[0]!.n; // … visible inside the transaction …
+ throw new Error("injected failure after the job insert"); // … and then the transaction fails
+ }) as unknown as PgBoss["send"],
+ };
+ const put = storage.put.bind(storage);
+ const spyStorage = Object.assign(Object.create(storage) as S3BlobStore, {
+ put: async (key: string, bytes: Uint8Array, type: string) => {
+ keys.push(key);
+ return put(key, bytes, type);
+ },
+ });
+
+ await expect(
+ submitUpload({ ...deps, boss: failingBoss, storage: spyStorage }, clerkA, [{ name: "anfrage.pdf", bytes: pdf(unique("fail")) }]),
+ ).rejects.toThrow(/injected failure/);
+
+ expect(requestId).not.toBe("");
+ expect(jobsInsideTransaction).toBe(1);
+ expect(await requestExists(clerkA, requestId)).toBe(false);
+ const audit = await deps.tenancy.withTenant(clerkA.companyId, (tx) => listAuditEvents(tx, "request", requestId));
+ expect(audit).toHaveLength(0);
+ expect(await jobCount(requestId)).toBe(0);
+ const documents = await deps.tenancy.withTenant(clerkA.companyId, (tx) => listDocuments(tx, requestId));
+ expect(documents).toHaveLength(0);
+ expect(keys).toHaveLength(1);
+ expect(await objectExists(keys[0]!)).toBe(false);
+ });
+
+ it("flags a second upload with the same Message-ID as a possible duplicate of the first", async () => {
+ const messageId = `<${unique("dup")}@example.com>`;
+ const first = await submitUpload(deps, clerkA, [{ name: "a.eml", bytes: mail(messageId) }]);
+
+ const second = await submitUpload(deps, clerkA, [
+ { name: "b.eml", bytes: enc(`Subject: Re: Anfrage\r\nMessage-ID: ${messageId}\r\nFrom: x@example.com\r\n\r\nanderer Text`) },
+ ]);
+
+ expect(first.possibleDuplicate).toBe(false);
+ expect(second).toMatchObject({ possibleDuplicate: true, duplicateOfId: first.requestId });
+ });
+
+ it("flags the same set of files as a possible duplicate, in any order", async () => {
+ const one = pdf(unique("x"));
+ const two = pdf(unique("y"));
+ const first = await submitUpload(deps, clerkA, [
+ { name: "1.pdf", bytes: one },
+ { name: "2.pdf", bytes: two },
+ ]);
+
+ const second = await submitUpload(deps, clerkA, [
+ { name: "zwei.pdf", bytes: two },
+ { name: "eins.pdf", bytes: one },
+ ]);
+
+ expect(second).toMatchObject({ possibleDuplicate: true, duplicateOfId: first.requestId });
+ });
+
+ it("detects duplicates only within the same company", async () => {
+ const bytes = pdf(unique("shared"));
+ await submitUpload(deps, clerkA, [{ name: "a.pdf", bytes }]);
+
+ const other = await submitUpload(deps, adminB, [{ name: "a.pdf", bytes }]);
+
+ expect(other.possibleDuplicate).toBe(false);
+ });
+
+ it("rejects a disallowed type with a clear message and stores nothing", async () => {
+ await expect(submitUpload(deps, clerkA, [{ name: "makro.xlsm", bytes: new Uint8Array([0x50, 0x4b, 3, 4]) }])).rejects.toThrow(
+ UploadRejected,
+ );
+ });
+
+ it("keeps the audit trail append-only for the runtime role", async () => {
+ const result = await submitUpload(deps, clerkA, [{ name: "a.pdf", bytes: pdf(unique("audit")) }]);
+ const client = await stack.database.pool.connect();
+ try {
+ await client.query("begin");
+ await client.query("select set_config('app.company_id', $1, true)", [clerkA.companyId]);
+ await expect(client.query("update app.audit_events set action = 'x' where entity_id = $1", [result.requestId])).rejects.toThrow(
+ /permission denied/,
+ );
+ await client.query("rollback");
+ await client.query("begin");
+ await client.query("select set_config('app.company_id', $1, true)", [clerkA.companyId]);
+ await expect(client.query("delete from app.audit_events where entity_id = $1", [result.requestId])).rejects.toThrow(/permission denied/);
+ await client.query("rollback");
+ } finally {
+ client.release();
+ }
+ });
+
+ it("does not let a company attach a document to another company's request (composite FK)", async () => {
+ const foreign = await submitUpload(deps, adminB, [{ name: "b.pdf", bytes: pdf(unique("b")) }]);
+ const client = await stack.database.pool.connect();
+ try {
+ await client.query("begin");
+ await client.query("select set_config('app.company_id', $1, true)", [clerkA.companyId]);
+ const insert = client.query(
+ `insert into app.documents (company_id, request_id, filename, content_type, kind, size_bytes, sha256, storage_key)
+ values ($1, $2, 'x.pdf', 'application/pdf', 'pdf', 1, 'x', 'x')`,
+ [clerkA.companyId, foreign.requestId],
+ );
+ await expect(insert).rejects.toThrow(/foreign key/);
+ await client.query("rollback");
+ } finally {
+ client.release();
+ }
+ });
+});
diff --git a/tests/integration/setup/global-setup.ts b/tests/integration/setup/global-setup.ts
index a190386..6322551 100644
--- a/tests/integration/setup/global-setup.ts
+++ b/tests/integration/setup/global-setup.ts
@@ -1,4 +1,6 @@
import { runMigrations } from "@/db/migrate";
+import { installJobQueues } from "@/db/job-queue-client";
+import { QUEUE_DEFINITIONS } from "@/features/jobs";
import { S3BlobStore } from "@/features/storage";
import { loadConfig } from "@/config/env";
@@ -8,6 +10,7 @@ export default async function setup(): Promise {
const migrationUrl = process.env.MIGRATION_DATABASE_URL;
if (!migrationUrl) throw new Error("MIGRATION_DATABASE_URL is required for integration tests");
await runMigrations(migrationUrl);
+ await installJobQueues(migrationUrl, QUEUE_DEFINITIONS);
const store = new S3BlobStore(loadConfig().storage);
try {
diff --git a/tests/integration/tenancy.test.ts b/tests/integration/tenancy.test.ts
new file mode 100644
index 0000000..2b06f5c
--- /dev/null
+++ b/tests/integration/tenancy.test.ts
@@ -0,0 +1,117 @@
+import pg from "pg";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { createDatabase, type DatabaseHandle } from "@/db";
+import { bootstrapCompany } from "@/features/identity";
+import { createRequest, listRequests } from "@/features/requests";
+import { createTenancy, MissingTenantError, type Tenancy, type TenantTx } from "@/features/tenancy";
+import { unique } from "./helpers/stack";
+
+// Two synthetic companies, A and B. Proof of ADR-0001 D7 on both layers: repository and raw SQL as app_rw.
+describe("tenancy: withTenant and forced RLS", () => {
+ let database: DatabaseHandle;
+ let tenancy: Tenancy;
+ let companyA: string;
+ let companyB: string;
+
+ beforeAll(async () => {
+ database = createDatabase(process.env.DATABASE_URL!, { max: 4 });
+ tenancy = createTenancy(database.db);
+ const a = await bootstrapCompany(database.db, { name: "Firma A (synthetisch)", slug: unique("a"), adminEmail: `${unique("a")}@example.com` });
+ const b = await bootstrapCompany(database.db, { name: "Firma B (synthetisch)", slug: unique("b"), adminEmail: `${unique("b")}@example.com` });
+ companyA = a.company.id;
+ companyB = b.company.id;
+ await tenancy.withTenant(companyA, (tx) => createRequest(tx));
+ await tenancy.withTenant(companyB, (tx) => createRequest(tx));
+ });
+
+ afterAll(async () => {
+ await database.pool.end();
+ });
+
+ it("returns only the own company's requests through the repository", async () => {
+ const rowsA = await tenancy.withTenant(companyA, (tx) => listRequests(tx));
+ const rowsB = await tenancy.withTenant(companyB, (tx) => listRequests(tx));
+
+ expect(rowsA.length).toBeGreaterThan(0);
+ expect(rowsA.every((row) => row.companyId === companyA)).toBe(true);
+ expect(rowsB.every((row) => row.companyId === companyB)).toBe(true);
+ });
+
+ describe("raw SQL as app_rw", () => {
+ let client: pg.Client;
+
+ beforeAll(async () => {
+ client = new pg.Client({ connectionString: process.env.DATABASE_URL });
+ await client.connect();
+ });
+
+ afterAll(async () => {
+ await client.end();
+ });
+
+ it("sees only company A with app.company_id = A, even without a WHERE clause", async () => {
+ await client.query("begin");
+ await client.query("select set_config('app.company_id', $1, true)", [companyA]);
+ const { rows } = await client.query("select distinct company_id from app.requests");
+ await client.query("commit");
+
+ expect(rows.map((row) => row.company_id)).toEqual([companyA]);
+ });
+
+ it("sees no rows at all without a company context", async () => {
+ const { rows } = await client.query("select count(*)::int as n from app.requests");
+
+ expect(rows[0].n).toBe(0);
+ });
+
+ it("rejects inserting a row of company B while acting for company A", async () => {
+ await client.query("begin");
+ await client.query("select set_config('app.company_id', $1, true)", [companyA]);
+ const insert = client.query("insert into app.requests (company_id) values ($1)", [companyB]);
+
+ await expect(insert).rejects.toThrow(/row-level security/);
+ await client.query("rollback");
+ });
+
+ it("cannot move an own row to another company", async () => {
+ await client.query("begin");
+ await client.query("select set_config('app.company_id', $1, true)", [companyA]);
+ const update = client.query("update app.requests set company_id = $1", [companyB]);
+
+ await expect(update).rejects.toThrow(/row-level security/);
+ await client.query("rollback");
+ });
+ });
+
+ it("keeps the company setting transaction-local: a reused pooled connection has none", async () => {
+ const single = createDatabase(process.env.DATABASE_URL!, { max: 1 });
+ try {
+ await createTenancy(single.db).withTenant(companyA, (tx) => listRequests(tx));
+
+ const { rows } = await single.pool.query(
+ "select coalesce(current_setting('app.company_id', true), '') as company, (select count(*)::int from app.requests) as n",
+ );
+ expect(rows[0]).toEqual({ company: "", n: 0 });
+ } finally {
+ await single.pool.end();
+ }
+ });
+
+ it("has RLS enabled and forced on app.requests", async () => {
+ const { rows } = await database.pool.query(
+ "select relrowsecurity, relforcerowsecurity from pg_class where oid = 'app.requests'::regclass",
+ );
+
+ expect(rows[0]).toEqual({ relrowsecurity: true, relforcerowsecurity: true });
+ });
+
+ it("refuses repository calls without a tenant transaction", async () => {
+ const plain = await database.db.transaction(async (tx) => listRequests(tx as TenantTx).catch((error: unknown) => error));
+
+ expect(plain).toBeInstanceOf(MissingTenantError);
+ });
+
+ it("refuses a company id that is not a UUID", async () => {
+ await expect(tenancy.withTenant("' or 1=1 --", (tx) => listRequests(tx))).rejects.toThrow(MissingTenantError);
+ });
+});
diff --git a/tests/integration/upload-routes.test.ts b/tests/integration/upload-routes.test.ts
new file mode 100644
index 0000000..cbb3da1
--- /dev/null
+++ b/tests/integration/upload-routes.test.ts
@@ -0,0 +1,89 @@
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { GET as download } from "@/app/api/documents/[id]/route";
+import { POST as upload } from "@/app/api/requests/route";
+import { getJobClient, getRuntime } from "@/app/_server/runtime";
+import { listDocuments } from "@/features/documents";
+import { companyWithAdmin, createStack, unique, type Stack } from "./helpers/stack";
+
+// The HTTP boundary of intake: session → actor → tenant. Uses the web process's own runtime
+// (same env as `next start`), so the routes run exactly as deployed.
+describe("upload and download routes", () => {
+ let stack: Stack;
+ let cookieA: string;
+ let cookieB: string;
+ let companyA: string;
+
+ const pdf = (marker: string) => new File([`%PDF-1.7\n% synthetic ${marker}\n`], "anfrage.pdf", { type: "application/pdf" });
+ // Serialise like a browser does, including Content-Length (the route refuses uploads without it).
+ const post = async (cookie: string | null, files: File[], override: { contentLength?: string | null } = {}) => {
+ const form = new FormData();
+ for (const file of files) form.append("files", file);
+ const encoded = new Response(form);
+ const body = new Uint8Array(await encoded.arrayBuffer());
+ const headers = new Headers({ "content-type": encoded.headers.get("content-type")! });
+ const length = override.contentLength === undefined ? String(body.byteLength) : override.contentLength;
+ if (length !== null) headers.set("content-length", length);
+ if (cookie) headers.set("cookie", cookie);
+ return upload(new Request("http://localhost:3000/api/requests", { method: "POST", body, headers }));
+ };
+ const get = (cookie: string | null, id: string) =>
+ download(new Request(`http://localhost:3000/api/documents/${id}`, { headers: cookie ? { cookie } : {} }), {
+ params: Promise.resolve({ id }),
+ });
+
+ beforeAll(async () => {
+ stack = createStack();
+ const a = await companyWithAdmin(stack);
+ const b = await companyWithAdmin(stack);
+ cookieA = a.cookie;
+ cookieB = b.cookie;
+ companyA = a.company.id;
+ });
+
+ afterAll(async () => {
+ await (await getJobClient()).stop({ graceful: false });
+ await getRuntime().database.pool.end();
+ await stack.close();
+ });
+
+ it("accepts an upload of a signed-in user and serves the original only to the same company", async () => {
+ const marker = unique("route");
+ const created = await post(cookieA, [pdf(marker)]);
+ expect(created.status).toBe(201);
+ const { requestId } = (await created.json()) as { requestId: string };
+ const [document] = await getRuntime().tenancy.withTenant(companyA, (tx) => listDocuments(tx, requestId));
+
+ const own = await get(cookieA, document!.id);
+ const foreign = await get(cookieB, document!.id);
+ const anonymous = await get(null, document!.id);
+
+ expect(own.status).toBe(200);
+ expect(own.headers.get("content-disposition")).toMatch(/^attachment;/);
+ expect(own.headers.get("x-content-type-options")).toBe("nosniff");
+ expect(await own.text()).toContain(marker);
+ expect(foreign.status).toBe(404);
+ expect(anonymous.status).toBe(401);
+ });
+
+ it("rejects an anonymous upload and a disallowed type with a clear message", async () => {
+ expect((await post(null, [pdf("x")])).status).toBe(401);
+
+ const rejected = await post(cookieA, [new File(["MZ"], "tool.exe")]);
+
+ expect(rejected.status).toBe(422);
+ expect(((await rejected.json()) as { error: { title: string } }).error.title).toMatch(/Dateityp nicht erlaubt/);
+ });
+
+ it("refuses uploads without Content-Length (411), above the request cap (413) and with too many files (422)", async () => {
+ expect((await post(cookieA, [pdf("a")], { contentLength: null })).status).toBe(411);
+ expect((await post(cookieA, [pdf("b")], { contentLength: String(1024 * 1024 * 1024) })).status).toBe(413);
+
+ const tooMany = await post(cookieA, Array.from({ length: 11 }, (_, i) => pdf(`many-${i}`)));
+
+ expect(tooMany.status).toBe(422);
+ });
+
+ it("answers 404 for a malformed document id", async () => {
+ expect((await get(cookieA, "../../etc/passwd")).status).toBe(404);
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index 3632749..3d1ac06 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -15,6 +15,9 @@ const localStackDefaults: Record = {
S3_ACCESS_KEY_ID: "local-access-key",
S3_SECRET_ACCESS_KEY: "local-secret-key",
S3_FORCE_PATH_STYLE: "true",
+ BETTER_AUTH_SECRET: "local-dev-only-secret-change-me-0123456789",
+ BETTER_AUTH_URL: "http://localhost:3000",
+ APP_ENV: "local",
};
const integrationEnv = Object.fromEntries(
Object.entries(localStackDefaults).map(([name, value]) => [name, process.env[name] ?? value]),