Files
pn-new-crm/docs/superpowers/specs/2026-04-23-data-model-refactor-design.md
Matt Ciaccio 1c0a16fd59 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) <noreply@anthropic.com>
2026-04-23 17:04:41 +02:00

45 KiB

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_leadyachts: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

// src/lib/services/eoi-context.ts
export async function buildEoiContext(interestId: string): Promise<EoiContext>

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):

  1. One client with 3 yachts, 3 interests, 3 different berths — all representable
  2. One person as broker for 2 companies, each owning 1 yacht — memberships + owned yachts visible from client detail

Portal flows:

  1. Portal user views "my yachts" — sees only owned/represented
  2. 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