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>
This commit is contained in:
663
docs/superpowers/specs/2026-04-23-data-model-refactor-design.md
Normal file
663
docs/superpowers/specs/2026-04-23-data-model-refactor-design.md
Normal file
@@ -0,0 +1,663 @@
|
|||||||
|
# Data-Model Refactor: Yachts and Companies as First-Class Entities
|
||||||
|
|
||||||
|
**Status:** Draft — awaiting final review
|
||||||
|
**Date:** 2026-04-23
|
||||||
|
**Spec position:** 1 of 3 (Spec 2 = NocoDB+MinIO importer; Spec 3 = client merge endpoint)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This spec delivers a refactor of the core client / yacht / company data model to support real-world ownership relationships that the current schema cannot express.
|
||||||
|
|
||||||
|
The current `clients` table holds yacht dimensions and company name as columns directly on the person row. This enforces a one-person = one-yacht = one-company assumption that breaks the moment:
|
||||||
|
|
||||||
|
- A client owns multiple yachts (a common marina scenario)
|
||||||
|
- A person is a broker or director of multiple companies
|
||||||
|
- A yacht is legally owned by a shell company (common for tax / liability reasons) rather than by the human on the dock
|
||||||
|
- A yacht changes hands between owners and the marina needs chain-of-title
|
||||||
|
|
||||||
|
The refactor pulls yacht and company data into their own first-class tables, adds join tables for person↔company memberships, and introduces a proper `berth_reservations` table for exclusive-reservation lifecycle tracking.
|
||||||
|
|
||||||
|
This spec also fixes two existing schema gaps that surface during the refactor:
|
||||||
|
|
||||||
|
- `berths.status` tracks the state of a berth but there is no table recording which client/yacht exclusively reserves a berth
|
||||||
|
- `invoices.clientName` is a text field with no FK — there's no first-class link between invoices and billing entities
|
||||||
|
|
||||||
|
## Scope boundaries
|
||||||
|
|
||||||
|
### In scope (this spec)
|
||||||
|
|
||||||
|
- New `yachts`, `yacht_ownership_history`, `yacht_notes`, `yacht_tags` tables
|
||||||
|
- New `companies`, `company_memberships`, `company_addresses`, `company_notes`, `company_tags` tables
|
||||||
|
- New `berth_reservations` table with partial-unique-index exclusivity enforcement
|
||||||
|
- Updates to `interests`, `berth_waiting_list`, `invoices`, `files`, `documents` to add FKs to the new entities
|
||||||
|
- Removal of yacht, company, and proxy columns from `clients`
|
||||||
|
- New services, API routes, permissions, and socket/webhook events
|
||||||
|
- New UI pages for yachts, companies, and berth reservations; modifications to client, interest, berth, invoice forms
|
||||||
|
- Dual-path EOI generation (Documenso + in-app PDF template) with a shared payload builder
|
||||||
|
- Comprehensive test coverage: unit, integration, E2E, exhaustive click-through, template regression
|
||||||
|
- Seeder with realistic multi-cardinality dummy data
|
||||||
|
|
||||||
|
### Explicitly out of scope
|
||||||
|
|
||||||
|
- **Importing NocoDB records and MinIO documents** → Spec 2
|
||||||
|
- **Client merge endpoint** → Spec 3
|
||||||
|
- Yacht survey / class-cert document categorization
|
||||||
|
- Company hierarchy (holding → subsidiary)
|
||||||
|
- Line-item-level yacht references on invoices
|
||||||
|
- Auto-renewal flow for berth reservations
|
||||||
|
- Per-yacht row-level permissions
|
||||||
|
- Portal branding per company
|
||||||
|
|
||||||
|
## Decisions and rationale
|
||||||
|
|
||||||
|
| Topic | Decision | Why |
|
||||||
|
| ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Yacht scope | Full entity: own page, documents, ownership history, yacht-keyed interests / reservations / invoices | Marina domain cares about yachts as first-class objects (dimensions for berth fit, registration for port entry, ownership for liability) |
|
||||||
|
| Company scope | Full entity: memberships join, company-owned yachts, company billing | Yachts are frequently owned by shell companies for tax/liability reasons — the human on the dock is a director or broker. Lightweight/medium models can't route invoices to the correct legal entity |
|
||||||
|
| Ownership history | Dedicated `yacht_ownership_history` table + denormalized current-owner columns on `yachts` | Ownership change is exactly the kind of event that needs queryable history (chain of title, insurance, broker commission attribution). Denormalized current-owner keeps common reads fast |
|
||||||
|
| Proxy fields on clients (`isProxy`, `proxyType`, `actualOwnerName`, `relationshipNotes`) | Drop all four | Every real proxy scenario is expressible through `company_memberships` roles or `client_relationships`. Keeping the old fields creates two sources of truth and drift risk |
|
||||||
|
| Berth exclusive reservation | New `berth_reservations` table with partial unique index `WHERE status = 'active'` | Current schema tracks berth state via `berths.status` but does not record which client/yacht holds the reservation. Partial unique index enforces exclusivity at the DB level |
|
||||||
|
| Invoice billing entity | `billingEntityType` (`'client' \| 'company'`) + `billingEntityId`; `clientName` retained as an immutable snapshot | Companies become first-class payers. `clientName` as text is preserved on the invoice as a snapshot so invoices never retroactively rename themselves |
|
||||||
|
| Data state | Green-field with dummy seeder; real data arrives via Spec 2 | No production data lives in this Postgres DB yet. NocoDB holds the real records until Spec 2 imports them |
|
||||||
|
| Delivery | One cohesive spec covering both yacht + company refactor | Splitting doubles the migration/UI/test churn for no architectural gain; both sets of changes overlap heavily |
|
||||||
|
| EOI template strategy | Support both Documenso-template path and in-app PDF template path, both fully functional from day one | Handoff risk: client must not come back claiming "EOIs don't work." If Documenso breaks or is replaced, in-app path is the fallback. Both consume the same payload builder for data consistency |
|
||||||
|
| EOI UI picker | Dropdown at generation time (user picks Documenso or in-app explicitly) | Explicit beats automatic fallback for handoff — misconfiguration is visible, not silently masked |
|
||||||
|
| Testing | Unit, integration, full E2E scenarios, exhaustive Playwright click-through, template regression (including visual diff) | Explicit "test thoroughly" direction plus the handoff concern justify going heavier than normal on integration + E2E tiers |
|
||||||
|
|
||||||
|
## Schema design
|
||||||
|
|
||||||
|
### New tables
|
||||||
|
|
||||||
|
```
|
||||||
|
yachts
|
||||||
|
id text PK
|
||||||
|
portId text NOT NULL FK → ports.id
|
||||||
|
name text NOT NULL
|
||||||
|
hullNumber text
|
||||||
|
registration text
|
||||||
|
flag text
|
||||||
|
yearBuilt integer
|
||||||
|
builder text
|
||||||
|
model text
|
||||||
|
hullMaterial text
|
||||||
|
lengthFt numeric
|
||||||
|
widthFt numeric
|
||||||
|
draftFt numeric
|
||||||
|
lengthM numeric
|
||||||
|
widthM numeric
|
||||||
|
draftM numeric
|
||||||
|
currentOwnerType text NOT NULL -- 'client' | 'company'
|
||||||
|
currentOwnerId text NOT NULL
|
||||||
|
status text NOT NULL DEFAULT 'active' -- 'active' | 'retired' | 'sold_away'
|
||||||
|
notes text
|
||||||
|
archivedAt timestamptz
|
||||||
|
createdAt timestamptz NOT NULL DEFAULT now()
|
||||||
|
updatedAt timestamptz NOT NULL DEFAULT now()
|
||||||
|
Indexes:
|
||||||
|
idx_yachts_port on (portId)
|
||||||
|
idx_yachts_current_owner on (portId, currentOwnerType, currentOwnerId)
|
||||||
|
idx_yachts_name on (portId, name)
|
||||||
|
|
||||||
|
yacht_ownership_history
|
||||||
|
id text PK
|
||||||
|
yachtId text NOT NULL FK → yachts.id ON DELETE CASCADE
|
||||||
|
ownerType text NOT NULL -- 'client' | 'company'
|
||||||
|
ownerId text NOT NULL
|
||||||
|
startDate date NOT NULL
|
||||||
|
endDate date -- NULL = currently active
|
||||||
|
transferReason text -- 'sale' | 'inheritance' | 'gift' | 'company_restructure' | 'other'
|
||||||
|
transferNotes text
|
||||||
|
createdBy text NOT NULL
|
||||||
|
createdAt timestamptz NOT NULL DEFAULT now()
|
||||||
|
Indexes:
|
||||||
|
idx_yoh_yacht on (yachtId)
|
||||||
|
idx_yoh_active (partial) on (yachtId) WHERE endDate IS NULL
|
||||||
|
|
||||||
|
yacht_notes -- mirrors client_notes shape
|
||||||
|
id, yachtId (FK CASCADE), authorId, content, mentions text[], isLocked, createdAt, updatedAt
|
||||||
|
|
||||||
|
yacht_tags
|
||||||
|
yachtId, tagId composite PK; tagId references system.tags.id
|
||||||
|
|
||||||
|
companies
|
||||||
|
id text PK
|
||||||
|
portId text NOT NULL FK → ports.id
|
||||||
|
name text NOT NULL
|
||||||
|
legalName text
|
||||||
|
taxId text
|
||||||
|
registrationNumber text
|
||||||
|
incorporationCountry text
|
||||||
|
incorporationDate date
|
||||||
|
status text NOT NULL DEFAULT 'active' -- 'active' | 'dissolved'
|
||||||
|
billingEmail text
|
||||||
|
notes text
|
||||||
|
archivedAt timestamptz
|
||||||
|
createdAt timestamptz NOT NULL DEFAULT now()
|
||||||
|
updatedAt timestamptz NOT NULL DEFAULT now()
|
||||||
|
Indexes:
|
||||||
|
idx_companies_port on (portId)
|
||||||
|
idx_companies_name_unique UNIQUE on (portId, lower(name)) -- case-insensitive
|
||||||
|
idx_companies_taxid on (portId, taxId) WHERE taxId IS NOT NULL
|
||||||
|
|
||||||
|
company_memberships
|
||||||
|
id text PK
|
||||||
|
companyId text NOT NULL FK → companies.id ON DELETE CASCADE
|
||||||
|
clientId text NOT NULL FK → clients.id ON DELETE CASCADE
|
||||||
|
role text NOT NULL -- 'director' | 'officer' | 'broker' | 'representative' | 'legal_counsel' | 'employee' | 'shareholder' | 'other'
|
||||||
|
roleDetail text -- free-text qualifier: "Managing Director", "Exclusive Broker"
|
||||||
|
startDate date NOT NULL
|
||||||
|
endDate date -- NULL = active
|
||||||
|
isPrimary boolean NOT NULL DEFAULT false
|
||||||
|
notes text
|
||||||
|
createdAt timestamptz NOT NULL DEFAULT now()
|
||||||
|
updatedAt timestamptz NOT NULL DEFAULT now()
|
||||||
|
Indexes:
|
||||||
|
idx_cm_company on (companyId)
|
||||||
|
idx_cm_client on (clientId)
|
||||||
|
idx_cm_active (partial) on (companyId, clientId) WHERE endDate IS NULL
|
||||||
|
unique_cm_exact UNIQUE on (companyId, clientId, role, startDate)
|
||||||
|
|
||||||
|
company_addresses -- mirrors client_addresses shape with companyId FK
|
||||||
|
company_notes -- mirrors client_notes shape with companyId FK
|
||||||
|
company_tags
|
||||||
|
companyId, tagId composite PK
|
||||||
|
|
||||||
|
berth_reservations
|
||||||
|
id text PK
|
||||||
|
berthId text NOT NULL FK → berths.id
|
||||||
|
portId text NOT NULL FK → ports.id
|
||||||
|
clientId text NOT NULL FK → clients.id -- contract holder
|
||||||
|
yachtId text NOT NULL FK → yachts.id -- which yacht occupies the slip
|
||||||
|
interestId text FK → interests.id -- nullable link back to originating interest
|
||||||
|
status text NOT NULL -- 'pending' | 'active' | 'ended' | 'cancelled'
|
||||||
|
startDate date NOT NULL
|
||||||
|
endDate date -- NULL = open-ended
|
||||||
|
tenureType text NOT NULL DEFAULT 'permanent' -- 'permanent' | 'fixed_term' | 'seasonal'
|
||||||
|
contractFileId text FK → files.id
|
||||||
|
createdBy text NOT NULL
|
||||||
|
createdAt timestamptz NOT NULL DEFAULT now()
|
||||||
|
updatedAt timestamptz NOT NULL DEFAULT now()
|
||||||
|
Indexes:
|
||||||
|
idx_br_berth on (berthId)
|
||||||
|
idx_br_client on (clientId)
|
||||||
|
idx_br_yacht on (yachtId)
|
||||||
|
idx_br_active (partial) UNIQUE on (berthId) WHERE status = 'active'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modified tables
|
||||||
|
|
||||||
|
```
|
||||||
|
clients
|
||||||
|
DROP COLUMN yachtName, yachtLengthFt, yachtWidthFt, yachtDraftFt,
|
||||||
|
yachtLengthM, yachtWidthM, yachtDraftM, berthSizeDesired
|
||||||
|
DROP COLUMN companyName
|
||||||
|
DROP COLUMN isProxy, proxyType, actualOwnerName, relationshipNotes
|
||||||
|
(retains: fullName, nationality, preferredContactMethod, preferredLanguage,
|
||||||
|
timezone, source, sourceDetails, archivedAt, createdAt, updatedAt)
|
||||||
|
|
||||||
|
interests
|
||||||
|
ADD COLUMN yachtId text FK → yachts.id -- nullable initially; enforced non-null before pipeline_stage leaves 'open'
|
||||||
|
ADD INDEX idx_interests_yacht on (yachtId)
|
||||||
|
|
||||||
|
berth_waiting_list
|
||||||
|
ADD COLUMN yachtId text FK → yachts.id
|
||||||
|
|
||||||
|
invoices
|
||||||
|
ADD COLUMN billingEntityType text NOT NULL -- 'client' | 'company'
|
||||||
|
ADD COLUMN billingEntityId text NOT NULL
|
||||||
|
(clientName column kept as immutable snapshot — must never auto-update)
|
||||||
|
ADD INDEX idx_invoices_billing_entity on (portId, billingEntityType, billingEntityId)
|
||||||
|
|
||||||
|
files
|
||||||
|
ADD COLUMN yachtId text FK → yachts.id -- nullable
|
||||||
|
ADD COLUMN companyId text FK → companies.id -- nullable
|
||||||
|
(existing clientId stays nullable; a file links to one of: client, yacht, or company)
|
||||||
|
|
||||||
|
documents
|
||||||
|
ADD COLUMN yachtId text FK → yachts.id -- nullable
|
||||||
|
ADD COLUMN companyId text FK → companies.id -- nullable
|
||||||
|
```
|
||||||
|
|
||||||
|
### DB-level invariants
|
||||||
|
|
||||||
|
| # | Invariant | Enforced by |
|
||||||
|
| --- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 1 | One active ownership row per yacht | Partial unique index on `yacht_ownership_history(yachtId) WHERE endDate IS NULL` |
|
||||||
|
| 2 | One active reservation per berth | Partial unique index on `berth_reservations(berthId) WHERE status = 'active'` |
|
||||||
|
| 3 | Yacht always has a current owner | Both `currentOwnerType` and `currentOwnerId` NOT NULL; ownership row inserted atomically with yacht creation inside service transaction |
|
||||||
|
| 4 | Company names unique per port (case-insensitive) | Unique index on `(portId, lower(name))` |
|
||||||
|
| 5 | Exact-duplicate memberships blocked | Unique index on `(companyId, clientId, role, startDate)` |
|
||||||
|
|
||||||
|
### Service-layer invariants (not DB-enforceable due to polymorphic columns)
|
||||||
|
|
||||||
|
| # | Invariant | Enforced by |
|
||||||
|
| --- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||||
|
| 6 | `yacht.currentOwnerType='client'` ↔ `currentOwnerId` references an existing row in `clients`; same for `'company'` ↔ `companies` | Zod validator + service-layer lookup before insert/update |
|
||||||
|
| 7 | `yacht_ownership_history.ownerType/ownerId` consistent with the corresponding entity table | Same as #6 |
|
||||||
|
| 8 | `invoices.billingEntityType` + `billingEntityId` consistent with entity table | Same as #6 |
|
||||||
|
| 9 | `files.clientId`, `files.yachtId`, `files.companyId` — exactly one of the three must be non-null if the file is entity-scoped | Service-layer validation on insert/update |
|
||||||
|
|
||||||
|
### Drizzle relations (`relations.ts`)
|
||||||
|
|
||||||
|
All new tables wire into the relations map. Notable additions:
|
||||||
|
|
||||||
|
- `clientsRelations`: `companyMemberships` (many), `ownedYachts` (many, via polymorphic query), `berthReservations` (many)
|
||||||
|
- `yachtsRelations`: `port` (one), `ownershipHistory` (many), `notes` (many), `tags` (many), `interests` (many), `reservations` (many), `documents` (many)
|
||||||
|
- `companiesRelations`: `port` (one), `memberships` (many), `addresses` (many), `notes` (many), `tags` (many), `documents` (many)
|
||||||
|
- `berthReservationsRelations`: `berth`, `port`, `client`, `yacht`, `interest`, `contractFile`
|
||||||
|
|
||||||
|
## Service layer and API
|
||||||
|
|
||||||
|
### New services (`src/lib/services/`)
|
||||||
|
|
||||||
|
| File | Key functions |
|
||||||
|
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `yachts.service.ts` | `list`, `getById`, `create`, `update`, `archive`, `transferOwnership(yachtId, newOwnerType, newOwnerId, effectiveDate, reason, notes)` — atomic: closes current history row, opens new row, updates denormalized `currentOwner*` columns |
|
||||||
|
| `companies.service.ts` | `list`, `getById`, `create`, `update`, `archive`, `upsertByName(portId, name)` (case-insensitive, for autocomplete) |
|
||||||
|
| `company-memberships.service.ts` | `addMembership`, `endMembership(id, endDate)`, `updateMembership`, `listByCompany`, `listByClient`, `setPrimary` |
|
||||||
|
| `berth-reservations.service.ts` | `createPending`, `activate(id)` (gates on partial unique index), `end(id, endDate)`, `cancel(id)`, `listByBerth`, `listByClient`, `listByYacht` |
|
||||||
|
|
||||||
|
### Modified services
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `clients.service.ts` | Strip yacht/company/proxy field handling from create/update paths |
|
||||||
|
| `interests.service.ts` | Accept `yachtId`; validate yacht is owned by the interest's client OR by a company the client actively represents. Promote-to-stage helpers require `yachtId` non-null before leaving `'open'` |
|
||||||
|
| `berths.service.ts` | Read reservation state via `berth_reservations` instead of deriving from `berths.status`. Reservation state changes also update `berths.status` via trigger-in-service-layer |
|
||||||
|
| `invoices.service.ts` | Accept `billingEntityType` + `billingEntityId`; snapshot the entity's current display name into `clientName` at creation (immutable afterward) |
|
||||||
|
| `search.service.ts` | Extend to yachts and companies; include yacht name, hull number, registration in search index; include company name, legal name, taxId |
|
||||||
|
| `recommendations.ts` (berth matcher) | Pull yacht dimensions from `yachts` table via `interest.yachtId` instead of from `clients.yacht*` |
|
||||||
|
| `document-templates.ts` | Update `MERGE_FIELDS` catalog: deprecate `{{client.yachtName}}`, `{{client.companyName}}` and old yacht dimension tokens; add `{{yacht.*}}`, `{{company.*}}`, `{{owner.*}}` scopes. Update `resolveTemplate()` to resolve new scopes |
|
||||||
|
| `portal.service.ts` | Portal user dashboards surface their yachts (owned + represented via memberships), their active memberships, and their active berth reservations |
|
||||||
|
|
||||||
|
### New REST endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
# Yachts
|
||||||
|
GET /api/v1/yachts
|
||||||
|
POST /api/v1/yachts
|
||||||
|
GET /api/v1/yachts/:id
|
||||||
|
PATCH /api/v1/yachts/:id
|
||||||
|
DELETE /api/v1/yachts/:id — archive (soft delete)
|
||||||
|
POST /api/v1/yachts/:id/transfer — ownership transfer
|
||||||
|
GET /api/v1/yachts/:id/ownership-history
|
||||||
|
GET /api/v1/yachts/autocomplete?q=…
|
||||||
|
|
||||||
|
# Companies
|
||||||
|
GET /api/v1/companies
|
||||||
|
POST /api/v1/companies
|
||||||
|
GET /api/v1/companies/:id
|
||||||
|
PATCH /api/v1/companies/:id
|
||||||
|
DELETE /api/v1/companies/:id — archive
|
||||||
|
GET /api/v1/companies/autocomplete?q=…
|
||||||
|
|
||||||
|
# Company memberships
|
||||||
|
GET /api/v1/companies/:id/members
|
||||||
|
POST /api/v1/companies/:id/members
|
||||||
|
PATCH /api/v1/companies/:id/members/:mid
|
||||||
|
DELETE /api/v1/companies/:id/members/:mid — sets endDate
|
||||||
|
|
||||||
|
# Berth reservations
|
||||||
|
GET /api/v1/berths/:id/reservations
|
||||||
|
POST /api/v1/berths/:id/reservations — create pending
|
||||||
|
PATCH /api/v1/berth-reservations/:id — state transitions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modified endpoints
|
||||||
|
|
||||||
|
- `GET /api/v1/clients/:id` — response now includes nested `yachts` (owned + represented), `companies` (via active memberships), `activeReservations`
|
||||||
|
- `POST /api/v1/clients` — no longer accepts yacht/company/proxy fields
|
||||||
|
- `POST /api/v1/interests` — requires `yachtId`
|
||||||
|
- `POST /api/v1/invoices` — requires `billingEntityType` + `billingEntityId`
|
||||||
|
- `POST /api/public/interests` — creates new `client` + `yacht` + optional `company` + `membership` + `interest` in one transaction, all marked `source: 'public_submission'`. No dedup against existing records (anonymous trust boundary).
|
||||||
|
|
||||||
|
### Permissions (new keys)
|
||||||
|
|
||||||
|
```
|
||||||
|
yachts:view
|
||||||
|
yachts:write
|
||||||
|
yachts:transfer — higher-stakes operation, separate from :write
|
||||||
|
yachts:delete — archive permission
|
||||||
|
|
||||||
|
companies:view
|
||||||
|
companies:write
|
||||||
|
companies:delete
|
||||||
|
|
||||||
|
memberships:write — covers both directions of company_memberships
|
||||||
|
|
||||||
|
reservations:view
|
||||||
|
reservations:write
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing role updates:
|
||||||
|
|
||||||
|
- `admin` — all new keys
|
||||||
|
- `team_lead` — `yachts:view`, `yachts:write`, `companies:view`, `companies:write`, `memberships:write`, `reservations:view`; NOT `yachts:transfer` or `reservations:write`
|
||||||
|
- `front_desk` — all `:view` keys
|
||||||
|
|
||||||
|
### Socket / webhook events (new)
|
||||||
|
|
||||||
|
```
|
||||||
|
yacht.created
|
||||||
|
yacht.updated
|
||||||
|
yacht.ownership_transferred
|
||||||
|
yacht.archived
|
||||||
|
company.created
|
||||||
|
company.updated
|
||||||
|
company.archived
|
||||||
|
company_membership.added
|
||||||
|
company_membership.ended
|
||||||
|
berth_reservation.created
|
||||||
|
berth_reservation.activated
|
||||||
|
berth_reservation.ended
|
||||||
|
berth_reservation.cancelled
|
||||||
|
```
|
||||||
|
|
||||||
|
Webhook event map in `src/lib/services/webhooks.ts` gains the same list.
|
||||||
|
|
||||||
|
## EOI template strategy (dual-path)
|
||||||
|
|
||||||
|
Both paths fully supported from day one. Required to mitigate handoff risk — if Documenso breaks or is replaced, the in-app path is the fallback.
|
||||||
|
|
||||||
|
### Shared payload builder
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/lib/services/eoi-context.ts
|
||||||
|
export async function buildEoiContext(interestId: string): Promise<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
|
||||||
Reference in New Issue
Block a user