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

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
