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>
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.statustracks the state of a berth but there is no table recording which client/yacht exclusively reserves a berthinvoices.clientNameis 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_tagstables - New
companies,company_memberships,company_addresses,company_notes,company_tagstables - New
berth_reservationstable with partial-unique-index exclusivity enforcement - Updates to
interests,berth_waiting_list,invoices,files,documentsto 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 nestedyachts(owned + represented),companies(via active memberships),activeReservationsPOST /api/v1/clients— no longer accepts yacht/company/proxy fieldsPOST /api/v1/interests— requiresyachtIdPOST /api/v1/invoices— requiresbillingEntityType+billingEntityIdPOST /api/public/interests— creates newclient+yacht+ optionalcompany+membership+interestin one transaction, all markedsource: '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 keysteam_lead—yachts:view,yachts:write,companies:view,companies:write,memberships:write,reservations:view; NOTyachts:transferorreservations:writefront_desk— all:viewkeys
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'sNUXT_DOCUMENSO_TEMPLATE_IDpattern — a single global template ID; per-port templates are a future extension if needed) - Payload builder flattens
EoiContextinto 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_templatestable on first boot. Template references tokens:{{client.fullName}},{{yacht.name}},{{yacht.lengthFt}},{{company.name}},{{berth.mooringNumber}},{{interest.dateFirstContact}}, etc. resolveTemplate()substitutes tokens fromEoiContextpdfmerenders 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
nodemaileras 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
endDateon 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:
- Create client → add yacht → create interest → generate EOI (Documenso path) → PDF in MinIO
- Same, in-app template path → verify PDF content contains expected yacht name
- Create company → add two clients as members → create yacht owned by company → generate invoice billed to company
- Yacht transfer: client-owned → company-owned; verify history + denormalized column + UI
- Reserve berth: create → verify visible → attempt duplicate reservation → blocked
- Public interest form → admin sees new client+yacht+company+interest trio
- (Spec 3 stub): merge flow tested end-to-end in Spec 3
Multi-cardinality flows (the core justification for this refactor):
- One client with 3 yachts, 3 interests, 3 different berths — all representable
- One person as broker for 2 companies, each owning 1 yacht — memberships + owned yachts visible from client detail
Portal flows:
- Portal user views "my yachts" — sees only owned/represented
- 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:
- Navigate to page
- Enumerate every interactive element (
button,a,[role="button"],[data-testid], form inputs) - Click/fill each; post-click: assert no console errors, no 4xx/5xx network responses, UI returns to stable state
- 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
pdfmeproduces 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:writecannotPOST /yachts;yachts:transferrequired 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:
- All PRs in the sequence are merged to
main - CI is green: all coverage gates met, zero skipped tests, exhaustive click-through suite passes
- Manual verification: developer walks through every multi-cardinality scenario in Tier 3 E2E list against a dev build
- Both EOI paths produce documents that match the current system's outputs (visual verification + golden images committed)
- Documentation (CLAUDE.md + numbered spec files) updated
- Spec 2 (NocoDB+MinIO importer) can begin against a frozen schema