From 1c0a16fd59e8efc79ab89b0ddd57d795e2074327 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Thu, 23 Apr 2026 17:04:41 +0200 Subject: [PATCH] docs(spec): add data-model refactor design (Spec 1 of 3) Introduces yachts and companies as first-class entities with memberships, ownership history, berth reservations, and dual-path EOI templates. Explicit non-goals (importer, merge endpoint) carved out as Specs 2 and 3. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-23-data-model-refactor-design.md | 663 ++++++++++++++++++ 1 file changed, 663 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-23-data-model-refactor-design.md diff --git a/docs/superpowers/specs/2026-04-23-data-model-refactor-design.md b/docs/superpowers/specs/2026-04-23-data-model-refactor-design.md new file mode 100644 index 0000000..cca711b --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-data-model-refactor-design.md @@ -0,0 +1,663 @@ +# Data-Model Refactor: Yachts and Companies as First-Class Entities + +**Status:** Draft — awaiting final review +**Date:** 2026-04-23 +**Spec position:** 1 of 3 (Spec 2 = NocoDB+MinIO importer; Spec 3 = client merge endpoint) + +## Overview + +This spec delivers a refactor of the core client / yacht / company data model to support real-world ownership relationships that the current schema cannot express. + +The current `clients` table holds yacht dimensions and company name as columns directly on the person row. This enforces a one-person = one-yacht = one-company assumption that breaks the moment: + +- A client owns multiple yachts (a common marina scenario) +- A person is a broker or director of multiple companies +- A yacht is legally owned by a shell company (common for tax / liability reasons) rather than by the human on the dock +- A yacht changes hands between owners and the marina needs chain-of-title + +The refactor pulls yacht and company data into their own first-class tables, adds join tables for person↔company memberships, and introduces a proper `berth_reservations` table for exclusive-reservation lifecycle tracking. + +This spec also fixes two existing schema gaps that surface during the refactor: + +- `berths.status` tracks the state of a berth but there is no table recording which client/yacht exclusively reserves a berth +- `invoices.clientName` is a text field with no FK — there's no first-class link between invoices and billing entities + +## Scope boundaries + +### In scope (this spec) + +- New `yachts`, `yacht_ownership_history`, `yacht_notes`, `yacht_tags` tables +- New `companies`, `company_memberships`, `company_addresses`, `company_notes`, `company_tags` tables +- New `berth_reservations` table with partial-unique-index exclusivity enforcement +- Updates to `interests`, `berth_waiting_list`, `invoices`, `files`, `documents` to add FKs to the new entities +- Removal of yacht, company, and proxy columns from `clients` +- New services, API routes, permissions, and socket/webhook events +- New UI pages for yachts, companies, and berth reservations; modifications to client, interest, berth, invoice forms +- Dual-path EOI generation (Documenso + in-app PDF template) with a shared payload builder +- Comprehensive test coverage: unit, integration, E2E, exhaustive click-through, template regression +- Seeder with realistic multi-cardinality dummy data + +### Explicitly out of scope + +- **Importing NocoDB records and MinIO documents** → Spec 2 +- **Client merge endpoint** → Spec 3 +- Yacht survey / class-cert document categorization +- Company hierarchy (holding → subsidiary) +- Line-item-level yacht references on invoices +- Auto-renewal flow for berth reservations +- Per-yacht row-level permissions +- Portal branding per company + +## Decisions and rationale + +| Topic | Decision | Why | +| ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Yacht scope | Full entity: own page, documents, ownership history, yacht-keyed interests / reservations / invoices | Marina domain cares about yachts as first-class objects (dimensions for berth fit, registration for port entry, ownership for liability) | +| Company scope | Full entity: memberships join, company-owned yachts, company billing | Yachts are frequently owned by shell companies for tax/liability reasons — the human on the dock is a director or broker. Lightweight/medium models can't route invoices to the correct legal entity | +| Ownership history | Dedicated `yacht_ownership_history` table + denormalized current-owner columns on `yachts` | Ownership change is exactly the kind of event that needs queryable history (chain of title, insurance, broker commission attribution). Denormalized current-owner keeps common reads fast | +| Proxy fields on clients (`isProxy`, `proxyType`, `actualOwnerName`, `relationshipNotes`) | Drop all four | Every real proxy scenario is expressible through `company_memberships` roles or `client_relationships`. Keeping the old fields creates two sources of truth and drift risk | +| Berth exclusive reservation | New `berth_reservations` table with partial unique index `WHERE status = 'active'` | Current schema tracks berth state via `berths.status` but does not record which client/yacht holds the reservation. Partial unique index enforces exclusivity at the DB level | +| Invoice billing entity | `billingEntityType` (`'client' \| 'company'`) + `billingEntityId`; `clientName` retained as an immutable snapshot | Companies become first-class payers. `clientName` as text is preserved on the invoice as a snapshot so invoices never retroactively rename themselves | +| Data state | Green-field with dummy seeder; real data arrives via Spec 2 | No production data lives in this Postgres DB yet. NocoDB holds the real records until Spec 2 imports them | +| Delivery | One cohesive spec covering both yacht + company refactor | Splitting doubles the migration/UI/test churn for no architectural gain; both sets of changes overlap heavily | +| EOI template strategy | Support both Documenso-template path and in-app PDF template path, both fully functional from day one | Handoff risk: client must not come back claiming "EOIs don't work." If Documenso breaks or is replaced, in-app path is the fallback. Both consume the same payload builder for data consistency | +| EOI UI picker | Dropdown at generation time (user picks Documenso or in-app explicitly) | Explicit beats automatic fallback for handoff — misconfiguration is visible, not silently masked | +| Testing | Unit, integration, full E2E scenarios, exhaustive Playwright click-through, template regression (including visual diff) | Explicit "test thoroughly" direction plus the handoff concern justify going heavier than normal on integration + E2E tiers | + +## Schema design + +### New tables + +``` +yachts + id text PK + portId text NOT NULL FK → ports.id + name text NOT NULL + hullNumber text + registration text + flag text + yearBuilt integer + builder text + model text + hullMaterial text + lengthFt numeric + widthFt numeric + draftFt numeric + lengthM numeric + widthM numeric + draftM numeric + currentOwnerType text NOT NULL -- 'client' | 'company' + currentOwnerId text NOT NULL + status text NOT NULL DEFAULT 'active' -- 'active' | 'retired' | 'sold_away' + notes text + archivedAt timestamptz + createdAt timestamptz NOT NULL DEFAULT now() + updatedAt timestamptz NOT NULL DEFAULT now() + Indexes: + idx_yachts_port on (portId) + idx_yachts_current_owner on (portId, currentOwnerType, currentOwnerId) + idx_yachts_name on (portId, name) + +yacht_ownership_history + id text PK + yachtId text NOT NULL FK → yachts.id ON DELETE CASCADE + ownerType text NOT NULL -- 'client' | 'company' + ownerId text NOT NULL + startDate date NOT NULL + endDate date -- NULL = currently active + transferReason text -- 'sale' | 'inheritance' | 'gift' | 'company_restructure' | 'other' + transferNotes text + createdBy text NOT NULL + createdAt timestamptz NOT NULL DEFAULT now() + Indexes: + idx_yoh_yacht on (yachtId) + idx_yoh_active (partial) on (yachtId) WHERE endDate IS NULL + +yacht_notes -- mirrors client_notes shape + id, yachtId (FK CASCADE), authorId, content, mentions text[], isLocked, createdAt, updatedAt + +yacht_tags + yachtId, tagId composite PK; tagId references system.tags.id + +companies + id text PK + portId text NOT NULL FK → ports.id + name text NOT NULL + legalName text + taxId text + registrationNumber text + incorporationCountry text + incorporationDate date + status text NOT NULL DEFAULT 'active' -- 'active' | 'dissolved' + billingEmail text + notes text + archivedAt timestamptz + createdAt timestamptz NOT NULL DEFAULT now() + updatedAt timestamptz NOT NULL DEFAULT now() + Indexes: + idx_companies_port on (portId) + idx_companies_name_unique UNIQUE on (portId, lower(name)) -- case-insensitive + idx_companies_taxid on (portId, taxId) WHERE taxId IS NOT NULL + +company_memberships + id text PK + companyId text NOT NULL FK → companies.id ON DELETE CASCADE + clientId text NOT NULL FK → clients.id ON DELETE CASCADE + role text NOT NULL -- 'director' | 'officer' | 'broker' | 'representative' | 'legal_counsel' | 'employee' | 'shareholder' | 'other' + roleDetail text -- free-text qualifier: "Managing Director", "Exclusive Broker" + startDate date NOT NULL + endDate date -- NULL = active + isPrimary boolean NOT NULL DEFAULT false + notes text + createdAt timestamptz NOT NULL DEFAULT now() + updatedAt timestamptz NOT NULL DEFAULT now() + Indexes: + idx_cm_company on (companyId) + idx_cm_client on (clientId) + idx_cm_active (partial) on (companyId, clientId) WHERE endDate IS NULL + unique_cm_exact UNIQUE on (companyId, clientId, role, startDate) + +company_addresses -- mirrors client_addresses shape with companyId FK +company_notes -- mirrors client_notes shape with companyId FK +company_tags + companyId, tagId composite PK + +berth_reservations + id text PK + berthId text NOT NULL FK → berths.id + portId text NOT NULL FK → ports.id + clientId text NOT NULL FK → clients.id -- contract holder + yachtId text NOT NULL FK → yachts.id -- which yacht occupies the slip + interestId text FK → interests.id -- nullable link back to originating interest + status text NOT NULL -- 'pending' | 'active' | 'ended' | 'cancelled' + startDate date NOT NULL + endDate date -- NULL = open-ended + tenureType text NOT NULL DEFAULT 'permanent' -- 'permanent' | 'fixed_term' | 'seasonal' + contractFileId text FK → files.id + createdBy text NOT NULL + createdAt timestamptz NOT NULL DEFAULT now() + updatedAt timestamptz NOT NULL DEFAULT now() + Indexes: + idx_br_berth on (berthId) + idx_br_client on (clientId) + idx_br_yacht on (yachtId) + idx_br_active (partial) UNIQUE on (berthId) WHERE status = 'active' +``` + +### Modified tables + +``` +clients + DROP COLUMN yachtName, yachtLengthFt, yachtWidthFt, yachtDraftFt, + yachtLengthM, yachtWidthM, yachtDraftM, berthSizeDesired + DROP COLUMN companyName + DROP COLUMN isProxy, proxyType, actualOwnerName, relationshipNotes + (retains: fullName, nationality, preferredContactMethod, preferredLanguage, + timezone, source, sourceDetails, archivedAt, createdAt, updatedAt) + +interests + ADD COLUMN yachtId text FK → yachts.id -- nullable initially; enforced non-null before pipeline_stage leaves 'open' + ADD INDEX idx_interests_yacht on (yachtId) + +berth_waiting_list + ADD COLUMN yachtId text FK → yachts.id + +invoices + ADD COLUMN billingEntityType text NOT NULL -- 'client' | 'company' + ADD COLUMN billingEntityId text NOT NULL + (clientName column kept as immutable snapshot — must never auto-update) + ADD INDEX idx_invoices_billing_entity on (portId, billingEntityType, billingEntityId) + +files + ADD COLUMN yachtId text FK → yachts.id -- nullable + ADD COLUMN companyId text FK → companies.id -- nullable + (existing clientId stays nullable; a file links to one of: client, yacht, or company) + +documents + ADD COLUMN yachtId text FK → yachts.id -- nullable + ADD COLUMN companyId text FK → companies.id -- nullable +``` + +### DB-level invariants + +| # | Invariant | Enforced by | +| --- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | One active ownership row per yacht | Partial unique index on `yacht_ownership_history(yachtId) WHERE endDate IS NULL` | +| 2 | One active reservation per berth | Partial unique index on `berth_reservations(berthId) WHERE status = 'active'` | +| 3 | Yacht always has a current owner | Both `currentOwnerType` and `currentOwnerId` NOT NULL; ownership row inserted atomically with yacht creation inside service transaction | +| 4 | Company names unique per port (case-insensitive) | Unique index on `(portId, lower(name))` | +| 5 | Exact-duplicate memberships blocked | Unique index on `(companyId, clientId, role, startDate)` | + +### Service-layer invariants (not DB-enforceable due to polymorphic columns) + +| # | Invariant | Enforced by | +| --- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| 6 | `yacht.currentOwnerType='client'` ↔ `currentOwnerId` references an existing row in `clients`; same for `'company'` ↔ `companies` | Zod validator + service-layer lookup before insert/update | +| 7 | `yacht_ownership_history.ownerType/ownerId` consistent with the corresponding entity table | Same as #6 | +| 8 | `invoices.billingEntityType` + `billingEntityId` consistent with entity table | Same as #6 | +| 9 | `files.clientId`, `files.yachtId`, `files.companyId` — exactly one of the three must be non-null if the file is entity-scoped | Service-layer validation on insert/update | + +### Drizzle relations (`relations.ts`) + +All new tables wire into the relations map. Notable additions: + +- `clientsRelations`: `companyMemberships` (many), `ownedYachts` (many, via polymorphic query), `berthReservations` (many) +- `yachtsRelations`: `port` (one), `ownershipHistory` (many), `notes` (many), `tags` (many), `interests` (many), `reservations` (many), `documents` (many) +- `companiesRelations`: `port` (one), `memberships` (many), `addresses` (many), `notes` (many), `tags` (many), `documents` (many) +- `berthReservationsRelations`: `berth`, `port`, `client`, `yacht`, `interest`, `contractFile` + +## Service layer and API + +### New services (`src/lib/services/`) + +| File | Key functions | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `yachts.service.ts` | `list`, `getById`, `create`, `update`, `archive`, `transferOwnership(yachtId, newOwnerType, newOwnerId, effectiveDate, reason, notes)` — atomic: closes current history row, opens new row, updates denormalized `currentOwner*` columns | +| `companies.service.ts` | `list`, `getById`, `create`, `update`, `archive`, `upsertByName(portId, name)` (case-insensitive, for autocomplete) | +| `company-memberships.service.ts` | `addMembership`, `endMembership(id, endDate)`, `updateMembership`, `listByCompany`, `listByClient`, `setPrimary` | +| `berth-reservations.service.ts` | `createPending`, `activate(id)` (gates on partial unique index), `end(id, endDate)`, `cancel(id)`, `listByBerth`, `listByClient`, `listByYacht` | + +### Modified services + +| File | Change | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `clients.service.ts` | Strip yacht/company/proxy field handling from create/update paths | +| `interests.service.ts` | Accept `yachtId`; validate yacht is owned by the interest's client OR by a company the client actively represents. Promote-to-stage helpers require `yachtId` non-null before leaving `'open'` | +| `berths.service.ts` | Read reservation state via `berth_reservations` instead of deriving from `berths.status`. Reservation state changes also update `berths.status` via trigger-in-service-layer | +| `invoices.service.ts` | Accept `billingEntityType` + `billingEntityId`; snapshot the entity's current display name into `clientName` at creation (immutable afterward) | +| `search.service.ts` | Extend to yachts and companies; include yacht name, hull number, registration in search index; include company name, legal name, taxId | +| `recommendations.ts` (berth matcher) | Pull yacht dimensions from `yachts` table via `interest.yachtId` instead of from `clients.yacht*` | +| `document-templates.ts` | Update `MERGE_FIELDS` catalog: deprecate `{{client.yachtName}}`, `{{client.companyName}}` and old yacht dimension tokens; add `{{yacht.*}}`, `{{company.*}}`, `{{owner.*}}` scopes. Update `resolveTemplate()` to resolve new scopes | +| `portal.service.ts` | Portal user dashboards surface their yachts (owned + represented via memberships), their active memberships, and their active berth reservations | + +### New REST endpoints + +``` +# Yachts +GET /api/v1/yachts +POST /api/v1/yachts +GET /api/v1/yachts/:id +PATCH /api/v1/yachts/:id +DELETE /api/v1/yachts/:id — archive (soft delete) +POST /api/v1/yachts/:id/transfer — ownership transfer +GET /api/v1/yachts/:id/ownership-history +GET /api/v1/yachts/autocomplete?q=… + +# Companies +GET /api/v1/companies +POST /api/v1/companies +GET /api/v1/companies/:id +PATCH /api/v1/companies/:id +DELETE /api/v1/companies/:id — archive +GET /api/v1/companies/autocomplete?q=… + +# Company memberships +GET /api/v1/companies/:id/members +POST /api/v1/companies/:id/members +PATCH /api/v1/companies/:id/members/:mid +DELETE /api/v1/companies/:id/members/:mid — sets endDate + +# Berth reservations +GET /api/v1/berths/:id/reservations +POST /api/v1/berths/:id/reservations — create pending +PATCH /api/v1/berth-reservations/:id — state transitions +``` + +### Modified endpoints + +- `GET /api/v1/clients/:id` — response now includes nested `yachts` (owned + represented), `companies` (via active memberships), `activeReservations` +- `POST /api/v1/clients` — no longer accepts yacht/company/proxy fields +- `POST /api/v1/interests` — requires `yachtId` +- `POST /api/v1/invoices` — requires `billingEntityType` + `billingEntityId` +- `POST /api/public/interests` — creates new `client` + `yacht` + optional `company` + `membership` + `interest` in one transaction, all marked `source: 'public_submission'`. No dedup against existing records (anonymous trust boundary). + +### Permissions (new keys) + +``` +yachts:view +yachts:write +yachts:transfer — higher-stakes operation, separate from :write +yachts:delete — archive permission + +companies:view +companies:write +companies:delete + +memberships:write — covers both directions of company_memberships + +reservations:view +reservations:write +``` + +Existing role updates: + +- `admin` — all new keys +- `team_lead` — `yachts:view`, `yachts:write`, `companies:view`, `companies:write`, `memberships:write`, `reservations:view`; NOT `yachts:transfer` or `reservations:write` +- `front_desk` — all `:view` keys + +### Socket / webhook events (new) + +``` +yacht.created +yacht.updated +yacht.ownership_transferred +yacht.archived +company.created +company.updated +company.archived +company_membership.added +company_membership.ended +berth_reservation.created +berth_reservation.activated +berth_reservation.ended +berth_reservation.cancelled +``` + +Webhook event map in `src/lib/services/webhooks.ts` gains the same list. + +## EOI template strategy (dual-path) + +Both paths fully supported from day one. Required to mitigate handoff risk — if Documenso breaks or is replaced, the in-app path is the fallback. + +### Shared payload builder + +```ts +// src/lib/services/eoi-context.ts +export async function buildEoiContext(interestId: string): Promise + +type EoiContext = { + client: { fullName; nationality; primaryEmail; primaryPhone; address; … } + yacht: { name; lengthFt; widthFt; draftFt; hullNumber; flag; yearBuilt; … } // via interest.yachtId + company: { name; legalName; taxId; billingAddress } | null // if yacht owner is a company + owner: { type: 'client' | 'company'; name; … } // polymorphic current owner + berth: { mooringNumber; area; lengthFt; price; priceCurrency; tenureType; … } + interest: { stage; leadCategory; dateFirstContact; notes; … } + port: { name; defaultCurrency; legalEntity; … } + date: { today; year } +} +``` + +Both paths consume this. Guarantees the two rendering engines see the same data and stay in sync as schema evolves. + +### Path A — Documenso template + +- Documenso hosts the template, referenced by ID via env var `DOCUMENSO_TEMPLATE_ID` (matches the old system's `NUXT_DOCUMENSO_TEMPLATE_ID` pattern — a single global template ID; per-port templates are a future extension if needed) +- Payload builder flattens `EoiContext` into Documenso's field-name format, POSTs to `/api/v1/templates/{id}/generate-document` +- Signing flow unchanged: Documenso emails signers, webhook updates status in our DB +- Mitigation for "Documenso's template expects specific field names": one-time audit mapping every field name expected by `templateId=8` (from the old system) to a source in the new schema + +### Path B — In-app PDF template + +- Seed a "Standard EOI" HTML template into `document_templates` table on first boot. Template references tokens: `{{client.fullName}}`, `{{yacht.name}}`, `{{yacht.lengthFt}}`, `{{company.name}}`, `{{berth.mooringNumber}}`, `{{interest.dateFirstContact}}`, etc. +- `resolveTemplate()` substitutes tokens from `EoiContext` +- `pdfme` renders the resolved HTML to PDF +- **Signing**: generated PDF is uploaded to Documenso via existing `documensoCreate` + `documensoSend` — Documenso supports signing ad-hoc PDFs (not just its own templates). Signing experience identical to Path A from the signer's perspective. +- **Fallback**: if Documenso is unavailable, the PDF can be emailed to the signer via `nodemailer` as a manual fallback (flag in UI, not auto-fallback) + +### UI picker + +Generate-EOI dialog adds a Template dropdown: + +``` +Template: [ Documenso — Standard EOI v ] + [ Documenso — Standard EOI ] + [ In-app — Standard EOI ] + [ In-app — (any custom template user authored) ] +``` + +Explicit picker chosen over automatic fallback: misconfiguration is visible, not silently masked — important for handoff. + +## UI impact + +### New pages + +| Route | Purpose | +| ----------------------------------- | ------------------------------------------------------------------------------------------- | +| `/[portSlug]/yachts` | List view: name, dimensions, current owner, status. Filters by owner type, size, status | +| `/[portSlug]/yachts/[yachtId]` | Detail — Tabs: Overview, Ownership History, Interests, Reservations, Documents, Notes, Tags | +| `/[portSlug]/companies` | List view: name, legal name, # members, # owned yachts | +| `/[portSlug]/companies/[companyId]` | Detail — Tabs: Overview, Members, Owned Yachts, Addresses, Documents, Notes, Tags | + +### Modified pages + +| Page | Change | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `client-form` | Remove yacht / companyName / proxy fields. Becomes a clean "person" form. Yacht and company associations managed from detail page, not here | +| `client-detail` | Add tabs: Yachts (owned + represented), Companies (active memberships), Reservations | +| `client-columns` | Replace yacht/company text columns with "# yachts" and "Primary company" (from active memberships marked `isPrimary`) | +| `interest-form` | New required field: yacht picker, constrained to client's yachts (with inline "Add new yacht" option) | +| `interest-detail` | Display yacht prominently; berth recommendations match against yacht dimensions | +| `berth-detail` | New tab: Reservations. Shows active reservation + history. "Reserve this berth" button opens reservation dialog | +| `invoice-form` | New billing-entity picker (client or company toggle + autocomplete); `clientName` snapshot populates automatically | +| `eoi-generate-dialog` | New template-picker dropdown (per dual-path strategy) | +| Global search | Extended to yachts and companies | +| Sidebar | Adds "Yachts" and "Companies" entries. Reservations lives inside the Berths page | +| `/api/public/interest` form (new interest submission) | Captures yacht + company sub-forms; creates new trio on submission | + +### Portal pages + +- Dashboard: shows owned + represented yachts, active memberships, active reservations +- New "My Yachts" tab — read-only yacht detail scoped to ones user owns or represents +- New "My Reservations" tab +- Authenticated interest submissions create yacht row linked to the portal user (not anonymous) + +### New components (`src/components/`) + +``` +yachts/ + yacht-form.tsx + yacht-detail.tsx + yacht-detail-header.tsx + yacht-tabs.tsx + yacht-columns.tsx + yacht-picker.tsx + yacht-ownership-history.tsx + yacht-transfer-dialog.tsx +companies/ + company-form.tsx + company-detail.tsx + company-detail-header.tsx + company-tabs.tsx + company-columns.tsx + company-picker.tsx + company-members-tab.tsx + company-owned-yachts-tab.tsx + add-membership-dialog.tsx +reservations/ + reservation-form.tsx + reservation-list.tsx + berth-reserve-dialog.tsx +shared/ + owner-picker.tsx — polymorphic client|company autocomplete + billing-entity-picker.tsx +``` + +All follow existing `shadcn/ui` + CVA + react-hook-form + zod pattern. + +### Seeder (`src/lib/db/seed.ts`) — rewrite + +Produces realistic multi-cardinality fixtures: + +- 3 companies (two with multiple members, one dissolved with an `endDate` on all memberships) +- 8 clients (some personal-only, some with company memberships, at least one representing multiple companies) +- 12 yachts (mix of client-owned and company-owned; 2-3 with ownership-transfer history) +- Interests linking clients ↔ yachts ↔ berths with realistic pipeline-stage distribution +- A handful of active berth reservations + a few ended/cancelled ones +- Rich contact / address / membership / ownership-history data covering every test scenario + +Seeder shares factory helpers with tests (`tests/helpers/factories.ts`). + +## Testing strategy + +### Coverage targets (CI-enforced) + +| Tier | Target | +| ------------- | ------------------- | +| Service layer | ≥ 90% line coverage | +| Validators | 100% line coverage | +| API routes | ≥ 85% line coverage | +| Overall | ≥ 85% line coverage | + +Hard rules: no skipped tests on `main`; no PR merge without green CI on all tiers. + +### Tier 1 — Unit tests (Vitest) + +- Every new service function: happy path, each validation failure, each precondition failure, tenant-scoping +- Merge-field resolver: every new token resolves correctly across each context shape +- Validators: every zod schema tested for pass + fail on each field + +### Tier 2 — Integration tests (Vitest + Postgres via docker-compose test DB) + +- Migration up/down correctness +- Partial unique indexes (`berth_reservations(berthId) WHERE status='active'`, `yacht_ownership_history(yachtId) WHERE endDate IS NULL`) reject duplicate inserts +- FK cascades: deleting a client cascades contacts/addresses; yacht-with-this-owner is BLOCKED from being lost +- Atomic `transferOwnership`: concurrent retries result in consistent state +- Polymorphic integrity checks: `yacht.currentOwnerType='client'` with a companyId is rejected by service-layer validation +- Company name case-insensitive uniqueness +- Every new API route: auth → permission → service → DB → response shape + +### Tier 3 — E2E scenario tests (Playwright) + +Full-lifecycle flows: + +1. Create client → add yacht → create interest → generate EOI (Documenso path) → PDF in MinIO +2. Same, in-app template path → verify PDF content contains expected yacht name +3. Create company → add two clients as members → create yacht owned by company → generate invoice billed to company +4. Yacht transfer: client-owned → company-owned; verify history + denormalized column + UI +5. Reserve berth: create → verify visible → attempt duplicate reservation → blocked +6. Public interest form → admin sees new client+yacht+company+interest trio +7. (Spec 3 stub): merge flow tested end-to-end in Spec 3 + +Multi-cardinality flows (the core justification for this refactor): + +8. One client with 3 yachts, 3 interests, 3 different berths — all representable +9. One person as broker for 2 companies, each owning 1 yacht — memberships + owned yachts visible from client detail + +Portal flows: + +10. Portal user views "my yachts" — sees only owned/represented +11. Portal user submits interest — new yacht linked to their identity + +### Tier 3.5 — Exhaustive Playwright click-through suite + +Location: `tests/e2e/exhaustive/`. Separate CI job (15-20 min, runs in parallel with other tiers, blocks merge if failing). + +Spec files: `yachts`, `companies`, `reservations`, `client-detail-refactored`, `eoi-generate`, `invoice-form`, `berths-with-reservations`, `portal`, `navigation`. + +Per-page logic: + +1. Navigate to page +2. Enumerate every interactive element (`button`, `a`, `[role="button"]`, `[data-testid]`, form inputs) +3. Click/fill each; post-click: assert no console errors, no 4xx/5xx network responses, UI returns to stable state +4. Coverage assertion: elements clicked ≥ total elements on page (minus declared destructive-action allowlist) + +Helper: `tests/helpers/click-everything.ts` exports `clickEverythingOnPage(page, opts)`. + +Destructive actions allowlist (tested separately with create-then-destroy isolation): + +``` +yachts.delete, yachts.archive, yachts.transferOwnership +companies.delete, companies.archive +companyMemberships.end +berthReservations.cancel, berthReservations.end +invoices.delete +``` + +Acceptance criteria for Spec 1 completion: + +- Every new or changed page has 100% coverage in the exhaustive suite (minus allowlist) +- Every allowlist entry has its own narrow destructive test +- Zero console errors across the full suite +- Zero unexpected 4xx/5xx responses + +### Tier 4 — EOI template regression + +- **Documenso payload snapshot test**: mock Documenso API; assert POST body contains every expected field name with correct value sourced from new schema +- **In-app template rendering test**: render seeded template against each scenario's context; assert resolved HTML contains expected substrings; assert `pdfme` produces a non-empty PDF +- **Visual diff**: render in-app EOI to PDF, compare against committed golden-image PDFs per scenario; regressions surface as image diffs in PR +- **Error paths**: missing yacht, missing company with company-owned yacht reference, missing config (Documenso API key missing) — all produce explicit errors, not silent blanks + +### Tier 5 — Security tests + +- Cross-tenant isolation: yacht/company/reservation in port A invisible/unmodifiable from port B +- Permission enforcement: user without `yachts:write` cannot `POST /yachts`; `yachts:transfer` required for transfer endpoint +- Portal authorization: portal user cannot see yachts they don't own/represent +- Public interest endpoint: anonymous submitter cannot read existing records + +### Test infrastructure + +Fixture factories in `tests/helpers/factories.ts`: + +``` +makeYacht({ owner: client|company, ...overrides }) +makeCompany({ overrides }) +makeMembership({ client, company, role, ...overrides }) +makeOwnershipHistoryRow({ yacht, owner, startDate, endDate }) +makeReservation({ berth, client, yacht, status }) +``` + +Scenario builders produce Tier 3 multi-cardinality setups in a single call. + +Integration tests run against a fresh migrated DB; each test file wraps in a transaction that rolls back OR uses per-file schema isolation. + +## Rollout plan + +Green-field Postgres DB — no dual-write, no phased migration needed. Concern is only sequencing so the working tree never enters a broken half-migrated state. + +### PR sequence (≈ 15 PRs, feature branch `refactor/data-model`) + +| # | PR | Depends on | +| --- | --------------------------------------------------------------------------------------------------- | ------------ | +| 1 | Schema migration: add all new tables, leave old client columns in place | — | +| 2 | Service layer: new services (yachts, companies, memberships, reservations) | 1 | +| 3 | API routes for new services + new permissions | 2 | +| 4 | Seeder rewrite with multi-cardinality fixtures | 2 | +| 5 | UI: yacht list + detail + form + picker + ownership-history + transfer-dialog | 3 | +| 6 | UI: company list + detail + form + picker + memberships tab + add-membership dialog | 3 | +| 7 | UI: berth reservations tab + reserve dialog + ownership-transfer wiring | 3 | +| 8 | Client form refactor: strip yacht/company/proxy fields, add nav links to yachts/companies | 5, 6 | +| 9 | Interest form: require `yachtId` + public interest form creates trio | 5 | +| 10 | Invoice billing-entity support (client or company) | 6 | +| 11 | EOI shared payload builder + seed in-app Standard EOI template + dual-path dialog | 5, 6 | +| 12 | Merge-field catalog update + resolver extension for `{{yacht.*}}` / `{{company.*}}` / `{{owner.*}}` | 11 | +| 13 | Drop old columns from `clients` (`yacht*`, `companyName`, proxy fields) | 8, 9, 10, 11 | +| 14 | Exhaustive Playwright click-through suite (Tier 3.5) | 13 | +| 15 | Documentation updates (CLAUDE.md, numbered spec files 01-15, API catalog) | 13 | + +After PR 15, merge the feature branch into `main` as one final PR. + +## Risks and mitigations + +| Risk | Severity | Mitigation | +| -------------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| Spec 2 (importer) depends on final schema; mid-development schema churn → rework | High | Schema freeze after PR 1 lands; amendments require deliberate spec update | +| Polymorphic owner columns have no DB-level FK — service-layer bug could insert inconsistent owner | Medium | Service-layer validation + integration test for every create/update path; runtime assertion in `buildEoiContext` | +| EOI dual-template drift (two engines produce subtly different output) | Medium | Golden-image visual-diff tests in Tier 4, CI-gated | +| Documenso template at `templateId=8` expects specific field names — new payload builder must match | Medium | One-time audit: document every field the existing template expects; map each to a source in new schema; Spec 2's importer uses same mapping | +| Old `client-portal/` sub-repo coordination during Spec 2 cutover | Low | Confirm old client-portal is decommissioned at Spec 2 cutover (not running concurrently against shared data) | +| Seeder becomes dev-onboarding bottleneck | Low | Seeder uses same factory helpers as tests — code path shared + tested | +| Documentation rot in numbered spec files | Low | PR 15 updates them before the feature branch merges to `main` | +| Exhaustive-click-suite runtime (15-20 min per PR) | Low | Separate CI job, runs in parallel with other tiers | +| Handoff quality — "EOIs don't work" / "I can't see my yachts" | Addressed | Dual template paths + exhaustive click coverage + golden-image diff + template regression tests collectively mitigate | + +## Open questions / deferred items + +Explicitly out of scope for this spec: + +- Yacht survey / class-cert document categorization (requires taxonomy work) +- Multi-level company hierarchy (holding → subsidiary) — additive later +- Invoice line items referencing specific yacht +- Berth reservation auto-renewal flow +- Per-yacht row-level permissions (e.g., "broker can only see yachts they represent") +- Portal branding per company + +## Success criteria + +Spec 1 is complete when: + +1. All PRs in the sequence are merged to `main` +2. CI is green: all coverage gates met, zero skipped tests, exhaustive click-through suite passes +3. Manual verification: developer walks through every multi-cardinality scenario in Tier 3 E2E list against a dev build +4. Both EOI paths produce documents that match the current system's outputs (visual verification + golden images committed) +5. Documentation (CLAUDE.md + numbered spec files) updated +6. Spec 2 (NocoDB+MinIO importer) can begin against a frozen schema