# 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