From e31518d253cf0e2eb4ca79e70f85dd4b6fc6083e Mon Sep 17 00:00:00 2001 From: Ediminator <211899731+Ediminator@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:03:25 +0100 Subject: [PATCH 1/3] feat(contracts): templates, schema, repositories, merge engine, and audit (TASK-0064) Signed-off-by: Ediminator <211899731+Ediminator@users.noreply.github.com> --- .../adr/0019-contracts-and-e-signature.md | 441 ++ drizzle/0022_nifty_blazing_skull.sql | 81 + drizzle/meta/0022_snapshot.json | 6343 +++++++++++++++++ drizzle/meta/_journal.json | 7 + messages/de.json | 4 + messages/en.json | 4 + src/server/audit/allowlist.ts | 59 + src/server/contracts/merge-engine.ts | 298 + src/server/contracts/repo.ts | 443 ++ src/server/contracts/schema.ts | 166 + src/server/contracts/template-repo.ts | 342 + src/server/db/schema/contracts.ts | 171 + src/server/db/schema/index.ts | 1 + src/server/db/schema/studio-settings.ts | 27 +- src/server/db/seed.ts | 10 +- src/server/db/seeds/contracts.ts | 213 + src/server/settings/repo.ts | 10 + src/server/studio/signing-settings.ts | 126 + src/server/timeline/registry.ts | 20 + .../contract-templates-repo.test.ts | 443 ++ tests/integration/contracts-repo.test.ts | 551 ++ tests/integration/contracts-seed.test.ts | 101 + .../studio-signing-settings.test.ts | 179 + tests/unit/ac-1-to-ac-13-pow.test.ts | 20 + tests/unit/clients-branches.test.ts | 10 + tests/unit/contracts-merge-engine.test.ts | 214 + tests/unit/contracts-registry-audit.test.ts | 124 + tests/unit/contracts-schema.test.ts | 262 + tests/unit/contracts-snapshot.test.ts | 103 + .../unit/packages-components-render.test.tsx | 10 + tests/unit/packages-form-schema.test.ts | 20 + tests/unit/proposal-builder-render.test.tsx | 10 + tests/unit/public-booking-components.test.tsx | 10 + 33 files changed, 10819 insertions(+), 4 deletions(-) create mode 100644 docs/architecture/adr/0019-contracts-and-e-signature.md create mode 100644 drizzle/0022_nifty_blazing_skull.sql create mode 100644 drizzle/meta/0022_snapshot.json create mode 100644 src/server/contracts/merge-engine.ts create mode 100644 src/server/contracts/repo.ts create mode 100644 src/server/contracts/schema.ts create mode 100644 src/server/contracts/template-repo.ts create mode 100644 src/server/db/schema/contracts.ts create mode 100644 src/server/db/seeds/contracts.ts create mode 100644 src/server/studio/signing-settings.ts create mode 100644 tests/integration/contract-templates-repo.test.ts create mode 100644 tests/integration/contracts-repo.test.ts create mode 100644 tests/integration/contracts-seed.test.ts create mode 100644 tests/integration/studio-signing-settings.test.ts create mode 100644 tests/unit/contracts-merge-engine.test.ts create mode 100644 tests/unit/contracts-registry-audit.test.ts create mode 100644 tests/unit/contracts-schema.test.ts create mode 100644 tests/unit/contracts-snapshot.test.ts diff --git a/docs/architecture/adr/0019-contracts-and-e-signature.md b/docs/architecture/adr/0019-contracts-and-e-signature.md new file mode 100644 index 0000000..32bc883 --- /dev/null +++ b/docs/architecture/adr/0019-contracts-and-e-signature.md @@ -0,0 +1,441 @@ +# ADR-0019: Contracts and e-signature with deterministic merge fields, pure-JS immutable PDF generation, and statutory commercial retention + +- Status: Accepted (2026-11-06) +- Date: 2026-11-06 +- Deciders: Maintainer, Architect +- Related ADRs: + - ADR-0002: Tech stack (Next.js App Router, PostgreSQL, Drizzle ORM, Tailwind CSS, TypeScript) + - ADR-0003: Self-hosted single studio architecture + - ADR-0005: Internationalization (en/de catalogues, ICU message formatting) + - ADR-0007: External accounting & tax platform connector (Lexware Office) + - ADR-0010: Client search strategy (in-memory filtering, zero PII in query params or logs) + - ADR-0011: Relationship history timeline as a dedicated, typed event table + - ADR-0012: Data subject rights: export, erasure, and legal holds + - ADR-0013: Retention runner and background worker loop + - ADR-0015: Lead capture through public inquiry forms with atomic spam defence + - ADR-0016: Projects and pipeline management (stages, shoots, lead conversion step) + - ADR-0017: Studio calendar, availability engine, and public booking scheduler + - ADR-0018: Packages, product catalogue, quotes, and interactive client proposals + +--- + +## Context + +Epic E6 ("Contracts with e-signature") introduces legally binding contractual agreements and electronic signatures to Ownlight. According to `projects/ownlight/docs/product/mvp-scope.md` (§E6) and `control/ownlight/roadmap.md`, the platform must provide: + +1. **Contract templates with localized content:** Reusable contract templates (e.g., Wedding Photography Contract, Portrait Session Agreement, Commercial Model Release, Commercial Licensing Agreement) authored in English and German with categorized boilerplate clauses. +2. **Deterministic merge-field engine:** Safe token replacement replacing variables such as `{{client.name}}`, `{{project.title}}`, `{{shoot.date}}`, `{{package.name}}`, `{{studio.name}}`, and `{{contract.issue_date}}` with runtime commission data. The engine must strictly sanitize and HTML-escape interpolated values to prevent XSS and injection attacks, and permanently freeze the rendered text snapshot when a contract is issued or sent to a client. +3. **eIDAS Simple Electronic Signature (SES) & German Law Compliance:** + - Under German civil law (BGB), photography and videography contracts are service or works contracts (_Dienstverträge_ § 611 BGB / _Werkverträge_ § 631 BGB). They do not require statutory notarisation or qualified electronic signatures (QES per § 126a BGB). Simple electronic form (_Textform_ per § 126b BGB) or contractually agreed electronic form (_Vereinbarte Form_ per § 127 Abs. 2 BGB) is legally valid, effective, and customary in the creative industry. + - Under EU Regulation (EU) No 910/2014 (eIDAS) Art. 3(10), a Simple Electronic Signature (SES) consists of "data in electronic form which is attached to or logically associated with other data in electronic form and which is used by the signatory to sign". Per eIDAS Art. 25(1), an electronic signature shall not be denied legal effect and admissibility as evidence in legal proceedings solely because it is in electronic form or does not meet QES requirements. + - To ensure maximum probative value (_Beweiskraft_) in court, the signature ceremony must create a tamper-evident evidence trail capturing: canonical text/HTML SHA-256 hash, final PDF SHA-256 hash, UTC timestamps, signer full name, verified email (delivered via private token link), signer IP address anonymised to subnet level (`/24` for IPv4, `/48` for IPv6 per GDPR data minimization), browser user-agent, and a dedicated audit certificate summary page appended to the signed PDF. +4. **Studio Countersignature Workflow:** Contracts require signatures from both the client and the studio owner. Solo photographers frequently prefer to pre-countersign standard agreements so the contract becomes immediately fully executed upon client signature, while other situations require explicit two-step countersigning (client signs first, studio owner reviews and countersigns). Studio settings must support both modes. +5. **Deterministic, Zero-Browser Pure-JS PDF Generation & Storage:** + - Production deployments run on lightweight Alpine Linux containers (`node:24.2.0-alpine3.21`) with restricted CPU and memory (e.g. Synology NAS devices with 2–4 GB total system RAM). Running headless Chromium (Puppeteer/Playwright) in production would add 400+ MB of native browser binaries, require dozens of shared C-libraries and fonts, consume 200–500 MB RAM per rendering task, and introduce a substantial browser attack surface. + - PDF generation must be executed 100% in pure JavaScript/TypeScript (e.g. `pdf-lib`) generating byte-for-byte deterministic, sealed PDFs within milliseconds without external process spawns. + - Signed PDFs must be stored immutably in private storage (local filesystem volume / MinIO S3 object storage abstraction), referenced by random UUID storage keys, and cryptographically verified against their PostgreSQL SHA-256 hash on every read. +6. **Public Cookieless Signing Route & Reminders:** Frictionless client access via a revocable secret token URL (`/c/[token]` and `/[locale]/c/[token]`). The route must be cookieless (session cookies stripped per TDDDG §25 / GDPR Art. 5), rate-limited against brute-force attacks, and support automated and manual reminder emails via `email_outbox`. +7. **GDPR & Commercial Statutory Retention:** + - GDPR Art. 15 DSAR export must include all contracts addressed to the data subject. + - GDPR Art. 17 erasure: Draft or voided contracts (pre-contractual, never executed) must be hard-deleted upon client erasure. + - Executed/signed contracts constitute commercial agreements and tax vouchers (_Handels- und Geschäftsbriefe, Buchungsbelege_) subject to statutory retention under §147 Abs. 1 Nr. 2 & 4 AO and §257 Abs. 1 Nr. 2 & 4 HGB. Under GDPR Art. 17(3)(b), statutory retention overrides erasure; signed contracts must enforce a 10-year legal hold preventing client erasure until the end of the statutory retention period. +8. **Bridge to Accepted Proposals (E5 → E6) & Project Timeline:** + - Accepted proposals (`getAcceptedProposalForProject`) must automatically seed the contract builder with package name, included deliverables, selected add-ons, and monetary totals. + - Contract lifecycle events (`contract.sent`, `contract.signed_by_client`, `contract.countersigned`, `contract.voided`) must integrate with the client relationship timeline and trigger optional project pipeline stage advancement (`booked`). + +--- + +## Decision + +### 1. Data Model & Database Schema + +We define two relational tables in `src/server/db/schema/contracts.ts` and augment `studio_settings`: + +```mermaid +erDiagram + studio_settings ||--o{ contract_templates : "configures signing defaults" + contract_templates ||--o{ contracts : "instantiates" + projects ||--o{ contracts : "belongs to" + clients ||--o{ contracts : "addressed to" + proposals ||--o{ contracts : "bridges accepted data" + contracts ||--o{ email_outbox : "enqueues reminders & delivery" +``` + +#### 1.1 Studio Settings Augmentation (`studio_settings`) + +We augment `studio_settings` (`src/server/db/schema/studio-settings.ts`) with signatory and studio contact details: + +- `contract_signatory_name`: `varchar(200)` nullable — legal name of the photographer/signatory (e.g. "Jane Doe"). +- `contract_signatory_title`: `varchar(100)` nullable — title/role (e.g. "Studio Owner", "Lead Photographer"). +- `contract_countersignature_mode`: `varchar(32)` not null default `'pre_signed'` — enum: `'pre_signed'` (studio signature applied automatically when client signs), `'explicit'` (studio owner must manually countersign in dashboard after client signs). +- `studio_address_line1`: `varchar(200)` nullable — physical street address for legal notices. +- `studio_address_line2`: `varchar(200)` nullable. +- `studio_postal_code`: `varchar(20)` nullable. +- `studio_city`: `varchar(100)` nullable. +- `studio_country_code`: `char(2)` not null default `'DE'`. +- `studio_phone`: `varchar(50)` nullable. +- `studio_email`: `varchar(255)` nullable — primary business email for contractual notices. + +#### 1.2 `contract_templates` + +Represents reusable contract boilerplates authored by the studio: + +- `id`: UUIDv7 PK. +- `name`: `varchar(150)` not null (e.g. "Standard Wedding Photography Agreement", "Portrait Session Terms"). +- `slug`: `varchar(100)` unique not null. +- `description`: `text` nullable. +- `project_type_key`: `varchar(40)` nullable — optional association to project types (`'wedding'`, `'portrait'`, `'commercial'`, `'event'`, `'video'`) to auto-select the matching template in the contract composer. +- `content_en`: `text` not null — markdown/plain contract clauses in English with merge tags. +- `content_de`: `text` not null — markdown/plain contract clauses in German with merge tags. +- `is_default`: `boolean` not null default `false` — default template for its project type. +- `is_active`: `boolean` not null default `true` — soft deactivation so historical contracts remain verifiable. +- `created_at`, `updated_at`: `timestamptz` not null default `now()`. + +_Indexes:_ + +- `contract_templates_slug_idx` on `(slug)` +- `contract_templates_project_type_key_idx` on `(project_type_key)` + +#### 1.3 `contracts` + +Core contract entity attached to a client project: + +- `id`: UUIDv7 PK. +- `project_id`: UUID FK → `projects.id` not null (`onDelete: 'cascade'`). +- `client_id`: UUID FK → `clients.id` not null (`onDelete: 'restrict'`). +- `proposal_id`: UUID FK → `proposals.id` nullable (`onDelete: 'set null'`) — optional back-reference to the accepted proposal. +- `template_id`: UUID FK → `contract_templates.id` nullable (`onDelete: 'set null'`). +- `contract_number`: `varchar(50)` not null unique — human-readable sequential identifier (e.g. `CTR-2026-0001`). +- `title`: `varchar(200)` not null (e.g. "Wedding Photography Agreement — Liam & Emma"). +- `locale`: `varchar(10)` not null default `'en'` — language of the contract (`'en'` or `'de'`). +- `status`: `varchar(32)` not null default `'draft'` — enum: + - `'draft'`: Editable by photographer in project workspace. + - `'sent'`: Delivered to client via secret signing link; awaiting client signature. + - `'signed_by_client'`: Client completed SES ceremony; awaiting studio countersignature (in `'explicit'` mode). + - `'signed'`: Fully executed by both client and studio; immutable PDF generated and sealed. + - `'declined'`: Client rejected terms or requested amendments. + - `'voided'`: Photographer cancelled contract before execution. + - `'superseded'`: Replaced by a revised contract version. +- `rendered_body`: `text` not null — snapshotted contract text after merge field interpolation. Once status leaves `'draft'`, this field is immutable. +- `merge_values_snapshot`: `jsonb` not null — complete JSON dictionary of all evaluated merge variables at issuance time. +- `canonical_body_sha256`: `varchar(64)` not null — SHA-256 hash of normalized UTF-8 `rendered_body` for tamper verification. +- `valid_until`: `timestamptz` nullable — expiration date for signing link. +- `token_hash`: `varchar(64)` not null unique — SHA-256 hash of revocable public signing token. +- `token_revoked_at`: `timestamptz` nullable. +- `sent_at`: `timestamptz` nullable. +- `reminded_at`: `timestamptz` nullable — timestamp of last signing reminder sent. +- `reminder_count`: `integer` not null default 0. + +_Client Signature Audit Trail:_ + +- `client_signed_at`: `timestamptz` nullable. +- `client_signer_name`: `varchar(200)` nullable — full legal name entered by client during signing. +- `client_signer_email`: `varchar(255)` nullable — email address confirmed during signing. +- `client_signature_type`: `varchar(20)` nullable — `'drawn'` (base64 PNG vector) or `'typed'` (cursive typography). +- `client_signature_data`: `text` nullable — base64 data URI of drawn signature glyph or typed text string. +- `client_ip_anonymized`: `varchar(64)` nullable — IPv4 `/24` or IPv6 `/48` subnet mask. +- `client_user_agent`: `varchar(255)` nullable — truncated browser user-agent. + +_Studio Countersignature Audit Trail:_ + +- `studio_signed_at`: `timestamptz` nullable. +- `studio_signer_name`: `varchar(200)` nullable. +- `studio_signer_title`: `varchar(100)` nullable. +- `studio_signature_data`: `text` nullable — base64 data URI of studio signature or typed representation. + +_Final Sealed PDF Storage & Verification:_ + +- `pdf_storage_key`: `varchar(255)` nullable — private object storage or filesystem path (e.g. `contracts/2026/CTR-2026-0001-sealed.pdf`). +- `pdf_sha256`: `varchar(64)` nullable — SHA-256 checksum of generated signed PDF. +- `pdf_byte_size`: `integer` nullable. +- `pdf_generated_at`: `timestamptz` nullable. + +_Internal Notes & Tracking:_ + +- `internal_notes`: `text` nullable — private photographer notes. +- `created_at`, `updated_at`: `timestamptz` not null default `now()`. + +_Indexes:_ + +- `contracts_project_id_idx` on `(project_id)` +- `contracts_client_id_idx` on `(client_id)` +- `contracts_proposal_id_idx` on `(proposal_id)` +- `contracts_token_hash_idx` on `(token_hash)` +- `contracts_status_idx` on `(status)` +- `contracts_contract_number_idx` on `(contract_number)` + +--- + +### 2. Deterministic Merge-Field Engine & Snapshot Immutability + +#### 2.1 Allowlisted Merge Fields + +The merge-field engine (`src/server/contracts/merge-engine.ts`) strictly defines supported merge tokens grouped into six namespaces: + +| Namespace | Token | Source | Example Value | +| -------------- | -------------------------- | ------------------------------------------- | -------------------------------------- | +| **`client`** | `{{client.name}}` | Client display name or primary contact name | Emma Watson | +| | `{{client.company}}` | Client company name (if company) | Watson Media GmbH | +| | `{{client.email}}` | Primary contact email | emma@example.com | +| | `{{client.phone}}` | Primary contact phone | +49 30 1234567 | +| | `{{client.address}}` | Postal address line 1 & 2 | Friedrichstraße 42 | +| | `{{client.city}}` | Postal city | Berlin | +| | `{{client.postal_code}}` | Postal code | 10117 | +| | `{{client.country}}` | Country code / name | DE | +| **`project`** | `{{project.title}}` | Project title | Emma & Liam Wedding | +| | `{{project.type}}` | Localized project type | Wedding / Hochzeit | +| | `{{project.price}}` | Formatted total price | €2.500,00 | +| | `{{project.currency}}` | Currency code | EUR | +| | `{{project.notes}}` | Sanitized project public notes | Special wishes: golden hour shoot | +| **`shoot`** | `{{shoot.date}}` | Primary shoot start date (localized) | 15. August 2026 | +| | `{{shoot.time}}` | Primary shoot start time + timezone | 14:00 (Europe/Berlin) | +| | `{{shoot.timezone}}` | Timezone identifier | Europe/Berlin | +| | `{{shoot.location}}` | Primary shoot location name | Schlosspark Sanssouci | +| | `{{shoot.address}}` | Primary shoot location address | Maulbeerallee, 14469 Potsdam | +| **`package`** | `{{package.name}}` | Accepted proposal / package name | Full Day Wedding Coverage | +| | `{{package.deliverables}}` | Bulleted list of included items | • 8h coverage\n• 400 photos\n• Gallery | +| | `{{package.total}}` | Accepted proposal total amount | €2.500,00 | +| | `{{package.subtotal}}` | Subtotal amount before tax | €2.100,84 | +| | `{{package.tax}}` | Total tax amount | €399,16 | +| | `{{package.addons}}` | Selected optional add-on products | • Second Photographer (1x): €400,00 | +| **`studio`** | `{{studio.name}}` | Studio business name | Lichtwerk Fotografie | +| | `{{studio.owner_name}}` | Studio signatory / owner name | Jane Doe | +| | `{{studio.email}}` | Studio business email | hello@lichtwerk.example | +| | `{{studio.phone}}` | Studio phone number | +49 30 9876543 | +| | `{{studio.address}}` | Studio physical address | Torstraße 100, 10119 Berlin | +| | `{{studio.tax_number}}` | Studio tax number (_Steuernummer_) | 12/345/67890 | +| | `{{studio.vat_id}}` | Studio VAT ID (_USt-IdNr._) | DE123456789 | +| **`contract`** | `{{contract.number}}` | Contract reference number | CTR-2026-0001 | +| | `{{contract.issue_date}}` | Date contract was generated/sent | 06.11.2026 | +| | `{{contract.valid_until}}` | Date contract signing link expires | 20.11.2026 | + +#### 2.2 Security, Sanitization & Strict Token Parsing + +1. **Strict Regex Token Matching:** The parser matches `\{\{([a-zA-Z_]+)\.([a-zA-Z_]+)\}\}` against the known allowlist. Unrecognized tokens are flagged in the composer preview or cleanly removed without crashing. +2. **HTML / Special Character Escaping:** Every dynamic string interpolated into the contract text is sanitized: `<` becomes `<`, `>` becomes `>`, `&` becomes `&`, `"` becomes `"`, and control characters are stripped. This neutralizes XSS injection attacks from malicious inquiry form inputs or client contact names. +3. **Snapshot Freezing:** When a contract is moved to `'sent'` (issued to the client): + - All merge variables are evaluated and serialized into `contracts.merge_values_snapshot` (`jsonb`). + - The final resolved text is saved in `contracts.rendered_body`. + - The canonical SHA-256 hash is computed: `contracts.canonical_body_sha256 = sha256(normalize(rendered_body))`. + - After issuance, `rendered_body` and `merge_values_snapshot` are permanently read-only. Subsequent changes to client names, package prices, or studio addresses will never alter the contract. + +--- + +### 3. Pure-JS Deterministic PDF Generator (`pdf-lib`) & Storage Architecture + +#### 3.1 Pure-JS Generation via `pdf-lib` (Zero Headless Browser) + +To ensure the Alpine production container remains lightweight (<150 MB), fast, and secure: + +- We use `pdf-lib` (pure JavaScript/TypeScript, zero native dependencies, zero external process spawns). +- **Page Layout & Typography:** + - Uses standard embedded PostScript fonts (Helvetica / Helvetica-Bold / Courier) or bundled TrueType font subsets for German umlauts (`ä`, `ö`, `ü`, `ß`). + - Implements clean, professional page layout: Header (Studio Name, Document Title, Contract Number, Page X of Y), Body (justified clause paragraphs, bulleted deliverables, pricing summary table), Signatures section (Studio + Client signature blocks with timestamp and legal text), and an integral **Certificate of Signature** final page. +- **Performance & Determinism:** + - PDF generation completes in 20–40 ms on single-core CPU. + - Generates byte-for-byte deterministic PDF binaries. + - Zero risk of Chromium memory leaks, zombie processes, or font rendering discrepancies between local Mac/Windows dev environments and Alpine production containers. + +#### 3.2 Certificate of Signature Page (Audit Trail) + +The generated PDF concludes with an official **"Certificate of Signature & Audit Trail"** (_Signaturprotokoll_) containing: + +1. **Document Integrity:** Canonical contract text SHA-256 hash, PDF creation timestamp (UTC). +2. **Client Signatory Block:** Full legal name, verified email, signature mode (`'drawn'` vector or `'typed'`), UTC timestamp, anonymised IP subnet (`/24` or `/48`), browser User-Agent summary. +3. **Studio Signatory Block:** Studio owner name, title, signature data, UTC timestamp. +4. **Legal Admissibility Clause:** Statement citing Regulation (EU) No 910/2014 (eIDAS) Art. 3(10) & Art. 25(1) and BGB § 126b / § 127(2). + +#### 3.3 Storage Abstraction & Cryptographic Verification + +1. **Storage Path:** Signed PDFs are saved to private storage under `contracts/[year]/[contract_number]-[uuid].pdf` (using the storage volume abstraction or MinIO S3 bucket `STORAGE_BUCKET_UPLOADS`). +2. **SHA-256 Seal:** At the moment of generation, the SHA-256 hash of the final PDF buffer is computed and written to `contracts.pdf_sha256`. +3. **Integrity Check on Read:** Whenever a signed PDF is downloaded or streamed (`/api/contracts/[id]/pdf` or `/c/[token]/pdf`), the service computes `sha256(retrieved_buffer)` and asserts it matches `contracts.pdf_sha256`. If the checksum differs, the service aborts with a `TamperedDocumentError` and logs a security alert. + +--- + +### 4. eIDAS Simple Electronic Signature (SES) Ceremony & Tamper-Evident Evidence Trail + +```mermaid +sequenceDiagram + autonumber + actor Client + participant Web as Ownlight Web (/c/[token]) + participant Action as Server Action (signContractAction) + participant Engine as SES Engine & PDF Generator + participant DB as PostgreSQL + participant Outbox as email_outbox + + Client->>Web: Opens /c/[token] (cookieless, rate-limited) + Web->>Client: Renders contract text, terms, signature modal + Client->>Web: Enters full name, draws/types signature, checks agreement + Client->>Action: Submits signContractAction(token, name, email, sigData) + Action->>DB: Validates token hash, status='sent', valid_until + Action->>Engine: Computes canonical hash, records UTC timestamp, anonymizes IP + alt studio_countersignature_mode == 'pre_signed' + Action->>Engine: Applies studio pre-countersignature + Engine->>Engine: Generates sealed immutable PDF (pdf-lib) + Engine->>DB: Stores pdf_storage_key, pdf_sha256, status='signed' + Action->>DB: Records timeline 'contract.signed' & audit log + Action->>Outbox: Enqueues contract_signed_client & contract_signed_owner + else studio_countersignature_mode == 'explicit' + Action->>DB: Records client sig, sets status='signed_by_client' + Action->>DB: Records timeline 'contract.signed_by_client' + Action->>Outbox: Enqueues contract_countersign_needed to owner + end + Action-->>Client: Returns signature confirmation & download link +``` + +#### 4.1 Client Signing Ceremony + +1. **Access:** Client receives signing link `https://studio.example/c/[token]`. +2. **Presentation:** Public, responsive contract viewer displaying studio branding, contract number, full snapshotted agreement clauses, deliverables, and financial terms. +3. **Execution Dialog:** When clicking _"Sign Contract"_, a modal prompts: + - Full Legal Name (required, pre-filled from primary contact). + - Email Address (required, pre-filled). + - Signature Selection: Tab 1 "Draw Signature" (HTML5 canvas signature pad capturing PNG data URI) or Tab 2 "Type Signature" (renders legal name in stylized cursive typography). + - Mandatory Checkbox: _"I have read, understood, and hereby agree to the terms of this contract. I acknowledge that clicking 'Sign & Accept' constitutes my legally binding simple electronic signature (eIDAS Art. 3(10) / BGB § 126b)."_ +4. **Server-Side Validation & Anonymization:** + - Evaluates token against `contracts.token_hash`. + - Verifies `status === 'sent'` and `valid_until > now()`. + - Anonymizes remote IP address using standard subnet masking (`192.0.2.123` → `192.0.2.0/24`, `2001:db8::1` → `2001:db8::/48`). + - Captures truncated User-Agent. + +#### 4.2 Studio Countersignature Modes + +1. **Pre-Countersigned Mode (`'pre_signed'`, Studio Default):** + - The studio owner configures their signature name, title, and default signature glyph in `/settings/contracts`. + - When the client completes signing, the system immediately applies the studio's countersignature in the same transaction. + - The contract transitions directly from `'sent'` → `'signed'`. + - The sealed PDF is generated immediately and attached to client and owner confirmation emails. +2. **Explicit Countersignature Mode (`'explicit'`):** + - When the client signs, status transitions to `'signed_by_client'`. + - Studio owner receives an email notification alerting them that countersignature is required. + - In the project workspace, the photographer reviews client signature details and clicks _"Countersign Contract"_. + - Upon owner countersigning, status moves to `'signed'`, the final PDF is generated and sealed, and client is notified. + +--- + +### 5. Public Cookieless Signing Route & Transactional Emails + +#### 5.1 Route Security (`src/middleware.ts`) + +- `/c` is added to `PUBLIC_PATH_SEGMENTS`. +- Helper `isPublicContractRoute(pathname)` detects `/c/[token]` and `/[locale]/c/[token]`. +- Enforces strict cookieless privacy: `response.headers.delete('set-cookie')` removes all session cookies. +- Protects against brute-force attacks via `rate-limiter.ts` (30 requests/minute per IP). +- Includes security headers: `X-Robots-Tag: noindex, nofollow`, `Cache-Control: no-store, max-age=0`. + +#### 5.2 Transactional Email Outbox Notifications + +We extend `src/server/db/schema/email-outbox.ts`: + +- Add `contract_id`: UUID FK → `contracts.id` nullable (`onDelete: 'cascade'`). +- Extend `EMAIL_OUTBOX_KINDS` with: + - `contract_sent`: Sent to client with signing link `/c/[token]`, expiration date, and studio details. + - `contract_reminder`: Polite reminder sent manually or automatically prior to expiration. + - `contract_signed_client`: Sent to client confirming full execution, with link to download sealed PDF. + - `contract_signed_owner`: Alert to studio owner that contract is executed (or needs countersigning). + +--- + +### 6. Downstream Subsystem Interfaces + +#### 6.1 Proposal-to-Contract Bridge (`E5 → E6`) + +Using `getAcceptedProposalForProject(projectId)` from `src/server/proposals/accepted-summary.ts`: + +- Project workspace features a 1-click button: _"Create Contract from Accepted Proposal"_. +- Automatically resolves: + - Base package name and bundled deliverables → `{{package.name}}`, `{{package.deliverables}}`. + - Selected add-on products and quantities → `{{package.addons}}`. + - Financial amounts → `{{package.subtotal}}`, `{{package.tax}}`, `{{package.total}}`. + - Client contacts, addresses, and shoot dates. +- Photographer reviews and edits clauses in the contract composer, then clicks _"Send Contract"_. + +#### 6.2 Relationship Timeline & Pipeline Stage Sync + +- Timeline events registered in `src/server/timeline/registry.ts`: + - `contract.sent` (`dataKeys: ['contract_id', 'contract_number']`) + - `contract.signed_by_client` (`dataKeys: ['contract_id', 'contract_number', 'signer_name']`) + - `contract.countersigned` (`dataKeys: ['contract_id', 'contract_number']`) + - `contract.voided` (`dataKeys: ['contract_id', 'contract_number']`) +- Pipeline Stage Hook: When a contract becomes fully `'signed'`: + - If project is currently in stage `'inquiry'` or `'booked'`, the system verifies if stage advancement or checklist task completion is configured. + +#### 6.3 Interface to E9 (Client Portal) & E12 (Lexware Connector) + +- **E9 Client Portal:** The token route `/c/[token]` seamlessly integrates into the future project portal hub (`/portal/[token]/contract`). +- **E12 Lexware Office Connector:** Contract execution provides the final binding trigger for automated Lexware down-payment invoice generation (_Anzahlungsrechnung_). + +--- + +### 7. Privacy, Security & Statutory Retention Compliance + +#### 7.1 GDPR Art. 15 DSAR Export Provider + +Registered via `registerExportProvider` in `src/server/privacy/export-registry.ts`: + +- Subject kinds: `client`, `contact`. +- JSON category: `contracts` (`ownlight.dsar.v1`). +- HTML export: Localized, accessible table (en/de) listing Contract Number, Title, Status, Issued Date, Signed Date, Signatory Name, and Verification Hash. + +#### 7.2 GDPR Art. 17 Erasure Provider + +Registered via `registerErasureProvider` in `src/server/privacy/erasure-registry.ts`: + +- Draft, declined, or voided contracts represent non-binding negotiations. +- When client erasure is processed, `contractsErasureProvider` hard-deletes unexecuted contracts and removes any ephemeral draft files. + +#### 7.3 Statutory Commercial Retention Lock Provider (§147 AO / §257 HGB) + +Registered via `registerErasureHoldProvider` in `src/server/privacy/erasure-registry.ts`: + +- Under German commercial and tax law (§147 Abs. 1 Nr. 2 & 4 AO, §257 Abs. 1 Nr. 2 & 4 HGB), executed commercial contracts, works contracts (_Werkverträge_), and associated booking documents must be preserved for **10 years** starting from the end of the calendar year in which the contract was executed. +- `contractsHoldProvider` checks for any contract with `status === 'signed'`. +- If an executed contract exists, client erasure is blocked with hold code `statutory.commercial_contract` and retention lock date calculated as: + $$\text{Lock Date} = \text{UTC}(\text{Year}(\text{signed\_at}) + 10, 11, 31, 23:59:59.999)$$ +- Strictly complies with GDPR Art. 17(3)(b) (statutory retention exception). + +#### 7.4 Audit Allowlist Registrations (`src/server/audit/allowlist.ts`) + +- `contract_template.created`, `contract_template.updated`, `contract_template.deleted` +- `contract.created`, `contract.updated`, `contract.sent`, `contract.signed_by_client`, `contract.countersigned`, `contract.voided`, `contract.revoked`, `contract.reminded` +- `studio_settings.contract_updated` + +--- + +## Alternatives Considered + +1. **Headless Browser (Puppeteer / Chromium / Playwright) for PDF Generation:** + - _Considered:_ Rendering contracts via headless Chrome to convert HTML/CSS into PDF. + - _Rejected:_ Adds 400+ MB of native browser binaries and libraries to the production container; consumes hundreds of MB RAM per render (risking OOM on 2 GB Synology NAS appliances); creates severe cold-start latency; opens major security vulnerabilities from headless browser sandboxing. +2. **Third-Party E-Signature SaaS (DocuSign / Adobe Sign / HelloSign):** + - _Considered:_ Offloading signature collection to an external cloud e-signature API. + - _Rejected:_ Violates core product principles (self-hosted, privacy-first, zero third-party data leaks, no monthly recurring API costs). Transmits sensitive client contracts and personal data to US cloud processors, triggering GDPR Schrems II transfer issues. +3. **Qualified Electronic Signatures (QES) with Hardware Token / ID-Card Verification:** + - _Considered:_ Requiring German eID / eIDAS QES signatures for all photography contracts. + - _Rejected:_ Grossly disproportionate friction for wedding and portrait clients. German law (§ 126b, § 127(2) BGB) explicitly permits simple electronic form for service agreements. QES would result in 90%+ abandonment by everyday consumers. +4. **Dynamic Contract Text Rendering on Demand Without Snapshotting:** + - _Considered:_ Merging fields dynamically whenever a contract is viewed instead of storing `rendered_body`. + - _Rejected:_ Fatal legal defect. If a photographer subsequently edits their studio address, or changes a package description, the contract text would retroactively mutate, destroying legal integrity and evidentiary validity. + +--- + +## Consequences + +- Photographers can create, customize, and manage reusable bilingual contract templates. +- Contracts can be issued in 1 click from accepted proposals with guaranteed mathematical and textual fidelity. +- Clients enjoy a seamless, mobile-friendly signing experience with zero login hurdles. +- Signed PDFs are generated in milliseconds in pure JavaScript, cryptographically sealed, and verifiable against tampering. +- Compliance with German civil law (BGB), commercial law (HGB, AO), EU eIDAS, and GDPR is built into the architecture. + +--- + +## Security & Privacy Impact + +- **OWASP ASVS 5.0 L2 Alignment:** + - V2 (Authentication): Public signing tokens are 256-bit cryptographically secure random strings hashed with SHA-256 in the database; links can be revoked instantly. + - V3 (Validation): Strict token allowlisting and HTML sanitization prevent stored XSS and injection attacks via contract text. + - V5 (File Storage): Signed PDFs are stored with unguessable keys in private storage, never exposed publicly, and cryptographically verified against SHA-256 on retrieval. + - V16 (Logging): IP addresses are masked to subnet level (`/24` or `/48`) before storage; raw tokens and PII are never logged. +- **GDPR Art. 25 (Privacy by Design):** Cookieless signing routes prevent cross-site tracking; public URLs include `X-Robots-Tag: noindex, nofollow` to prevent search engine indexing. diff --git a/drizzle/0022_nifty_blazing_skull.sql b/drizzle/0022_nifty_blazing_skull.sql new file mode 100644 index 0000000..764841b --- /dev/null +++ b/drizzle/0022_nifty_blazing_skull.sql @@ -0,0 +1,81 @@ +CREATE TABLE "contract_templates" ( + "id" uuid PRIMARY KEY NOT NULL, + "name" varchar(150) NOT NULL, + "slug" varchar(100) NOT NULL, + "description" text, + "project_type_key" varchar(40), + "content_en" text NOT NULL, + "content_de" text NOT NULL, + "is_default" boolean DEFAULT false NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "contract_templates_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +CREATE TABLE "contracts" ( + "id" uuid PRIMARY KEY NOT NULL, + "project_id" uuid NOT NULL, + "client_id" uuid NOT NULL, + "proposal_id" uuid, + "template_id" uuid, + "contract_number" varchar(50) NOT NULL, + "title" varchar(200) NOT NULL, + "locale" varchar(10) DEFAULT 'en' NOT NULL, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "rendered_body" text NOT NULL, + "merge_values_snapshot" jsonb NOT NULL, + "canonical_body_sha256" varchar(64) NOT NULL, + "valid_until" timestamp with time zone, + "token_hash" varchar(64) NOT NULL, + "token_revoked_at" timestamp with time zone, + "sent_at" timestamp with time zone, + "reminded_at" timestamp with time zone, + "reminder_count" integer DEFAULT 0 NOT NULL, + "client_signed_at" timestamp with time zone, + "client_signer_name" varchar(200), + "client_signer_email" varchar(255), + "client_signature_type" varchar(20), + "client_signature_data" text, + "client_ip_anonymized" varchar(64), + "client_user_agent" varchar(255), + "studio_signed_at" timestamp with time zone, + "studio_signer_name" varchar(200), + "studio_signer_title" varchar(100), + "studio_signature_data" text, + "pdf_storage_key" varchar(255), + "pdf_sha256" varchar(64), + "pdf_byte_size" integer, + "pdf_generated_at" timestamp with time zone, + "internal_notes" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "contracts_contract_number_unique" UNIQUE("contract_number"), + CONSTRAINT "contracts_token_hash_unique" UNIQUE("token_hash"), + CONSTRAINT "contracts_status_check" CHECK (status IN ('draft', 'sent', 'signed_by_client', 'signed', 'declined', 'voided', 'superseded')), + CONSTRAINT "contracts_client_signature_type_check" CHECK (client_signature_type IS NULL OR client_signature_type IN ('drawn', 'typed')) +); +--> statement-breakpoint +ALTER TABLE "studio_settings" ADD COLUMN "contract_signatory_name" varchar(200);--> statement-breakpoint +ALTER TABLE "studio_settings" ADD COLUMN "contract_signatory_title" varchar(100);--> statement-breakpoint +ALTER TABLE "studio_settings" ADD COLUMN "contract_countersignature_mode" varchar(32) DEFAULT 'pre_signed' NOT NULL;--> statement-breakpoint +ALTER TABLE "studio_settings" ADD COLUMN "studio_address_line1" varchar(200);--> statement-breakpoint +ALTER TABLE "studio_settings" ADD COLUMN "studio_address_line2" varchar(200);--> statement-breakpoint +ALTER TABLE "studio_settings" ADD COLUMN "studio_postal_code" varchar(20);--> statement-breakpoint +ALTER TABLE "studio_settings" ADD COLUMN "studio_city" varchar(100);--> statement-breakpoint +ALTER TABLE "studio_settings" ADD COLUMN "studio_country_code" char(2) DEFAULT 'DE' NOT NULL;--> statement-breakpoint +ALTER TABLE "studio_settings" ADD COLUMN "studio_phone" varchar(50);--> statement-breakpoint +ALTER TABLE "studio_settings" ADD COLUMN "studio_email" varchar(255);--> statement-breakpoint +ALTER TABLE "contracts" ADD CONSTRAINT "contracts_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "contracts" ADD CONSTRAINT "contracts_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "contracts" ADD CONSTRAINT "contracts_proposal_id_proposals_id_fk" FOREIGN KEY ("proposal_id") REFERENCES "public"."proposals"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "contracts" ADD CONSTRAINT "contracts_template_id_contract_templates_id_fk" FOREIGN KEY ("template_id") REFERENCES "public"."contract_templates"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "contract_templates_slug_idx" ON "contract_templates" USING btree ("slug");--> statement-breakpoint +CREATE INDEX "contract_templates_project_type_key_idx" ON "contract_templates" USING btree ("project_type_key");--> statement-breakpoint +CREATE INDEX "contracts_project_id_idx" ON "contracts" USING btree ("project_id");--> statement-breakpoint +CREATE INDEX "contracts_client_id_idx" ON "contracts" USING btree ("client_id");--> statement-breakpoint +CREATE INDEX "contracts_proposal_id_idx" ON "contracts" USING btree ("proposal_id");--> statement-breakpoint +CREATE INDEX "contracts_template_id_idx" ON "contracts" USING btree ("template_id");--> statement-breakpoint +CREATE INDEX "contracts_token_hash_idx" ON "contracts" USING btree ("token_hash");--> statement-breakpoint +CREATE INDEX "contracts_status_idx" ON "contracts" USING btree ("status");--> statement-breakpoint +CREATE INDEX "contracts_contract_number_idx" ON "contracts" USING btree ("contract_number"); \ No newline at end of file diff --git a/drizzle/meta/0022_snapshot.json b/drizzle/meta/0022_snapshot.json new file mode 100644 index 0000000..2cd3007 --- /dev/null +++ b/drizzle/meta/0022_snapshot.json @@ -0,0 +1,6343 @@ +{ + "id": "b6f6c07e-909d-43e3-a72c-75bded747858", + "prevId": "b9df6c9d-de8a-4065-ad55-f4c422b639bc", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.studio_settings": { + "name": "studio_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "studio_name": { + "name": "studio_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "default_locale": { + "name": "default_locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'EUR'" + }, + "mfa_required": { + "name": "mfa_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "mfa_postponed_until": { + "name": "mfa_postponed_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "forms_pow_hmac_key": { + "name": "forms_pow_hmac_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_number": { + "name": "tax_number", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "vat_id": { + "name": "vat_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "is_kleinunternehmer": { + "name": "is_kleinunternehmer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "kleinunternehmer_notice": { + "name": "kleinunternehmer_notice", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_tax_rate_basis_points": { + "name": "default_tax_rate_basis_points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1900 + }, + "prices_include_tax": { + "name": "prices_include_tax", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "contract_signatory_name": { + "name": "contract_signatory_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "contract_signatory_title": { + "name": "contract_signatory_title", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "contract_countersignature_mode": { + "name": "contract_countersignature_mode", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'pre_signed'" + }, + "studio_address_line1": { + "name": "studio_address_line1", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "studio_address_line2": { + "name": "studio_address_line2", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "studio_postal_code": { + "name": "studio_postal_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "studio_city": { + "name": "studio_city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "studio_country_code": { + "name": "studio_country_code", + "type": "char(2)", + "primaryKey": false, + "notNull": true, + "default": "'DE'" + }, + "studio_phone": { + "name": "studio_phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "studio_email": { + "name": "studio_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "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, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_user_id_fk": { + "name": "api_keys_user_id_user_id_fk", + "tableFrom": "api_keys", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey_credential": { + "name": "passkey_credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "transports": { + "name": "transports", + "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()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "passkey_credential_user_id_user_id_fk": { + "name": "passkey_credential_user_id_user_id_fk", + "tableFrom": "passkey_credential", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credential_credential_id_unique": { + "name": "passkey_credential_credential_id_unique", + "nullsNotDistinct": false, + "columns": ["credential_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limits": { + "name": "rate_limits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rate_limits_key_unique": { + "name": "rate_limits_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recovery_code": { + "name": "recovery_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "salt": { + "name": "salt", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recovery_code_user_id_user_id_fk": { + "name": "recovery_code_user_id_user_id_fk", + "tableFrom": "recovery_code", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "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()" + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "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 + }, + "last_reauthenticated_at": { + "name": "last_reauthenticated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "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 + }, + "public.totp_credential": { + "name": "totp_credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "secret_encrypted": { + "name": "secret_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_used_step": { + "name": "last_used_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "totp_credential_user_id_user_id_fk": { + "name": "totp_credential_user_id_user_id_fk", + "tableFrom": "totp_credential", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "totp_credential_user_id_unique": { + "name": "totp_credential_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "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 + }, + "role": { + "name": "role", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'owner'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "varchar(255)", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_type": { + "name": "actor_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "audit_events_occurred_at_idx": { + "name": "audit_events_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_type_idx": { + "name": "audit_events_actor_type_idx", + "columns": [ + { + "expression": "actor_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_action_idx": { + "name": "audit_events_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "audit_events_actor_type_check": { + "name": "audit_events_actor_type_check", + "value": "actor_type IN ('owner', 'client', 'system', 'agent', 'token')" + }, + "audit_events_outcome_check": { + "name": "audit_events_outcome_check", + "value": "outcome IN ('success', 'failure', 'denied')" + } + }, + "isRLSEnabled": false + }, + "public.client_addresses": { + "name": "client_addresses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "line1": { + "name": "line1", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "line2": { + "name": "line2", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "region": { + "name": "region", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "char(2)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_addresses_client_id_idx": { + "name": "client_addresses_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_addresses_client_id_clients_id_fk": { + "name": "client_addresses_client_id_clients_id_fk", + "tableFrom": "client_addresses", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "client_addresses_client_id_type_unique": { + "name": "client_addresses_client_id_type_unique", + "nullsNotDistinct": false, + "columns": ["client_id", "type"] + } + }, + "policies": {}, + "checkConstraints": { + "client_addresses_type_check": { + "name": "client_addresses_type_check", + "value": "type IN ('postal', 'billing')" + } + }, + "isRLSEnabled": false + }, + "public.client_contacts": { + "name": "client_contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(254)", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "varchar(254)", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_contacts_client_id_idx": { + "name": "client_contacts_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "client_contacts_email_normalized_idx": { + "name": "client_contacts_email_normalized_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "client_contacts_client_id_primary_idx": { + "name": "client_contacts_client_id_primary_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_primary = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_contacts_client_id_clients_id_fk": { + "name": "client_contacts_client_id_clients_id_fk", + "tableFrom": "client_contacts", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "client_contacts_name_check": { + "name": "client_contacts_name_check", + "value": "(given_name IS NOT NULL AND length(trim(given_name)) > 0) OR (family_name IS NOT NULL AND length(trim(family_name)) > 0)" + } + }, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "preferred_locale": { + "name": "preferred_locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "google_contacts_sync_enabled": { + "name": "google_contacts_sync_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "clients_kind_check": { + "name": "clients_kind_check", + "value": "kind IN ('person', 'company')" + }, + "clients_preferred_locale_check": { + "name": "clients_preferred_locale_check", + "value": "preferred_locale IN ('en', 'de')" + } + }, + "isRLSEnabled": false + }, + "public.client_tags": { + "name": "client_tags", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_tags_tag_id_idx": { + "name": "client_tags_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_tags_client_id_clients_id_fk": { + "name": "client_tags_client_id_clients_id_fk", + "tableFrom": "client_tags", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_tags_tag_id_tags_id_fk": { + "name": "client_tags_tag_id_tags_id_fk", + "tableFrom": "client_tags", + "tableTo": "tags", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "client_tags_client_id_tag_id_pk": { + "name": "client_tags_client_id_tag_id_pk", + "columns": ["client_id", "tag_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "name_normalized": { + "name": "name_normalized", + "type": "varchar(50)", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_normalized_unique": { + "name": "tags_name_normalized_unique", + "nullsNotDistinct": false, + "columns": ["name_normalized"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_notes": { + "name": "client_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_notes_client_id_idx": { + "name": "client_notes_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_notes_client_id_clients_id_fk": { + "name": "client_notes_client_id_clients_id_fk", + "tableFrom": "client_notes", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "client_notes_body_len_check": { + "name": "client_notes_body_len_check", + "value": "char_length(body) >= 1 AND char_length(body) <= 5000" + } + }, + "isRLSEnabled": false + }, + "public.client_timeline_events": { + "name": "client_timeline_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "subject_id": { + "name": "subject_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "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": { + "client_timeline_events_client_occurred_idx": { + "name": "client_timeline_events_client_occurred_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_timeline_events_client_id_clients_id_fk": { + "name": "client_timeline_events_client_id_clients_id_fk", + "tableFrom": "client_timeline_events", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "client_timeline_events_actor_type_check": { + "name": "client_timeline_events_actor_type_check", + "value": "actor_type IN ('owner', 'client', 'system', 'agent', 'token')" + } + }, + "isRLSEnabled": false + }, + "public.consent_records": { + "name": "consent_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "effective_on": { + "name": "effective_on", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "evidence_note": { + "name": "evidence_note", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "recorded_by_actor_type": { + "name": "recorded_by_actor_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "recorded_by_actor_id": { + "name": "recorded_by_actor_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "consent_records_contact_purpose_idx": { + "name": "consent_records_contact_purpose_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purpose", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_on", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "consent_records_contact_id_client_contacts_id_fk": { + "name": "consent_records_contact_id_client_contacts_id_fk", + "tableFrom": "consent_records", + "tableTo": "client_contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "consent_records_purpose_check": { + "name": "consent_records_purpose_check", + "value": "purpose IN ('marketing', 'image_usage')" + }, + "consent_records_status_check": { + "name": "consent_records_status_check", + "value": "status IN ('granted', 'withdrawn')" + }, + "consent_records_method_check": { + "name": "consent_records_method_check", + "value": "method IN ('in_person', 'paper_form', 'email', 'phone', 'other')" + }, + "consent_records_actor_type_check": { + "name": "consent_records_actor_type_check", + "value": "recorded_by_actor_type IN ('owner', 'client', 'system', 'agent', 'token')" + } + }, + "isRLSEnabled": false + }, + "public.client_legal_holds": { + "name": "client_legal_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "until": { + "name": "until", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "set_by_actor_type": { + "name": "set_by_actor_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "set_by_actor_id": { + "name": "set_by_actor_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_legal_holds_client_id_idx": { + "name": "client_legal_holds_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_legal_holds_client_id_clients_id_fk": { + "name": "client_legal_holds_client_id_clients_id_fk", + "tableFrom": "client_legal_holds", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "client_legal_holds_client_id_unique": { + "name": "client_legal_holds_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": { + "client_legal_holds_reason_check": { + "name": "client_legal_holds_reason_check", + "value": "reason IN ('dispute', 'statutory', 'other')" + }, + "client_legal_holds_actor_type_check": { + "name": "client_legal_holds_actor_type_check", + "value": "set_by_actor_type IN ('owner', 'client', 'system', 'agent', 'token')" + } + }, + "isRLSEnabled": false + }, + "public.google_oauth_tokens": { + "name": "google_oauth_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'google'" + }, + "encrypted_refresh_token": { + "name": "encrypted_refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_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 + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "calendar_sync_enabled": { + "name": "calendar_sync_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "selected_calendar_id": { + "name": "selected_calendar_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "default": "'primary'" + }, + "last_calendar_sync_at": { + "name": "last_calendar_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_calendar_sync_error": { + "name": "last_calendar_sync_error", + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "google_oauth_tokens_provider_unique": { + "name": "google_oauth_tokens_provider_unique", + "nullsNotDistinct": false, + "columns": ["provider"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.google_contacts_links": { + "name": "google_contacts_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "google_resource_name": { + "name": "google_resource_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "google_etag": { + "name": "google_etag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_synced_at": { + "name": "last_synced_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": {}, + "foreignKeys": { + "google_contacts_links_contact_id_client_contacts_id_fk": { + "name": "google_contacts_links_contact_id_client_contacts_id_fk", + "tableFrom": "google_contacts_links", + "tableTo": "client_contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "google_contacts_links_contact_id_unique": { + "name": "google_contacts_links_contact_id_unique", + "nullsNotDistinct": false, + "columns": ["contact_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.google_contacts_outbox": { + "name": "google_contacts_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_error": { + "name": "last_error", + "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()" + } + }, + "indexes": { + "google_contacts_outbox_status_next_attempt_idx": { + "name": "google_contacts_outbox_status_next_attempt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "google_contacts_outbox_contact_id_client_contacts_id_fk": { + "name": "google_contacts_outbox_contact_id_client_contacts_id_fk", + "tableFrom": "google_contacts_outbox", + "tableTo": "client_contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "google_contacts_outbox_action_check": { + "name": "google_contacts_outbox_action_check", + "value": "action IN ('UPSERT', 'DELETE')" + }, + "google_contacts_outbox_status_check": { + "name": "google_contacts_outbox_status_check", + "value": "status IN ('PENDING', 'PROCESSING', 'FAILED')" + } + }, + "isRLSEnabled": false + }, + "public.inquiry_form_notice_versions": { + "name": "inquiry_form_notice_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "form_id": { + "name": "form_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "acknowledgement_text_en": { + "name": "acknowledgement_text_en", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "acknowledgement_text_de": { + "name": "acknowledgement_text_de", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "privacy_policy_url": { + "name": "privacy_policy_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "inquiry_form_notice_versions_form_id_idx": { + "name": "inquiry_form_notice_versions_form_id_idx", + "columns": [ + { + "expression": "form_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inquiry_form_notice_versions_form_id_inquiry_forms_id_fk": { + "name": "inquiry_form_notice_versions_form_id_inquiry_forms_id_fk", + "tableFrom": "inquiry_form_notice_versions", + "tableTo": "inquiry_forms", + "columnsFrom": ["form_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "inquiry_form_notice_versions_form_id_version_unique": { + "name": "inquiry_form_notice_versions_form_id_version_unique", + "nullsNotDistinct": false, + "columns": ["form_id", "version"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inquiry_forms": { + "name": "inquiry_forms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "default_locale": { + "name": "default_locale", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "event_types": { + "name": "event_types", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "current_notice_version_id": { + "name": "current_notice_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auto_reply_enabled": { + "name": "auto_reply_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "accept_unverified": { + "name": "accept_unverified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "success_redirect_url": { + "name": "success_redirect_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "inquiry_forms_current_notice_version_id_inquiry_form_notice_versions_id_fk": { + "name": "inquiry_forms_current_notice_version_id_inquiry_form_notice_versions_id_fk", + "tableFrom": "inquiry_forms", + "tableTo": "inquiry_form_notice_versions", + "columnsFrom": ["current_notice_version_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "inquiry_forms_slug_unique": { + "name": "inquiry_forms_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": { + "inquiry_forms_status_check": { + "name": "inquiry_forms_status_check", + "value": "status IN ('draft', 'published', 'archived')" + }, + "inquiry_forms_default_locale_check": { + "name": "inquiry_forms_default_locale_check", + "value": "default_locale IN ('en', 'de')" + } + }, + "isRLSEnabled": false + }, + "public.inquiry_pow_replays": { + "name": "inquiry_pow_replays", + "schema": "", + "columns": { + "signature_hash": { + "name": "signature_hash", + "type": "char(64)", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leads": { + "name": "leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "form_id": { + "name": "form_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "notice_version_id": { + "name": "notice_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "notice_acknowledged_at": { + "name": "notice_acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "verification": { + "name": "verification", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "delivery_mode": { + "name": "delivery_mode", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "event_type_key": { + "name": "event_type_key", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "event_date": { + "name": "event_date", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "event_location": { + "name": "event_location", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_reply_status": { + "name": "auto_reply_status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'not_applicable'" + }, + "converted_at": { + "name": "converted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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": { + "leads_status_received_at_idx": { + "name": "leads_status_received_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "received_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_email_normalized_idx": { + "name": "leads_email_normalized_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_client_id_idx": { + "name": "leads_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_last_activity_at_idx": { + "name": "leads_last_activity_at_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leads_client_id_clients_id_fk": { + "name": "leads_client_id_clients_id_fk", + "tableFrom": "leads", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "leads_form_id_inquiry_forms_id_fk": { + "name": "leads_form_id_inquiry_forms_id_fk", + "tableFrom": "leads", + "tableTo": "inquiry_forms", + "columnsFrom": ["form_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "leads_notice_version_id_inquiry_form_notice_versions_id_fk": { + "name": "leads_notice_version_id_inquiry_form_notice_versions_id_fk", + "tableFrom": "leads", + "tableTo": "inquiry_form_notice_versions", + "columnsFrom": ["notice_version_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "leads_status_check": { + "name": "leads_status_check", + "value": "status IN ('new', 'contacted', 'converted', 'archived', 'spam')" + }, + "leads_verification_check": { + "name": "leads_verification_check", + "value": "verification IN ('verified', 'unverified')" + }, + "leads_delivery_mode_check": { + "name": "leads_delivery_mode_check", + "value": "delivery_mode IN ('hosted', 'embed', 'script_free')" + }, + "leads_locale_check": { + "name": "leads_locale_check", + "value": "locale IN ('en', 'de')" + }, + "leads_auto_reply_status_check": { + "name": "leads_auto_reply_status_check", + "value": "auto_reply_status IN ('not_applicable', 'queued', 'sent', 'failed', 'suppressed', 'disabled', 'pending')" + }, + "leads_converted_client_id_check": { + "name": "leads_converted_client_id_check", + "value": "(status = 'converted') = (client_id IS NOT NULL)" + }, + "leads_converted_or_name_email_check": { + "name": "leads_converted_or_name_email_check", + "value": "status = 'converted' OR (given_name IS NOT NULL AND email IS NOT NULL)" + }, + "leads_message_length_check": { + "name": "leads_message_length_check", + "value": "message IS NULL OR length(message) <= 2000" + } + }, + "isRLSEnabled": false + }, + "public.email_outbox": { + "name": "email_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "proposal_id": { + "name": "proposal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "recipient": { + "name": "recipient", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_error": { + "name": "last_error", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_outbox_status_next_attempt_idx": { + "name": "email_outbox_status_next_attempt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_outbox_appointment_id_idx": { + "name": "email_outbox_appointment_id_idx", + "columns": [ + { + "expression": "appointment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_outbox_proposal_id_idx": { + "name": "email_outbox_proposal_id_idx", + "columns": [ + { + "expression": "proposal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_outbox_lead_id_leads_id_fk": { + "name": "email_outbox_lead_id_leads_id_fk", + "tableFrom": "email_outbox", + "tableTo": "leads", + "columnsFrom": ["lead_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_outbox_appointment_id_appointments_id_fk": { + "name": "email_outbox_appointment_id_appointments_id_fk", + "tableFrom": "email_outbox", + "tableTo": "appointments", + "columnsFrom": ["appointment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_outbox_proposal_id_proposals_id_fk": { + "name": "email_outbox_proposal_id_proposals_id_fk", + "tableFrom": "email_outbox", + "tableTo": "proposals", + "columnsFrom": ["proposal_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_outbox_kind_check": { + "name": "email_outbox_kind_check", + "value": "kind IN ('lead_auto_reply', 'appointment_confirmation', 'appointment_cancellation', 'proposal_sent', 'proposal_accepted_client', 'proposal_accepted_owner')" + }, + "email_outbox_status_check": { + "name": "email_outbox_status_check", + "value": "status IN ('PENDING', 'PROCESSING', 'FAILED')" + } + }, + "isRLSEnabled": false + }, + "public.pipeline_stages": { + "name": "pipeline_stages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'neutral'" + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pipeline_stages_key_unique": { + "name": "pipeline_stages_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + }, + "pipeline_stages_position_unique": { + "name": "pipeline_stages_position_unique", + "nullsNotDistinct": false, + "columns": ["position"] + } + }, + "policies": {}, + "checkConstraints": { + "pipeline_stages_category_check": { + "name": "pipeline_stages_category_check", + "value": "category IN ('inquiry', 'booked', 'in_progress', 'review', 'completed', 'archived')" + } + }, + "isRLSEnabled": false + }, + "public.project_participants": { + "name": "project_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notes": { + "name": "notes", + "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()" + } + }, + "indexes": { + "project_participants_project_id_idx": { + "name": "project_participants_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_participants_contact_id_idx": { + "name": "project_participants_contact_id_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_participants_project_id_projects_id_fk": { + "name": "project_participants_project_id_projects_id_fk", + "tableFrom": "project_participants", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_participants_contact_id_client_contacts_id_fk": { + "name": "project_participants_contact_id_client_contacts_id_fk", + "tableFrom": "project_participants", + "tableTo": "client_contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_participants_contact_or_name_check": { + "name": "project_participants_contact_or_name_check", + "value": "(contact_id IS NOT NULL) OR (name IS NOT NULL AND length(trim(name)) > 0)" + } + }, + "isRLSEnabled": false + }, + "public.project_tasks": { + "name": "project_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stage_id": { + "name": "stage_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_completed": { + "name": "is_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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": { + "project_tasks_project_id_idx": { + "name": "project_tasks_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_tasks_stage_id_idx": { + "name": "project_tasks_stage_id_idx", + "columns": [ + { + "expression": "stage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_tasks_project_id_projects_id_fk": { + "name": "project_tasks_project_id_projects_id_fk", + "tableFrom": "project_tasks", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_tasks_stage_id_pipeline_stages_id_fk": { + "name": "project_tasks_stage_id_pipeline_stages_id_fk", + "tableFrom": "project_tasks", + "tableTo": "pipeline_stages", + "columnsFrom": ["stage_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stage_id": { + "name": "stage_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "project_type_key": { + "name": "project_type_key", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "lead_id": { + "name": "lead_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "primary_shoot_starts_at": { + "name": "primary_shoot_starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "primary_shoot_timezone": { + "name": "primary_shoot_timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "char(3)", + "primaryKey": false, + "notNull": true, + "default": "'EUR'" + }, + "price_minor": { + "name": "price_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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": { + "projects_client_id_idx": { + "name": "projects_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_stage_id_idx": { + "name": "projects_stage_id_idx", + "columns": [ + { + "expression": "stage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_primary_shoot_starts_at_idx": { + "name": "projects_primary_shoot_starts_at_idx", + "columns": [ + { + "expression": "primary_shoot_starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_last_activity_at_idx": { + "name": "projects_last_activity_at_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_client_id_clients_id_fk": { + "name": "projects_client_id_clients_id_fk", + "tableFrom": "projects", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_stage_id_pipeline_stages_id_fk": { + "name": "projects_stage_id_pipeline_stages_id_fk", + "tableFrom": "projects", + "tableTo": "pipeline_stages", + "columnsFrom": ["stage_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "projects_lead_id_leads_id_fk": { + "name": "projects_lead_id_leads_id_fk", + "tableFrom": "projects", + "tableTo": "leads", + "columnsFrom": ["lead_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shoot_locations": { + "name": "shoot_locations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "shoot_id": { + "name": "shoot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "address_line1": { + "name": "address_line1", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "address_line2": { + "name": "address_line2", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "char(2)", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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": { + "shoot_locations_shoot_id_idx": { + "name": "shoot_locations_shoot_id_idx", + "columns": [ + { + "expression": "shoot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shoot_locations_shoot_id_shoots_id_fk": { + "name": "shoot_locations_shoot_id_shoots_id_fk", + "tableFrom": "shoot_locations", + "tableTo": "shoots", + "columnsFrom": ["shoot_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shoot_timeline_items": { + "name": "shoot_timeline_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "shoot_id": { + "name": "shoot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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": { + "shoot_timeline_items_shoot_id_idx": { + "name": "shoot_timeline_items_shoot_id_idx", + "columns": [ + { + "expression": "shoot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shoot_timeline_items_starts_at_idx": { + "name": "shoot_timeline_items_starts_at_idx", + "columns": [ + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shoot_timeline_items_shoot_id_shoots_id_fk": { + "name": "shoot_timeline_items_shoot_id_shoots_id_fk", + "tableFrom": "shoot_timeline_items", + "tableTo": "shoots", + "columnsFrom": ["shoot_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shoot_timeline_items_location_id_shoot_locations_id_fk": { + "name": "shoot_timeline_items_location_id_shoot_locations_id_fk", + "tableFrom": "shoot_timeline_items", + "tableTo": "shoot_locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shoots": { + "name": "shoots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "default": "'Shoot'" + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "'Europe/Berlin'" + }, + "notes": { + "name": "notes", + "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()" + } + }, + "indexes": { + "shoots_project_id_idx": { + "name": "shoots_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shoots_starts_at_idx": { + "name": "shoots_starts_at_idx", + "columns": [ + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shoots_project_id_projects_id_fk": { + "name": "shoots_project_id_projects_id_fk", + "tableFrom": "shoots", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stage_task_templates": { + "name": "stage_task_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "stage_id": { + "name": "stage_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_type_key": { + "name": "project_type_key", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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": { + "stage_task_templates_stage_id_idx": { + "name": "stage_task_templates_stage_id_idx", + "columns": [ + { + "expression": "stage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stage_task_templates_stage_id_pipeline_stages_id_fk": { + "name": "stage_task_templates_stage_id_pipeline_stages_id_fk", + "tableFrom": "stage_task_templates", + "tableTo": "pipeline_stages", + "columnsFrom": ["stage_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointments": { + "name": "appointments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "booking_type_id": { + "name": "booking_type_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lead_id": { + "name": "lead_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "'Europe/Berlin'" + }, + "attendee_name": { + "name": "attendee_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "attendee_email": { + "name": "attendee_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "attendee_phone": { + "name": "attendee_phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "attendee_notes": { + "name": "attendee_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location_type": { + "name": "location_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'google_meet'" + }, + "location_details": { + "name": "location_details", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "meet_url": { + "name": "meet_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "cancellation_reason": { + "name": "cancellation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "appointments_starts_at_idx": { + "name": "appointments_starts_at_idx", + "columns": [ + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ends_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "appointments_client_id_idx": { + "name": "appointments_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "appointments_project_id_idx": { + "name": "appointments_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "appointments_status_idx": { + "name": "appointments_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "appointments_booking_type_id_booking_types_id_fk": { + "name": "appointments_booking_type_id_booking_types_id_fk", + "tableFrom": "appointments", + "tableTo": "booking_types", + "columnsFrom": ["booking_type_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_client_id_clients_id_fk": { + "name": "appointments_client_id_clients_id_fk", + "tableFrom": "appointments", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_project_id_projects_id_fk": { + "name": "appointments_project_id_projects_id_fk", + "tableFrom": "appointments", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_lead_id_leads_id_fk": { + "name": "appointments_lead_id_leads_id_fk", + "tableFrom": "appointments", + "tableTo": "leads", + "columnsFrom": ["lead_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "appointments_location_type_check": { + "name": "appointments_location_type_check", + "value": "location_type IN ('in_person', 'phone', 'google_meet')" + }, + "appointments_status_check": { + "name": "appointments_status_check", + "value": "status IN ('scheduled', 'completed', 'cancelled', 'no_show')" + } + }, + "isRLSEnabled": false + }, + "public.availability_overrides": { + "name": "availability_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "date": { + "name": "date", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "end_time": { + "name": "end_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "is_unavailable": { + "name": "is_unavailable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reason": { + "name": "reason", + "type": "varchar(150)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "availability_overrides_date_start_time_unique": { + "name": "availability_overrides_date_start_time_unique", + "nullsNotDistinct": false, + "columns": ["date", "start_time"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.availability_schedules": { + "name": "availability_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "day_of_week": { + "name": "day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "availability_schedules_day_of_week_check": { + "name": "availability_schedules_day_of_week_check", + "value": "day_of_week >= 0 AND day_of_week <= 6" + } + }, + "isRLSEnabled": false + }, + "public.blocked_time": { + "name": "blocked_time", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "default": "'Blocked Time'" + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "all_day": { + "name": "all_day", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notes": { + "name": "notes", + "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()" + } + }, + "indexes": { + "blocked_time_starts_at_idx": { + "name": "blocked_time_starts_at_idx", + "columns": [ + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ends_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.booking_types": { + "name": "booking_types", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "buffer_before_minutes": { + "name": "buffer_before_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "buffer_after_minutes": { + "name": "buffer_after_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "min_notice_hours": { + "name": "min_notice_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "max_future_days": { + "name": "max_future_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "location_type": { + "name": "location_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'google_meet'" + }, + "location_details": { + "name": "location_details", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'indigo'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "booking_types_slug_unique": { + "name": "booking_types_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": { + "booking_types_location_type_check": { + "name": "booking_types_location_type_check", + "value": "location_type IN ('in_person', 'phone', 'google_meet')" + } + }, + "isRLSEnabled": false + }, + "public.caldav_accounts": { + "name": "caldav_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "server_url": { + "name": "server_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "calendar_home_url": { + "name": "calendar_home_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "selected_calendar_url": { + "name": "selected_calendar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "selected_calendar_name": { + "name": "selected_calendar_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "ctag": { + "name": "ctag", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "sync_token": { + "name": "sync_token", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.calendar_feeds": { + "name": "calendar_feeds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'Main Studio Feed'" + }, + "summary_level": { + "name": "summary_level", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "calendar_feeds_token_hash_unique": { + "name": "calendar_feeds_token_hash_unique", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": { + "calendar_feeds_summary_level_check": { + "name": "calendar_feeds_summary_level_check", + "value": "summary_level IN ('minimal', 'standard', 'full')" + } + }, + "isRLSEnabled": false + }, + "public.calendar_sync_links": { + "name": "calendar_sync_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_event_id": { + "name": "external_event_id", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "etag": { + "name": "etag", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "calendar_sync_links_provider_entity_unique": { + "name": "calendar_sync_links_provider_entity_unique", + "nullsNotDistinct": false, + "columns": ["provider", "entity_type", "entity_id"] + } + }, + "policies": {}, + "checkConstraints": { + "calendar_sync_links_provider_check": { + "name": "calendar_sync_links_provider_check", + "value": "provider IN ('google', 'caldav')" + }, + "calendar_sync_links_entity_type_check": { + "name": "calendar_sync_links_entity_type_check", + "value": "entity_type IN ('shoot', 'appointment', 'blocked_time')" + } + }, + "isRLSEnabled": false + }, + "public.calendar_sync_outbox": { + "name": "calendar_sync_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_error": { + "name": "last_error", + "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()" + } + }, + "indexes": { + "calendar_sync_outbox_status_next_attempt_idx": { + "name": "calendar_sync_outbox_status_next_attempt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "calendar_sync_outbox_entity_type_check": { + "name": "calendar_sync_outbox_entity_type_check", + "value": "entity_type IN ('shoot', 'appointment', 'blocked_time')" + }, + "calendar_sync_outbox_action_check": { + "name": "calendar_sync_outbox_action_check", + "value": "action IN ('create', 'update', 'delete')" + }, + "calendar_sync_outbox_status_check": { + "name": "calendar_sync_outbox_status_check", + "value": "status IN ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED')" + } + }, + "isRLSEnabled": false + }, + "public.external_calendar_busy_intervals": { + "name": "external_calendar_busy_intervals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "external_calendar_busy_intervals_starts_at_idx": { + "name": "external_calendar_busy_intervals_starts_at_idx", + "columns": [ + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ends_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "external_calendar_busy_intervals_provider_check": { + "name": "external_calendar_busy_intervals_provider_check", + "value": "provider IN ('google', 'caldav')" + } + }, + "isRLSEnabled": false + }, + "public.catalogue_items": { + "name": "catalogue_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'service'" + }, + "unit_price_minor": { + "name": "unit_price_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "char(3)", + "primaryKey": false, + "notNull": true, + "default": "'EUR'" + }, + "tax_rate_basis_points": { + "name": "tax_rate_basis_points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1900 + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "catalogue_items_is_active_idx": { + "name": "catalogue_items_is_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "catalogue_items_category_idx": { + "name": "catalogue_items_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "catalogue_items_position_idx": { + "name": "catalogue_items_position_idx", + "columns": [ + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "catalogue_items_slug_unique": { + "name": "catalogue_items_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": { + "catalogue_items_category_check": { + "name": "catalogue_items_category_check", + "value": "category IN ('service', 'product', 'travel', 'licensing')" + } + }, + "isRLSEnabled": false + }, + "public.package_included_items": { + "name": "package_included_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "package_included_items_package_id_idx": { + "name": "package_included_items_package_id_idx", + "columns": [ + { + "expression": "package_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "package_included_items_position_idx": { + "name": "package_included_items_position_idx", + "columns": [ + { + "expression": "package_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "package_included_items_package_id_packages_id_fk": { + "name": "package_included_items_package_id_packages_id_fk", + "tableFrom": "package_included_items", + "tableTo": "packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.packages": { + "name": "packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_type_key": { + "name": "project_type_key", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "base_price_minor": { + "name": "base_price_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "char(3)", + "primaryKey": false, + "notNull": true, + "default": "'EUR'" + }, + "tax_rate_basis_points": { + "name": "tax_rate_basis_points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1900 + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "packages_is_active_idx": { + "name": "packages_is_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "packages_position_idx": { + "name": "packages_position_idx", + "columns": [ + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "packages_project_type_key_idx": { + "name": "packages_project_type_key_idx", + "columns": [ + { + "expression": "project_type_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "packages_slug_unique": { + "name": "packages_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.proposal_line_items": { + "name": "proposal_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "proposal_id": { + "name": "proposal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "catalogue_item_id": { + "name": "catalogue_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "item_type": { + "name": "item_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'addon'" + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit_price_minor": { + "name": "unit_price_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "min_quantity": { + "name": "min_quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_quantity": { + "name": "max_quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "tax_rate_basis_points": { + "name": "tax_rate_basis_points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1900 + }, + "is_optional": { + "name": "is_optional", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_selected": { + "name": "is_selected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "proposal_line_items_proposal_position_idx": { + "name": "proposal_line_items_proposal_position_idx", + "columns": [ + { + "expression": "proposal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proposal_line_items_proposal_id_proposals_id_fk": { + "name": "proposal_line_items_proposal_id_proposals_id_fk", + "tableFrom": "proposal_line_items", + "tableTo": "proposals", + "columnsFrom": ["proposal_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proposal_line_items_package_id_packages_id_fk": { + "name": "proposal_line_items_package_id_packages_id_fk", + "tableFrom": "proposal_line_items", + "tableTo": "packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "proposal_line_items_catalogue_item_id_catalogue_items_id_fk": { + "name": "proposal_line_items_catalogue_item_id_catalogue_items_id_fk", + "tableFrom": "proposal_line_items", + "tableTo": "catalogue_items", + "columnsFrom": ["catalogue_item_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proposal_line_items_item_type_check": { + "name": "proposal_line_items_item_type_check", + "value": "item_type IN ('base_package', 'addon', 'custom')" + } + }, + "isRLSEnabled": false + }, + "public.proposals": { + "name": "proposals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "currency": { + "name": "currency", + "type": "char(3)", + "primaryKey": false, + "notNull": true, + "default": "'EUR'" + }, + "subtotal_minor": { + "name": "subtotal_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tax_minor": { + "name": "tax_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_minor": { + "name": "total_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "prices_include_tax": { + "name": "prices_include_tax", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_kleinunternehmer": { + "name": "is_kleinunternehmer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tax_notice": { + "name": "tax_notice", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "token_revoked_at": { + "name": "token_revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "declined_at": { + "name": "declined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decline_reason": { + "name": "decline_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accepted_by_name": { + "name": "accepted_by_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "accepted_by_email": { + "name": "accepted_by_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "acceptance_ip_anonymized": { + "name": "acceptance_ip_anonymized", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "acceptance_user_agent": { + "name": "acceptance_user_agent", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "accepted_options_snapshot": { + "name": "accepted_options_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "internal_notes": { + "name": "internal_notes", + "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()" + } + }, + "indexes": { + "proposals_project_id_idx": { + "name": "proposals_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proposals_client_id_idx": { + "name": "proposals_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proposals_token_hash_idx": { + "name": "proposals_token_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proposals_status_idx": { + "name": "proposals_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proposals_project_id_projects_id_fk": { + "name": "proposals_project_id_projects_id_fk", + "tableFrom": "proposals", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proposals_client_id_clients_id_fk": { + "name": "proposals_client_id_clients_id_fk", + "tableFrom": "proposals", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "proposals_token_hash_unique": { + "name": "proposals_token_hash_unique", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": { + "proposals_status_check": { + "name": "proposals_status_check", + "value": "status IN ('draft', 'sent', 'accepted', 'declined', 'expired', 'superseded')" + } + }, + "isRLSEnabled": false + }, + "public.contract_templates": { + "name": "contract_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_type_key": { + "name": "project_type_key", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "content_en": { + "name": "content_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_de": { + "name": "content_de", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "contract_templates_slug_idx": { + "name": "contract_templates_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contract_templates_project_type_key_idx": { + "name": "contract_templates_project_type_key_idx", + "columns": [ + { + "expression": "project_type_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "contract_templates_slug_unique": { + "name": "contract_templates_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contracts": { + "name": "contracts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "proposal_id": { + "name": "proposal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "contract_number": { + "name": "contract_number", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "rendered_body": { + "name": "rendered_body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "merge_values_snapshot": { + "name": "merge_values_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "canonical_body_sha256": { + "name": "canonical_body_sha256", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "token_revoked_at": { + "name": "token_revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reminded_at": { + "name": "reminded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reminder_count": { + "name": "reminder_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "client_signed_at": { + "name": "client_signed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "client_signer_name": { + "name": "client_signer_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "client_signer_email": { + "name": "client_signer_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "client_signature_type": { + "name": "client_signature_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "client_signature_data": { + "name": "client_signature_data", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_ip_anonymized": { + "name": "client_ip_anonymized", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "client_user_agent": { + "name": "client_user_agent", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "studio_signed_at": { + "name": "studio_signed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "studio_signer_name": { + "name": "studio_signer_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "studio_signer_title": { + "name": "studio_signer_title", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "studio_signature_data": { + "name": "studio_signature_data", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pdf_storage_key": { + "name": "pdf_storage_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "pdf_sha256": { + "name": "pdf_sha256", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "pdf_byte_size": { + "name": "pdf_byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pdf_generated_at": { + "name": "pdf_generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "internal_notes": { + "name": "internal_notes", + "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()" + } + }, + "indexes": { + "contracts_project_id_idx": { + "name": "contracts_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contracts_client_id_idx": { + "name": "contracts_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contracts_proposal_id_idx": { + "name": "contracts_proposal_id_idx", + "columns": [ + { + "expression": "proposal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contracts_template_id_idx": { + "name": "contracts_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contracts_token_hash_idx": { + "name": "contracts_token_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contracts_status_idx": { + "name": "contracts_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contracts_contract_number_idx": { + "name": "contracts_contract_number_idx", + "columns": [ + { + "expression": "contract_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contracts_project_id_projects_id_fk": { + "name": "contracts_project_id_projects_id_fk", + "tableFrom": "contracts", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contracts_client_id_clients_id_fk": { + "name": "contracts_client_id_clients_id_fk", + "tableFrom": "contracts", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "contracts_proposal_id_proposals_id_fk": { + "name": "contracts_proposal_id_proposals_id_fk", + "tableFrom": "contracts", + "tableTo": "proposals", + "columnsFrom": ["proposal_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "contracts_template_id_contract_templates_id_fk": { + "name": "contracts_template_id_contract_templates_id_fk", + "tableFrom": "contracts", + "tableTo": "contract_templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "contracts_contract_number_unique": { + "name": "contracts_contract_number_unique", + "nullsNotDistinct": false, + "columns": ["contract_number"] + }, + "contracts_token_hash_unique": { + "name": "contracts_token_hash_unique", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": { + "contracts_status_check": { + "name": "contracts_status_check", + "value": "status IN ('draft', 'sent', 'signed_by_client', 'signed', 'declined', 'voided', 'superseded')" + }, + "contracts_client_signature_type_check": { + "name": "contracts_client_signature_type_check", + "value": "client_signature_type IS NULL OR client_signature_type IN ('drawn', 'typed')" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 4e68f44..843c2f5 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1793190483366, "tag": "0021_motionless_cerebro", "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1793997391366, + "tag": "0022_nifty_blazing_skull", + "breakpoints": true } ] } diff --git a/messages/de.json b/messages/de.json index 43ad791..ec9b268 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1089,6 +1089,10 @@ "proposal_sent": "Angebot gesendet", "proposal_accepted": "Angebot angenommen", "proposal_declined": "Angebot abgelehnt", + "contract_sent": "Vertrag gesendet", + "contract_signed_by_client": "Vertrag von Kund:in unterzeichnet", + "contract_countersigned": "Vertrag gegengezeichnet", + "contract_voided": "Vertrag storniert", "deleted_item": "Gelöschter Eintrag" }, "note": { diff --git a/messages/en.json b/messages/en.json index d6212cb..bd05945 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1089,6 +1089,10 @@ "proposal_sent": "Proposal sent", "proposal_accepted": "Proposal accepted", "proposal_declined": "Proposal declined", + "contract_sent": "Contract sent", + "contract_signed_by_client": "Contract signed by client", + "contract_countersigned": "Contract countersigned", + "contract_voided": "Contract voided", "deleted_item": "Deleted item" }, "note": { diff --git a/src/server/audit/allowlist.ts b/src/server/audit/allowlist.ts index 9e6cd66..c5dfa98 100644 --- a/src/server/audit/allowlist.ts +++ b/src/server/audit/allowlist.ts @@ -226,6 +226,65 @@ export const ACTION_METADATA_ALLOWLIST = new Map([ ['proposal_id', 'project_id', 'client_id', 'total_minor', 'currency', 'has_decline_reason'], ], ['proposal.revoked', ['proposal_id', 'project_id', 'client_id', 'reason']], + ['contract_template.created', ['template_id', 'slug', 'name', 'project_type_key', 'is_default']], + ['contract_template.updated', ['template_id', 'slug', 'name', 'changed_fields']], + ['contract_template.deleted', ['template_id', 'slug']], + [ + 'contract.created', + [ + 'contract_id', + 'project_id', + 'client_id', + 'contract_number', + 'title', + 'locale', + 'status', + 'template_id', + ], + ], + [ + 'contract.updated', + [ + 'contract_id', + 'project_id', + 'client_id', + 'contract_number', + 'title', + 'status', + 'changed_fields', + ], + ], + ['contract.sent', ['contract_id', 'project_id', 'client_id', 'contract_number', 'valid_until']], + [ + 'contract.signed_by_client', + ['contract_id', 'project_id', 'client_id', 'contract_number', 'signer_name', 'signature_type'], + ], + [ + 'contract.countersigned', + ['contract_id', 'project_id', 'client_id', 'contract_number', 'signer_name'], + ], + ['contract.voided', ['contract_id', 'project_id', 'client_id', 'contract_number', 'reason']], + ['contract.revoked', ['contract_id', 'project_id', 'client_id', 'contract_number', 'reason']], + [ + 'contract.reminded', + ['contract_id', 'project_id', 'client_id', 'contract_number', 'reminder_count'], + ], + [ + 'studio_settings.contract_updated', + [ + 'changed_fields', + 'contract_signatory_name', + 'contract_signatory_title', + 'contract_countersignature_mode', + 'studio_city', + 'studio_country_code', + 'studio_address_line1', + 'studio_address_line2', + 'studio_postal_code', + 'studio_phone', + 'studio_email', + ], + ], ]); /** diff --git a/src/server/contracts/merge-engine.ts b/src/server/contracts/merge-engine.ts new file mode 100644 index 0000000..39e7f43 --- /dev/null +++ b/src/server/contracts/merge-engine.ts @@ -0,0 +1,298 @@ +import { createHash } from 'node:crypto'; + +/** + * All officially supported and allowlisted merge tokens across 6 namespaces (ADR-0019 §2.1). + */ +export const ALLOWLISTED_MERGE_TOKENS = [ + // client namespace + 'client.name', + 'client.company', + 'client.email', + 'client.phone', + 'client.address', + 'client.city', + 'client.postal_code', + 'client.country', + + // project namespace + 'project.title', + 'project.type', + 'project.price', + 'project.currency', + 'project.notes', + + // shoot namespace + 'shoot.date', + 'shoot.time', + 'shoot.timezone', + 'shoot.location', + 'shoot.address', + + // package namespace + 'package.name', + 'package.deliverables', + 'package.total', + 'package.subtotal', + 'package.tax', + 'package.addons', + + // studio namespace + 'studio.name', + 'studio.owner_name', + 'studio.email', + 'studio.phone', + 'studio.address', + 'studio.tax_number', + 'studio.vat_id', + + // contract namespace + 'contract.number', + 'contract.issue_date', + 'contract.valid_until', +] as const; + +export type AllowlistedMergeToken = (typeof ALLOWLISTED_MERGE_TOKENS)[number]; +export const ALLOWLISTED_TOKENS_SET = new Set(ALLOWLISTED_MERGE_TOKENS); + +/** + * Structured input for contract merge resolution. + */ +export interface ContractMergeData { + client?: { + name?: string | null; + company?: string | null; + email?: string | null; + phone?: string | null; + address?: string | null; + city?: string | null; + postal_code?: string | null; + country?: string | null; + }; + project?: { + title?: string | null; + type?: string | null; + price?: string | null; + currency?: string | null; + notes?: string | null; + }; + shoot?: { + date?: string | null; + time?: string | null; + timezone?: string | null; + location?: string | null; + address?: string | null; + }; + package?: { + name?: string | null; + deliverables?: string | null; + total?: string | null; + subtotal?: string | null; + tax?: string | null; + addons?: string | null; + }; + studio?: { + name?: string | null; + owner_name?: string | null; + email?: string | null; + phone?: string | null; + address?: string | null; + tax_number?: string | null; + vat_id?: string | null; + }; + contract?: { + number?: string | null; + issue_date?: string | null; + valid_until?: string | null; + }; + [key: string]: unknown; +} + +export interface ResolveMergeFieldsOptions { + /** If true, unmapped tokens are left in place as {{token}} rather than safely cleared. Default: false */ + keepUnmappedTokens?: boolean; +} + +export interface ResolveMergeFieldsResult { + renderedText: string; + evaluatedValues: Record; + unmappedTokens: string[]; +} + +/** + * Strict HTML special character escaping (prevents XSS when interpolating client data). + */ +export function escapeHtml(str: string): string { + return str + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +/** + * Strips ASCII control characters (0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F, 0x7F) + * preserving standard whitespace: \t (0x09), \n (0x0A), and \r (0x0D). + */ +export function stripControlChars(str: string): string { + return str.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); +} + +/** + * Sanitizes an interpolated merge field: removes control chars and HTML-escapes. + */ +export function sanitizeMergeValue(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'string') { + return escapeHtml(stripControlChars(value)); + } + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return escapeHtml(stripControlChars(String(value))); + } + return ''; +} + +/** + * Flattens nested ContractMergeData or flat key-value pairs into a normalized Map. + */ +function extractFlatValues( + data?: ContractMergeData | Record | null, +): Map { + const map = new Map(); + if (!data || typeof data !== 'object') { + return map; + } + + for (const [key, val] of Object.entries(data)) { + if (val !== null && typeof val === 'object' && !Array.isArray(val) && !(val instanceof Date)) { + for (const [subKey, subVal] of Object.entries(val as Record)) { + map.set(`${key}.${subKey}`, subVal); + } + } else { + map.set(key, val); + } + } + + return map; +} + +/** + * Regex matching any merge token formatted as {{ ... }}. + */ +const TOKEN_MATCH_REGEX = /\{\{\s*([^}]+?)\s*\}\}/g; + +/** + * Deterministically resolves allowlisted merge tokens on template text (AC-3). + * Dynamic values are strictly sanitized and HTML-escaped. + * Unmapped tokens are cleanly reported and safely cleared (unless keepUnmappedTokens is true). + */ +export function resolveContractMergeFields( + templateText: string, + mergeData?: ContractMergeData | Record | null, + options?: ResolveMergeFieldsOptions, +): ResolveMergeFieldsResult { + const flatData = extractFlatValues(mergeData); + const evaluatedValues: Record = {}; + const unmappedSet = new Set(); + + const keepUnmapped = options?.keepUnmappedTokens ?? false; + + const renderedText = templateText.replace(TOKEN_MATCH_REGEX, (fullMatch, rawToken: string) => { + const token = rawToken.trim(); + + if (!ALLOWLISTED_TOKENS_SET.has(token)) { + unmappedSet.add(token); + return keepUnmapped ? fullMatch : ''; + } + + if (flatData.has(token)) { + const rawVal = flatData.get(token); + if (rawVal === null || rawVal === undefined || rawVal === '') { + evaluatedValues[token] = ''; + return ''; + } + const sanitized = sanitizeMergeValue(rawVal); + evaluatedValues[token] = sanitized; + return sanitized; + } + + // Allowlisted token is present in template but no value was provided + unmappedSet.add(token); + evaluatedValues[token] = ''; + return keepUnmapped ? fullMatch : ''; + }); + + return { + renderedText, + evaluatedValues, + unmappedTokens: Array.from(unmappedSet), + }; +} + +/** + * Normalizes rendered contract body into canonical UTF-8: + * Unicode NFC normalization and LF line-ending normalization. + */ +export function normalizeRenderedBody(body: string): string { + return body.normalize('NFC').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +/** + * Computes deterministic SHA-256 seal from canonical UTF-8 body. + */ +export function computeCanonicalSha256(body: string): string { + const normalized = normalizeRenderedBody(body); + return createHash('sha256').update(normalized, 'utf8').digest('hex'); +} + +export interface ContractSnapshot { + rendered_body: string; + renderedBody: string; + merge_values_snapshot: Record; + mergeValuesSnapshot: Record; + canonical_body_sha256: string; + canonicalBodySha256: string; +} + +/** + * Freezes a contract snapshot into an immutable record with canonical SHA-256 hash (AC-4). + */ +export function freezeContractSnapshot( + renderedBodyOrOptions: + | string + | { + renderedBody?: string; + rendered_body?: string; + mergeValuesSnapshot?: Record; + merge_values_snapshot?: Record; + }, + mergeValuesSnapshotArg?: Record, +): ContractSnapshot { + let body: string; + let values: Record; + + if (typeof renderedBodyOrOptions === 'string') { + body = renderedBodyOrOptions; + values = mergeValuesSnapshotArg ?? {}; + } else { + body = renderedBodyOrOptions.renderedBody ?? renderedBodyOrOptions.rendered_body ?? ''; + values = + renderedBodyOrOptions.mergeValuesSnapshot ?? + renderedBodyOrOptions.merge_values_snapshot ?? + mergeValuesSnapshotArg ?? + {}; + } + + const hash = computeCanonicalSha256(body); + + return { + rendered_body: body, + renderedBody: body, + merge_values_snapshot: values, + mergeValuesSnapshot: values, + canonical_body_sha256: hash, + canonicalBodySha256: hash, + }; +} diff --git a/src/server/contracts/repo.ts b/src/server/contracts/repo.ts new file mode 100644 index 0000000..8a8992a --- /dev/null +++ b/src/server/contracts/repo.ts @@ -0,0 +1,443 @@ +import { randomBytes, createHash } from 'node:crypto'; +import { eq, desc, like } from 'drizzle-orm'; +import type { DbClient } from '@/server/db/client'; +import { contracts, type Contract, type NewContract } from '@/server/db/schema/contracts'; +import { projects } from '@/server/db/schema/projects'; +import { contractTemplates } from '@/server/db/schema/contracts'; +import { proposals } from '@/server/db/schema/proposals'; +import { audit } from '@/server/audit'; +import { resolveRepoAuth, type RepoAuthInput } from '@/server/auth/repo-auth'; +import { recordTimelineEvent } from '@/server/timeline/service'; +import { freezeContractSnapshot, computeCanonicalSha256 } from './merge-engine'; +import { + createContractDraftSchema, + updateContractDraftSchema, + voidContractSchema, + type CreateContractDraftInput, + type UpdateContractDraftInput, +} from './schema'; + +export class ContractNotFoundError extends Error { + public readonly code = 'NOT_FOUND'; + public readonly statusCode = 404; + + constructor(message = 'Contract not found') { + super(message); + this.name = 'ContractNotFoundError'; + } +} + +export class ContractForbiddenError extends Error { + public readonly code = 'FORBIDDEN'; + public readonly statusCode = 403; + + constructor(message = 'FORBIDDEN') { + super(message); + this.name = 'ContractForbiddenError'; + } +} + +export class ContractImmutableError extends Error { + public readonly code = 'IMMUTABLE'; + public readonly statusCode = 400; + + constructor(message = 'Contract cannot be modified because it is no longer in draft status') { + super(message); + this.name = 'ContractImmutableError'; + } +} + +export class ContractStateTransitionError extends Error { + public readonly code = 'INVALID_TRANSITION'; + public readonly statusCode = 400; + + constructor(message = 'Invalid contract state transition') { + super(message); + this.name = 'ContractStateTransitionError'; + } +} + +export interface CreateContractDraftResult extends Contract { + rawToken: string; +} + +/** + * Computes next sequential contract number for given year (e.g. CTR-2026-0001). + */ +export async function generateNextContractNumber( + client: DbClient, + year = new Date().getFullYear(), +): Promise { + const prefix = `CTR-${String(year)}-`; + const existingContracts = await client + .select({ contractNumber: contracts.contractNumber }) + .from(contracts) + .where(like(contracts.contractNumber, `${prefix}%`)); + + let maxSeq = 0; + for (const c of existingContracts) { + const parts = c.contractNumber.split('-'); + const seqPart = parts[2]; + if (parts.length === 3 && seqPart) { + const num = parseInt(seqPart, 10); + if (!Number.isNaN(num) && num > maxSeq) { + maxSeq = num; + } + } + } + + const nextSeq = maxSeq + 1; + return `${prefix}${String(nextSeq).padStart(4, '0')}`; +} + +/** + * Lists contracts for a specific project. + * Strictly requires authenticated studio owner (AC-7). + */ +export async function listContractsForProject( + projectId: string, + authOrOptions?: RepoAuthInput, + explicitClient?: DbClient, +): Promise { + const { client } = await resolveRepoAuth(authOrOptions, explicitClient); + + return client + .select() + .from(contracts) + .where(eq(contracts.projectId, projectId)) + .orderBy(desc(contracts.createdAt)); +} + +/** + * Retrieves a contract by UUID. + * Strictly requires authenticated studio owner (AC-7). + */ +export async function getContractById( + id: string, + authOrOptions?: RepoAuthInput, + explicitClient?: DbClient, +): Promise { + const { client } = await resolveRepoAuth(authOrOptions, explicitClient); + + const [contract] = await client.select().from(contracts).where(eq(contracts.id, id)).limit(1); + + if (!contract) { + throw new ContractNotFoundError(`Contract not found: ${id}`); + } + + return contract; +} + +/** + * Retrieves a contract by public token hash (for public signing route). + * Does not require owner authorization, but returns 404 if not found. + */ +export async function getContractByTokenHash( + tokenHash: string, + explicitClient?: DbClient, +): Promise { + const client = explicitClient ?? (await resolveRepoAuth(null, explicitClient)).client; + + const [contract] = await client + .select() + .from(contracts) + .where(eq(contracts.tokenHash, tokenHash)) + .limit(1); + + if (!contract) { + throw new ContractNotFoundError('Contract not found for token hash'); + } + + return contract; +} + +/** + * Creates a new contract draft with sequential CTR-YYYY-NNNN identifier (AC-5). + * Validates project exists and belongs to client. + * Strictly requires authenticated studio owner (AC-7). + */ +export async function createContractDraft( + rawInput: CreateContractDraftInput, + authOrOptions?: RepoAuthInput, + explicitClient?: DbClient, +): Promise { + const { auth, client } = await resolveRepoAuth(authOrOptions, explicitClient); + const validated = createContractDraftSchema.parse(rawInput); + + // 1. Verify project exists and matches clientId (AC-8) + const [project] = await client + .select() + .from(projects) + .where(eq(projects.id, validated.projectId)) + .limit(1); + + if (!project) { + throw new ContractForbiddenError('Project does not exist or access is forbidden'); + } + + if (project.clientId !== validated.clientId) { + throw new ContractForbiddenError('FORBIDDEN: Project does not belong to the specified client'); + } + + // 2. If templateId provided, verify template exists + let templateBody = ''; + if (validated.templateId) { + const [tpl] = await client + .select() + .from(contractTemplates) + .where(eq(contractTemplates.id, validated.templateId)) + .limit(1); + + if (!tpl) { + throw new ContractNotFoundError(`Contract template not found: ${validated.templateId}`); + } + templateBody = validated.locale === 'de' ? tpl.contentDe : tpl.contentEn; + } + + // 3. If proposalId provided, verify proposal exists + if (validated.proposalId) { + const [prop] = await client + .select({ id: proposals.id }) + .from(proposals) + .where(eq(proposals.id, validated.proposalId)) + .limit(1); + + if (!prop) { + throw new ContractNotFoundError(`Proposal not found: ${validated.proposalId}`); + } + } + + // 4. Determine body text & snapshot + const bodyText = validated.renderedBody ?? templateBody; + const snapshot = freezeContractSnapshot(bodyText, validated.mergeValuesSnapshot ?? {}); + + // 5. Generate sequential contract number + const contractNumber = await generateNextContractNumber(client); + + // 6. Generate secret 32-byte signing token + const rawToken = randomBytes(32).toString('hex'); + const tokenHash = createHash('sha256').update(rawToken).digest('hex'); + + // 7. Insert draft contract + const [created] = await client + .insert(contracts) + .values({ + projectId: validated.projectId, + clientId: validated.clientId, + proposalId: validated.proposalId ?? null, + templateId: validated.templateId ?? null, + contractNumber, + title: validated.title, + locale: validated.locale, + status: 'draft', + renderedBody: snapshot.rendered_body, + mergeValuesSnapshot: snapshot.merge_values_snapshot, + canonicalBodySha256: snapshot.canonical_body_sha256, + validUntil: validated.validUntil ?? null, + tokenHash, + internalNotes: validated.internalNotes ?? null, + }) + .returning(); + + if (!created) { + throw new Error('Failed to create contract draft'); + } + + // 8. Record audit log + await audit( + { + actorType: auth.authType === 'apiKey' ? 'token' : 'owner', + actorId: auth.user.id, + action: 'contract.created', + targetType: 'contract', + targetId: created.id, + outcome: 'success', + metadata: { + contract_id: created.id, + project_id: created.projectId, + client_id: created.clientId, + contract_number: created.contractNumber, + title: created.title, + locale: created.locale, + status: created.status, + template_id: created.templateId ?? undefined, + }, + }, + client, + ); + + return { + ...created, + rawToken, + }; +} + +/** + * Updates an editable contract draft. + * Fails with ContractImmutableError if status is not 'draft'. + * Strictly requires authenticated studio owner (AC-7). + */ +export async function updateContractDraft( + id: string, + rawInput: UpdateContractDraftInput, + authOrOptions?: RepoAuthInput, + explicitClient?: DbClient, +): Promise { + const { auth, client } = await resolveRepoAuth(authOrOptions, explicitClient); + const validated = updateContractDraftSchema.parse(rawInput); + + const existing = await getContractById(id, auth, client); + + if (existing.status !== 'draft') { + throw new ContractImmutableError( + `Contract cannot be modified: current status is '${existing.status}'`, + ); + } + + const now = new Date(); + const updatePayload: Partial = { + updatedAt: now, + }; + const changedFields: string[] = []; + + if (validated.title !== undefined) { + updatePayload.title = validated.title; + changedFields.push('title'); + } + if (validated.locale !== undefined) { + updatePayload.locale = validated.locale; + changedFields.push('locale'); + } + if (validated.templateId !== undefined) { + updatePayload.templateId = validated.templateId; + changedFields.push('templateId'); + } + if (validated.proposalId !== undefined) { + updatePayload.proposalId = validated.proposalId; + changedFields.push('proposalId'); + } + if (validated.validUntil !== undefined) { + updatePayload.validUntil = validated.validUntil; + changedFields.push('validUntil'); + } + if (validated.internalNotes !== undefined) { + updatePayload.internalNotes = validated.internalNotes; + changedFields.push('internalNotes'); + } + if (validated.mergeValuesSnapshot !== undefined) { + updatePayload.mergeValuesSnapshot = validated.mergeValuesSnapshot; + changedFields.push('mergeValuesSnapshot'); + } + if (validated.renderedBody !== undefined) { + updatePayload.renderedBody = validated.renderedBody; + updatePayload.canonicalBodySha256 = computeCanonicalSha256(validated.renderedBody); + changedFields.push('renderedBody'); + changedFields.push('canonicalBodySha256'); + } + + const [updated] = await client + .update(contracts) + .set(updatePayload) + .where(eq(contracts.id, id)) + .returning(); + + if (!updated) { + throw new ContractNotFoundError(`Contract not found: ${id}`); + } + + // Record audit log + await audit( + { + actorType: auth.authType === 'apiKey' ? 'token' : 'owner', + actorId: auth.user.id, + action: 'contract.updated', + targetType: 'contract', + targetId: updated.id, + outcome: 'success', + metadata: { + contract_id: updated.id, + project_id: updated.projectId, + client_id: updated.clientId, + contract_number: updated.contractNumber, + title: updated.title, + status: updated.status, + changed_fields: changedFields, + }, + }, + client, + ); + + return updated; +} + +/** + * Voids an existing contract, revoking its signing token and recording timeline and audit events. + * Strictly requires authenticated studio owner (AC-7). + */ +export async function voidContract( + id: string, + reason?: string | null, + authOrOptions?: RepoAuthInput, + explicitClient?: DbClient, +): Promise { + const { auth, client } = await resolveRepoAuth(authOrOptions, explicitClient); + const validated = voidContractSchema.parse({ reason }); + + const existing = await getContractById(id, auth, client); + + if (existing.status === 'voided') { + return existing; + } + + const now = new Date(); + const [updated] = await client + .update(contracts) + .set({ + status: 'voided', + tokenRevokedAt: now, + updatedAt: now, + }) + .where(eq(contracts.id, id)) + .returning(); + + if (!updated) { + throw new ContractNotFoundError(`Contract not found: ${id}`); + } + + // Record timeline event + await recordTimelineEvent(client, { + clientId: updated.clientId, + type: 'contract.voided', + occurredAt: now, + actorType: auth.authType === 'apiKey' ? 'token' : 'owner', + actorId: auth.user.id, + subjectType: 'contract', + subjectId: updated.id, + data: { + contract_id: updated.id, + contract_number: updated.contractNumber, + }, + }); + + // Record audit log + await audit( + { + actorType: auth.authType === 'apiKey' ? 'token' : 'owner', + actorId: auth.user.id, + action: 'contract.voided', + targetType: 'contract', + targetId: updated.id, + outcome: 'success', + metadata: { + contract_id: updated.id, + project_id: updated.projectId, + client_id: updated.clientId, + contract_number: updated.contractNumber, + reason: validated.reason ?? undefined, + }, + }, + client, + ); + + return updated; +} diff --git a/src/server/contracts/schema.ts b/src/server/contracts/schema.ts new file mode 100644 index 0000000..8feb22a --- /dev/null +++ b/src/server/contracts/schema.ts @@ -0,0 +1,166 @@ +import { z } from 'zod'; +import { + CONTRACT_STATUSES, + CONTRACT_SIGNATURE_TYPES, + type ContractStatus, + type ContractSignatureType, +} from '@/server/db/schema/contracts'; +import { + CONTRACT_COUNTERSIGNATURE_MODES, + type ContractCountersignatureMode, +} from '@/server/db/schema/studio-settings'; + +export { CONTRACT_STATUSES, CONTRACT_SIGNATURE_TYPES, CONTRACT_COUNTERSIGNATURE_MODES }; +export type { ContractStatus, ContractSignatureType, ContractCountersignatureMode }; + +export const contractStatusSchema = z.enum(CONTRACT_STATUSES); +export type ContractStatusInput = z.infer; + +export const contractSignatureTypeSchema = z.enum(CONTRACT_SIGNATURE_TYPES); +export type ContractSignatureTypeInput = z.infer; + +export const contractCountersignatureModeSchema = z.enum(CONTRACT_COUNTERSIGNATURE_MODES); +export type ContractCountersignatureModeInput = z.infer; + +/** + * Regex for kebab-case slugs: lower-case alphanumeric segments joined by single hyphens. + */ +export const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * Allowed contract language locales. + */ +export const CONTRACT_LOCALES = ['en', 'de'] as const; +export type ContractLocale = (typeof CONTRACT_LOCALES)[number]; +export const contractLocaleSchema = z.enum(CONTRACT_LOCALES); + +/** + * Schema for creating a contract template (AC-1, AC-8). + */ +export const createContractTemplateSchema = z + .object({ + name: z + .string() + .trim() + .min(1, 'Template name is required') + .max(150, 'Template name must not exceed 150 characters'), + slug: z + .string() + .trim() + .min(1, 'Slug is required') + .max(100, 'Slug must not exceed 100 characters') + .regex( + SLUG_REGEX, + 'Slug must consist of lowercase alphanumeric words separated by single hyphens', + ), + description: z.string().max(1000, 'Description too long').nullable().optional(), + projectTypeKey: z.string().trim().max(40, 'Project type key too long').nullable().optional(), + contentEn: z.string().min(1, 'English content is required'), + contentDe: z.string().min(1, 'German content is required'), + isDefault: z.boolean().default(false), + isActive: z.boolean().default(true), + }) + .strict(); + +export type CreateContractTemplateInput = z.input; +export type CreateContractTemplateOutput = z.output; + +/** + * Schema for updating an existing contract template (AC-2, AC-8). + */ +export const updateContractTemplateSchema = z + .object({ + name: z.string().trim().min(1).max(150).optional(), + slug: z.string().trim().min(1).max(100).regex(SLUG_REGEX).optional(), + description: z.string().max(1000).nullable().optional(), + projectTypeKey: z.string().trim().max(40).nullable().optional(), + contentEn: z.string().min(1).optional(), + contentDe: z.string().min(1).optional(), + isDefault: z.boolean().optional(), + isActive: z.boolean().optional(), + }) + .strict(); + +export type UpdateContractTemplateInput = z.input; +export type UpdateContractTemplateOutput = z.output; + +/** + * Schema for creating a contract draft (AC-5, AC-8). + */ +export const createContractDraftSchema = z + .object({ + projectId: z.string().uuid('Project ID must be a valid UUID'), + clientId: z.string().uuid('Client ID must be a valid UUID'), + templateId: z.string().uuid('Template ID must be a valid UUID').nullable().optional(), + proposalId: z.string().uuid('Proposal ID must be a valid UUID').nullable().optional(), + title: z + .string() + .trim() + .min(1, 'Contract title is required') + .max(200, 'Contract title must not exceed 200 characters'), + locale: contractLocaleSchema.default('en'), + renderedBody: z.string().optional(), + mergeValuesSnapshot: z.record(z.unknown()).optional(), + validUntil: z.coerce.date().nullable().optional(), + internalNotes: z.string().max(5000, 'Internal notes too long').nullable().optional(), + }) + .strict(); + +export type CreateContractDraftInput = z.input; +export type CreateContractDraftOutput = z.output; + +/** + * Schema for updating an editable draft contract. + */ +export const updateContractDraftSchema = z + .object({ + title: z.string().trim().min(1).max(200).optional(), + locale: contractLocaleSchema.optional(), + templateId: z.string().uuid().nullable().optional(), + proposalId: z.string().uuid().nullable().optional(), + renderedBody: z.string().optional(), + mergeValuesSnapshot: z.record(z.unknown()).optional(), + validUntil: z.coerce.date().nullable().optional(), + internalNotes: z.string().max(5000).nullable().optional(), + }) + .strict(); + +export type UpdateContractDraftInput = z.input; +export type UpdateContractDraftOutput = z.output; + +/** + * Schema for voiding a contract. + */ +export const voidContractSchema = z + .object({ + reason: z.string().trim().max(1000).nullable().optional(), + }) + .strict(); + +export type VoidContractInput = z.input; + +/** + * Schema for updating studio signing profile and contact details (AC-6, AC-8). + */ +export const updateStudioSigningProfileSchema = z + .object({ + contractSignatoryName: z.string().trim().max(200).nullable().optional(), + contractSignatoryTitle: z.string().trim().max(100).nullable().optional(), + contractCountersignatureMode: contractCountersignatureModeSchema.optional(), + studioAddressLine1: z.string().trim().max(200).nullable().optional(), + studioAddressLine2: z.string().trim().max(200).nullable().optional(), + studioPostalCode: z.string().trim().max(20).nullable().optional(), + studioCity: z.string().trim().max(100).nullable().optional(), + studioCountryCode: z + .string() + .trim() + .length(2, 'Country code must be exactly 2 characters (ISO 3166-1 alpha-2)') + .toUpperCase() + .optional(), + studioPhone: z.string().trim().max(50).nullable().optional(), + studioEmail: z.string().trim().email('Invalid email address').max(255).nullable().optional(), + }) + .strict(); + +export type UpdateStudioSigningProfileInput = z.input; +export type UpdateStudioSigningProfileOutput = z.output; diff --git a/src/server/contracts/template-repo.ts b/src/server/contracts/template-repo.ts new file mode 100644 index 0000000..8659eef --- /dev/null +++ b/src/server/contracts/template-repo.ts @@ -0,0 +1,342 @@ +import { eq, and, desc, asc, isNull } from 'drizzle-orm'; +import type { DbClient } from '@/server/db/client'; +import { + contractTemplates, + type ContractTemplate, + type NewContractTemplate, +} from '@/server/db/schema/contracts'; +import { audit } from '@/server/audit'; +import { resolveRepoAuth, type RepoAuthInput } from '@/server/auth/repo-auth'; +import { + createContractTemplateSchema, + updateContractTemplateSchema, + type CreateContractTemplateInput, + type UpdateContractTemplateInput, +} from './schema'; + +export class ContractTemplateNotFoundError extends Error { + public readonly code = 'NOT_FOUND'; + public readonly statusCode = 404; + + constructor(message = 'Contract template not found') { + super(message); + this.name = 'ContractTemplateNotFoundError'; + } +} + +export interface ListContractTemplatesOptions { + activeOnly?: boolean; + projectTypeKey?: string | null; +} + +/** + * Lists contract templates ordered by name. + * Strictly requires authenticated studio owner (AC-7). + */ +export async function listContractTemplates( + options?: ListContractTemplatesOptions, + authOrOptions?: RepoAuthInput, + explicitClient?: DbClient, +): Promise { + const { client } = await resolveRepoAuth(authOrOptions, explicitClient); + + const conditions = []; + + if (options?.activeOnly) { + conditions.push(eq(contractTemplates.isActive, true)); + } + + if (options?.projectTypeKey !== undefined) { + if (options.projectTypeKey === null) { + conditions.push(isNull(contractTemplates.projectTypeKey)); + } else { + conditions.push(eq(contractTemplates.projectTypeKey, options.projectTypeKey)); + } + } + + const query = client.select().from(contractTemplates); + + if (conditions.length > 0) { + return query + .where(and(...conditions)) + .orderBy(desc(contractTemplates.isDefault), asc(contractTemplates.name)); + } + + return query.orderBy(desc(contractTemplates.isDefault), asc(contractTemplates.name)); +} + +/** + * Retrieves a single contract template by its UUID. + * Strictly requires authenticated studio owner (AC-7). + */ +export async function getContractTemplateById( + id: string, + authOrOptions?: RepoAuthInput, + explicitClient?: DbClient, +): Promise { + const { client } = await resolveRepoAuth(authOrOptions, explicitClient); + + const [template] = await client + .select() + .from(contractTemplates) + .where(eq(contractTemplates.id, id)) + .limit(1); + + if (!template) { + throw new ContractTemplateNotFoundError(`Contract template not found: ${id}`); + } + + return template; +} + +/** + * Retrieves a single contract template by unique slug. + * Strictly requires authenticated studio owner (AC-7). + */ +export async function getContractTemplateBySlug( + slug: string, + authOrOptions?: RepoAuthInput, + explicitClient?: DbClient, +): Promise { + const { client } = await resolveRepoAuth(authOrOptions, explicitClient); + + const [template] = await client + .select() + .from(contractTemplates) + .where(eq(contractTemplates.slug, slug)) + .limit(1); + + if (!template) { + throw new ContractTemplateNotFoundError(`Contract template not found with slug: ${slug}`); + } + + return template; +} + +/** + * Creates a new contract template and records audit event contract_template.created (AC-1). + * Strictly requires authenticated studio owner (AC-7). + */ +export async function createContractTemplate( + rawInput: CreateContractTemplateInput, + authOrOptions?: RepoAuthInput, + explicitClient?: DbClient, +): Promise { + const { auth, client } = await resolveRepoAuth(authOrOptions, explicitClient); + const validated = createContractTemplateSchema.parse(rawInput); + + // If this template is set as default for a project type, unset existing default + if (validated.isDefault) { + if (validated.projectTypeKey) { + await client + .update(contractTemplates) + .set({ isDefault: false, updatedAt: new Date() }) + .where( + and( + eq(contractTemplates.projectTypeKey, validated.projectTypeKey), + eq(contractTemplates.isDefault, true), + ), + ); + } else { + await client + .update(contractTemplates) + .set({ isDefault: false, updatedAt: new Date() }) + .where( + and(isNull(contractTemplates.projectTypeKey), eq(contractTemplates.isDefault, true)), + ); + } + } + + const [created] = await client + .insert(contractTemplates) + .values({ + name: validated.name, + slug: validated.slug, + description: validated.description ?? null, + projectTypeKey: validated.projectTypeKey ?? null, + contentEn: validated.contentEn, + contentDe: validated.contentDe, + isDefault: validated.isDefault, + isActive: validated.isActive, + }) + .returning(); + + if (!created) { + throw new Error('Failed to create contract template'); + } + + // Record audit log + await audit( + { + actorType: auth.authType === 'apiKey' ? 'token' : 'owner', + actorId: auth.user.id, + action: 'contract_template.created', + targetType: 'contract_template', + targetId: created.id, + outcome: 'success', + metadata: { + template_id: created.id, + slug: created.slug, + name: created.name, + project_type_key: created.projectTypeKey ?? undefined, + is_default: created.isDefault, + }, + }, + client, + ); + + return created; +} + +/** + * Updates an existing contract template and records audit event contract_template.updated (AC-2). + * Strictly requires authenticated studio owner (AC-7). + */ +export async function updateContractTemplate( + id: string, + rawInput: UpdateContractTemplateInput, + authOrOptions?: RepoAuthInput, + explicitClient?: DbClient, +): Promise { + const { auth, client } = await resolveRepoAuth(authOrOptions, explicitClient); + const validated = updateContractTemplateSchema.parse(rawInput); + + const existing = await getContractTemplateById(id, auth, client); + const now = new Date(); + + const updatePayload: Partial = { + updatedAt: now, + }; + const changedFields: string[] = []; + + if (validated.name !== undefined) { + updatePayload.name = validated.name; + changedFields.push('name'); + } + if (validated.slug !== undefined) { + updatePayload.slug = validated.slug; + changedFields.push('slug'); + } + if (validated.description !== undefined) { + updatePayload.description = validated.description; + changedFields.push('description'); + } + if (validated.projectTypeKey !== undefined) { + updatePayload.projectTypeKey = validated.projectTypeKey; + changedFields.push('projectTypeKey'); + } + if (validated.contentEn !== undefined) { + updatePayload.contentEn = validated.contentEn; + changedFields.push('contentEn'); + } + if (validated.contentDe !== undefined) { + updatePayload.contentDe = validated.contentDe; + changedFields.push('contentDe'); + } + if (validated.isActive !== undefined) { + updatePayload.isActive = validated.isActive; + changedFields.push('isActive'); + } + if (validated.isDefault !== undefined) { + updatePayload.isDefault = validated.isDefault; + changedFields.push('isDefault'); + + if (validated.isDefault) { + const targetTypeKey = validated.projectTypeKey ?? existing.projectTypeKey; + if (targetTypeKey) { + await client + .update(contractTemplates) + .set({ isDefault: false, updatedAt: now }) + .where( + and( + eq(contractTemplates.projectTypeKey, targetTypeKey), + eq(contractTemplates.isDefault, true), + ), + ); + } else { + await client + .update(contractTemplates) + .set({ isDefault: false, updatedAt: now }) + .where( + and(isNull(contractTemplates.projectTypeKey), eq(contractTemplates.isDefault, true)), + ); + } + } + } + + const [updated] = await client + .update(contractTemplates) + .set(updatePayload) + .where(eq(contractTemplates.id, id)) + .returning(); + + if (!updated) { + throw new ContractTemplateNotFoundError(`Contract template not found: ${id}`); + } + + // Record audit log + await audit( + { + actorType: auth.authType === 'apiKey' ? 'token' : 'owner', + actorId: auth.user.id, + action: 'contract_template.updated', + targetType: 'contract_template', + targetId: updated.id, + outcome: 'success', + metadata: { + template_id: updated.id, + slug: updated.slug, + name: updated.name, + changed_fields: changedFields, + }, + }, + client, + ); + + return updated; +} + +/** + * Soft deletes a contract template by setting isActive = false and emits contract_template.deleted (AC-2). + * Strictly requires authenticated studio owner (AC-7). + */ +export async function deleteContractTemplate( + id: string, + authOrOptions?: RepoAuthInput, + explicitClient?: DbClient, +): Promise { + const { auth, client } = await resolveRepoAuth(authOrOptions, explicitClient); + await getContractTemplateById(id, auth, client); + + const [updated] = await client + .update(contractTemplates) + .set({ + isActive: false, + updatedAt: new Date(), + }) + .where(eq(contractTemplates.id, id)) + .returning(); + + if (!updated) { + throw new ContractTemplateNotFoundError(`Contract template not found: ${id}`); + } + + // Record audit log + await audit( + { + actorType: auth.authType === 'apiKey' ? 'token' : 'owner', + actorId: auth.user.id, + action: 'contract_template.deleted', + targetType: 'contract_template', + targetId: updated.id, + outcome: 'success', + metadata: { + template_id: updated.id, + slug: updated.slug, + }, + }, + client, + ); + + return updated; +} diff --git a/src/server/db/schema/contracts.ts b/src/server/db/schema/contracts.ts new file mode 100644 index 0000000..3d38411 --- /dev/null +++ b/src/server/db/schema/contracts.ts @@ -0,0 +1,171 @@ +import { + pgTable, + uuid, + varchar, + boolean, + integer, + text, + timestamp, + index, + check, + jsonb, +} from 'drizzle-orm/pg-core'; +import { sql, relations } from 'drizzle-orm'; +import { generateUuidV7 } from '@/lib/id'; +import { projects } from './projects'; +import { clients } from './clients'; +import { proposals } from './proposals'; + +export const CONTRACT_STATUSES = [ + 'draft', + 'sent', + 'signed_by_client', + 'signed', + 'declined', + 'voided', + 'superseded', +] as const; +export type ContractStatus = (typeof CONTRACT_STATUSES)[number]; + +export const CONTRACT_SIGNATURE_TYPES = ['drawn', 'typed'] as const; +export type ContractSignatureType = (typeof CONTRACT_SIGNATURE_TYPES)[number]; + +/** + * Contract templates table (TASK-0064 / ADR-0019 §1.2). + * Reusable contract boilerplates in English and German authored by the studio. + */ +export const contractTemplates = pgTable( + 'contract_templates', + { + id: uuid('id') + .primaryKey() + .$defaultFn(() => generateUuidV7()), + name: varchar('name', { length: 150 }).notNull(), + slug: varchar('slug', { length: 100 }).notNull().unique(), + description: text('description'), + projectTypeKey: varchar('project_type_key', { length: 40 }), + contentEn: text('content_en').notNull(), + contentDe: text('content_de').notNull(), + isDefault: boolean('is_default').notNull().default(false), + isActive: boolean('is_active').notNull().default(true), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(), + }, + (table) => [ + index('contract_templates_slug_idx').on(table.slug), + index('contract_templates_project_type_key_idx').on(table.projectTypeKey), + ], +); + +export type ContractTemplate = typeof contractTemplates.$inferSelect; +export type NewContractTemplate = typeof contractTemplates.$inferInsert; + +/** + * Contracts table (TASK-0064 / ADR-0019 §1.3). + * Core contract agreement attached to a client project with tamper-evident audit seals. + */ +export const contracts = pgTable( + 'contracts', + { + id: uuid('id') + .primaryKey() + .$defaultFn(() => generateUuidV7()), + projectId: uuid('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + clientId: uuid('client_id') + .notNull() + .references(() => clients.id, { onDelete: 'restrict' }), + proposalId: uuid('proposal_id').references(() => proposals.id, { onDelete: 'set null' }), + templateId: uuid('template_id').references(() => contractTemplates.id, { + onDelete: 'set null', + }), + contractNumber: varchar('contract_number', { length: 50 }).notNull().unique(), + title: varchar('title', { length: 200 }).notNull(), + locale: varchar('locale', { length: 10 }).notNull().default('en'), + status: varchar('status', { length: 32 }).notNull().default('draft').$type(), + renderedBody: text('rendered_body').notNull(), + mergeValuesSnapshot: jsonb('merge_values_snapshot').notNull().$type>(), + canonicalBodySha256: varchar('canonical_body_sha256', { length: 64 }).notNull(), + validUntil: timestamp('valid_until', { withTimezone: true, mode: 'date' }), + tokenHash: varchar('token_hash', { length: 64 }).notNull().unique(), + tokenRevokedAt: timestamp('token_revoked_at', { withTimezone: true, mode: 'date' }), + sentAt: timestamp('sent_at', { withTimezone: true, mode: 'date' }), + remindedAt: timestamp('reminded_at', { withTimezone: true, mode: 'date' }), + reminderCount: integer('reminder_count').notNull().default(0), + + // Client signature audit trail + clientSignedAt: timestamp('client_signed_at', { withTimezone: true, mode: 'date' }), + clientSignerName: varchar('client_signer_name', { length: 200 }), + clientSignerEmail: varchar('client_signer_email', { length: 255 }), + clientSignatureType: varchar('client_signature_type', { + length: 20, + }).$type(), + clientSignatureData: text('client_signature_data'), + clientIpAnonymized: varchar('client_ip_anonymized', { length: 64 }), + clientUserAgent: varchar('client_user_agent', { length: 255 }), + + // Studio countersignature audit trail + studioSignedAt: timestamp('studio_signed_at', { withTimezone: true, mode: 'date' }), + studioSignerName: varchar('studio_signer_name', { length: 200 }), + studioSignerTitle: varchar('studio_signer_title', { length: 100 }), + studioSignatureData: text('studio_signature_data'), + + // Final sealed PDF storage and verification + pdfStorageKey: varchar('pdf_storage_key', { length: 255 }), + pdfSha256: varchar('pdf_sha256', { length: 64 }), + pdfByteSize: integer('pdf_byte_size'), + pdfGeneratedAt: timestamp('pdf_generated_at', { withTimezone: true, mode: 'date' }), + + // Private studio internal notes + internalNotes: text('internal_notes'), + + createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(), + }, + (table) => [ + check( + 'contracts_status_check', + sql`status IN ('draft', 'sent', 'signed_by_client', 'signed', 'declined', 'voided', 'superseded')`, + ), + check( + 'contracts_client_signature_type_check', + sql`client_signature_type IS NULL OR client_signature_type IN ('drawn', 'typed')`, + ), + index('contracts_project_id_idx').on(table.projectId), + index('contracts_client_id_idx').on(table.clientId), + index('contracts_proposal_id_idx').on(table.proposalId), + index('contracts_template_id_idx').on(table.templateId), + index('contracts_token_hash_idx').on(table.tokenHash), + index('contracts_status_idx').on(table.status), + index('contracts_contract_number_idx').on(table.contractNumber), + ], +); + +export type Contract = typeof contracts.$inferSelect; +export type NewContract = typeof contracts.$inferInsert; + +// --- Drizzle Relations --- + +export const contractTemplatesRelations = relations(contractTemplates, ({ many }) => ({ + contracts: many(contracts), +})); + +export const contractsRelations = relations(contracts, ({ one }) => ({ + project: one(projects, { + fields: [contracts.projectId], + references: [projects.id], + }), + client: one(clients, { + fields: [contracts.clientId], + references: [clients.id], + }), + proposal: one(proposals, { + fields: [contracts.proposalId], + references: [proposals.id], + }), + template: one(contractTemplates, { + fields: [contracts.templateId], + references: [contractTemplates.id], + }), +})); diff --git a/src/server/db/schema/index.ts b/src/server/db/schema/index.ts index 3626000..c00f78e 100644 --- a/src/server/db/schema/index.ts +++ b/src/server/db/schema/index.ts @@ -16,3 +16,4 @@ export * from './projects'; export * from './calendar'; export * from './packages'; export * from './proposals'; +export * from './contracts'; diff --git a/src/server/db/schema/studio-settings.ts b/src/server/db/schema/studio-settings.ts index fba7f2b..1ec9111 100644 --- a/src/server/db/schema/studio-settings.ts +++ b/src/server/db/schema/studio-settings.ts @@ -1,6 +1,18 @@ -import { pgTable, uuid, varchar, timestamp, boolean, text, integer } from 'drizzle-orm/pg-core'; +import { + pgTable, + uuid, + varchar, + char, + timestamp, + boolean, + text, + integer, +} from 'drizzle-orm/pg-core'; import { generateUuidV7 } from '@/lib/id'; +export const CONTRACT_COUNTERSIGNATURE_MODES = ['pre_signed', 'explicit'] as const; +export type ContractCountersignatureMode = (typeof CONTRACT_COUNTERSIGNATURE_MODES)[number]; + /** * Studio settings table (singleton row). * @@ -24,6 +36,19 @@ export const studioSettings = pgTable('studio_settings', { kleinunternehmer_notice: text('kleinunternehmer_notice'), default_tax_rate_basis_points: integer('default_tax_rate_basis_points').notNull().default(1900), prices_include_tax: boolean('prices_include_tax').notNull().default(true), + contract_signatory_name: varchar('contract_signatory_name', { length: 200 }), + contract_signatory_title: varchar('contract_signatory_title', { length: 100 }), + contract_countersignature_mode: varchar('contract_countersignature_mode', { length: 32 }) + .notNull() + .default('pre_signed') + .$type(), + studio_address_line1: varchar('studio_address_line1', { length: 200 }), + studio_address_line2: varchar('studio_address_line2', { length: 200 }), + studio_postal_code: varchar('studio_postal_code', { length: 20 }), + studio_city: varchar('studio_city', { length: 100 }), + studio_country_code: char('studio_country_code', { length: 2 }).notNull().default('DE'), + studio_phone: varchar('studio_phone', { length: 50 }), + studio_email: varchar('studio_email', { length: 255 }), created_at: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(), updated_at: timestamp('updated_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(), }); diff --git a/src/server/db/seed.ts b/src/server/db/seed.ts index 55cb3de..1eb37f9 100644 --- a/src/server/db/seed.ts +++ b/src/server/db/seed.ts @@ -3,25 +3,29 @@ import { seedDefaultPipelineStages } from '@/server/db/seeds/pipeline-stages'; import { seedDefaultCalendarData } from '@/server/db/seeds/calendar'; import { seedPackagesAndCatalogue, type SeedResult } from '@/server/db/seeds/packages'; import { seedProposals, type SeedProposalsResult } from '@/server/db/seeds/proposals'; +import { seedContracts, type SeedContractTemplatesResult } from '@/server/db/seeds/contracts'; export { seedPackagesAndCatalogue, seedDefaultPipelineStages, seedDefaultCalendarData, seedProposals, + seedContracts, }; /** - * Master synthetic seed script (TASK-0056 / TASK-0058). - * Seeds pipeline stages, calendar defaults, photography packages, catalogue add-ons, and sample proposals. + * Master synthetic seed script (TASK-0056 / TASK-0058 / TASK-0064). + * Seeds pipeline stages, calendar defaults, photography packages, catalogue add-ons, sample proposals, and contract templates. */ export async function seedAll(tx: DbClient = defaultDb): Promise<{ packagesResult: SeedResult; proposalsResult: SeedProposalsResult; + contractsResult: SeedContractTemplatesResult; }> { await seedDefaultPipelineStages(tx); await seedDefaultCalendarData(tx); const packagesResult = await seedPackagesAndCatalogue(tx); const proposalsResult = await seedProposals(tx); - return { packagesResult, proposalsResult }; + const contractsResult = await seedContracts(tx); + return { packagesResult, proposalsResult, contractsResult }; } diff --git a/src/server/db/seeds/contracts.ts b/src/server/db/seeds/contracts.ts new file mode 100644 index 0000000..50dcd4b --- /dev/null +++ b/src/server/db/seeds/contracts.ts @@ -0,0 +1,213 @@ +import { eq } from 'drizzle-orm'; +import type { DbClient } from '@/server/db/client'; +import { contractTemplates } from '@/server/db/schema/contracts'; + +export const SEED_TEMPLATE_WEDDING_ID = '01920000-0000-7000-8000-000000000301'; +export const SEED_TEMPLATE_PORTRAIT_ID = '01920000-0000-7000-8000-000000000302'; +export const SEED_TEMPLATE_COMMERCIAL_ID = '01920000-0000-7000-8000-000000000303'; + +export interface SeedContractTemplatesResult { + seededTemplates: number; +} + +export const SEED_CONTRACT_TEMPLATES = [ + { + id: SEED_TEMPLATE_WEDDING_ID, + name: 'Wedding Photography Agreement', + slug: 'wedding-photography-agreement', + description: 'Standard contract for full-day and half-day wedding photography commissions.', + projectTypeKey: 'wedding', + isDefault: true, + isActive: true, + contentEn: `# Wedding Photography Agreement + +This Agreement is entered into between **{{studio.name}}** ("Photographer") and **{{client.name}}** ("Client"). + +## 1. Scope of Work +Photographer agrees to provide professional wedding photography services for Client's event: +- **Project**: {{project.title}} +- **Shoot Date**: {{shoot.date}} +- **Time**: {{shoot.time}} +- **Location**: {{shoot.location}}, {{shoot.address}} +- **Package**: {{package.name}} +- **Deliverables**: {{package.deliverables}} +- **Selected Add-ons**: {{package.addons}} + +## 2. Fees & Payment Terms +- **Total Amount**: {{package.total}} +- **Subtotal**: {{package.subtotal}} +- **Tax**: {{package.tax}} +- Studio VAT ID: {{studio.vat_id}} +- Studio Tax Number: {{studio.tax_number}} + +## 3. Copyright & Image Usage +Photographer retains all copyright in all images created during the event. Client is granted a private, non-exclusive license for personal use and reproduction. + +## 4. Contract Details +- **Contract Number**: {{contract.number}} +- **Issue Date**: {{contract.issue_date}} +- **Valid Until**: {{contract.valid_until}}`, + contentDe: `# Vertrag über Hochzeitsfotografie + +Dieser Vertrag wird geschlossen zwischen **{{studio.name}}** („Fotograf“) und **{{client.name}}** („Auftraggeber“). + +## 1. Leistungsumfang +Der Fotograf erbringt fotografische Dienstleistungen für die folgende Veranstaltung: +- **Projekt**: {{project.title}} +- **Termin**: {{shoot.date}} +- **Uhrzeit**: {{shoot.time}} +- **Ort**: {{shoot.location}}, {{shoot.address}} +- **Paket**: {{package.name}} +- **Leistungsumfang**: {{package.deliverables}} +- **Zusatzoptionen**: {{package.addons}} + +## 2. Honorar und Zahlungsbedingungen +- **Gesamtbetrag**: {{package.total}} +- **Nettobetrag**: {{package.subtotal}} +- **Umsatzsteuer**: {{package.tax}} +- USt-IdNr.: {{studio.vat_id}} +- Steuernummer: {{studio.tax_number}} + +## 3. Urheberrecht und Nutzungsrechte +Dem Fotografen steht das Urheberrecht an allen Lichtbildern zu. Dem Auftraggeber wird ein einfaches, nicht übertragbares Nutzungsrecht für private Zwecke eingeräumt. + +## 4. Vertragsdaten +- **Vertragsnummer**: {{contract.number}} +- **Ausstellungsdatum**: {{contract.issue_date}} +- **Gültig bis**: {{contract.valid_until}}`, + }, + { + id: SEED_TEMPLATE_PORTRAIT_ID, + name: 'Portrait Session Terms', + slug: 'portrait-session-terms', + description: + 'Standard terms and conditions for individual, family, and editorial portrait sessions.', + projectTypeKey: 'portrait', + isDefault: true, + isActive: true, + contentEn: `# Portrait Photography Agreement + +This Agreement is made between **{{studio.name}}** ("Photographer") and **{{client.name}}** ("Client"). + +## 1. Session Details +- **Project**: {{project.title}} +- **Session Date**: {{shoot.date}} at {{shoot.time}} +- **Location**: {{shoot.location}}, {{shoot.address}} +- **Package**: {{package.name}} +- **Deliverables**: {{package.deliverables}} + +## 2. Payment +- Total Fee: **{{package.total}}** (Subtotal: {{package.subtotal}}, Tax: {{package.tax}}). +- Payment is due upon receipt of invoice or prior to delivery of digital files. + +## 3. License +Client receives high-resolution edited images for personal, non-commercial use. + +## 4. Execution +Contract Reference: **{{contract.number}}** +Date: **{{contract.issue_date}}** +Expires: **{{contract.valid_until}}**`, + contentDe: `# Vertrag über Portraitfotografie + +Dieser Vertrag wird geschlossen zwischen **{{studio.name}}** („Fotograf“) und **{{client.name}}** („Auftraggeber“). + +## 1. Details des Fotoshootings +- **Projekt**: {{project.title}} +- **Aufnahmetermin**: {{shoot.date}} um {{shoot.time}} +- **Aufnahmeort**: {{shoot.location}}, {{shoot.address}} +- **Paket**: {{package.name}} +- **Leistungsumfang**: {{package.deliverables}} + +## 2. Vergütung +- Gesamthonorar: **{{package.total}}** (Netto: {{package.subtotal}}, USt: {{package.tax}}). +- Fälligkeit gemäß Rechnungsstellung bzw. vor Freigabe der hochauflösenden Bilddateien. + +## 3. Nutzungsrechte +Der Auftraggeber erhält hochauflösende bearbeitete Bilddateien zur privaten, nicht-kommerziellen Nutzung. + +## 4. Vertragsabschluss +Vertragsnummer: **{{contract.number}}** +Datum: **{{contract.issue_date}}** +Gültig bis: **{{contract.valid_until}}**`, + }, + { + id: SEED_TEMPLATE_COMMERCIAL_ID, + name: 'Commercial Licensing Agreement', + slug: 'commercial-licensing-agreement', + description: + 'Commercial photography licensing, usage rights, and commercial commission agreement.', + projectTypeKey: 'commercial', + isDefault: true, + isActive: true, + contentEn: `# Commercial Photography & Licensing Agreement + +This Commercial Photography Agreement is entered into by **{{studio.name}}** ("Licensor") and **{{client.company}}** ("Licensee", Contact: {{client.name}}). + +## 1. Assignment Scope +- **Project**: {{project.title}} +- **Production Date**: {{shoot.date}} +- **Location**: {{shoot.location}}, {{shoot.address}} +- **Deliverables**: {{package.deliverables}} + +## 2. Compensation & Commercial Terms +- Total Contract Value: **{{package.total}}** + applicable taxes. +- Studio Tax ID: {{studio.tax_number}} / VAT: {{studio.vat_id}} + +## 3. Grant of Rights & License +Licensor grants Licensee a commercial, non-transferable license for specified media channels and territories. + +## 4. Verification +Contract: **{{contract.number}}** | Issued: **{{contract.issue_date}}** | Valid: **{{contract.valid_until}}**`, + contentDe: `# Vertrag über kommerzielle Auftragsfotografie und Lizenzierung + +Dieser Vertrag wird geschlossen zwischen **{{studio.name}}** („Lizenzgeber“) und **{{client.company}}** („Lizenznehmer“, Ansprechpartner: {{client.name}}). + +## 1. Auftragsgegenstand +- **Projekt**: {{project.title}} +- **Produktionstag**: {{shoot.date}} +- **Drehort / Location**: {{shoot.location}}, {{shoot.address}} +- **Lieferumfang**: {{package.deliverables}} + +## 2. Vergütung +- Gesamtauftragswert: **{{package.total}}** zzgl. gesetzlicher USt. +- Steuernummer: {{studio.tax_number}} / USt-IdNr.: {{studio.vat_id}} + +## 3. Rechteeinräumung +Der Lizenzgeber räumt dem Lizenznehmer die vereinbarten gewerblichen Nutzungsrechte für die definierten Medien und Gebiete ein. + +## 4. Vertragsdaten +Vertragsnummer: **{{contract.number}}** | Ausgestellt: **{{contract.issue_date}}** | Gültig bis: **{{contract.valid_until}}**`, + }, +] as const; + +/** + * Seeds default contract templates into database idempotently (TASK-0064). + */ +export async function seedContracts(db: DbClient): Promise { + let seededCount = 0; + + for (const tpl of SEED_CONTRACT_TEMPLATES) { + const existing = await db + .select({ id: contractTemplates.id }) + .from(contractTemplates) + .where(eq(contractTemplates.slug, tpl.slug)) + .limit(1); + + if (existing.length === 0) { + await db.insert(contractTemplates).values({ + id: tpl.id, + name: tpl.name, + slug: tpl.slug, + description: tpl.description, + projectTypeKey: tpl.projectTypeKey, + contentEn: tpl.contentEn, + contentDe: tpl.contentDe, + isDefault: tpl.isDefault, + isActive: tpl.isActive, + }); + seededCount++; + } + } + + return { seededTemplates: seededCount }; +} diff --git a/src/server/settings/repo.ts b/src/server/settings/repo.ts index fa2fd56..23f465c 100644 --- a/src/server/settings/repo.ts +++ b/src/server/settings/repo.ts @@ -19,6 +19,16 @@ export const DEFAULT_STUDIO_SETTINGS: Omit { + const { client } = await resolveRepoAuth(authOrOptions, explicitClient); + return getBaseStudioSettings(client); +} + +/** + * Updates studio contract signing profile and physical address details (AC-6). + * Emits audit event studio_settings.contract_updated. + * Strictly requires authenticated studio owner (AC-7). + */ +export async function updateStudioSigningProfile( + rawInput: unknown, + authOrOptions?: RepoAuthInput, + explicitClient?: DbClient, +): Promise { + const { auth, client } = await resolveRepoAuth(authOrOptions, explicitClient); + const validated = updateStudioSigningProfileSchema.parse(rawInput); + + const current = await getBaseStudioSettings(client); + const now = new Date(); + + const updatePayload: Record = { + updated_at: now, + }; + + const changedFields: string[] = []; + + if (validated.contractSignatoryName !== undefined) { + updatePayload.contract_signatory_name = validated.contractSignatoryName; + changedFields.push('contract_signatory_name'); + } + if (validated.contractSignatoryTitle !== undefined) { + updatePayload.contract_signatory_title = validated.contractSignatoryTitle; + changedFields.push('contract_signatory_title'); + } + if (validated.contractCountersignatureMode !== undefined) { + updatePayload.contract_countersignature_mode = validated.contractCountersignatureMode; + changedFields.push('contract_countersignature_mode'); + } + if (validated.studioAddressLine1 !== undefined) { + updatePayload.studio_address_line1 = validated.studioAddressLine1; + changedFields.push('studio_address_line1'); + } + if (validated.studioAddressLine2 !== undefined) { + updatePayload.studio_address_line2 = validated.studioAddressLine2; + changedFields.push('studio_address_line2'); + } + if (validated.studioPostalCode !== undefined) { + updatePayload.studio_postal_code = validated.studioPostalCode; + changedFields.push('studio_postal_code'); + } + if (validated.studioCity !== undefined) { + updatePayload.studio_city = validated.studioCity; + changedFields.push('studio_city'); + } + if (validated.studioCountryCode !== undefined) { + updatePayload.studio_country_code = validated.studioCountryCode; + changedFields.push('studio_country_code'); + } + if (validated.studioPhone !== undefined) { + updatePayload.studio_phone = validated.studioPhone; + changedFields.push('studio_phone'); + } + if (validated.studioEmail !== undefined) { + updatePayload.studio_email = validated.studioEmail; + changedFields.push('studio_email'); + } + + const [updated] = await client + .update(studioSettings) + .set(updatePayload) + .where(eq(studioSettings.id, current.id)) + .returning(); + + if (!updated) { + throw new Error('Failed to update studio signing profile'); + } + + // Record audit log + await audit( + { + actorType: auth.authType === 'apiKey' ? 'token' : 'owner', + actorId: auth.user.id, + action: 'studio_settings.contract_updated', + targetType: 'studio_settings', + targetId: updated.id, + outcome: 'success', + metadata: { + changed_fields: changedFields, + contract_signatory_name: updated.contract_signatory_name ?? undefined, + contract_signatory_title: updated.contract_signatory_title ?? undefined, + contract_countersignature_mode: updated.contract_countersignature_mode, + studio_city: updated.studio_city ?? undefined, + studio_country_code: updated.studio_country_code, + studio_address_line1: updated.studio_address_line1 ?? undefined, + studio_address_line2: updated.studio_address_line2 ?? undefined, + studio_postal_code: updated.studio_postal_code ?? undefined, + studio_phone: updated.studio_phone ?? undefined, + studio_email: updated.studio_email ?? undefined, + }, + }, + client, + ); + + return updated; +} diff --git a/src/server/timeline/registry.ts b/src/server/timeline/registry.ts index f3b1a30..db79f8f 100644 --- a/src/server/timeline/registry.ts +++ b/src/server/timeline/registry.ts @@ -96,6 +96,26 @@ const INITIAL_EVENT_TYPES: TimelineEventTypeDefinition[] = [ dataKeys: ['proposal_id', 'total_minor', 'currency'], messageKey: 'clients.timeline.events.proposal_declined', }, + { + type: 'contract.sent', + dataKeys: ['contract_id', 'contract_number'], + messageKey: 'clients.timeline.events.contract_sent', + }, + { + type: 'contract.signed_by_client', + dataKeys: ['contract_id', 'contract_number', 'signer_name'], + messageKey: 'clients.timeline.events.contract_signed_by_client', + }, + { + type: 'contract.countersigned', + dataKeys: ['contract_id', 'contract_number'], + messageKey: 'clients.timeline.events.contract_countersigned', + }, + { + type: 'contract.voided', + dataKeys: ['contract_id', 'contract_number'], + messageKey: 'clients.timeline.events.contract_voided', + }, ]; const registry = new Map(); diff --git a/tests/integration/contract-templates-repo.test.ts b/tests/integration/contract-templates-repo.test.ts new file mode 100644 index 0000000..82d76ae --- /dev/null +++ b/tests/integration/contract-templates-repo.test.ts @@ -0,0 +1,443 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); + +import { createIsolatedTestDatabase, type TestDatabaseInstance } from '../helpers/db-test-helper'; +import type { DbClient } from '@/server/db/client'; +import { type AuthContext } from '@/server/auth/guards'; +import { UnauthenticatedError } from '@/server/auth/repo-auth'; +import { contractTemplates } from '@/server/db/schema/contracts'; +import { auditEvents } from '@/server/db/schema/audit'; +import { + listContractTemplates, + getContractTemplateById, + getContractTemplateBySlug, + createContractTemplate, + updateContractTemplate, + deleteContractTemplate, + ContractTemplateNotFoundError, +} from '@/server/contracts/template-repo'; +import { eq, desc } from 'drizzle-orm'; + +describe('Contract Templates Repository Integration Tests (AC-1, AC-2, AC-7, AC-8)', () => { + let testDb: TestDatabaseInstance; + let dbClient: DbClient; + + const ownerAuth: AuthContext = { + user: { + id: '01920000-0000-7000-8000-000000000001', + name: 'Studio Owner', + email: 'owner@example.org', + emailVerified: true, + image: null, + role: 'owner', + createdAt: new Date(), + updatedAt: new Date(), + }, + authType: 'session', + scopes: ['*'], + }; + + const clientAuth: AuthContext = { + user: { + id: '01920000-0000-7000-8000-000000000002', + name: 'Client User', + email: 'client@example.org', + emailVerified: true, + image: null, + role: 'client', + createdAt: new Date(), + updatedAt: new Date(), + }, + authType: 'session', + scopes: ['*'], + }; + + beforeEach(async () => { + testDb = await createIsolatedTestDatabase(); + dbClient = testDb.db as unknown as DbClient; + }); + + afterEach(async () => { + await testDb.destroy(); + }); + + it('AC-1: createContractTemplate inserts template with UUIDv7 ID and emits contract_template.created audit event', async () => { + const created = await createContractTemplate( + { + name: 'Wedding Photography Agreement', + slug: 'wedding-photography-agreement', + description: 'Standard contract for weddings', + projectTypeKey: 'wedding', + contentEn: '# Wedding Agreement\nBetween {{studio.name}} and {{client.name}}.', + contentDe: '# Hochzeitsvertrag\nZwischen {{studio.name}} und {{client.name}}.', + isDefault: true, + isActive: true, + }, + ownerAuth, + dbClient, + ); + + expect(created.id).toBeDefined(); + expect(created.name).toBe('Wedding Photography Agreement'); + expect(created.slug).toBe('wedding-photography-agreement'); + expect(created.projectTypeKey).toBe('wedding'); + expect(created.isDefault).toBe(true); + expect(created.isActive).toBe(true); + + // Verify in database + const [persisted] = await dbClient + .select() + .from(contractTemplates) + .where(eq(contractTemplates.id, created.id)) + .limit(1); + + expect(persisted).toBeDefined(); + expect(persisted?.name).toBe('Wedding Photography Agreement'); + + // Verify audit log + const [auditLog] = await dbClient + .select() + .from(auditEvents) + .where(eq(auditEvents.action, 'contract_template.created')) + .orderBy(desc(auditEvents.occurredAt)) + .limit(1); + + expect(auditLog).toBeDefined(); + expect(auditLog?.targetType).toBe('contract_template'); + expect(auditLog?.targetId).toBe(created.id); + expect(auditLog?.metadata).toMatchObject({ + template_id: created.id, + slug: 'wedding-photography-agreement', + name: 'Wedding Photography Agreement', + project_type_key: 'wedding', + is_default: true, + }); + }); + + it('AC-2: updateContractTemplate persists modifications and records contract_template.updated audit event', async () => { + const template = await createContractTemplate( + { + name: 'Portrait Terms', + slug: 'portrait-terms', + contentEn: 'English original', + contentDe: 'German original', + }, + ownerAuth, + dbClient, + ); + + const updated = await updateContractTemplate( + template.id, + { + name: 'Updated Portrait Terms & Conditions', + description: 'Updated description for portraits', + contentEn: 'Updated English terms', + }, + ownerAuth, + dbClient, + ); + + expect(updated.name).toBe('Updated Portrait Terms & Conditions'); + expect(updated.description).toBe('Updated description for portraits'); + expect(updated.contentEn).toBe('Updated English terms'); + + // Verify audit log + const [auditLog] = await dbClient + .select() + .from(auditEvents) + .where(eq(auditEvents.action, 'contract_template.updated')) + .orderBy(desc(auditEvents.occurredAt)) + .limit(1); + + expect(auditLog).toBeDefined(); + expect(auditLog?.targetId).toBe(template.id); + expect(auditLog?.metadata).toMatchObject({ + template_id: template.id, + slug: 'portrait-terms', + name: 'Updated Portrait Terms & Conditions', + changed_fields: ['name', 'description', 'contentEn'], + }); + }); + + it('AC-2: deleteContractTemplate performs soft deactivation (isActive = false) and records contract_template.deleted audit event', async () => { + const template = await createContractTemplate( + { + name: 'Commercial Agreement', + slug: 'commercial-agreement', + contentEn: 'Commercial terms', + contentDe: 'Gewerbliche Bedingungen', + }, + ownerAuth, + dbClient, + ); + + const deleted = await deleteContractTemplate(template.id, ownerAuth, dbClient); + expect(deleted.isActive).toBe(false); + + // Verify row still exists in database with isActive = false + const [persisted] = await dbClient + .select() + .from(contractTemplates) + .where(eq(contractTemplates.id, template.id)) + .limit(1); + + expect(persisted).toBeDefined(); + expect(persisted?.isActive).toBe(false); + + // Verify audit log + const [auditLog] = await dbClient + .select() + .from(auditEvents) + .where(eq(auditEvents.action, 'contract_template.deleted')) + .orderBy(desc(auditEvents.occurredAt)) + .limit(1); + + expect(auditLog).toBeDefined(); + expect(auditLog?.targetId).toBe(template.id); + expect(auditLog?.metadata).toMatchObject({ + template_id: template.id, + slug: 'commercial-agreement', + }); + }); + + it('AC-2: unsets existing default when setting a new default template for the same project type', async () => { + const firstDefault = await createContractTemplate( + { + name: 'Wedding Standard', + slug: 'wedding-standard', + projectTypeKey: 'wedding', + contentEn: 'T1 En', + contentDe: 'T1 De', + isDefault: true, + }, + ownerAuth, + dbClient, + ); + expect(firstDefault.isDefault).toBe(true); + + const secondDefault = await createContractTemplate( + { + name: 'Wedding Premium Default', + slug: 'wedding-premium-default', + projectTypeKey: 'wedding', + contentEn: 'T2 En', + contentDe: 'T2 De', + isDefault: true, + }, + ownerAuth, + dbClient, + ); + expect(secondDefault.isDefault).toBe(true); + + // First template must have been flipped to isDefault: false + const refreshedFirst = await getContractTemplateById(firstDefault.id, ownerAuth, dbClient); + expect(refreshedFirst.isDefault).toBe(false); + }); + + it('AC-7: rejects unauthenticated caller with UNAUTHENTICATED', async () => { + await expect(listContractTemplates(undefined, clientAuth, dbClient)).rejects.toThrow( + UnauthenticatedError, + ); + + await expect( + createContractTemplate( + { + name: 'Unauth Template', + slug: 'unauth-template', + contentEn: 'En', + contentDe: 'De', + }, + clientAuth, + dbClient, + ), + ).rejects.toThrow(UnauthenticatedError); + + await expect( + getContractTemplateById('01920000-0000-7000-8000-000000000000', clientAuth, dbClient), + ).rejects.toThrow(UnauthenticatedError); + + await expect(getContractTemplateBySlug('any-slug', clientAuth, dbClient)).rejects.toThrow( + UnauthenticatedError, + ); + }); + + it('AC-8: rejects invalid template input (empty name or invalid slug)', async () => { + await expect( + createContractTemplate( + { + name: ' ', + slug: 'valid-slug', + contentEn: 'En', + contentDe: 'De', + }, + ownerAuth, + dbClient, + ), + ).rejects.toThrow(); + + await expect( + createContractTemplate( + { + name: 'Valid Name', + slug: 'NOT_KEBAB_CASE', + contentEn: 'En', + contentDe: 'De', + }, + ownerAuth, + dbClient, + ), + ).rejects.toThrow(); + }); + + it('AC-8: throws ContractTemplateNotFoundError for non-existent template ID or slug', async () => { + await expect( + getContractTemplateById('01920000-0000-7000-8000-000000000099', ownerAuth, dbClient), + ).rejects.toThrow(ContractTemplateNotFoundError); + + await expect( + getContractTemplateBySlug('non-existent-slug', ownerAuth, dbClient), + ).rejects.toThrow(ContractTemplateNotFoundError); + + await expect( + updateContractTemplate( + '01920000-0000-7000-8000-000000000099', + { name: 'X' }, + ownerAuth, + dbClient, + ), + ).rejects.toThrow(ContractTemplateNotFoundError); + }); + + it('AC-1: listContractTemplates filters by activeOnly and projectTypeKey', async () => { + // Universal active template + await createContractTemplate( + { + name: 'Universal Terms', + slug: 'universal-terms', + projectTypeKey: null, + contentEn: 'En', + contentDe: 'De', + isDefault: true, + isActive: true, + }, + ownerAuth, + dbClient, + ); + + // Wedding active template + await createContractTemplate( + { + name: 'Wedding Active', + slug: 'wedding-active', + projectTypeKey: 'wedding', + contentEn: 'En', + contentDe: 'De', + isDefault: false, + isActive: true, + }, + ownerAuth, + dbClient, + ); + + // Inactive template + const inactive = await createContractTemplate( + { + name: 'Old Inactive Template', + slug: 'old-inactive', + projectTypeKey: 'portrait', + contentEn: 'En', + contentDe: 'De', + isDefault: false, + isActive: false, + }, + ownerAuth, + dbClient, + ); + + // Test activeOnly filter + const activeList = await listContractTemplates({ activeOnly: true }, ownerAuth, dbClient); + expect(activeList.some((t) => t.id === inactive.id)).toBe(false); + + // Test projectTypeKey filter + const weddingList = await listContractTemplates( + { projectTypeKey: 'wedding' }, + ownerAuth, + dbClient, + ); + expect(weddingList.every((t) => t.projectTypeKey === 'wedding')).toBe(true); + + // Test projectTypeKey null filter + const universalList = await listContractTemplates( + { projectTypeKey: null }, + ownerAuth, + dbClient, + ); + expect(universalList.every((t) => t.projectTypeKey === null)).toBe(true); + + // Test without options + const allList = await listContractTemplates(undefined, ownerAuth, dbClient); + expect(allList.length).toBeGreaterThanOrEqual(3); + }); + + it('AC-2: handles updating universal default template and all template fields', async () => { + const tpl = await createContractTemplate( + { + name: 'Initial Universal', + slug: 'initial-universal', + projectTypeKey: null, + contentEn: 'En', + contentDe: 'De', + }, + ownerAuth, + dbClient, + ); + + const updated = await updateContractTemplate( + tpl.id, + { + slug: 'renamed-universal', + description: 'New Description', + projectTypeKey: 'portrait', + contentEn: 'New En', + contentDe: 'New De', + isActive: false, + isDefault: true, + }, + ownerAuth, + dbClient, + ); + + expect(updated.slug).toBe('renamed-universal'); + expect(updated.description).toBe('New Description'); + expect(updated.projectTypeKey).toBe('portrait'); + expect(updated.contentDe).toBe('New De'); + expect(updated.isActive).toBe(false); + expect(updated.isDefault).toBe(true); + + // Another universal default update + const tplUniversal2 = await createContractTemplate( + { + name: 'Univ 2', + slug: 'univ-2', + projectTypeKey: null, + contentEn: 'En', + contentDe: 'De', + isDefault: true, + }, + ownerAuth, + dbClient, + ); + + const updatedUniv2 = await updateContractTemplate( + tplUniversal2.id, + { + isDefault: true, + projectTypeKey: null, + }, + ownerAuth, + dbClient, + ); + expect(updatedUniv2.isDefault).toBe(true); + }); +}); diff --git a/tests/integration/contracts-repo.test.ts b/tests/integration/contracts-repo.test.ts new file mode 100644 index 0000000..f3df560 --- /dev/null +++ b/tests/integration/contracts-repo.test.ts @@ -0,0 +1,551 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); + +import { createIsolatedTestDatabase, type TestDatabaseInstance } from '../helpers/db-test-helper'; +import type { DbClient } from '@/server/db/client'; +import { type AuthContext } from '@/server/auth/guards'; +import { UnauthenticatedError } from '@/server/auth/repo-auth'; +import { contracts, contractTemplates } from '@/server/db/schema/contracts'; +import { clients } from '@/server/db/schema/clients'; +import { pipelineStages, projects } from '@/server/db/schema/projects'; +import { auditEvents } from '@/server/db/schema/audit'; +import { clientTimelineEvents } from '@/server/db/schema/timeline'; +import { + listContractsForProject, + getContractById, + getContractByTokenHash, + createContractDraft, + updateContractDraft, + voidContract, + ContractNotFoundError, + ContractForbiddenError, + ContractImmutableError, +} from '@/server/contracts/repo'; +import { seedDefaultPipelineStages } from '@/server/db/seeds/pipeline-stages'; +import { eq, desc } from 'drizzle-orm'; + +describe('Contracts Repository Integration Tests (AC-5, AC-7, AC-8)', () => { + let testDb: TestDatabaseInstance; + let dbClient: DbClient; + + const ownerAuth: AuthContext = { + user: { + id: '01920000-0000-7000-8000-000000000001', + name: 'Studio Owner', + email: 'owner@example.org', + emailVerified: true, + image: null, + role: 'owner', + createdAt: new Date(), + updatedAt: new Date(), + }, + authType: 'session', + scopes: ['*'], + }; + + const clientAuth: AuthContext = { + user: { + id: '01920000-0000-7000-8000-000000000002', + name: 'Client User', + email: 'client@example.org', + emailVerified: true, + image: null, + role: 'client', + createdAt: new Date(), + updatedAt: new Date(), + }, + authType: 'session', + scopes: ['*'], + }; + + let testClientId: string; + let otherClientId: string; + let testProjectId: string; + let testTemplateId: string; + + beforeEach(async () => { + testDb = await createIsolatedTestDatabase(); + dbClient = testDb.db as unknown as DbClient; + + await seedDefaultPipelineStages(dbClient); + + const now = new Date(); + + // 1. Seed clients + const [c1] = (await dbClient + .insert(clients) + .values({ + kind: 'person', + displayName: 'Emma Watson', + preferredLocale: 'en', + createdAt: now, + updatedAt: now, + }) + .returning()) as [typeof clients.$inferSelect]; + testClientId = c1.id; + + const [c2] = (await dbClient + .insert(clients) + .values({ + kind: 'person', + displayName: 'Other Client', + preferredLocale: 'en', + createdAt: now, + updatedAt: now, + }) + .returning()) as [typeof clients.$inferSelect]; + otherClientId = c2.id; + + // 2. Seed project + const [stage] = await dbClient.select().from(pipelineStages).limit(1); + if (!stage) throw new Error('Stage missing'); + + const [p1] = (await dbClient + .insert(projects) + .values({ + clientId: testClientId, + stageId: stage.id, + title: 'Emma & Liam Wedding', + projectTypeKey: 'wedding', + currency: 'EUR', + createdAt: now, + updatedAt: now, + }) + .returning()) as [typeof projects.$inferSelect]; + testProjectId = p1.id; + + // 3. Seed template + const [tpl] = (await dbClient + .insert(contractTemplates) + .values({ + name: 'Wedding Template', + slug: 'wedding-test-tpl', + contentEn: '# Wedding Agreement\nClient: {{client.name}}', + contentDe: '# Hochzeitsvertrag\nKunde: {{client.name}}', + isDefault: true, + isActive: true, + }) + .returning()) as [typeof contractTemplates.$inferSelect]; + testTemplateId = tpl.id; + }); + + afterEach(async () => { + await testDb.destroy(); + }); + + it('AC-5: createContractDraft creates a draft contract with sequential CTR-YYYY-NNNN number and emits contract.created audit event', async () => { + const currentYear = new Date().getFullYear(); + const result = await createContractDraft( + { + projectId: testProjectId, + clientId: testClientId, + templateId: testTemplateId, + title: 'Wedding Photography Agreement — Emma & Liam', + locale: 'en', + mergeValuesSnapshot: { 'client.name': 'Emma Watson' }, + }, + ownerAuth, + dbClient, + ); + + expect(result.id).toBeDefined(); + expect(result.status).toBe('draft'); + expect(result.title).toBe('Wedding Photography Agreement — Emma & Liam'); + expect(result.contractNumber).toBe(`CTR-${String(currentYear)}-0001`); + expect(result.locale).toBe('en'); + expect(result.renderedBody).toBe('# Wedding Agreement\nClient: {{client.name}}'); + expect(result.canonicalBodySha256).toMatch(/^[a-f0-9]{64}$/); + + // Raw signing token is returned + expect(result.rawToken).toHaveLength(64); + expect(result.tokenHash).toHaveLength(64); + + // Verify persisted in database + const [persisted] = await dbClient + .select() + .from(contracts) + .where(eq(contracts.id, result.id)) + .limit(1); + + expect(persisted).toBeDefined(); + expect(persisted?.contractNumber).toBe(`CTR-${String(currentYear)}-0001`); + + // Verify contract.created audit log + const [auditLog] = await dbClient + .select() + .from(auditEvents) + .where(eq(auditEvents.action, 'contract.created')) + .orderBy(desc(auditEvents.occurredAt)) + .limit(1); + + expect(auditLog).toBeDefined(); + expect(auditLog?.targetType).toBe('contract'); + expect(auditLog?.targetId).toBe(result.id); + expect(auditLog?.metadata).toMatchObject({ + contract_id: result.id, + project_id: testProjectId, + client_id: testClientId, + contract_number: `CTR-${String(currentYear)}-0001`, + status: 'draft', + template_id: testTemplateId, + }); + }); + + it('AC-5: sequentially increments contract numbers for consecutive contracts', async () => { + const currentYear = new Date().getFullYear(); + + const c1 = await createContractDraft( + { + projectId: testProjectId, + clientId: testClientId, + title: 'Contract 1', + }, + ownerAuth, + dbClient, + ); + + const c2 = await createContractDraft( + { + projectId: testProjectId, + clientId: testClientId, + title: 'Contract 2', + }, + ownerAuth, + dbClient, + ); + + expect(c1.contractNumber).toBe(`CTR-${String(currentYear)}-0001`); + expect(c2.contractNumber).toBe(`CTR-${String(currentYear)}-0002`); + }); + + it('AC-5: getContractByTokenHash retrieves contract via public token hash', async () => { + const draft = await createContractDraft( + { + projectId: testProjectId, + clientId: testClientId, + title: 'Public Viewable Contract', + }, + ownerAuth, + dbClient, + ); + + const fetched = await getContractByTokenHash(draft.tokenHash, dbClient); + expect(fetched.id).toBe(draft.id); + expect(fetched.title).toBe('Public Viewable Contract'); + }); + + it('AC-5: updateContractDraft updates title and body while in draft and records contract.updated audit event', async () => { + const draft = await createContractDraft( + { + projectId: testProjectId, + clientId: testClientId, + title: 'Original Title', + renderedBody: 'Original Body', + }, + ownerAuth, + dbClient, + ); + + const updated = await updateContractDraft( + draft.id, + { + title: 'Updated Contract Title', + renderedBody: 'Amended Contract Terms', + }, + ownerAuth, + dbClient, + ); + + expect(updated.title).toBe('Updated Contract Title'); + expect(updated.renderedBody).toBe('Amended Contract Terms'); + expect(updated.canonicalBodySha256).not.toBe(draft.canonicalBodySha256); + + // Verify audit log + const [auditLog] = await dbClient + .select() + .from(auditEvents) + .where(eq(auditEvents.action, 'contract.updated')) + .orderBy(desc(auditEvents.occurredAt)) + .limit(1); + + expect(auditLog).toBeDefined(); + expect(auditLog?.targetId).toBe(draft.id); + const meta = auditLog?.metadata as { contract_id?: string; changed_fields?: string[] } | null; + expect(meta?.contract_id).toBe(draft.id); + expect(meta?.changed_fields).toContain('title'); + expect(meta?.changed_fields).toContain('renderedBody'); + expect(meta?.changed_fields).toContain('canonicalBodySha256'); + }); + + it('AC-5: updateContractDraft throws ContractImmutableError when contract is not in draft status', async () => { + const draft = await createContractDraft( + { + projectId: testProjectId, + clientId: testClientId, + title: 'Will Be Voided', + }, + ownerAuth, + dbClient, + ); + + // Transition to voided + await voidContract(draft.id, 'Cancelled by studio', ownerAuth, dbClient); + + // Attempting to edit must fail with ContractImmutableError + await expect( + updateContractDraft( + draft.id, + { + title: 'Illegal Amendment', + }, + ownerAuth, + dbClient, + ), + ).rejects.toThrow(ContractImmutableError); + }); + + it('AC-5: voidContract transitions status to voided, revokes signing token, records audit and timeline events', async () => { + const draft = await createContractDraft( + { + projectId: testProjectId, + clientId: testClientId, + title: 'Contract To Void', + }, + ownerAuth, + dbClient, + ); + + const voided = await voidContract( + draft.id, + 'Client postponed indefinitely', + ownerAuth, + dbClient, + ); + + expect(voided.status).toBe('voided'); + expect(voided.tokenRevokedAt).toBeDefined(); + + // Verify audit log + const [auditLog] = await dbClient + .select() + .from(auditEvents) + .where(eq(auditEvents.action, 'contract.voided')) + .orderBy(desc(auditEvents.occurredAt)) + .limit(1); + + expect(auditLog).toBeDefined(); + expect(auditLog?.metadata).toMatchObject({ + contract_id: draft.id, + reason: 'Client postponed indefinitely', + }); + + // Verify timeline event + const [timelineEvent] = await dbClient + .select() + .from(clientTimelineEvents) + .where(eq(clientTimelineEvents.type, 'contract.voided')) + .orderBy(desc(clientTimelineEvents.occurredAt)) + .limit(1); + + expect(timelineEvent).toBeDefined(); + expect(timelineEvent?.clientId).toBe(testClientId); + expect(timelineEvent?.data).toMatchObject({ + contract_id: draft.id, + contract_number: draft.contractNumber, + }); + }); + + it('AC-5: listContractsForProject returns all contracts for a project ordered by createdAt desc', async () => { + await createContractDraft( + { + projectId: testProjectId, + clientId: testClientId, + title: 'Project Contract A', + }, + ownerAuth, + dbClient, + ); + + await createContractDraft( + { + projectId: testProjectId, + clientId: testClientId, + title: 'Project Contract B', + }, + ownerAuth, + dbClient, + ); + + const list = await listContractsForProject(testProjectId, ownerAuth, dbClient); + expect(list).toHaveLength(2); + expect(list[0]?.title).toBe('Project Contract B'); + }); + + it('AC-7: rejects unauthenticated caller with UNAUTHENTICATED', async () => { + await expect( + createContractDraft( + { + projectId: testProjectId, + clientId: testClientId, + title: 'Unauth Contract', + }, + clientAuth, + dbClient, + ), + ).rejects.toThrow(UnauthenticatedError); + + await expect( + updateContractDraft( + '01920000-0000-7000-8000-000000000099', + { title: 'X' }, + clientAuth, + dbClient, + ), + ).rejects.toThrow(UnauthenticatedError); + + await expect( + voidContract('01920000-0000-7000-8000-000000000099', null, clientAuth, dbClient), + ).rejects.toThrow(UnauthenticatedError); + + await expect(listContractsForProject(testProjectId, clientAuth, dbClient)).rejects.toThrow( + UnauthenticatedError, + ); + }); + + it('AC-8: rejects contract creation when project does not belong to specified clientId (FORBIDDEN)', async () => { + await expect( + createContractDraft( + { + projectId: testProjectId, + clientId: otherClientId, // mismatched client ID + title: 'Mismatched Ownership Contract', + }, + ownerAuth, + dbClient, + ), + ).rejects.toThrow(ContractForbiddenError); + }); + + it('AC-8: throws ContractNotFoundError for non-existent project or contract IDs', async () => { + await expect( + createContractDraft( + { + projectId: '01920000-0000-7000-8000-000000000099', + clientId: testClientId, + title: 'Non-existent Project Contract', + }, + ownerAuth, + dbClient, + ), + ).rejects.toThrow(ContractForbiddenError); + + await expect( + getContractById('01920000-0000-7000-8000-000000000099', ownerAuth, dbClient), + ).rejects.toThrow(ContractNotFoundError); + + await expect( + getContractByTokenHash( + 'non-existent-token-hash-64-characters-long-000000000000000000000000', + dbClient, + ), + ).rejects.toThrow(ContractNotFoundError); + }); + + it('AC-5: creates contract draft in German locale using German template content and populates proposalId', async () => { + // 1. Create a proposal to link + const { createProposal } = await import('@/server/proposals/repo'); + const prop = await createProposal( + { + projectId: testProjectId, + clientId: testClientId, + title: 'Wedding Package Proposal', + }, + ownerAuth, + dbClient, + ); + + // 2. Create German contract draft referencing template and proposal + const germanDraft = await createContractDraft( + { + projectId: testProjectId, + clientId: testClientId, + templateId: testTemplateId, + proposalId: prop.id, + title: 'Hochzeitsfotografie Vertrag', + locale: 'de', + validUntil: new Date('2026-12-31T23:59:59Z'), + internalNotes: 'VIP Client', + }, + ownerAuth, + dbClient, + ); + + expect(germanDraft.locale).toBe('de'); + expect(germanDraft.renderedBody).toBe('# Hochzeitsvertrag\nKunde: {{client.name}}'); + expect(germanDraft.proposalId).toBe(prop.id); + expect(germanDraft.internalNotes).toBe('VIP Client'); + + // 3. Update contract draft with various fields + const updated = await updateContractDraft( + germanDraft.id, + { + locale: 'en', + templateId: null, + proposalId: null, + validUntil: null, + internalNotes: 'Updated notes', + mergeValuesSnapshot: { 'client.name': 'Emma Watson' }, + }, + ownerAuth, + dbClient, + ); + + expect(updated.locale).toBe('en'); + expect(updated.internalNotes).toBe('Updated notes'); + expect(updated.proposalId).toBeNull(); + + // 4. Test error paths for invalid templateId or proposalId + await expect( + createContractDraft( + { + projectId: testProjectId, + clientId: testClientId, + templateId: '01920000-0000-7000-8000-000000000099', + title: 'Invalid Template Contract', + }, + ownerAuth, + dbClient, + ), + ).rejects.toThrow(ContractNotFoundError); + + await expect( + createContractDraft( + { + projectId: testProjectId, + clientId: testClientId, + proposalId: '01920000-0000-7000-8000-000000000099', + title: 'Invalid Proposal Contract', + }, + ownerAuth, + dbClient, + ), + ).rejects.toThrow(ContractNotFoundError); + + // 5. Update non-existent contract throws + await expect( + updateContractDraft( + '01920000-0000-7000-8000-000000000099', + { title: 'X' }, + ownerAuth, + dbClient, + ), + ).rejects.toThrow(ContractNotFoundError); + + // 6. Calling voidContract on an already voided contract returns existing contract + const voided1 = await voidContract(germanDraft.id, 'First void', ownerAuth, dbClient); + const voided2 = await voidContract(germanDraft.id, 'Second void', ownerAuth, dbClient); + expect(voided2.id).toBe(voided1.id); + expect(voided2.status).toBe('voided'); + }); +}); diff --git a/tests/integration/contracts-seed.test.ts b/tests/integration/contracts-seed.test.ts new file mode 100644 index 0000000..291aeae --- /dev/null +++ b/tests/integration/contracts-seed.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); + +import { createIsolatedTestDatabase, type TestDatabaseInstance } from '../helpers/db-test-helper'; +import type { DbClient } from '@/server/db/client'; +import { contractTemplates } from '@/server/db/schema/contracts'; +import { + seedContracts, + SEED_TEMPLATE_WEDDING_ID, + SEED_TEMPLATE_PORTRAIT_ID, + SEED_TEMPLATE_COMMERCIAL_ID, +} from '@/server/db/seeds/contracts'; +import { seedAll } from '@/server/db/seed'; +import { eq } from 'drizzle-orm'; + +describe('Contracts Seed Integration Tests (TASK-0064)', () => { + let testDb: TestDatabaseInstance; + let dbClient: DbClient; + + beforeEach(async () => { + testDb = await createIsolatedTestDatabase(); + dbClient = testDb.db as unknown as DbClient; + }); + + afterEach(async () => { + await testDb.destroy(); + }); + + it('seedContracts inserts the 3 representative contract templates into database', async () => { + const result = await seedContracts(dbClient); + expect(result.seededTemplates).toBe(3); + + const rows = await dbClient.select().from(contractTemplates); + expect(rows).toHaveLength(3); + + const slugs = rows.map((r) => r.slug); + expect(slugs).toContain('wedding-photography-agreement'); + expect(slugs).toContain('portrait-session-terms'); + expect(slugs).toContain('commercial-licensing-agreement'); + + // Verify wedding template details + const [wedding] = await dbClient + .select() + .from(contractTemplates) + .where(eq(contractTemplates.id, SEED_TEMPLATE_WEDDING_ID)) + .limit(1); + + expect(wedding).toBeDefined(); + expect(wedding?.name).toBe('Wedding Photography Agreement'); + expect(wedding?.projectTypeKey).toBe('wedding'); + expect(wedding?.isDefault).toBe(true); + expect(wedding?.contentEn).toContain('{{client.name}}'); + expect(wedding?.contentEn).toContain('{{studio.name}}'); + expect(wedding?.contentDe).toContain('{{client.name}}'); + expect(wedding?.contentDe).toContain('{{studio.name}}'); + + // Verify portrait template details + const [portrait] = await dbClient + .select() + .from(contractTemplates) + .where(eq(contractTemplates.id, SEED_TEMPLATE_PORTRAIT_ID)) + .limit(1); + + expect(portrait).toBeDefined(); + expect(portrait?.projectTypeKey).toBe('portrait'); + + // Verify commercial template details + const [commercial] = await dbClient + .select() + .from(contractTemplates) + .where(eq(contractTemplates.id, SEED_TEMPLATE_COMMERCIAL_ID)) + .limit(1); + + expect(commercial).toBeDefined(); + expect(commercial?.projectTypeKey).toBe('commercial'); + }); + + it('seedContracts is completely idempotent and safe to execute multiple times', async () => { + const firstRun = await seedContracts(dbClient); + expect(firstRun.seededTemplates).toBe(3); + + const secondRun = await seedContracts(dbClient); + expect(secondRun.seededTemplates).toBe(0); + + const rows = await dbClient.select().from(contractTemplates); + expect(rows).toHaveLength(3); + }); + + it('seedAll executes master seed script and includes contract templates result', async () => { + const { packagesResult, proposalsResult, contractsResult } = await seedAll(dbClient); + + expect(contractsResult).toBeDefined(); + expect(contractsResult.seededTemplates).toBe(3); + expect(packagesResult.seededPackages).toBeGreaterThan(0); + expect(proposalsResult.seededProposals).toBeGreaterThan(0); + + const count = await dbClient.select().from(contractTemplates); + expect(count).toHaveLength(3); + }); +}); diff --git a/tests/integration/studio-signing-settings.test.ts b/tests/integration/studio-signing-settings.test.ts new file mode 100644 index 0000000..b4c6689 --- /dev/null +++ b/tests/integration/studio-signing-settings.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); + +import { createIsolatedTestDatabase, type TestDatabaseInstance } from '../helpers/db-test-helper'; +import type { DbClient } from '@/server/db/client'; +import { type AuthContext } from '@/server/auth/guards'; +import { UnauthenticatedError } from '@/server/auth/repo-auth'; +import { auditEvents } from '@/server/db/schema/audit'; +import { + getStudioSigningProfile, + updateStudioSigningProfile, +} from '@/server/studio/signing-settings'; +import { eq, desc } from 'drizzle-orm'; + +describe('Studio Signing Settings Integration Tests (AC-6, AC-7, AC-8)', () => { + let testDb: TestDatabaseInstance; + let dbClient: DbClient; + + const ownerAuth: AuthContext = { + user: { + id: '01920000-0000-7000-8000-000000000001', + name: 'Studio Owner', + email: 'owner@example.org', + emailVerified: true, + image: null, + role: 'owner', + createdAt: new Date(), + updatedAt: new Date(), + }, + authType: 'session', + scopes: ['*'], + }; + + const clientAuth: AuthContext = { + user: { + id: '01920000-0000-7000-8000-000000000002', + name: 'Client User', + email: 'client@example.org', + emailVerified: true, + image: null, + role: 'client', + createdAt: new Date(), + updatedAt: new Date(), + }, + authType: 'session', + scopes: ['*'], + }; + + beforeEach(async () => { + testDb = await createIsolatedTestDatabase(); + dbClient = testDb.db as unknown as DbClient; + }); + + afterEach(async () => { + await testDb.destroy(); + }); + + it('AC-6: updateStudioSigningProfile updates signatory info and physical address and records studio_settings.contract_updated audit event', async () => { + const updated = await updateStudioSigningProfile( + { + contractSignatoryName: 'Jane Doe', + contractSignatoryTitle: 'Lead Photographer & Studio Owner', + contractCountersignatureMode: 'explicit', + studioAddressLine1: 'Torstraße 100', + studioAddressLine2: 'Hinterhof 2. OG', + studioPostalCode: '10119', + studioCity: 'Berlin', + studioCountryCode: 'DE', + studioPhone: '+49 30 1234567', + studioEmail: 'photographer@example.org', + }, + ownerAuth, + dbClient, + ); + + expect(updated.contract_signatory_name).toBe('Jane Doe'); + expect(updated.contract_signatory_title).toBe('Lead Photographer & Studio Owner'); + expect(updated.contract_countersignature_mode).toBe('explicit'); + expect(updated.studio_address_line1).toBe('Torstraße 100'); + expect(updated.studio_address_line2).toBe('Hinterhof 2. OG'); + expect(updated.studio_postal_code).toBe('10119'); + expect(updated.studio_city).toBe('Berlin'); + expect(updated.studio_country_code).toBe('DE'); + expect(updated.studio_phone).toBe('+49 30 1234567'); + expect(updated.studio_email).toBe('photographer@example.org'); + + // Verify in database via getStudioSigningProfile + const fetched = await getStudioSigningProfile(ownerAuth, dbClient); + expect(fetched.contract_signatory_name).toBe('Jane Doe'); + expect(fetched.contract_countersignature_mode).toBe('explicit'); + expect(fetched.studio_city).toBe('Berlin'); + + // Verify audit log + const [auditLog] = await dbClient + .select() + .from(auditEvents) + .where(eq(auditEvents.action, 'studio_settings.contract_updated')) + .orderBy(desc(auditEvents.occurredAt)) + .limit(1); + + expect(auditLog).toBeDefined(); + expect(auditLog?.targetType).toBe('studio_settings'); + const meta = auditLog?.metadata as { + contract_signatory_name?: string; + contract_countersignature_mode?: string; + studio_city?: string; + studio_country_code?: string; + changed_fields?: string[]; + } | null; + expect(meta?.contract_signatory_name).toBe('Jane Doe'); + expect(meta?.contract_countersignature_mode).toBe('explicit'); + expect(meta?.studio_city).toBe('Berlin'); + expect(meta?.studio_country_code).toBe('DE'); + expect(meta?.changed_fields).toContain('contract_signatory_name'); + expect(meta?.changed_fields).toContain('contract_signatory_title'); + expect(meta?.changed_fields).toContain('contract_countersignature_mode'); + expect(meta?.changed_fields).toContain('studio_city'); + }); + + it('AC-7: rejects unauthenticated caller with UNAUTHENTICATED', async () => { + await expect(getStudioSigningProfile(clientAuth, dbClient)).rejects.toThrow( + UnauthenticatedError, + ); + + await expect( + updateStudioSigningProfile( + { + contractSignatoryName: 'Hacker', + }, + clientAuth, + dbClient, + ), + ).rejects.toThrow(UnauthenticatedError); + }); + + it('AC-8: invalid signing profile payload throws typed validation error without mutating database', async () => { + await expect( + updateStudioSigningProfile( + { + contractCountersignatureMode: 'unsupported_mode', + }, + ownerAuth, + dbClient, + ), + ).rejects.toThrow(); + + await expect( + updateStudioSigningProfile( + { + studioEmail: 'invalid-email-format', + }, + ownerAuth, + dbClient, + ), + ).rejects.toThrow(); + }); + + it('AC-6: supports partial updates to signing profile and address fields', async () => { + const updatedName = await updateStudioSigningProfile( + { + contractSignatoryName: 'Alex River', + }, + ownerAuth, + dbClient, + ); + expect(updatedName.contract_signatory_name).toBe('Alex River'); + + const updatedMode = await updateStudioSigningProfile( + { + contractCountersignatureMode: 'pre_signed', + }, + ownerAuth, + dbClient, + ); + expect(updatedMode.contract_countersignature_mode).toBe('pre_signed'); + expect(updatedMode.contract_signatory_name).toBe('Alex River'); + }); +}); diff --git a/tests/unit/ac-1-to-ac-13-pow.test.ts b/tests/unit/ac-1-to-ac-13-pow.test.ts index d414113..302b782 100644 --- a/tests/unit/ac-1-to-ac-13-pow.test.ts +++ b/tests/unit/ac-1-to-ac-13-pow.test.ts @@ -246,6 +246,16 @@ describe('TASK-0029: Proof-of-Work (ALTCHA) Unit Tests', () => { kleinunternehmer_notice: null, default_tax_rate_basis_points: 1900, prices_include_tax: true, + contract_signatory_name: null, + contract_signatory_title: null, + contract_countersignature_mode: 'pre_signed', + studio_address_line1: null, + studio_address_line2: null, + studio_postal_code: null, + studio_city: null, + studio_country_code: 'DE', + studio_phone: null, + studio_email: null, created_at: new Date(), updated_at: new Date(), }; @@ -285,6 +295,16 @@ describe('TASK-0029: Proof-of-Work (ALTCHA) Unit Tests', () => { kleinunternehmer_notice: null, default_tax_rate_basis_points: 1900, prices_include_tax: true, + contract_signatory_name: null, + contract_signatory_title: null, + contract_countersignature_mode: 'pre_signed', + studio_address_line1: null, + studio_address_line2: null, + studio_postal_code: null, + studio_city: null, + studio_country_code: 'DE', + studio_phone: null, + studio_email: null, created_at: new Date(), updated_at: new Date(), }; diff --git a/tests/unit/clients-branches.test.ts b/tests/unit/clients-branches.test.ts index da0980d..36e2271 100644 --- a/tests/unit/clients-branches.test.ts +++ b/tests/unit/clients-branches.test.ts @@ -493,6 +493,16 @@ describe('Clients Domain & Actions Branch Coverage (Unit)', () => { kleinunternehmer_notice: null, default_tax_rate_basis_points: 1900, prices_include_tax: true, + contract_signatory_name: null, + contract_signatory_title: null, + contract_countersignature_mode: 'pre_signed', + studio_address_line1: null, + studio_address_line2: null, + studio_postal_code: null, + studio_city: null, + studio_country_code: 'DE', + studio_phone: null, + studio_email: null, }); vi.spyOn(clientRepo, 'findDuplicateEmails').mockResolvedValueOnce([]); vi.spyOn(clientRepo, 'insertClientWithPrimaryContact').mockResolvedValueOnce({ diff --git a/tests/unit/contracts-merge-engine.test.ts b/tests/unit/contracts-merge-engine.test.ts new file mode 100644 index 0000000..be9d690 --- /dev/null +++ b/tests/unit/contracts-merge-engine.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect } from 'vitest'; +import { + resolveContractMergeFields, + escapeHtml, + stripControlChars, + sanitizeMergeValue, + ALLOWLISTED_MERGE_TOKENS, + type ContractMergeData, +} from '@/server/contracts/merge-engine'; + +describe('Contracts Merge-Field Engine Unit Tests (AC-3)', () => { + const sampleCommissionData: ContractMergeData = { + client: { + name: 'Emma Watson', + company: 'Watson Media GmbH', + email: 'emma@example.com', + phone: '+49 30 1234567', + address: 'Friedrichstraße 42', + city: 'Berlin', + postal_code: '10117', + country: 'DE', + }, + project: { + title: 'Emma & Liam Wedding', + type: 'Wedding Photography', + price: '€2.500,00', + currency: 'EUR', + notes: 'Please capture golden hour portraits.', + }, + shoot: { + date: '15. August 2026', + time: '14:00', + timezone: 'Europe/Berlin', + location: 'Schlosspark Sanssouci', + address: 'Maulbeerallee, 14469 Potsdam', + }, + package: { + name: 'Full Day Wedding Coverage', + deliverables: '• 8h coverage\n• 400 photos\n• Online gallery', + total: '€2.500,00', + subtotal: '€2.100,84', + tax: '€399,16', + addons: '• Second Photographer: €400,00', + }, + studio: { + name: 'Lichtwerk Fotografie', + owner_name: 'Jane Doe', + email: 'hello@lichtwerk.example', + phone: '+49 30 9876543', + address: 'Torstraße 100, 10119 Berlin', + tax_number: '12/345/67890', + vat_id: 'DE123456789', + }, + contract: { + number: 'CTR-2026-0001', + issue_date: '06.11.2026', + valid_until: '20.11.2026', + }, + }; + + it('AC-3: interpolates all allowlisted merge tokens across 6 namespaces accurately', () => { + const templateText = ` +Agreement between {{studio.name}} (Owner: {{studio.owner_name}}, Email: {{studio.email}}, Phone: {{studio.phone}}, Address: {{studio.address}}, Tax: {{studio.tax_number}}, VAT: {{studio.vat_id}}) +and {{client.name}} (Company: {{client.company}}, Email: {{client.email}}, Phone: {{client.phone}}, Address: {{client.address}}, City: {{client.city}}, Postal: {{client.postal_code}}, Country: {{client.country}}). + +Project: {{project.title}} (Type: {{project.type}}, Price: {{project.price}}, Currency: {{project.currency}}, Notes: {{project.notes}}) +Shoot: Date: {{shoot.date}}, Time: {{shoot.time}}, Timezone: {{shoot.timezone}}, Location: {{shoot.location}}, Address: {{shoot.address}} +Package: {{package.name}}, Deliverables: {{package.deliverables}}, Subtotal: {{package.subtotal}}, Tax: {{package.tax}}, Total: {{package.total}}, Addons: {{package.addons}} +Contract: Number: {{contract.number}}, Issued: {{contract.issue_date}}, Valid Until: {{contract.valid_until}} +`; + + const result = resolveContractMergeFields(templateText, sampleCommissionData); + + expect(result.renderedText).toContain('Agreement between Lichtwerk Fotografie'); + expect(result.renderedText).toContain('Owner: Jane Doe'); + expect(result.renderedText).toContain('Tax: 12/345/67890'); + expect(result.renderedText).toContain('VAT: DE123456789'); + expect(result.renderedText).toContain('and Emma Watson'); + expect(result.renderedText).toContain('Company: Watson Media GmbH'); + expect(result.renderedText).toContain('Project: Emma & Liam Wedding'); + expect(result.renderedText).toContain('Date: 15. August 2026'); + expect(result.renderedText).toContain('Package: Full Day Wedding Coverage'); + expect(result.renderedText).toContain('Total: €2.500,00'); + expect(result.renderedText).toContain('Contract: Number: CTR-2026-0001'); + + expect(result.unmappedTokens).toHaveLength(0); + expect(result.evaluatedValues['client.name']).toBe('Emma Watson'); + expect(result.evaluatedValues['studio.name']).toBe('Lichtwerk Fotografie'); + }); + + it('AC-3: strictly HTML-escapes dynamic values to prevent XSS injection', () => { + const maliciousData: ContractMergeData = { + client: { + name: '', + company: '">', + address: "Tom & Jerry's Studio 'Special'", + }, + project: { + title: + '', + notes: 'Ampersands & inside notes', + }, + }; + + const templateText = + 'Client: {{client.name}}, Company: {{client.company}}, Project: {{project.title}}'; + const result = resolveContractMergeFields(templateText, maliciousData); + + // Verify HTML escaping + expect(result.renderedText).not.toContain(''); + expect(result.renderedText).toContain('"><img src=x onerror=alert(1)>'); + expect(result.renderedText).not.toContain(' { + const inputWithControlChars = 'Hello\x00\x01\x07World\x1F!\nLine 2\tTabbed'; + const sanitized = sanitizeMergeValue(inputWithControlChars); + + expect(sanitized).not.toContain('\x00'); + expect(sanitized).not.toContain('\x01'); + expect(sanitized).not.toContain('\x07'); + expect(sanitized).not.toContain('\x1F'); + expect(sanitized).toContain('HelloWorld!\nLine 2\tTabbed'); + }); + + it('AC-3: cleanly reports unmapped tokens and safely clears them from rendered output', () => { + const templateWithUnknownAndMissing = ` +Hello {{client.name}}, welcome to {{unknown.token}}! +Your appointment is on {{shoot.date}}. +Bonus token: {{custom_namespace.invalid_field}}. +`; + + // Only provide client.name + const result = resolveContractMergeFields(templateWithUnknownAndMissing, { + client: { name: 'Alice' }, + }); + + expect(result.renderedText).toContain('Hello Alice, welcome to !'); + expect(result.renderedText).toContain('Your appointment is on .'); + expect(result.renderedText).not.toContain('{{unknown.token}}'); + expect(result.renderedText).not.toContain('{{shoot.date}}'); + + expect(result.unmappedTokens).toContain('unknown.token'); + expect(result.unmappedTokens).toContain('shoot.date'); + expect(result.unmappedTokens).toContain('custom_namespace.invalid_field'); + }); + + it('AC-3: preserves unmapped tokens when keepUnmappedTokens option is true', () => { + const template = 'Client: {{client.name}}, Pending: {{package.name}}'; + const result = resolveContractMergeFields( + template, + { client: { name: 'Bob' } }, + { + keepUnmappedTokens: true, + }, + ); + + expect(result.renderedText).toBe('Client: Bob, Pending: {{package.name}}'); + expect(result.unmappedTokens).toContain('package.name'); + }); + + it('AC-3: handles empty, null, or undefined data objects without throwing exceptions', () => { + expect(() => resolveContractMergeFields('Empty template', null)).not.toThrow(); + expect(() => resolveContractMergeFields('Empty template', undefined)).not.toThrow(); + expect(() => resolveContractMergeFields('Empty template', {})).not.toThrow(); + + const result = resolveContractMergeFields('Hello {{client.name}}', null); + expect(result.renderedText).toBe('Hello '); + expect(result.unmappedTokens).toContain('client.name'); + }); + + it('AC-3: verifies exhaustive namespace coverage in ALLOWLISTED_MERGE_TOKENS', () => { + const namespaces = new Set(ALLOWLISTED_MERGE_TOKENS.map((t) => t.split('.')[0])); + expect(namespaces.has('client')).toBe(true); + expect(namespaces.has('project')).toBe(true); + expect(namespaces.has('shoot')).toBe(true); + expect(namespaces.has('package')).toBe(true); + expect(namespaces.has('studio')).toBe(true); + expect(namespaces.has('contract')).toBe(true); + expect(ALLOWLISTED_MERGE_TOKENS.length).toBeGreaterThanOrEqual(30); + }); + + it('AC-3: sanitizeMergeValue handles numbers, booleans, bigints, and objects', () => { + expect(sanitizeMergeValue(1234)).toBe('1234'); + expect(sanitizeMergeValue(true)).toBe('true'); + expect(sanitizeMergeValue(BigInt(999999))).toBe('999999'); + expect(sanitizeMergeValue({ complex: 'object' })).toBe(''); + expect(sanitizeMergeValue(null)).toBe(''); + expect(sanitizeMergeValue(undefined)).toBe(''); + + expect(escapeHtml(' & "quotes" \'apostrophe\'')).toBe( + '<script>alert("xss")</script> & "quotes" 'apostrophe'', + ); + expect(stripControlChars('Hello\u0000World\u0008Test\t\r\n')).toBe('HelloWorldTest\t\r\n'); + }); + + it('AC-3: resolveContractMergeFields handles empty string token values and tokens with whitespace', () => { + const template = 'Client: {{ client.name }}, Company: {{ client.company }}'; + const result = resolveContractMergeFields(template, { + client: { + name: 'Jane', + company: '', // explicitly empty string + }, + }); + + expect(result.renderedText).toBe('Client: Jane, Company: '); + expect(result.evaluatedValues['client.company']).toBe(''); + }); +}); diff --git a/tests/unit/contracts-registry-audit.test.ts b/tests/unit/contracts-registry-audit.test.ts new file mode 100644 index 0000000..4a2e612 --- /dev/null +++ b/tests/unit/contracts-registry-audit.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +vi.mock('server-only', () => ({})); +import { getTimelineEventType } from '@/server/timeline/registry'; +import { ACTION_METADATA_ALLOWLIST } from '@/server/audit/allowlist'; + +describe('Contracts Registry, Audit Allowlist & ADR-0019 Unit Tests (AC-9, AC-10)', () => { + it('AC-9: timeline registry defines contract.sent event type with expected dataKeys', () => { + const event = getTimelineEventType('contract.sent'); + expect(event).toBeDefined(); + expect(event?.dataKeys).toEqual(['contract_id', 'contract_number']); + expect(event?.messageKey).toBe('clients.timeline.events.contract_sent'); + }); + + it('AC-9: timeline registry defines contract.signed_by_client event type with expected dataKeys', () => { + const event = getTimelineEventType('contract.signed_by_client'); + expect(event).toBeDefined(); + expect(event?.dataKeys).toEqual(['contract_id', 'contract_number', 'signer_name']); + expect(event?.messageKey).toBe('clients.timeline.events.contract_signed_by_client'); + }); + + it('AC-9: timeline registry defines contract.countersigned event type with expected dataKeys', () => { + const event = getTimelineEventType('contract.countersigned'); + expect(event).toBeDefined(); + expect(event?.dataKeys).toEqual(['contract_id', 'contract_number']); + expect(event?.messageKey).toBe('clients.timeline.events.contract_countersigned'); + }); + + it('AC-9: timeline registry defines contract.voided event type with expected dataKeys', () => { + const event = getTimelineEventType('contract.voided'); + expect(event).toBeDefined(); + expect(event?.dataKeys).toEqual(['contract_id', 'contract_number']); + expect(event?.messageKey).toBe('clients.timeline.events.contract_voided'); + }); + + it('AC-9: messages/en.json and messages/de.json contain all contract timeline event message keys', () => { + const enPath = path.resolve(process.cwd(), 'messages/en.json'); + const dePath = path.resolve(process.cwd(), 'messages/de.json'); + + interface MessagesStructure { + clients?: { + timeline?: { + events?: Record; + }; + }; + } + + const enJson = JSON.parse(fs.readFileSync(enPath, 'utf8')) as MessagesStructure; + const deJson = JSON.parse(fs.readFileSync(dePath, 'utf8')) as MessagesStructure; + + const requiredKeys = [ + 'contract_sent', + 'contract_signed_by_client', + 'contract_countersigned', + 'contract_voided', + ]; + + const enEvents = new Map(Object.entries(enJson.clients?.timeline?.events ?? {})); + const deEvents = new Map(Object.entries(deJson.clients?.timeline?.events ?? {})); + + for (const key of requiredKeys) { + expect(enEvents.has(key)).toBe(true); + expect(typeof enEvents.get(key)).toBe('string'); + expect(deEvents.has(key)).toBe(true); + expect(typeof deEvents.get(key)).toBe('string'); + } + }); + + it('AC-9: audit allowlist registers all 12 contract actions with allowed metadata keys', () => { + const expectedActions = [ + 'contract_template.created', + 'contract_template.updated', + 'contract_template.deleted', + 'contract.created', + 'contract.updated', + 'contract.sent', + 'contract.signed_by_client', + 'contract.countersigned', + 'contract.voided', + 'contract.revoked', + 'contract.reminded', + 'studio_settings.contract_updated', + ]; + + for (const action of expectedActions) { + const allowed = ACTION_METADATA_ALLOWLIST.get(action); + expect(allowed, `Action ${action} should be registered in allowlist`).toBeDefined(); + expect(allowed?.length).toBeGreaterThan(0); + } + + // Verify key fields for template actions + const tplCreated = ACTION_METADATA_ALLOWLIST.get('contract_template.created'); + expect(tplCreated).toContain('template_id'); + expect(tplCreated).toContain('slug'); + expect(tplCreated).toContain('name'); + + // Verify key fields for contract actions + const ctrCreated = ACTION_METADATA_ALLOWLIST.get('contract.created'); + expect(ctrCreated).toContain('contract_id'); + expect(ctrCreated).toContain('project_id'); + expect(ctrCreated).toContain('client_id'); + expect(ctrCreated).toContain('contract_number'); + + // Verify studio settings contract update action + const studioContract = ACTION_METADATA_ALLOWLIST.get('studio_settings.contract_updated'); + expect(studioContract).toContain('contract_signatory_name'); + expect(studioContract).toContain('contract_countersignature_mode'); + expect(studioContract).toContain('studio_city'); + }); + + it('AC-10: ADR-0019 exists in projects/ownlight/docs/architecture/adr/ and specifies status Accepted (2026-11-06)', () => { + const adrPath = path.resolve( + process.cwd(), + 'docs/architecture/adr/0019-contracts-and-e-signature.md', + ); + expect(fs.existsSync(adrPath), `ADR file should exist at ${adrPath}`).toBe(true); + + const content = fs.readFileSync(adrPath, 'utf8'); + expect(content).toContain('ADR-0019: Contracts and e-signature'); + expect(content).toMatch(/Status:\s*Accepted\s*\(2026-11-06\)/); + }); +}); diff --git a/tests/unit/contracts-schema.test.ts b/tests/unit/contracts-schema.test.ts new file mode 100644 index 0000000..357ab09 --- /dev/null +++ b/tests/unit/contracts-schema.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect } from 'vitest'; +import { + createContractTemplateSchema, + updateContractTemplateSchema, + createContractDraftSchema, + updateContractDraftSchema, + voidContractSchema, + updateStudioSigningProfileSchema, + contractStatusSchema, + contractSignatureTypeSchema, + contractCountersignatureModeSchema, + CONTRACT_LOCALES, +} from '@/server/contracts/schema'; + +describe('Contracts Schema Validation Unit Tests (AC-8)', () => { + describe('createContractTemplateSchema', () => { + it('AC-8: validates a valid contract template payload successfully', () => { + const valid = { + name: 'Wedding Photography Agreement', + slug: 'wedding-photography-agreement', + description: 'Standard wedding agreement', + projectTypeKey: 'wedding', + contentEn: 'Clause 1: Photography services for {{client.name}}', + contentDe: 'Klausel 1: Fotografische Leistungen für {{client.name}}', + isDefault: true, + isActive: true, + }; + + const parsed = createContractTemplateSchema.parse(valid); + expect(parsed.name).toBe('Wedding Photography Agreement'); + expect(parsed.slug).toBe('wedding-photography-agreement'); + expect(parsed.isDefault).toBe(true); + expect(parsed.isActive).toBe(true); + }); + + it('AC-8: rejects empty template name', () => { + expect(() => + createContractTemplateSchema.parse({ + name: ' ', + slug: 'valid-slug', + contentEn: 'content', + contentDe: 'inhalt', + }), + ).toThrow(/Template name is required/); + }); + + it('AC-8: rejects malformed slug formats', () => { + const invalidSlugs = [ + 'Invalid Slug With Spaces', + 'UPPERCASE-SLUG', + 'slug_with_underscores', + '-leading-hyphen', + 'trailing-hyphen-', + 'double--hyphens', + 'special$chars!', + ]; + + for (const slug of invalidSlugs) { + expect(() => + createContractTemplateSchema.parse({ + name: 'Template Name', + slug, + contentEn: 'content', + contentDe: 'inhalt', + }), + ).toThrow(); + } + }); + + it('AC-8: rejects empty content in English or German', () => { + expect(() => + createContractTemplateSchema.parse({ + name: 'Template', + slug: 'valid-slug', + contentEn: '', + contentDe: 'German content', + }), + ).toThrow(/English content is required/); + + expect(() => + createContractTemplateSchema.parse({ + name: 'Template', + slug: 'valid-slug', + contentEn: 'English content', + contentDe: '', + }), + ).toThrow(/German content is required/); + }); + + it('AC-8: rejects unknown extra properties due to strict validation', () => { + expect(() => + createContractTemplateSchema.parse({ + name: 'Template', + slug: 'valid-slug', + contentEn: 'En', + contentDe: 'De', + forbiddenProperty: 'attack', + }), + ).toThrow(); + }); + }); + + describe('updateContractTemplateSchema', () => { + it('AC-8: allows partial updates with valid fields', () => { + const parsed = updateContractTemplateSchema.parse({ + name: 'Updated Template Name', + isDefault: false, + }); + + expect(parsed.name).toBe('Updated Template Name'); + expect(parsed.isDefault).toBe(false); + }); + + it('AC-8: rejects malformed slug in update', () => { + expect(() => + updateContractTemplateSchema.parse({ + slug: 'Not Kebab', + }), + ).toThrow(); + }); + }); + + describe('createContractDraftSchema', () => { + const validDraft = { + projectId: '01920000-0000-7000-8000-000000000071', + clientId: '01920000-0000-7000-8000-000000000081', + title: 'Wedding Photography Agreement — Emma & Liam', + locale: 'en', + }; + + it('AC-8: validates a valid contract draft payload', () => { + const parsed = createContractDraftSchema.parse(validDraft); + expect(parsed.title).toBe('Wedding Photography Agreement — Emma & Liam'); + expect(parsed.locale).toBe('en'); + }); + + it('AC-8: rejects non-UUID project and client identifiers', () => { + expect(() => + createContractDraftSchema.parse({ + ...validDraft, + projectId: 'not-a-uuid', + }), + ).toThrow(/Project ID must be a valid UUID/); + + expect(() => + createContractDraftSchema.parse({ + ...validDraft, + clientId: 'not-a-uuid', + }), + ).toThrow(/Client ID must be a valid UUID/); + }); + + it('AC-8: rejects empty title', () => { + expect(() => + createContractDraftSchema.parse({ + ...validDraft, + title: ' ', + }), + ).toThrow(/Contract title is required/); + }); + + it('AC-8: rejects unsupported locale values', () => { + expect(() => + createContractDraftSchema.parse({ + ...validDraft, + locale: 'fr', + }), + ).toThrow(); + }); + }); + + describe('updateStudioSigningProfileSchema', () => { + it('AC-8: validates studio signing profile and physical address input', () => { + const valid = { + contractSignatoryName: 'Jane Doe', + contractSignatoryTitle: 'Lead Photographer & Studio Owner', + contractCountersignatureMode: 'pre_signed', + studioAddressLine1: 'Torstraße 100', + studioPostalCode: '10119', + studioCity: 'Berlin', + studioCountryCode: 'DE', + studioPhone: '+49 30 1234567', + studioEmail: 'photographer@example.org', + }; + + const parsed = updateStudioSigningProfileSchema.parse(valid); + expect(parsed.contractSignatoryName).toBe('Jane Doe'); + expect(parsed.contractCountersignatureMode).toBe('pre_signed'); + expect(parsed.studioCountryCode).toBe('DE'); + }); + + it('AC-8: rejects invalid countersignature mode', () => { + expect(() => + updateStudioSigningProfileSchema.parse({ + contractCountersignatureMode: 'unsupported_mode', + }), + ).toThrow(); + }); + + it('AC-8: rejects invalid country code length and malformed email', () => { + expect(() => + updateStudioSigningProfileSchema.parse({ + studioCountryCode: 'DEU', + }), + ).toThrow(/Country code must be exactly 2 characters/); + + expect(() => + updateStudioSigningProfileSchema.parse({ + studioEmail: 'not-an-email', + }), + ).toThrow(/Invalid email address/); + }); + }); + + describe('contractStatusSchema & contractSignatureTypeSchema', () => { + it('AC-8: verifies contract status enum options', () => { + const validStatuses = [ + 'draft', + 'sent', + 'signed_by_client', + 'signed', + 'declined', + 'voided', + 'superseded', + ]; + + for (const status of validStatuses) { + expect(contractStatusSchema.parse(status)).toBe(status); + } + + expect(() => contractStatusSchema.parse('invalid_status')).toThrow(); + }); + + it('AC-8: verifies signature types drawn and typed', () => { + expect(contractSignatureTypeSchema.parse('drawn')).toBe('drawn'); + expect(contractSignatureTypeSchema.parse('typed')).toBe('typed'); + expect(() => contractSignatureTypeSchema.parse('biometric')).toThrow(); + }); + + it('AC-8: verifies countersignature mode and contract locales', () => { + expect(contractCountersignatureModeSchema.parse('explicit')).toBe('explicit'); + expect(contractCountersignatureModeSchema.parse('pre_signed')).toBe('pre_signed'); + expect(() => contractCountersignatureModeSchema.parse('automatic')).toThrow(); + expect(CONTRACT_LOCALES).toEqual(['en', 'de']); + }); + + it('AC-8: validates updateContractDraftSchema and voidContractSchema', () => { + const updateDraft = updateContractDraftSchema.parse({ + title: 'Updated Contract Title', + locale: 'de', + internalNotes: 'Reviewed with studio manager', + }); + expect(updateDraft.title).toBe('Updated Contract Title'); + expect(updateDraft.locale).toBe('de'); + + const voidPayload = voidContractSchema.parse({ + reason: 'Client requested cancellation', + }); + expect(voidPayload.reason).toBe('Client requested cancellation'); + }); + }); +}); diff --git a/tests/unit/contracts-snapshot.test.ts b/tests/unit/contracts-snapshot.test.ts new file mode 100644 index 0000000..ec7cb22 --- /dev/null +++ b/tests/unit/contracts-snapshot.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; +import { + freezeContractSnapshot, + computeCanonicalSha256, + normalizeRenderedBody, +} from '@/server/contracts/merge-engine'; + +describe('Contracts Snapshot Freezing & Cryptographic Seal Unit Tests (AC-4)', () => { + const sampleText = `# Wedding Photography Contract +This Agreement is between Lichtwerk Fotografie and Emma Watson. +1. Total Investment: €2.500,00 +2. Date: 15. August 2026 +3. German Umlauts: Übergrößen, Märchenhaft, Schöne Grüße! +`; + + const sampleMergeValues = { + 'client.name': 'Emma Watson', + 'package.total': '€2.500,00', + 'shoot.date': '15. August 2026', + 'studio.name': 'Lichtwerk Fotografie', + }; + + it('AC-4: freezeContractSnapshot generates rendered_body, merge_values_snapshot, and canonical SHA-256 hash', () => { + const snapshot = freezeContractSnapshot(sampleText, sampleMergeValues); + + expect(snapshot.rendered_body).toBe(sampleText); + expect(snapshot.renderedBody).toBe(sampleText); + expect(snapshot.merge_values_snapshot).toEqual(sampleMergeValues); + expect(snapshot.mergeValuesSnapshot).toEqual(sampleMergeValues); + + expect(typeof snapshot.canonical_body_sha256).toBe('string'); + expect(snapshot.canonical_body_sha256).toHaveLength(64); + expect(snapshot.canonical_body_sha256).toMatch(/^[a-f0-9]{64}$/); + expect(snapshot.canonicalBodySha256).toBe(snapshot.canonical_body_sha256); + }); + + it('AC-4: ensures deterministic hash parity across Windows CRLF and Linux LF line endings', () => { + const crlfText = sampleText.replace(/\n/g, '\r\n'); + const lfText = sampleText.replace(/\r\n/g, '\n'); + + expect(crlfText).not.toBe(lfText); // Text representations differ in bytes + + const hashCrlf = computeCanonicalSha256(crlfText); + const hashLf = computeCanonicalSha256(lfText); + + expect(hashCrlf).toBe(hashLf); // Canonical SHA-256 must match exactly + }); + + it('AC-4: ensures deterministic hash parity across Unicode NFC and NFD representations', () => { + const textNfc = 'Schöne Grüße in München'.normalize('NFC'); + const textNfd = 'Schöne Grüße in München'.normalize('NFD'); + + expect(textNfc).not.toBe(textNfd); // Code points differ in raw form + + const hashNfc = computeCanonicalSha256(textNfc); + const hashNfd = computeCanonicalSha256(textNfd); + + expect(hashNfc).toBe(hashNfd); // Normalized canonical SHA-256 must match + }); + + it('AC-4: tamper detection: any single character modification produces a distinct SHA-256 hash', () => { + const originalHash = computeCanonicalSha256(sampleText); + + const tamperedPrice = sampleText.replace('€2.500,00', '€2.499,00'); + const tamperedHash = computeCanonicalSha256(tamperedPrice); + + expect(tamperedHash).not.toBe(originalHash); + + const whitespaceTampered = sampleText + ' '; + const whitespaceHash = computeCanonicalSha256(whitespaceTampered); + expect(whitespaceHash).not.toBe(originalHash); + }); + + it('AC-4: supports calling freezeContractSnapshot with an options object', () => { + const snapshot = freezeContractSnapshot({ + renderedBody: 'Short contract text', + mergeValuesSnapshot: { 'client.name': 'Alice' }, + }); + + expect(snapshot.rendered_body).toBe('Short contract text'); + expect(snapshot.canonical_body_sha256).toBe(computeCanonicalSha256('Short contract text')); + expect(snapshot.merge_values_snapshot).toEqual({ 'client.name': 'Alice' }); + + // With snake_case properties + const snakeSnapshot = freezeContractSnapshot({ + rendered_body: 'Snake contract text', + merge_values_snapshot: { 'client.city': 'Berlin' }, + }); + expect(snakeSnapshot.rendered_body).toBe('Snake contract text'); + expect(snakeSnapshot.merge_values_snapshot).toEqual({ 'client.city': 'Berlin' }); + + // With empty options + const emptySnapshot = freezeContractSnapshot({}); + expect(emptySnapshot.rendered_body).toBe(''); + expect(emptySnapshot.merge_values_snapshot).toEqual({}); + }); + + it('AC-4: normalizeRenderedBody normalizes standalone carriage returns', () => { + const crText = 'Line 1\rLine 2\r\nLine 3\n'; + const normalized = normalizeRenderedBody(crText); + expect(normalized).toBe('Line 1\nLine 2\nLine 3\n'); + }); +}); diff --git a/tests/unit/packages-components-render.test.tsx b/tests/unit/packages-components-render.test.tsx index 4cb8b5a..39d6d07 100644 --- a/tests/unit/packages-components-render.test.tsx +++ b/tests/unit/packages-components-render.test.tsx @@ -170,6 +170,16 @@ const mockTaxSettings: StudioSettings = { kleinunternehmer_notice: null, default_tax_rate_basis_points: 1900, prices_include_tax: true, + contract_signatory_name: null, + contract_signatory_title: null, + contract_countersignature_mode: 'pre_signed', + studio_address_line1: null, + studio_address_line2: null, + studio_postal_code: null, + studio_city: null, + studio_country_code: 'DE', + studio_phone: null, + studio_email: null, created_at: new Date('2026-01-01T10:00:00Z'), updated_at: new Date('2026-01-01T10:00:00Z'), }; diff --git a/tests/unit/packages-form-schema.test.ts b/tests/unit/packages-form-schema.test.ts index 32f96b3..c9d44fa 100644 --- a/tests/unit/packages-form-schema.test.ts +++ b/tests/unit/packages-form-schema.test.ts @@ -321,6 +321,16 @@ describe('TASK-0057: Packages and Catalogue Studio Settings Form Schemas & Helpe kleinunternehmer_notice: null, default_tax_rate_basis_points: 0, prices_include_tax: true, + contract_signatory_name: null, + contract_signatory_title: null, + contract_countersignature_mode: 'pre_signed', + studio_address_line1: null, + studio_address_line2: null, + studio_postal_code: null, + studio_city: null, + studio_country_code: 'DE', + studio_phone: null, + studio_email: null, created_at: new Date(), updated_at: new Date(), }; @@ -877,6 +887,16 @@ describe('TASK-0057: Packages and Catalogue Studio Settings Form Schemas & Helpe is_kleinunternehmer: true, kleinunternehmer_notice: 'Custom statutory note', prices_include_tax: true, + contract_signatory_name: null, + contract_signatory_title: null, + contract_countersignature_mode: 'pre_signed', + studio_address_line1: null, + studio_address_line2: null, + studio_postal_code: null, + studio_city: null, + studio_country_code: 'DE', + studio_phone: null, + studio_email: null, created_at: new Date(), updated_at: new Date(), }; diff --git a/tests/unit/proposal-builder-render.test.tsx b/tests/unit/proposal-builder-render.test.tsx index 1aa2f0b..903c73a 100644 --- a/tests/unit/proposal-builder-render.test.tsx +++ b/tests/unit/proposal-builder-render.test.tsx @@ -168,6 +168,16 @@ const mockStudioSettings: StudioSettings = { kleinunternehmer_notice: null, default_tax_rate_basis_points: 1900, prices_include_tax: true, + contract_signatory_name: null, + contract_signatory_title: null, + contract_countersignature_mode: 'pre_signed', + studio_address_line1: null, + studio_address_line2: null, + studio_postal_code: null, + studio_city: null, + studio_country_code: 'DE', + studio_phone: null, + studio_email: null, created_at: new Date('2026-01-01T00:00:00Z'), updated_at: new Date('2026-01-01T00:00:00Z'), }; diff --git a/tests/unit/public-booking-components.test.tsx b/tests/unit/public-booking-components.test.tsx index cbca308..90169f1 100644 --- a/tests/unit/public-booking-components.test.tsx +++ b/tests/unit/public-booking-components.test.tsx @@ -225,6 +225,16 @@ function createMockStudioSettings(overrides: Partial = {}): Stud kleinunternehmer_notice: null, default_tax_rate_basis_points: 1900, prices_include_tax: true, + contract_signatory_name: null, + contract_signatory_title: null, + contract_countersignature_mode: 'pre_signed', + studio_address_line1: null, + studio_address_line2: null, + studio_postal_code: null, + studio_city: null, + studio_country_code: 'DE', + studio_phone: null, + studio_email: null, created_at: new Date('2026-01-01T00:00:00.000Z'), updated_at: new Date('2026-01-01T00:00:00.000Z'), ...overrides, From bc2ae5f723efffccde270443bbde335ce25d2971 Mon Sep 17 00:00:00 2001 From: Ediminator <211899731+Ediminator@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:19:41 +0100 Subject: [PATCH 2/3] fix(privacy): exempt contracts table pending TASK-0066 and update migration assertions Signed-off-by: Ediminator <211899731+Ediminator@users.noreply.github.com> --- src/server/privacy/erasure/allowlist.ts | 17 +++++------------ src/server/privacy/export/allowlist.ts | 5 +++++ tests/integration/ac-1-migrations.test.ts | 12 ++++++++++++ 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/server/privacy/erasure/allowlist.ts b/src/server/privacy/erasure/allowlist.ts index 1f3f37c..66977e9 100644 --- a/src/server/privacy/erasure/allowlist.ts +++ b/src/server/privacy/erasure/allowlist.ts @@ -11,16 +11,9 @@ export interface ErasureCoverageExemption { } export const ERASURE_COVERAGE_ALLOWLIST: readonly ErasureCoverageExemption[] = [ - // All current tables with client_id or contact_id columns are fully covered - // by domain erasure providers: - // - client_contacts -> contacts - // - client_addresses -> addresses - // - client_tags -> tags (removes client_tags join rows; tags table itself remains) - // - client_notes -> notes - // - client_timeline_events -> timeline - // - consent_records -> consents - // - projects, project_participants -> projects - // - proposals, proposal_line_items -> proposals (TASK-0059) - // When future epics introduce tables with client_id or contact_id columns - // that have statutory retention or non-deletable data, register an exemption here with reasoning. + { + tableName: 'contracts', + reason: + 'Legal contracts and signature records. DSAR erasure provider and statutory retention hold implementation is scheduled in TASK-0066 as part of Epic E6.', + }, ]; diff --git a/src/server/privacy/export/allowlist.ts b/src/server/privacy/export/allowlist.ts index fdebd6e..8438310 100644 --- a/src/server/privacy/export/allowlist.ts +++ b/src/server/privacy/export/allowlist.ts @@ -21,4 +21,9 @@ export const EXPORT_COVERAGE_ALLOWLIST: readonly ExportCoverageExemption[] = [ reason: 'Commercial proposals and snapshot records. DSAR export provider implementation is scheduled in TASK-0059 as part of Epic E5.', }, + { + tableName: 'contracts', + reason: + 'Legal contracts and signature records. DSAR export provider implementation is scheduled in TASK-0066 as part of Epic E6.', + }, ]; diff --git a/tests/integration/ac-1-migrations.test.ts b/tests/integration/ac-1-migrations.test.ts index ffeb7df..a1226a9 100644 --- a/tests/integration/ac-1-migrations.test.ts +++ b/tests/integration/ac-1-migrations.test.ts @@ -69,6 +69,16 @@ describe('AC-1: Database migrations and drift detection', () => { 'kleinunternehmer_notice', 'default_tax_rate_basis_points', 'prices_include_tax', + 'contract_signatory_name', + 'contract_signatory_title', + 'contract_countersignature_mode', + 'studio_address_line1', + 'studio_address_line2', + 'studio_postal_code', + 'studio_city', + 'studio_country_code', + 'studio_phone', + 'studio_email', ]); // Verify MFA tables and PoW tables exist @@ -85,6 +95,8 @@ describe('AC-1: Database migrations and drift detection', () => { expect(tableNames).toContain('catalogue_items'); expect(tableNames).toContain('proposals'); expect(tableNames).toContain('proposal_line_items'); + expect(tableNames).toContain('contract_templates'); + expect(tableNames).toContain('contracts'); } finally { await client.close(); } From 8b69468dd648ec86c7d2d9a3ca42bac5562c3133 Mon Sep 17 00:00:00 2001 From: Ediminator <211899731+Ediminator@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:00:48 +0100 Subject: [PATCH 3/3] fix(contracts): address review feedback on audit PII, data inventory, and public token resolution (TASK-0064) Signed-off-by: Ediminator <211899731+Ediminator@users.noreply.github.com> --- docs/privacy/data-inventory.md | 59 +++++++++++++--- src/server/audit/allowlist.ts | 21 +----- src/server/contracts/repo.ts | 19 +++--- src/server/studio/signing-settings.ts | 8 --- tests/integration/contracts-repo.test.ts | 21 ++++-- .../studio-signing-settings.test.ts | 35 ++++++---- tests/unit/contracts-registry-audit.test.ts | 68 ++++++++++++++++++- 7 files changed, 166 insertions(+), 65 deletions(-) diff --git a/docs/privacy/data-inventory.md b/docs/privacy/data-inventory.md index 828331b..4cb7e7c 100644 --- a/docs/privacy/data-inventory.md +++ b/docs/privacy/data-inventory.md @@ -6,15 +6,24 @@ This register documents all personal data processed by the application, its lawf ## 1. Studio Owner Personal Data -| Data Field / Entity | Purpose | Lawful Basis (GDPR) | Retention Period | Deletion / Export | -| --------------------------------- | -------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------- | ------------------------------------------------------------------- | -| `owner.name`, `owner.email` | Account identification & administration | Art. 6(1)(b) Contract / System Operation | Account lifetime | Deleted on instance reset / factory purge; included in Owner Export | -| `owner.password_hash` | Authentication security | Art. 6(1)(f) Legitimate Interest / Art. 32 | Account lifetime | Overwritten on reset | -| `owner.mfa_secret` | Two-factor authentication (encrypted at rest) | Art. 32 Security of processing | Account lifetime / until MFA disabled | Purged on disable | -| `studio_settings.studio_name` | Business identification (may identify sole trader) | Art. 6(1)(b)/(f) Operation & Owner Data | Instance lifetime | Exported with settings | -| `studio_settings.tax_number` | Commercial studio operations, statutory invoice notices per §14 UStG | Art. 6(1)(f) GDPR / §14 UStG | Instance lifetime | Exported with settings | -| `studio_settings.vat_id` | Commercial studio operations, statutory VAT ID notices per §27a UStG | Art. 6(1)(c) Compliance / §27a UStG | Instance lifetime | Exported with settings | -| `studio_settings.lexware_api_key` | Lexware Office integration secret (AES-256-GCM encrypted) | Art. 6(1)(c) Compliance / Art. 32 | Until integration removed | Purged on disconnect | +| Data Field / Entity | Purpose | Lawful Basis (GDPR) | Retention Period | Deletion / Export | +| ------------------------------------------------------------------------------ | -------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------- | ------------------------------------------------------------------- | +| `owner.name`, `owner.email` | Account identification & administration | Art. 6(1)(b) Contract / System Operation | Account lifetime | Deleted on instance reset / factory purge; included in Owner Export | +| `owner.password_hash` | Authentication security | Art. 6(1)(f) Legitimate Interest / Art. 32 | Account lifetime | Overwritten on reset | +| `owner.mfa_secret` | Two-factor authentication (encrypted at rest) | Art. 32 Security of processing | Account lifetime / until MFA disabled | Purged on disable | +| `studio_settings.studio_name` | Business identification (may identify sole trader) | Art. 6(1)(b)/(f) Operation & Owner Data | Instance lifetime | Exported with settings | +| `studio_settings.tax_number` | Commercial studio operations, statutory invoice notices per §14 UStG | Art. 6(1)(f) GDPR / §14 UStG | Instance lifetime | Exported with settings | +| `studio_settings.vat_id` | Commercial studio operations, statutory VAT ID notices per §27a UStG | Art. 6(1)(c) Compliance / §27a UStG | Instance lifetime | Exported with settings | +| `studio_settings.lexware_api_key` | Lexware Office integration secret (AES-256-GCM encrypted) | Art. 6(1)(c) Compliance / Art. 32 | Until integration removed | Purged on disconnect | +| `studio_settings.contract_signatory_name` | Full legal name of studio representative signing legal contracts | Art. 6(1)(b) Contract / Art. 6(1)(c) | Instance lifetime | Exported with settings; populated on executed contracts | +| `studio_settings.contract_signatory_title` | Professional title/role of studio representative (e.g. Studio Owner) | Art. 6(1)(b) Contract / Art. 6(1)(c) | Instance lifetime | Exported with settings; populated on executed contracts | +| `studio_settings.contract_countersignature_mode` | Mode of countersignature execution (`pre_signed` or `explicit`) | Art. 6(1)(b) Contract / System Operation | Instance lifetime | Exported with settings | +| `studio_settings.studio_address_line1`, `studio_settings.studio_address_line2` | Physical address / studio premises for legal vendor identification | Art. 6(1)(b) Contract / §14 UStG / §5 DDG | Instance lifetime | Exported with settings; populated on contracts and invoices | +| `studio_settings.studio_postal_code` | Postal code for vendor identification and statutory contract notices | Art. 6(1)(b) Contract / §14 UStG / §5 DDG | Instance lifetime | Exported with settings; populated on contracts and invoices | +| `studio_settings.studio_city` | City for studio legal seat and court of jurisdiction notices | Art. 6(1)(b) Contract / §14 UStG / §5 DDG | Instance lifetime | Exported with settings; populated on contracts and invoices | +| `studio_settings.studio_country_code` | ISO 3166-1 alpha-2 country code for statutory notices and contracts | Art. 6(1)(b) Contract / §14 UStG / §5 DDG | Instance lifetime | Exported with settings; populated on contracts and invoices | +| `studio_settings.studio_phone` | Business telephone contact displayed on contracts and client portal | Art. 6(1)(b) Contract / §5 DDG | Instance lifetime | Exported with settings; populated on contracts and client portal | +| `studio_settings.studio_email` | Business email contact displayed on contracts and client portal | Art. 6(1)(b) Contract / §5 DDG | Instance lifetime | Exported with settings; populated on contracts and client portal | --- @@ -180,3 +189,35 @@ Proposals track pre-contractual commercial quotations, package/add-on option sel | `proposals.decline_reason` | Optional free-text reason provided by client when declining proposal | Art. 6(1)(b) Pre-contractual steps | Retained until proposal purge or client erasure | Included in client DSAR export; deleted on lead/client erasure; never logged in audit metadata (only boolean `has_decline_reason` logged in audit metadata) | | `proposals.internal_notes` | Photographer internal pricing and negotiation notes | Art. 6(1)(f) Legitimate interest (studio business management) | Retained with proposal record | Excluded from client portal and public token view (`internalNotes: null`); included in owner audit export; deleted on client erasure if not subject to statutory hold | | `proposal_line_items.*` | Quotation line items (package/catalogue snapshots, unit prices, tax rates, quantities) | Art. 6(1)(b) Pre-contractual steps / Contract | Follows parent proposal lifecycle | Included in DSAR export; cascade deleted on parent proposal deletion | + +--- + +## 10. Contracts & Electronic Signatures Data (TASK-0064, ADR-0019) + +Contracts track formal photography service agreements, electronic signatures, and legal execution terms between the studio and client. + +| Data Field / Entity | Purpose | Lawful Basis (GDPR) | Retention Period | Deletion / Export | +| --------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `contracts.id` | Monotonic UUIDv7 entity identifier | Art. 6(1)(b) Contract formation and execution | Retained with contract record | Hard-deleted on project deletion or client erasure (unexecuted drafts) | +| `contracts.project_id` | FK to `projects.id` associating contract with specific engagement | Art. 6(1)(b) Contract | Lifetime of project profile | Cascade deleted on project deletion; unexecuted drafts deleted on client erasure (Art. 17) | +| `contracts.client_id` | FK to `clients.id` linking legal agreement to contracting client | Art. 6(1)(b) Contract / Art. 6(1)(c) Compliance | Lifetime of client profile; executed contracts held under §147 AO / §257 HGB | Unexecuted drafts erased on client erasure (Art. 17); executed contracts held under statutory retention hold (TASK-0066) | +| `contracts.contract_number` | Human-readable sequential reference (`CTR-YYYY-NNNN`) | Art. 6(1)(b) Contract / §147 AO | Retained with contract record | Included in DSAR export; erased on client erasure if draft | +| `contracts.title` | Descriptive title of the agreement | Art. 6(1)(b) Contract | Retained with contract record | Included in DSAR export; erased on client erasure if draft | +| `contracts.rendered_body` | Merged markdown agreement text (contains client & venue details) | Art. 6(1)(b) Contract / §147 AO | Retained with contract record; executed: 10-year statutory retention (§147 AO / §257 HGB) | Included in client DSAR export (TASK-0066); never logged in audit metadata | +| `contracts.merge_values_snapshot` | Structured key-value snapshot of merged dynamic fields | Art. 6(1)(b) Contract / §147 AO | Retained with contract record; executed: 10-year statutory retention (§147 AO / §257 HGB) | Included in client DSAR export (TASK-0066); never logged in audit metadata | +| `contracts.canonical_body_sha256` | Cryptographic SHA-256 fingerprint of rendered agreement text | Art. 32 Security of processing / Art. 5(1)(f) Integrity | Retained with contract record (evidentiary integrity proof) | Included in DSAR export; tamper-evident verification | +| `contracts.token_hash` | SHA-256 digest of secret token for cookieless client signing URL | Art. 6(1)(b) Contract / Art. 32 Security of processing | Retained while contract is pending signature | Invalidated on revocation (`token_revoked_at`); erased with contract record | +| `contracts.client_signed_at` | Timestamp of client electronic signature execution | Art. 6(1)(b) Contract / §147 AO / §257 HGB | 10-year statutory retention from close of calendar year in which contract was executed (§147(3) AO, §257(4) HGB) | Included in client DSAR export (Art. 15); exempt from Art. 17 erasure during statutory retention (TASK-0066) | +| `contracts.client_signer_name` | Full legal name of client signatory | Art. 6(1)(b) Contract / §147 AO (Statutory commercial contract) | 10-year statutory retention from close of calendar year in which contract was executed (§147(3) AO, §257(4) HGB) | Included in client DSAR export (Art. 15); exempt from Art. 17 erasure during statutory retention; never logged in audit metadata | +| `contracts.client_signer_email` | Email address of client signatory at time of signing | Art. 6(1)(b) Contract / §147 AO (Statutory commercial contract) | 10-year statutory retention from close of calendar year in which contract was executed (§147(3) AO, §257(4) HGB) | Included in client DSAR export (Art. 15); exempt from Art. 17 erasure during statutory retention; never logged in audit metadata | +| `contracts.client_signature_type` | Signature modality (`drawn` or `typed`) | Art. 6(1)(b) Contract / eIDAS (SES legal validity) | 10-year statutory retention matching executed contract record (§147 AO / §257 HGB) | Included in client DSAR export (Art. 15); exempt from Art. 17 erasure during statutory hold | +| `contracts.client_signature_data` | Signature artifact (Base64 SVG vector path or typed full legal name) | Art. 6(1)(b) Contract / eIDAS / §147 AO | 10-year statutory retention matching executed contract record (§147 AO / §257 HGB) | Included in client DSAR export (Art. 15); exempt from Art. 17 erasure during statutory hold; never logged in audit metadata | +| `contracts.client_ip_anonymized` | Truncated IP address (IPv4 `/24`, IPv6 `/48`) proving execution context without persistent device fingerprinting | Art. 6(1)(b) / §147 AO / Art. 6(1)(f) Legitimate interest (evidentiary proof) | 10-year statutory retention matching executed contract record (§147 AO / §257 HGB) | Included in client DSAR export (Art. 15); exempt from Art. 17 erasure during statutory hold; never logged in audit metadata | +| `contracts.client_user_agent` | Browser / device user-agent string at time of client signature | Art. 6(1)(b) / §147 AO / Art. 6(1)(f) Legitimate interest (evidentiary proof) | 10-year statutory retention matching executed contract record (§147 AO / §257 HGB) | Included in client DSAR export (Art. 15); exempt from Art. 17 erasure during statutory hold; never logged in audit metadata | +| `contracts.studio_signed_at` | Timestamp of studio countersignature execution | Art. 6(1)(b) Contract / §147 AO / §257 HGB | 10-year statutory retention from close of calendar year in which contract was executed (§147(3) AO, §257(4) HGB) | Included in client DSAR export (Art. 15); exempt from Art. 17 erasure during statutory retention | +| `contracts.studio_signer_name` | Full legal name of studio representative signing contract | Art. 6(1)(b) Contract / §147 AO (Statutory commercial contract) | 10-year statutory retention matching executed contract record (§147 AO / §257 HGB) | Exported with contract; exempt from Art. 17 erasure during statutory retention; never logged in audit metadata | +| `contracts.studio_signer_title` | Professional role/title of studio representative (e.g. Studio Owner) | Art. 6(1)(b) Contract / §147 AO (Statutory commercial contract) | 10-year statutory retention matching executed contract record (§147 AO / §257 HGB) | Exported with contract; exempt from Art. 17 erasure during statutory retention; never logged in audit metadata | +| `contracts.studio_signature_data` | Studio countersignature artifact (Base64 SVG vector path or typed name) | Art. 6(1)(b) Contract / eIDAS / §147 AO | 10-year statutory retention matching executed contract record (§147 AO / §257 HGB) | Exported with contract; exempt from Art. 17 erasure during statutory retention; never logged in audit metadata | +| `contracts.pdf_storage_key` | Internal object storage key of generated executed contract PDF | Art. 6(1)(b) Contract / §147 AO (Archival document reference) | 10-year statutory retention matching executed contract record (§147 AO / §257 HGB) | Storage object retained under statutory hold; delivered in client DSAR export (TASK-0066) | +| `contracts.pdf_sha256` | Cryptographic SHA-256 hash of generated contract PDF document | Art. 32 Security of processing / Art. 5(1)(f) Integrity | 10-year statutory retention matching executed contract record (§147 AO / §257 HGB) | Included in DSAR export; tamper-evident verification | +| `contracts.internal_notes` | Photographer private notes on contract terms or negotiations | Art. 6(1)(f) Legitimate interest (studio business management) | Retained with contract record | Excluded from client portal and public token view (`internalNotes: null`); included in owner audit export; deleted on client erasure if not subject to statutory hold | diff --git a/src/server/audit/allowlist.ts b/src/server/audit/allowlist.ts index c5dfa98..b14788d 100644 --- a/src/server/audit/allowlist.ts +++ b/src/server/audit/allowlist.ts @@ -257,12 +257,9 @@ export const ACTION_METADATA_ALLOWLIST = new Map([ ['contract.sent', ['contract_id', 'project_id', 'client_id', 'contract_number', 'valid_until']], [ 'contract.signed_by_client', - ['contract_id', 'project_id', 'client_id', 'contract_number', 'signer_name', 'signature_type'], - ], - [ - 'contract.countersigned', - ['contract_id', 'project_id', 'client_id', 'contract_number', 'signer_name'], + ['contract_id', 'project_id', 'client_id', 'contract_number', 'signature_type'], ], + ['contract.countersigned', ['contract_id', 'project_id', 'client_id', 'contract_number']], ['contract.voided', ['contract_id', 'project_id', 'client_id', 'contract_number', 'reason']], ['contract.revoked', ['contract_id', 'project_id', 'client_id', 'contract_number', 'reason']], [ @@ -271,19 +268,7 @@ export const ACTION_METADATA_ALLOWLIST = new Map([ ], [ 'studio_settings.contract_updated', - [ - 'changed_fields', - 'contract_signatory_name', - 'contract_signatory_title', - 'contract_countersignature_mode', - 'studio_city', - 'studio_country_code', - 'studio_address_line1', - 'studio_address_line2', - 'studio_postal_code', - 'studio_phone', - 'studio_email', - ], + ['changed_fields', 'contract_countersignature_mode', 'studio_country_code'], ], ]); diff --git a/src/server/contracts/repo.ts b/src/server/contracts/repo.ts index 8a8992a..451dbba 100644 --- a/src/server/contracts/repo.ts +++ b/src/server/contracts/repo.ts @@ -1,6 +1,6 @@ import { randomBytes, createHash } from 'node:crypto'; -import { eq, desc, like } from 'drizzle-orm'; -import type { DbClient } from '@/server/db/client'; +import { eq, desc, like, and, isNull } from 'drizzle-orm'; +import { db as defaultDb, type DbClient } from '@/server/db/client'; import { contracts, type Contract, type NewContract } from '@/server/db/schema/contracts'; import { projects } from '@/server/db/schema/projects'; import { contractTemplates } from '@/server/db/schema/contracts'; @@ -130,25 +130,28 @@ export async function getContractById( /** * Retrieves a contract by public token hash (for public signing route). - * Does not require owner authorization, but returns 404 if not found. + * Does not require owner authorization, validates that token is not revoked, and masks private internalNotes (I1-S03). */ export async function getContractByTokenHash( tokenHash: string, explicitClient?: DbClient, -): Promise { - const client = explicitClient ?? (await resolveRepoAuth(null, explicitClient)).client; +): Promise { + const client = explicitClient ?? defaultDb; const [contract] = await client .select() .from(contracts) - .where(eq(contracts.tokenHash, tokenHash)) + .where(and(eq(contracts.tokenHash, tokenHash), isNull(contracts.tokenRevokedAt))) .limit(1); if (!contract) { - throw new ContractNotFoundError('Contract not found for token hash'); + return null; } - return contract; + return { + ...contract, + internalNotes: null, + }; } /** diff --git a/src/server/studio/signing-settings.ts b/src/server/studio/signing-settings.ts index 023cb67..cccc0d9 100644 --- a/src/server/studio/signing-settings.ts +++ b/src/server/studio/signing-settings.ts @@ -107,16 +107,8 @@ export async function updateStudioSigningProfile( outcome: 'success', metadata: { changed_fields: changedFields, - contract_signatory_name: updated.contract_signatory_name ?? undefined, - contract_signatory_title: updated.contract_signatory_title ?? undefined, contract_countersignature_mode: updated.contract_countersignature_mode, - studio_city: updated.studio_city ?? undefined, studio_country_code: updated.studio_country_code, - studio_address_line1: updated.studio_address_line1 ?? undefined, - studio_address_line2: updated.studio_address_line2 ?? undefined, - studio_postal_code: updated.studio_postal_code ?? undefined, - studio_phone: updated.studio_phone ?? undefined, - studio_email: updated.studio_email ?? undefined, }, }, client, diff --git a/tests/integration/contracts-repo.test.ts b/tests/integration/contracts-repo.test.ts index f3df560..e93c708 100644 --- a/tests/integration/contracts-repo.test.ts +++ b/tests/integration/contracts-repo.test.ts @@ -219,20 +219,33 @@ describe('Contracts Repository Integration Tests (AC-5, AC-7, AC-8)', () => { expect(c2.contractNumber).toBe(`CTR-${String(currentYear)}-0002`); }); - it('AC-5: getContractByTokenHash retrieves contract via public token hash', async () => { + it('AC-5 & I1-S03: getContractByTokenHash retrieves contract, masks internalNotes, rejects revoked tokens without requiring auth', async () => { const draft = await createContractDraft( { projectId: testProjectId, clientId: testClientId, title: 'Public Viewable Contract', + internalNotes: 'Private photographer pricing strategy and negotiation history', }, ownerAuth, dbClient, ); + // 1. Valid token lookup masks internalNotes to null (I1-S03) const fetched = await getContractByTokenHash(draft.tokenHash, dbClient); - expect(fetched.id).toBe(draft.id); - expect(fetched.title).toBe('Public Viewable Contract'); + expect(fetched).not.toBeNull(); + expect(fetched?.id).toBe(draft.id); + expect(fetched?.title).toBe('Public Viewable Contract'); + expect(fetched?.internalNotes).toBeNull(); + + // 2. Non-existent token returns null without error + const nonExistent = await getContractByTokenHash('0'.repeat(64), dbClient); + expect(nonExistent).toBeNull(); + + // 3. Revoked token returns null (I1-S03) + await voidContract(draft.id, 'Customer changed requirements', ownerAuth, dbClient); + const afterRevocation = await getContractByTokenHash(draft.tokenHash, dbClient); + expect(afterRevocation).toBeNull(); }); it('AC-5: updateContractDraft updates title and body while in draft and records contract.updated audit event', async () => { @@ -449,7 +462,7 @@ describe('Contracts Repository Integration Tests (AC-5, AC-7, AC-8)', () => { 'non-existent-token-hash-64-characters-long-000000000000000000000000', dbClient, ), - ).rejects.toThrow(ContractNotFoundError); + ).resolves.toBeNull(); }); it('AC-5: creates contract draft in German locale using German template content and populates proposalId', async () => { diff --git a/tests/integration/studio-signing-settings.test.ts b/tests/integration/studio-signing-settings.test.ts index b4c6689..acacb80 100644 --- a/tests/integration/studio-signing-settings.test.ts +++ b/tests/integration/studio-signing-settings.test.ts @@ -101,21 +101,26 @@ describe('Studio Signing Settings Integration Tests (AC-6, AC-7, AC-8)', () => { expect(auditLog).toBeDefined(); expect(auditLog?.targetType).toBe('studio_settings'); - const meta = auditLog?.metadata as { - contract_signatory_name?: string; - contract_countersignature_mode?: string; - studio_city?: string; - studio_country_code?: string; - changed_fields?: string[]; - } | null; - expect(meta?.contract_signatory_name).toBe('Jane Doe'); - expect(meta?.contract_countersignature_mode).toBe('explicit'); - expect(meta?.studio_city).toBe('Berlin'); - expect(meta?.studio_country_code).toBe('DE'); - expect(meta?.changed_fields).toContain('contract_signatory_name'); - expect(meta?.changed_fields).toContain('contract_signatory_title'); - expect(meta?.changed_fields).toContain('contract_countersignature_mode'); - expect(meta?.changed_fields).toContain('studio_city'); + const meta = auditLog?.metadata ?? {}; + + // Non-PII operational fields must be present + expect(meta.contract_countersignature_mode).toBe('explicit'); + expect(meta.studio_country_code).toBe('DE'); + const changedFields = meta.changed_fields as string[]; + expect(changedFields).toContain('contract_signatory_name'); + expect(changedFields).toContain('contract_signatory_title'); + expect(changedFields).toContain('contract_countersignature_mode'); + expect(changedFields).toContain('studio_city'); + + // Regression test for I1-S01: Personal data MUST NOT be stored in immutable audit_events + expect(meta.contract_signatory_name).toBeUndefined(); + expect(meta.contract_signatory_title).toBeUndefined(); + expect(meta.studio_city).toBeUndefined(); + expect(meta.studio_address_line1).toBeUndefined(); + expect(meta.studio_address_line2).toBeUndefined(); + expect(meta.studio_postal_code).toBeUndefined(); + expect(meta.studio_phone).toBeUndefined(); + expect(meta.studio_email).toBeUndefined(); }); it('AC-7: rejects unauthenticated caller with UNAUTHENTICATED', async () => { diff --git a/tests/unit/contracts-registry-audit.test.ts b/tests/unit/contracts-registry-audit.test.ts index 4a2e612..5a46ca2 100644 --- a/tests/unit/contracts-registry-audit.test.ts +++ b/tests/unit/contracts-registry-audit.test.ts @@ -103,11 +103,73 @@ describe('Contracts Registry, Audit Allowlist & ADR-0019 Unit Tests (AC-9, AC-10 expect(ctrCreated).toContain('client_id'); expect(ctrCreated).toContain('contract_number'); - // Verify studio settings contract update action + // Verify studio settings contract update action contains only non-PII keys (I1-S01) const studioContract = ACTION_METADATA_ALLOWLIST.get('studio_settings.contract_updated'); - expect(studioContract).toContain('contract_signatory_name'); + expect(studioContract).toContain('changed_fields'); expect(studioContract).toContain('contract_countersignature_mode'); - expect(studioContract).toContain('studio_city'); + expect(studioContract).toContain('studio_country_code'); + expect(studioContract).not.toContain('contract_signatory_name'); + expect(studioContract).not.toContain('contract_signatory_title'); + expect(studioContract).not.toContain('studio_city'); + expect(studioContract).not.toContain('studio_address_line1'); + expect(studioContract).not.toContain('studio_address_line2'); + expect(studioContract).not.toContain('studio_postal_code'); + expect(studioContract).not.toContain('studio_phone'); + expect(studioContract).not.toContain('studio_email'); + + // Verify signing actions do NOT contain signer_name (I1-S04) + const clientSigned = ACTION_METADATA_ALLOWLIST.get('contract.signed_by_client'); + expect(clientSigned).toContain('signature_type'); + expect(clientSigned).not.toContain('signer_name'); + + const countersigned = ACTION_METADATA_ALLOWLIST.get('contract.countersigned'); + expect(countersigned).not.toContain('signer_name'); + }); + + it('I1-S02: data inventory registers all personal data fields for contracts and studio signatory profile', () => { + const inventoryPath = path.resolve(process.cwd(), 'docs/privacy/data-inventory.md'); + const content = fs.readFileSync(inventoryPath, 'utf8'); + + const requiredContractFields = [ + 'contracts.client_signer_name', + 'contracts.client_signer_email', + 'contracts.client_signature_type', + 'contracts.client_signature_data', + 'contracts.client_ip_anonymized', + 'contracts.client_user_agent', + 'contracts.studio_signer_name', + 'contracts.studio_signer_title', + 'contracts.studio_signature_data', + 'contracts.rendered_body', + 'contracts.merge_values_snapshot', + 'contracts.internal_notes', + ]; + + for (const field of requiredContractFields) { + expect(content).toContain(`\`${field}\``); + } + + const requiredStudioFields = [ + 'studio_settings.contract_signatory_name', + 'studio_settings.contract_signatory_title', + 'studio_settings.contract_countersignature_mode', + 'studio_settings.studio_address_line1', + 'studio_settings.studio_address_line2', + 'studio_settings.studio_postal_code', + 'studio_settings.studio_city', + 'studio_settings.studio_country_code', + 'studio_settings.studio_phone', + 'studio_settings.studio_email', + ]; + + for (const field of requiredStudioFields) { + expect(content).toContain(`\`${field}\``); + } + + // Verify statutory retention basis is referenced + expect(content).toContain('10-year statutory retention'); + expect(content).toContain('§147 AO'); + expect(content).toContain('§257 HGB'); }); it('AC-10: ADR-0019 exists in projects/ownlight/docs/architecture/adr/ and specifies status Accepted (2026-11-06)', () => {