Files
pn-new-crm/docs/superpowers/plans/2026-04-23-data-model-refactor.md
Matt Ciaccio 11969c0d8a docs(plan): add data-model refactor implementation plan (Spec 1)
15-PR sequenced plan covering schema migration, services, API,
seeder, UI, EOI dual-path, exhaustive click-through tests,
documentation updates, and final merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:17:02 +02:00

92 KiB

Data-Model Refactor Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Refactor the core client/yacht/company data model into first-class entities so the system can represent real-world multi-cardinality relationships (one person with many yachts, one yacht owned by a company, one berth with one active reservation). Ships with rich dummy data for dev; real data arrives in Spec 2. Hand-off ready: dual-path EOI generation, exhaustive click-through test suite, golden-image template regression.

Architecture: New tables yachts, yacht_ownership_history, companies, company_memberships, berth_reservations (plus notes/tags/addresses mirrors). Removes yacht/company/proxy columns from clients. Adds yachtId and companyId FKs to interests, berth_waiting_list, invoices, files, documents. Polymorphic owner columns (currentOwnerType + currentOwnerId) validated in the service layer; partial unique indexes enforce exclusivity at the DB level. EOI generation routes through a shared buildEoiContext() that feeds either Documenso or in-app pdfme.

Tech Stack: Drizzle ORM (Postgres), Zod (validation), better-auth, BullMQ, Redis, MinIO, Socket.IO, Next.js 15 App Router, React 19, TanStack Table/Query, react-hook-form, shadcn/ui, pdfme, Documenso API, Vitest, Playwright.

Spec: docs/superpowers/specs/2026-04-23-data-model-refactor-design.md — refer to it for WHY decisions were made. This plan focuses on HOW to execute them.


Plan conventions

  • Branch: All work on refactor/data-model (long-lived feature branch). Rebase onto main daily; merge to main in one final PR after Task 15.5.
  • Commits: After each task, commit with feat(scope): description or refactor(scope): description or test(scope): description. Example: feat(yachts): add yachts schema.
  • Tests before code (TDD): For every service function and every route handler, write the failing test first, verify it fails, then implement.
  • Running tests: pnpm vitest run path/to/test.test.ts for unit/integration; pnpm playwright test tests/e2e/... for E2E.
  • Schema changes: After any src/lib/db/schema/*.ts change, run pnpm db:generate to produce a migration file, then pnpm db:push to apply locally.
  • Linting: pnpm lint and pnpm format before every commit (or rely on Husky pre-commit hook).
  • Permission checks: Every new route MUST call requirePermission(context, '<key>') before service calls. Cross-tenant guards are handled inside services via portId scoping.

File structure

New files (created during this plan):

src/lib/db/schema/
  yachts.ts                          — yachts + yacht_ownership_history + yacht_notes + yacht_tags
  companies.ts                       — companies + company_memberships + company_addresses + company_notes + company_tags
  reservations.ts                    — berth_reservations

src/lib/validators/
  yachts.ts                          — zod schemas for yacht CRUD + transfer
  companies.ts                       — zod for company CRUD
  company-memberships.ts             — zod for membership CRUD
  reservations.ts                    — zod for reservation CRUD + state transitions

src/lib/services/
  yachts.service.ts
  companies.service.ts
  company-memberships.service.ts
  berth-reservations.service.ts
  eoi-context.ts                     — shared EOI payload builder

src/app/api/v1/yachts/
  route.ts                           — GET list, POST create
  autocomplete/route.ts
  [id]/route.ts                      — GET, PATCH, DELETE
  [id]/transfer/route.ts             — POST transfer
  [id]/ownership-history/route.ts    — GET history

src/app/api/v1/companies/
  route.ts
  autocomplete/route.ts
  [id]/route.ts
  [id]/members/route.ts              — GET list, POST add
  [id]/members/[mid]/route.ts        — PATCH, DELETE

src/app/api/v1/berths/[id]/reservations/
  route.ts                           — GET, POST

src/app/api/v1/berth-reservations/[id]/route.ts  — PATCH state transitions

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

src/components/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

src/components/reservations/
  reservation-form.tsx
  reservation-list.tsx
  berth-reserve-dialog.tsx

src/components/shared/
  owner-picker.tsx                   — polymorphic client|company combobox
  billing-entity-picker.tsx

src/app/(dashboard)/[portSlug]/yachts/
  page.tsx                           — list
  [yachtId]/page.tsx                 — detail

src/app/(dashboard)/[portSlug]/companies/
  page.tsx
  [companyId]/page.tsx

tests/helpers/factories.ts           — EXTEND with yacht/company/membership/reservation factories
tests/helpers/click-everything.ts    — NEW utility for Tier 3.5 exhaustive suite

tests/unit/services/yachts.test.ts
tests/unit/services/companies.test.ts
tests/unit/services/company-memberships.test.ts
tests/unit/services/berth-reservations.test.ts
tests/unit/services/eoi-context.test.ts
tests/unit/validators/yachts.test.ts
tests/unit/validators/companies.test.ts
tests/integration/schema-constraints.test.ts
tests/integration/ownership-transfer.test.ts
tests/integration/reservation-exclusivity.test.ts

tests/e2e/scenarios/yacht-lifecycle.spec.ts
tests/e2e/scenarios/company-lifecycle.spec.ts
tests/e2e/scenarios/multi-cardinality.spec.ts
tests/e2e/scenarios/eoi-documenso-path.spec.ts
tests/e2e/scenarios/eoi-inapp-path.spec.ts
tests/e2e/scenarios/portal.spec.ts

tests/e2e/exhaustive/yachts.spec.ts
tests/e2e/exhaustive/companies.spec.ts
tests/e2e/exhaustive/reservations.spec.ts
tests/e2e/exhaustive/client-detail-refactored.spec.ts
tests/e2e/exhaustive/eoi-generate.spec.ts
tests/e2e/exhaustive/invoice-form.spec.ts
tests/e2e/exhaustive/berths-with-reservations.spec.ts
tests/e2e/exhaustive/portal.spec.ts
tests/e2e/exhaustive/navigation.spec.ts

tests/e2e/templates/eoi-golden-image.spec.ts
tests/e2e/fixtures/eoi-golden/*.pdf  — committed reference PDFs

src/lib/pdf/templates/eoi-standard-inapp.ts  — new in-app EOI template

Modified files (major ones):

src/lib/db/schema/clients.ts          — drop yacht/company/proxy columns
src/lib/db/schema/interests.ts        — add yachtId
src/lib/db/schema/berths.ts           — add yachtId to waiting list
src/lib/db/schema/financial.ts        — add billingEntityType + billingEntityId to invoices
src/lib/db/schema/documents.ts        — add yachtId + companyId to files + documents
src/lib/db/schema/relations.ts        — wire all new tables
src/lib/db/schema/index.ts            — re-export new tables
src/lib/db/seed.ts                    — rewrite for new model
src/lib/services/clients.service.ts   — strip yacht/company/proxy handling
src/lib/services/interests.service.ts — accept yachtId
src/lib/services/berths.service.ts    — integrate berth_reservations
src/lib/services/invoices.service.ts  — billingEntityType + billingEntityId
src/lib/services/search.service.ts    — extend to yachts + companies
src/lib/services/recommendations.ts   — read yacht dims from yachts table
src/lib/services/document-templates.ts — update MERGE_FIELDS + resolveTemplate
src/lib/services/portal.service.ts    — portal: my-yachts, my-memberships, my-reservations
src/lib/validators/clients.ts         — drop yacht/company/proxy fields
src/lib/validators/interests.ts       — add yachtId
src/lib/validators/invoices.ts        — add billingEntityType + billingEntityId
src/components/clients/client-form.tsx
src/components/clients/client-detail.tsx
src/components/clients/client-tabs.tsx
src/components/clients/client-columns.tsx
src/components/interests/interest-form.tsx
src/components/invoices/invoice-form.tsx
src/components/berths/berth-detail-*.tsx
src/app/api/public/interests/route.ts
src/app/(portal)/portal/**/*.tsx

PR 1 — Schema Migration

Goal: Add every new table. Leave old client columns in place. Old code still works; new tables exist and are ready for the service layer in PR 2.

Branch off: main. Merge to: refactor/data-model.

Task 1.1: Create yachts.ts schema file

Files:

  • Create: src/lib/db/schema/yachts.ts

  • Step 1: Write the schema file

import {
  pgTable,
  text,
  integer,
  numeric,
  timestamp,
  boolean,
  index,
  uniqueIndex,
  primaryKey,
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { ports } from './ports';

export const yachts = pgTable(
  'yachts',
  {
    id: text('id')
      .primaryKey()
      .$defaultFn(() => crypto.randomUUID()),
    portId: text('port_id')
      .notNull()
      .references(() => ports.id),
    name: text('name').notNull(),
    hullNumber: text('hull_number'),
    registration: text('registration'),
    flag: text('flag'),
    yearBuilt: integer('year_built'),
    builder: text('builder'),
    model: text('model'),
    hullMaterial: text('hull_material'),
    lengthFt: numeric('length_ft'),
    widthFt: numeric('width_ft'),
    draftFt: numeric('draft_ft'),
    lengthM: numeric('length_m'),
    widthM: numeric('width_m'),
    draftM: numeric('draft_m'),
    currentOwnerType: text('current_owner_type').notNull(), // 'client' | 'company'
    currentOwnerId: text('current_owner_id').notNull(),
    status: text('status').notNull().default('active'), // 'active' | 'retired' | 'sold_away'
    notes: text('notes'),
    archivedAt: timestamp('archived_at', { withTimezone: true }),
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (table) => [
    index('idx_yachts_port').on(table.portId),
    index('idx_yachts_current_owner').on(
      table.portId,
      table.currentOwnerType,
      table.currentOwnerId,
    ),
    index('idx_yachts_name').on(table.portId, table.name),
    index('idx_yachts_archived').on(table.portId, table.archivedAt),
  ],
);

export const yachtOwnershipHistory = pgTable(
  'yacht_ownership_history',
  {
    id: text('id')
      .primaryKey()
      .$defaultFn(() => crypto.randomUUID()),
    yachtId: text('yacht_id')
      .notNull()
      .references(() => yachts.id, { onDelete: 'cascade' }),
    ownerType: text('owner_type').notNull(),
    ownerId: text('owner_id').notNull(),
    startDate: timestamp('start_date', { withTimezone: true, mode: 'date' }).notNull(),
    endDate: timestamp('end_date', { withTimezone: true, mode: 'date' }),
    transferReason: text('transfer_reason'),
    transferNotes: text('transfer_notes'),
    createdBy: text('created_by').notNull(),
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (table) => [
    index('idx_yoh_yacht').on(table.yachtId),
    uniqueIndex('idx_yoh_active')
      .on(table.yachtId)
      .where(sql`${table.endDate} IS NULL`),
  ],
);

export const yachtNotes = pgTable(
  'yacht_notes',
  {
    id: text('id')
      .primaryKey()
      .$defaultFn(() => crypto.randomUUID()),
    yachtId: text('yacht_id')
      .notNull()
      .references(() => yachts.id, { onDelete: 'cascade' }),
    authorId: text('author_id').notNull(),
    content: text('content').notNull(),
    mentions: text('mentions').array(),
    isLocked: boolean('is_locked').notNull().default(false),
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (table) => [index('idx_yn_yacht').on(table.yachtId)],
);

export const yachtTags = pgTable(
  'yacht_tags',
  {
    yachtId: text('yacht_id')
      .notNull()
      .references(() => yachts.id, { onDelete: 'cascade' }),
    tagId: text('tag_id').notNull(),
  },
  (table) => [primaryKey({ columns: [table.yachtId, table.tagId] })],
);

export type Yacht = typeof yachts.$inferSelect;
export type NewYacht = typeof yachts.$inferInsert;
export type YachtOwnershipHistoryRow = typeof yachtOwnershipHistory.$inferSelect;
export type NewYachtOwnershipHistoryRow = typeof yachtOwnershipHistory.$inferInsert;
export type YachtNote = typeof yachtNotes.$inferSelect;
export type NewYachtNote = typeof yachtNotes.$inferInsert;
  • Step 2: Re-export from schema index

Add to src/lib/db/schema/index.ts:

export * from './yachts';
  • Step 3: Generate migration

Run: pnpm db:generate Expected: new file in src/lib/db/migrations/ containing the four table creations.

  • Step 4: Apply migration

Run: pnpm db:push Expected: no errors; tables created in local Postgres.

  • Step 5: Commit
git add src/lib/db/schema/yachts.ts src/lib/db/schema/index.ts src/lib/db/migrations/
git commit -m "feat(yachts): add yachts, ownership history, notes, tags schema"

Task 1.2: Create companies.ts schema file

Files:

  • Create: src/lib/db/schema/companies.ts

  • Step 1: Write the schema file

import {
  pgTable,
  text,
  timestamp,
  boolean,
  index,
  uniqueIndex,
  primaryKey,
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { ports } from './ports';
import { clients } from './clients';

export const companies = pgTable(
  'companies',
  {
    id: text('id')
      .primaryKey()
      .$defaultFn(() => crypto.randomUUID()),
    portId: text('port_id')
      .notNull()
      .references(() => ports.id),
    name: text('name').notNull(),
    legalName: text('legal_name'),
    taxId: text('tax_id'),
    registrationNumber: text('registration_number'),
    incorporationCountry: text('incorporation_country'),
    incorporationDate: timestamp('incorporation_date', { withTimezone: true, mode: 'date' }),
    status: text('status').notNull().default('active'), // 'active' | 'dissolved'
    billingEmail: text('billing_email'),
    notes: text('notes'),
    archivedAt: timestamp('archived_at', { withTimezone: true }),
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (table) => [
    index('idx_companies_port').on(table.portId),
    uniqueIndex('idx_companies_name_unique').on(table.portId, sql`lower(${table.name})`),
    index('idx_companies_taxid')
      .on(table.portId, table.taxId)
      .where(sql`${table.taxId} IS NOT NULL`),
  ],
);

export const companyMemberships = pgTable(
  'company_memberships',
  {
    id: text('id')
      .primaryKey()
      .$defaultFn(() => crypto.randomUUID()),
    companyId: text('company_id')
      .notNull()
      .references(() => companies.id, { onDelete: 'cascade' }),
    clientId: text('client_id')
      .notNull()
      .references(() => clients.id, { onDelete: 'cascade' }),
    role: text('role').notNull(), // director | officer | broker | representative | legal_counsel | employee | shareholder | other
    roleDetail: text('role_detail'),
    startDate: timestamp('start_date', { withTimezone: true, mode: 'date' }).notNull(),
    endDate: timestamp('end_date', { withTimezone: true, mode: 'date' }),
    isPrimary: boolean('is_primary').notNull().default(false),
    notes: text('notes'),
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (table) => [
    index('idx_cm_company').on(table.companyId),
    index('idx_cm_client').on(table.clientId),
    index('idx_cm_active')
      .on(table.companyId, table.clientId)
      .where(sql`${table.endDate} IS NULL`),
    uniqueIndex('unique_cm_exact').on(table.companyId, table.clientId, table.role, table.startDate),
  ],
);

export const companyAddresses = pgTable(
  'company_addresses',
  {
    id: text('id')
      .primaryKey()
      .$defaultFn(() => crypto.randomUUID()),
    companyId: text('company_id')
      .notNull()
      .references(() => companies.id, { onDelete: 'cascade' }),
    portId: text('port_id')
      .notNull()
      .references(() => ports.id, { onDelete: 'cascade' }),
    label: text('label').notNull().default('Primary'),
    streetAddress: text('street_address'),
    city: text('city'),
    stateProvince: text('state_province'),
    postalCode: text('postal_code'),
    country: text('country'),
    isPrimary: boolean('is_primary').notNull().default(true),
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (table) => [
    index('idx_compa_company').on(table.companyId),
    index('idx_compa_port').on(table.portId),
    uniqueIndex('idx_compa_primary')
      .on(table.companyId)
      .where(sql`${table.isPrimary} = true`),
  ],
);

export const companyNotes = pgTable(
  'company_notes',
  {
    id: text('id')
      .primaryKey()
      .$defaultFn(() => crypto.randomUUID()),
    companyId: text('company_id')
      .notNull()
      .references(() => companies.id, { onDelete: 'cascade' }),
    authorId: text('author_id').notNull(),
    content: text('content').notNull(),
    mentions: text('mentions').array(),
    isLocked: boolean('is_locked').notNull().default(false),
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (table) => [index('idx_compn_company').on(table.companyId)],
);

export const companyTags = pgTable(
  'company_tags',
  {
    companyId: text('company_id')
      .notNull()
      .references(() => companies.id, { onDelete: 'cascade' }),
    tagId: text('tag_id').notNull(),
  },
  (table) => [primaryKey({ columns: [table.companyId, table.tagId] })],
);

export type Company = typeof companies.$inferSelect;
export type NewCompany = typeof companies.$inferInsert;
export type CompanyMembership = typeof companyMemberships.$inferSelect;
export type NewCompanyMembership = typeof companyMemberships.$inferInsert;
export type CompanyAddress = typeof companyAddresses.$inferSelect;
export type NewCompanyAddress = typeof companyAddresses.$inferInsert;
export type CompanyNote = typeof companyNotes.$inferSelect;
export type NewCompanyNote = typeof companyNotes.$inferInsert;
  • Step 2: Re-export from schema index

Add to src/lib/db/schema/index.ts:

export * from './companies';
  • Step 3: Generate + apply migration
pnpm db:generate
pnpm db:push
  • Step 4: Commit
git add src/lib/db/schema/companies.ts src/lib/db/schema/index.ts src/lib/db/migrations/
git commit -m "feat(companies): add companies, memberships, addresses, notes, tags schema"

Task 1.3: Create reservations.ts schema file

Files:

  • Create: src/lib/db/schema/reservations.ts

  • Step 1: Write the schema

import { pgTable, text, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { ports } from './ports';
import { berths } from './berths';
import { clients } from './clients';
import { yachts } from './yachts';
import { interests } from './interests';
import { files } from './documents';

export const berthReservations = pgTable(
  'berth_reservations',
  {
    id: text('id')
      .primaryKey()
      .$defaultFn(() => crypto.randomUUID()),
    berthId: text('berth_id')
      .notNull()
      .references(() => berths.id),
    portId: text('port_id')
      .notNull()
      .references(() => ports.id),
    clientId: text('client_id')
      .notNull()
      .references(() => clients.id),
    yachtId: text('yacht_id')
      .notNull()
      .references(() => yachts.id),
    interestId: text('interest_id').references(() => interests.id),
    status: text('status').notNull(), // 'pending' | 'active' | 'ended' | 'cancelled'
    startDate: timestamp('start_date', { withTimezone: true, mode: 'date' }).notNull(),
    endDate: timestamp('end_date', { withTimezone: true, mode: 'date' }),
    tenureType: text('tenure_type').notNull().default('permanent'),
    contractFileId: text('contract_file_id').references(() => files.id),
    notes: text('notes'),
    createdBy: text('created_by').notNull(),
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (table) => [
    index('idx_br_berth').on(table.berthId),
    index('idx_br_client').on(table.clientId),
    index('idx_br_yacht').on(table.yachtId),
    index('idx_br_port').on(table.portId),
    uniqueIndex('idx_br_active')
      .on(table.berthId)
      .where(sql`${table.status} = 'active'`),
  ],
);

export type BerthReservation = typeof berthReservations.$inferSelect;
export type NewBerthReservation = typeof berthReservations.$inferInsert;
  • Step 2: Re-export from schema index

  • Step 3: Generate + apply migration, commit

pnpm db:generate && pnpm db:push
git add src/lib/db/schema/reservations.ts src/lib/db/schema/index.ts src/lib/db/migrations/
git commit -m "feat(reservations): add berth_reservations schema with partial unique exclusivity"

Task 1.4: Add yachtId to interests, berth_waiting_list

Files:

  • Modify: src/lib/db/schema/interests.ts

  • Modify: src/lib/db/schema/berths.ts

  • Step 1: Add yachtId to interests schema

In src/lib/db/schema/interests.ts, add a yachtId column after berthId:

yachtId: text('yacht_id'), // FK added via relation; nullable until pipeline leaves 'open'

Add an index:

index('idx_interests_yacht').on(table.yachtId),
  • Step 2: Add yachtId to berth_waiting_list

In src/lib/db/schema/berths.ts, find berthWaitingList and add:

yachtId: text('yacht_id'), // FK added via relation; nullable
  • Step 3: Generate + apply migration, commit
pnpm db:generate && pnpm db:push
git add src/lib/db/schema/interests.ts src/lib/db/schema/berths.ts src/lib/db/migrations/
git commit -m "feat(schema): add yachtId to interests and berth_waiting_list"

Task 1.5: Modify invoices — add billing entity

Files:

  • Modify: src/lib/db/schema/financial.ts

  • Step 1: Add billing entity columns

After clientName in the invoices table, add:

billingEntityType: text('billing_entity_type').notNull().default('client'), // 'client' | 'company' — default needed for backfill-less green-field
billingEntityId: text('billing_entity_id').notNull().default(''),

Add an index:

index('idx_invoices_billing_entity').on(table.portId, table.billingEntityType, table.billingEntityId),
  • Step 2: Generate + apply migration, commit
pnpm db:generate && pnpm db:push
git add src/lib/db/schema/financial.ts src/lib/db/migrations/
git commit -m "feat(invoices): add billingEntityType/Id for polymorphic billing"

Task 1.6: Modify files and documents — add yachtId/companyId FKs

Files:

  • Modify: src/lib/db/schema/documents.ts

  • Step 1: Add FKs to files table

yachtId: text('yacht_id'), // FK wired in relations.ts
companyId: text('company_id'), // FK wired in relations.ts

Also add indexes.

  • Step 2: Add same FKs to documents table

Same two columns + indexes.

  • Step 3: Generate + apply migration, commit
pnpm db:generate && pnpm db:push
git add src/lib/db/schema/documents.ts src/lib/db/migrations/
git commit -m "feat(documents): add yachtId/companyId to files and documents"

Task 1.7: Wire new tables into relations.ts

Files:

  • Modify: src/lib/db/schema/relations.ts

  • Step 1: Import new tables

At the top of relations.ts, add imports:

import { yachts, yachtOwnershipHistory, yachtNotes, yachtTags } from './yachts';
import {
  companies,
  companyMemberships,
  companyAddresses,
  companyNotes,
  companyTags,
} from './companies';
import { berthReservations } from './reservations';
  • Step 2: Add relations for yachts
export const yachtsRelations = relations(yachts, ({ one, many }) => ({
  port: one(ports, { fields: [yachts.portId], references: [ports.id] }),
  ownershipHistory: many(yachtOwnershipHistory),
  notes: many(yachtNotes),
  tags: many(yachtTags),
  interests: many(interests),
  reservations: many(berthReservations),
  documents: many(documents),
}));

export const yachtOwnershipHistoryRelations = relations(yachtOwnershipHistory, ({ one }) => ({
  yacht: one(yachts, { fields: [yachtOwnershipHistory.yachtId], references: [yachts.id] }),
}));

export const yachtNotesRelations = relations(yachtNotes, ({ one }) => ({
  yacht: one(yachts, { fields: [yachtNotes.yachtId], references: [yachts.id] }),
}));

export const yachtTagsRelations = relations(yachtTags, ({ one }) => ({
  yacht: one(yachts, { fields: [yachtTags.yachtId], references: [yachts.id] }),
  tag: one(tags, { fields: [yachtTags.tagId], references: [tags.id] }),
}));
  • Step 3: Add relations for companies + memberships + addresses + notes + tags

Mirror the pattern from Step 2. Include companyMembershipsRelations wiring both company: one(companies, ...) and client: one(clients, ...).

  • Step 4: Add relations for berthReservations
export const berthReservationsRelations = relations(berthReservations, ({ one }) => ({
  berth: one(berths, { fields: [berthReservations.berthId], references: [berths.id] }),
  port: one(ports, { fields: [berthReservations.portId], references: [ports.id] }),
  client: one(clients, { fields: [berthReservations.clientId], references: [clients.id] }),
  yacht: one(yachts, { fields: [berthReservations.yachtId], references: [yachts.id] }),
  interest: one(interests, {
    fields: [berthReservations.interestId],
    references: [interests.id],
  }),
  contractFile: one(files, {
    fields: [berthReservations.contractFileId],
    references: [files.id],
  }),
}));
  • Step 5: Extend clientsRelations

Add to the clients relations many() block:

companyMemberships: many(companyMemberships),
berthReservations: many(berthReservations),

Note: owned yachts (where client is the current owner) is a polymorphic lookup; query via yachts where currentOwnerType='client' AND currentOwnerId=.... Don't attempt a Drizzle relation for this — service layer handles it.

  • Step 6: Extend interestsRelations

Add: yacht: one(yachts, { fields: [interests.yachtId], references: [yachts.id] }).

  • Step 7: Extend filesRelations, documentsRelations

Add yacht and company one-to-one relations on each.

  • Step 8: Commit
git add src/lib/db/schema/relations.ts
git commit -m "feat(schema): wire yacht, company, reservation relations in Drizzle"

Task 1.8: Integration test for partial unique indexes

Files:

  • Create: tests/integration/schema-constraints.test.ts

  • Step 1: Write failing test

import { describe, it, expect, beforeEach } from 'vitest';
import { db } from '@/lib/db';
import { yachts, yachtOwnershipHistory } from '@/lib/db/schema';
import { berthReservations } from '@/lib/db/schema/reservations';
import { makeClient, makePort, makeYacht, makeBerth } from 'tests/helpers/factories';

describe('schema constraints', () => {
  it('partial unique: rejects a second active ownership row per yacht', async () => {
    const port = await makePort();
    const client1 = await makeClient({ portId: port.id });
    const client2 = await makeClient({ portId: port.id });
    const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client1.id });
    // ownership row #1 already created by makeYacht

    await expect(
      db.insert(yachtOwnershipHistory).values({
        yachtId: yacht.id,
        ownerType: 'client',
        ownerId: client2.id,
        startDate: new Date(),
        endDate: null, // another "active" row — should fail
        createdBy: 'test',
      }),
    ).rejects.toThrow(/duplicate key/i);
  });

  it('partial unique: rejects a second active reservation per berth', async () => {
    const port = await makePort();
    const client1 = await makeClient({ portId: port.id });
    const client2 = await makeClient({ portId: port.id });
    const yacht1 = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client1.id });
    const yacht2 = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client2.id });
    const berth = await makeBerth({ portId: port.id });

    await db.insert(berthReservations).values({
      berthId: berth.id,
      portId: port.id,
      clientId: client1.id,
      yachtId: yacht1.id,
      status: 'active',
      startDate: new Date(),
      createdBy: 'test',
    });

    await expect(
      db.insert(berthReservations).values({
        berthId: berth.id,
        portId: port.id,
        clientId: client2.id,
        yachtId: yacht2.id,
        status: 'active',
        startDate: new Date(),
        createdBy: 'test',
      }),
    ).rejects.toThrow(/duplicate key/i);
  });

  it('case-insensitive company name uniqueness per port', async () => {
    const port = await makePort();
    await db.insert(companies).values({ portId: port.id, name: 'Aegean Holdings' });
    await expect(
      db.insert(companies).values({ portId: port.id, name: 'AEGEAN HOLDINGS' }),
    ).rejects.toThrow(/duplicate key/i);
  });
});
  • Step 2: Add makeYacht, makeBerth factories to tests/helpers/factories.ts
export async function makeYacht(args: {
  portId: string;
  ownerType: 'client' | 'company';
  ownerId: string;
  overrides?: Partial<NewYacht>;
}) {
  const [yacht] = await db
    .insert(yachts)
    .values({
      portId: args.portId,
      name: faker.word.noun() + ' ' + faker.word.adjective(),
      currentOwnerType: args.ownerType,
      currentOwnerId: args.ownerId,
      ...args.overrides,
    })
    .returning();
  await db.insert(yachtOwnershipHistory).values({
    yachtId: yacht!.id,
    ownerType: args.ownerType,
    ownerId: args.ownerId,
    startDate: new Date(),
    endDate: null,
    createdBy: 'test',
  });
  return yacht!;
}

export async function makeBerth(args: { portId: string; overrides?: Partial<NewBerth> }) {
  const [berth] = await db
    .insert(berths)
    .values({
      portId: args.portId,
      mooringNumber: faker.string.alphanumeric(6),
      ...args.overrides,
    })
    .returning();
  return berth!;
}
  • Step 3: Run the test — verify all three cases pass
pnpm vitest run tests/integration/schema-constraints.test.ts

Expected: 3 passed.

  • Step 4: Commit
git add tests/integration/schema-constraints.test.ts tests/helpers/factories.ts
git commit -m "test(schema): verify partial unique indexes and case-insensitive company uniqueness"

Task 1.9: Create PR 1

  • Open PR from refactor/data-model (or push changes if working directly on it).
  • PR title: feat(schema): foundation tables for yacht/company refactor (PR 1 of 15)
  • Body: summary of tables created + link to spec.

PR 2 — New Services

Goal: Build the four new services (yachts, companies, memberships, berth-reservations) + shared EOI context builder. Unit tests for every function. Integration tests for atomic operations.

Task 2.1: Validators — yachts.ts

Files:

  • Create: src/lib/validators/yachts.ts

  • Step 1: Write zod schemas

import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';

export const ownerRefSchema = z.object({
  type: z.enum(['client', 'company']),
  id: z.string().min(1),
});

export const createYachtSchema = z.object({
  name: z.string().min(1).max(200),
  hullNumber: z.string().optional(),
  registration: z.string().optional(),
  flag: z.string().optional(),
  yearBuilt: z.number().int().min(1800).max(2100).optional(),
  builder: z.string().optional(),
  model: z.string().optional(),
  hullMaterial: z.string().optional(),
  lengthFt: z.string().optional(),
  widthFt: z.string().optional(),
  draftFt: z.string().optional(),
  lengthM: z.string().optional(),
  widthM: z.string().optional(),
  draftM: z.string().optional(),
  owner: ownerRefSchema, // required; yacht must have an owner
  status: z.enum(['active', 'retired', 'sold_away']).optional().default('active'),
  notes: z.string().optional(),
  tagIds: z.array(z.string()).optional().default([]),
});

export const updateYachtSchema = createYachtSchema.partial().omit({ owner: true });
// Owner changes go through /transfer, not PATCH.

export const transferOwnershipSchema = z.object({
  newOwner: ownerRefSchema,
  effectiveDate: z.coerce.date(),
  transferReason: z
    .enum(['sale', 'inheritance', 'gift', 'company_restructure', 'other'])
    .optional(),
  transferNotes: z.string().optional(),
});

export const listYachtsQuery = baseListQuerySchema.extend({
  ownerType: z.enum(['client', 'company']).optional(),
  ownerId: z.string().optional(),
  status: z.enum(['active', 'retired', 'sold_away']).optional(),
  search: z.string().optional(),
});

export type CreateYachtInput = z.infer<typeof createYachtSchema>;
export type UpdateYachtInput = z.infer<typeof updateYachtSchema>;
export type TransferOwnershipInput = z.infer<typeof transferOwnershipSchema>;
export type ListYachtsInput = z.infer<typeof listYachtsQuery>;
  • Step 2: Write unit tests for validators
// tests/unit/validators/yachts.test.ts
import { describe, it, expect } from 'vitest';
import { createYachtSchema, transferOwnershipSchema } from '@/lib/validators/yachts';

describe('createYachtSchema', () => {
  it('rejects empty name', () => {
    const result = createYachtSchema.safeParse({
      name: '',
      owner: { type: 'client', id: 'c1' },
    });
    expect(result.success).toBe(false);
  });

  it('requires owner', () => {
    const result = createYachtSchema.safeParse({ name: 'Sea Breeze' });
    expect(result.success).toBe(false);
  });

  it('rejects invalid yearBuilt', () => {
    const result = createYachtSchema.safeParse({
      name: 'Sea Breeze',
      owner: { type: 'client', id: 'c1' },
      yearBuilt: 1700,
    });
    expect(result.success).toBe(false);
  });

  it('accepts minimal valid input', () => {
    const result = createYachtSchema.safeParse({
      name: 'Sea Breeze',
      owner: { type: 'client', id: 'c1' },
    });
    expect(result.success).toBe(true);
  });
});

describe('transferOwnershipSchema', () => {
  it('requires newOwner + effectiveDate', () => {
    expect(transferOwnershipSchema.safeParse({}).success).toBe(false);
  });

  it('accepts valid input', () => {
    const result = transferOwnershipSchema.safeParse({
      newOwner: { type: 'company', id: 'co1' },
      effectiveDate: new Date(),
      transferReason: 'sale',
    });
    expect(result.success).toBe(true);
  });
});
  • Step 3: Run tests, commit
pnpm vitest run tests/unit/validators/yachts.test.ts
git add src/lib/validators/yachts.ts tests/unit/validators/yachts.test.ts
git commit -m "feat(yachts): add zod validators + tests"

Task 2.2: Service — yachts.service.ts (create + list + get)

Files:

  • Create: src/lib/services/yachts.service.ts

  • Create: tests/unit/services/yachts.test.ts

  • Step 1: Write failing test for createYacht

// tests/unit/services/yachts.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { createYacht, getYachtById } from '@/lib/services/yachts.service';
import { makeClient, makeCompany, makePort, makeAuditMeta } from 'tests/helpers/factories';
import { db } from '@/lib/db';
import { yachts, yachtOwnershipHistory } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';

describe('yachts.service — createYacht', () => {
  it('creates a yacht with a client owner and opens an ownership history row', async () => {
    const port = await makePort();
    const client = await makeClient({ portId: port.id });

    const yacht = await createYacht(
      port.id,
      {
        name: 'Sea Breeze',
        owner: { type: 'client', id: client.id },
      },
      makeAuditMeta(),
    );

    expect(yacht.currentOwnerType).toBe('client');
    expect(yacht.currentOwnerId).toBe(client.id);

    const history = await db
      .select()
      .from(yachtOwnershipHistory)
      .where(eq(yachtOwnershipHistory.yachtId, yacht.id));
    expect(history).toHaveLength(1);
    expect(history[0]!.endDate).toBeNull();
  });

  it('rejects when ownerType=client but ownerId does not exist', async () => {
    const port = await makePort();
    await expect(
      createYacht(
        port.id,
        { name: 'Phantom', owner: { type: 'client', id: 'nonexistent' } },
        makeAuditMeta(),
      ),
    ).rejects.toThrow(/owner not found/i);
  });

  it('rejects when ownerType=company but ownerId does not exist', async () => {
    const port = await makePort();
    await expect(
      createYacht(
        port.id,
        { name: 'Phantom', owner: { type: 'company', id: 'nonexistent' } },
        makeAuditMeta(),
      ),
    ).rejects.toThrow(/owner not found/i);
  });

  it('rejects owner from a different tenant (cross-tenant guard)', async () => {
    const portA = await makePort();
    const portB = await makePort();
    const clientInB = await makeClient({ portId: portB.id });
    await expect(
      createYacht(
        portA.id,
        { name: 'Wrong Port', owner: { type: 'client', id: clientInB.id } },
        makeAuditMeta(),
      ),
    ).rejects.toThrow(/owner not found/i);
  });
});
  • Step 2: Run test — verify 4 failures
pnpm vitest run tests/unit/services/yachts.test.ts
  • Step 3: Implement createYacht
// src/lib/services/yachts.service.ts
import { and, eq, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { yachts, yachtOwnershipHistory, clients } from '@/lib/db/schema';
import { companies } from '@/lib/db/schema/companies';
import { createAuditLog } from '@/lib/audit';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import type { CreateYachtInput } from '@/lib/validators/yachts';

interface AuditMeta {
  userId: string;
  portId: string;
  ipAddress: string;
  userAgent: string;
}

async function assertOwnerExists(
  portId: string,
  owner: { type: 'client' | 'company'; id: string },
): Promise<void> {
  if (owner.type === 'client') {
    const client = await db.query.clients.findFirst({
      where: and(eq(clients.id, owner.id), eq(clients.portId, portId)),
    });
    if (!client) throw new ValidationError('owner not found');
  } else {
    const company = await db.query.companies.findFirst({
      where: and(eq(companies.id, owner.id), eq(companies.portId, portId)),
    });
    if (!company) throw new ValidationError('owner not found');
  }
}

export async function createYacht(portId: string, data: CreateYachtInput, meta: AuditMeta) {
  return await db.transaction(async (tx) => {
    await assertOwnerExists(portId, data.owner);

    const [yacht] = await tx
      .insert(yachts)
      .values({
        portId,
        name: data.name,
        hullNumber: data.hullNumber ?? null,
        registration: data.registration ?? null,
        flag: data.flag ?? null,
        yearBuilt: data.yearBuilt ?? null,
        builder: data.builder ?? null,
        model: data.model ?? null,
        hullMaterial: data.hullMaterial ?? null,
        lengthFt: data.lengthFt ?? null,
        widthFt: data.widthFt ?? null,
        draftFt: data.draftFt ?? null,
        lengthM: data.lengthM ?? null,
        widthM: data.widthM ?? null,
        draftM: data.draftM ?? null,
        currentOwnerType: data.owner.type,
        currentOwnerId: data.owner.id,
        status: data.status ?? 'active',
        notes: data.notes ?? null,
      })
      .returning();

    await tx.insert(yachtOwnershipHistory).values({
      yachtId: yacht!.id,
      ownerType: data.owner.type,
      ownerId: data.owner.id,
      startDate: new Date(),
      endDate: null,
      createdBy: meta.userId,
    });

    void createAuditLog({
      userId: meta.userId,
      portId,
      action: 'create',
      entityType: 'yacht',
      entityId: yacht!.id,
      newValue: { name: yacht!.name, owner: data.owner },
      ipAddress: meta.ipAddress,
      userAgent: meta.userAgent,
    });

    emitToRoom(`port:${portId}`, 'yacht:created', { yachtId: yacht!.id });

    return yacht!;
  });
}

export async function getYachtById(id: string, portId: string) {
  const yacht = await db.query.yachts.findFirst({
    where: and(eq(yachts.id, id), eq(yachts.portId, portId)),
  });
  if (!yacht) throw new NotFoundError('Yacht');
  return yacht;
}
  • Step 4: Run test — verify all 4 pass

  • Step 5: Commit

git add src/lib/services/yachts.service.ts tests/unit/services/yachts.test.ts
git commit -m "feat(yachts): createYacht + getYachtById services with tests"

Task 2.3: Service — yachts.service.ts (update + archive)

Files:

  • Modify: src/lib/services/yachts.service.ts

  • Modify: tests/unit/services/yachts.test.ts

  • Step 1: Add failing tests for updateYacht, archiveYacht

Tests: update fields succeeds; owner fields cannot be changed via PATCH (must use transfer); archive sets archivedAt.

  • Step 2: Implement updateYacht and archiveYacht

Pattern mirrors other services (diffEntity, audit log, socket emit). Reject any attempt to mutate currentOwnerType or currentOwnerId via update (throw ValidationError('use /transfer to change ownership')).

  • Step 3: Run tests, commit
git commit -am "feat(yachts): updateYacht + archiveYacht"

Task 2.4: Service — yachts.service.ts (transferOwnership — atomic)

Files:

  • Modify: src/lib/services/yachts.service.ts

  • Create: tests/integration/ownership-transfer.test.ts

  • Step 1: Write integration test for atomicity

// tests/integration/ownership-transfer.test.ts
import { describe, it, expect } from 'vitest';
import { transferOwnership } from '@/lib/services/yachts.service';
import {
  makeClient,
  makeCompany,
  makePort,
  makeYacht,
  makeAuditMeta,
} from 'tests/helpers/factories';
import { db } from '@/lib/db';
import { yachtOwnershipHistory, yachts } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';

describe('transferOwnership', () => {
  it('closes prior history row and opens a new one atomically', async () => {
    const port = await makePort();
    const clientA = await makeClient({ portId: port.id });
    const clientB = await makeClient({ portId: port.id });
    const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: clientA.id });

    await transferOwnership(
      yacht.id,
      port.id,
      {
        newOwner: { type: 'client', id: clientB.id },
        effectiveDate: new Date(),
        transferReason: 'sale',
      },
      makeAuditMeta(),
    );

    const history = await db
      .select()
      .from(yachtOwnershipHistory)
      .where(eq(yachtOwnershipHistory.yachtId, yacht.id));
    expect(history).toHaveLength(2);
    const [prior, current] = history.sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
    expect(prior!.endDate).not.toBeNull();
    expect(current!.endDate).toBeNull();
    expect(current!.ownerId).toBe(clientB.id);

    const updatedYacht = await db.query.yachts.findFirst({ where: eq(yachts.id, yacht.id) });
    expect(updatedYacht!.currentOwnerId).toBe(clientB.id);
  });

  it('rejects when newOwner = currentOwner (no-op)', async () => {
    const port = await makePort();
    const client = await makeClient({ portId: port.id });
    const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id });

    await expect(
      transferOwnership(
        yacht.id,
        port.id,
        { newOwner: { type: 'client', id: client.id }, effectiveDate: new Date() },
        makeAuditMeta(),
      ),
    ).rejects.toThrow(/same owner/i);
  });

  it('partial unique index prevents concurrent double-open ownership rows', async () => {
    // Prior to the atomic close-then-open, a naive impl would insert a new open row
    // without closing the old one. Verify this would be blocked at the DB level.
    const port = await makePort();
    const clientA = await makeClient({ portId: port.id });
    const clientB = await makeClient({ portId: port.id });
    const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: clientA.id });

    await expect(
      db.insert(yachtOwnershipHistory).values({
        yachtId: yacht.id,
        ownerType: 'client',
        ownerId: clientB.id,
        startDate: new Date(),
        endDate: null,
        createdBy: 'test',
      }),
    ).rejects.toThrow(/duplicate key/i);
  });
});
  • Step 2: Implement transferOwnership
export async function transferOwnership(
  yachtId: string,
  portId: string,
  data: TransferOwnershipInput,
  meta: AuditMeta,
) {
  return await db.transaction(async (tx) => {
    const yacht = await tx.query.yachts.findFirst({
      where: and(eq(yachts.id, yachtId), eq(yachts.portId, portId)),
    });
    if (!yacht) throw new NotFoundError('Yacht');

    if (
      yacht.currentOwnerType === data.newOwner.type &&
      yacht.currentOwnerId === data.newOwner.id
    ) {
      throw new ValidationError('same owner — nothing to transfer');
    }

    await assertOwnerExists(portId, data.newOwner);

    // Close the currently-active history row
    await tx
      .update(yachtOwnershipHistory)
      .set({ endDate: data.effectiveDate })
      .where(
        and(
          eq(yachtOwnershipHistory.yachtId, yachtId),
          sql`${yachtOwnershipHistory.endDate} IS NULL`,
        ),
      );

    // Open new row
    await tx.insert(yachtOwnershipHistory).values({
      yachtId,
      ownerType: data.newOwner.type,
      ownerId: data.newOwner.id,
      startDate: data.effectiveDate,
      endDate: null,
      transferReason: data.transferReason ?? null,
      transferNotes: data.transferNotes ?? null,
      createdBy: meta.userId,
    });

    // Update denormalized current-owner columns
    const [updated] = await tx
      .update(yachts)
      .set({
        currentOwnerType: data.newOwner.type,
        currentOwnerId: data.newOwner.id,
        updatedAt: new Date(),
      })
      .where(eq(yachts.id, yachtId))
      .returning();

    void createAuditLog({
      userId: meta.userId,
      portId,
      action: 'update',
      entityType: 'yacht',
      entityId: yachtId,
      newValue: { ownerTransferTo: data.newOwner, reason: data.transferReason },
      ipAddress: meta.ipAddress,
      userAgent: meta.userAgent,
    });

    emitToRoom(`port:${portId}`, 'yacht:ownership_transferred', {
      yachtId,
      newOwner: data.newOwner,
    });

    return updated!;
  });
}
  • Step 3: Run tests, commit
pnpm vitest run tests/integration/ownership-transfer.test.ts
git commit -am "feat(yachts): atomic transferOwnership with partial-unique guard"

Task 2.5: Service — yachts.service.ts (list + search + owner lookups)

Files:

  • Modify: src/lib/services/yachts.service.ts

  • Step 1: Write failing tests for listYachts

Tests: filters by ownerType+ownerId; filters by status; filters by search; respects pagination; tenant-scoped.

  • Step 2: Implement listYachts

Uses buildListQuery helper. Filter predicates: ownerType, ownerId, status, name ILIKE.

  • Step 3: Implement listYachtsForOwner(ownerType, ownerId) — queries by polymorphic current owner, used by client detail page.

  • Step 4: Implement autocomplete(portId, q) — ILIKE match on name + hullNumber + registration, returns top 10.

  • Step 5: Commit

git commit -am "feat(yachts): list + owner-scoped list + autocomplete"

Task 2.6: Service — companies.service.ts

Files:

  • Create: src/lib/validators/companies.ts

  • Create: src/lib/services/companies.service.ts

  • Create: tests/unit/services/companies.test.ts

  • Step 1: ValidatorscreateCompanySchema, updateCompanySchema, listCompaniesQuery. Required: name. Optional: legalName, taxId, registrationNumber, incorporationCountry, incorporationDate, status, billingEmail, notes, tagIds.

  • Step 2: Tests (TDD)createCompany happy path; case-insensitive name uniqueness (create "Aegean Holdings", then "AEGEAN HOLDINGS" → throws); upsertByName returns existing row on case-insensitive match; archiveCompany sets archivedAt; all tenant-scoped.

  • Step 3: Implementation mirrors yachts.service pattern. Notable: upsertByName(portId, name) uses INSERT ... ON CONFLICT (portId, lower(name)) DO UPDATE SET updatedAt = NOW() RETURNING * or a SELECT-then-INSERT transaction. Socket: emit company:created, company:updated, company:archived.

  • Step 4: Run tests, commit

git commit -am "feat(companies): service + validators + unit tests"

Task 2.7: Service — company-memberships.service.ts

Files:

  • Create: src/lib/validators/company-memberships.ts

  • Create: src/lib/services/company-memberships.service.ts

  • Create: tests/unit/services/company-memberships.test.ts

  • Step 1: ValidatorsaddMembershipSchema (companyId via path, body: clientId, role, roleDetail?, startDate, isPrimary?, notes?). updateMembershipSchema. endMembershipSchema (endDate).

  • Step 2: TestsaddMembership creates row; rejects duplicate exact (companyId+clientId+role+startDate via unique constraint); setPrimary ensures only one membership-per-company has isPrimary=true (enforce in service; unique partial index is isPrimary=true per company could also work). endMembership sets endDate. listByCompany and listByClient return active-only by default.

  • Step 3: Implementation. setPrimary runs in a transaction: un-primary all others for this company, set this one primary. Emit socket events: company_membership:added, company_membership:ended.

  • Step 4: Commit

git commit -am "feat(company-memberships): service + validators + tests"

Task 2.8: Service — berth-reservations.service.ts

Files:

  • Create: src/lib/validators/reservations.ts

  • Create: src/lib/services/berth-reservations.service.ts

  • Create: tests/unit/services/berth-reservations.test.ts

  • Create: tests/integration/reservation-exclusivity.test.ts

  • Step 1: ValidatorscreatePendingSchema, activateSchema, endReservationSchema, cancelSchema.

  • Step 2: Unit tests — lifecycle transitions: pending → active; active → ended; pending → cancelled; active → cancelled; invalid transitions (e.g., ended → active) throw ValidationError. Cross-validation: yacht belongs to a client who matches the reservation's clientId (or represents a company that owns the yacht).

  • Step 3: Integration test — partial unique idx_br_active: two concurrent activate attempts on same berth → one succeeds, one throws with distinct error we catch and surface as ConflictError.

  • Step 4: Implementation. activate(id) reads current row, verifies status=pending, updates to active. If DB rejects with unique violation, re-query for the conflicting active row and throw ConflictError with that info. Emit berth_reservation:created|activated|ended|cancelled.

  • Step 5: Commit

git commit -am "feat(reservations): service + validators + exclusivity tests"

Task 2.9: Service — eoi-context.ts (shared EOI payload builder)

Files:

  • Create: src/lib/services/eoi-context.ts

  • Create: tests/unit/services/eoi-context.test.ts

  • Step 1: Define the EoiContext type

export type EoiContext = {
  client: {
    fullName: string;
    nationality: string | null;
    primaryEmail: string | null;
    primaryPhone: string | null;
    address: { street: string; city: string; country: string } | null;
  };
  yacht: {
    name: string;
    lengthFt: string | null;
    widthFt: string | null;
    draftFt: string | null;
    lengthM: string | null;
    widthM: string | null;
    draftM: string | null;
    hullNumber: string | null;
    flag: string | null;
    yearBuilt: number | null;
  };
  company: {
    name: string;
    legalName: string | null;
    taxId: string | null;
    billingAddress: string | null;
  } | null;
  owner: {
    type: 'client' | 'company';
    name: string;
    legalName?: string;
  };
  berth: {
    mooringNumber: string;
    area: string | null;
    lengthFt: string | null;
    price: string | null;
    priceCurrency: string;
    tenureType: string;
  };
  interest: {
    stage: string;
    leadCategory: string | null;
    dateFirstContact: Date | null;
    notes: string | null;
  };
  port: {
    name: string;
    defaultCurrency: string;
  };
  date: {
    today: string;
    year: string;
  };
};
  • Step 2: Failing tests for buildEoiContext

Scenarios (each should return a correctly-populated context):

  • Client-owned yacht

  • Company-owned yacht

  • Company-owned yacht where interest's client is a company member (verify client is the interest's client, company is the yacht's owner, owner.type === 'company')

  • Missing berth (interest has no berth yet) — context.berth is still populated if linked; otherwise throw ValidationError('interest has no berth') (we need berth for EOI)

  • Missing yacht (interest has no yacht) — throws

  • Step 3: Implementation

Fetches interest + client + primary contact + primary address + yacht + yacht's current owner (polymorphic resolution) + berth + port. Returns fully-populated EoiContext.

  • Step 4: Run tests, commit
git commit -am "feat(eoi): shared context builder + tests"

Task 2.10: Create PR 2

  • Push refactor/data-model. PR title: feat(services): yachts, companies, memberships, reservations, EOI context (PR 2 of 15).

PR 3 — API Routes and Permissions

Goal: Wire every new service to REST endpoints with proper permission gates. Add new permission keys. Every route has an integration test.

Task 3.1: Add new permission keys

Files:

  • Modify: src/lib/auth/permissions.ts (or wherever permission keys are declared)

  • Modify: seed for default roles (src/lib/db/seed.ts) or role-update migration

  • Step 1: Add the following keys to the permissions enum/list:

yachts:view
yachts:write
yachts:transfer
yachts:delete
companies:view
companies:write
companies:delete
memberships:write
reservations:view
reservations:write
  • Step 2: Update default role assignments

In whichever migration/seed owns the default role configuration:

  • admin: add all new keys

  • team_lead: yachts:view, yachts:write, companies:view, companies:write, memberships:write, reservations:view

  • front_desk: all :view keys

  • Step 3: Test — verify requirePermission(context, 'yachts:transfer') succeeds for admin, throws for team_lead.

  • Step 4: Commit

git commit -am "feat(permissions): add yacht, company, membership, reservation keys"

Task 3.2: Yacht API routes — list + create

Files:

  • Create: src/app/api/v1/yachts/route.ts

  • Create: tests alongside

  • Step 1: Write route integration test

// tests/integration/api/yachts.test.ts
import { describe, it, expect } from 'vitest';
import { testFetch, makeTestSession } from 'tests/helpers/api';
import { makeClient, makePort } from 'tests/helpers/factories';

describe('POST /api/v1/yachts', () => {
  it('creates a yacht when user has yachts:write', async () => {
    const { port, session } = await makeTestSession({ permissions: ['yachts:write'] });
    const client = await makeClient({ portId: port.id });

    const res = await testFetch('POST', '/api/v1/yachts', {
      session,
      body: { name: 'Sea Breeze', owner: { type: 'client', id: client.id } },
    });
    expect(res.status).toBe(201);
    const body = await res.json();
    expect(body.name).toBe('Sea Breeze');
  });

  it('rejects when user lacks yachts:write', async () => {
    const { port, session } = await makeTestSession({ permissions: ['yachts:view'] });
    const client = await makeClient({ portId: port.id });

    const res = await testFetch('POST', '/api/v1/yachts', {
      session,
      body: { name: 'Sea Breeze', owner: { type: 'client', id: client.id } },
    });
    expect(res.status).toBe(403);
  });

  it('returns 400 on invalid body', async () => {
    const { session } = await makeTestSession({ permissions: ['yachts:write'] });
    const res = await testFetch('POST', '/api/v1/yachts', { session, body: { name: '' } });
    expect(res.status).toBe(400);
  });
});
  • Step 2: Implement route
// src/app/api/v1/yachts/route.ts
import { NextRequest } from 'next/server';
import { withContext } from '@/lib/api/route-helpers';
import { requirePermission } from '@/lib/auth/permissions';
import { createYachtSchema, listYachtsQuery } from '@/lib/validators/yachts';
import { createYacht, listYachts } from '@/lib/services/yachts.service';

export async function GET(req: NextRequest) {
  return withContext(req, async (ctx) => {
    requirePermission(ctx, 'yachts:view');
    const query = listYachtsQuery.parse(Object.fromEntries(req.nextUrl.searchParams));
    const result = await listYachts(ctx.portId, query);
    return Response.json(result);
  });
}

export async function POST(req: NextRequest) {
  return withContext(req, async (ctx) => {
    requirePermission(ctx, 'yachts:write');
    const body = createYachtSchema.parse(await req.json());
    const yacht = await createYacht(ctx.portId, body, ctx.auditMeta);
    return Response.json(yacht, { status: 201 });
  });
}
  • Step 3: Run tests, commit
git commit -am "feat(api): GET/POST /api/v1/yachts"

Task 3.3: Yacht API routes — detail, update, archive, transfer, history

Files:

  • Create: src/app/api/v1/yachts/[id]/route.ts — GET, PATCH, DELETE

  • Create: src/app/api/v1/yachts/[id]/transfer/route.ts — POST

  • Create: src/app/api/v1/yachts/[id]/ownership-history/route.ts — GET

  • Create: src/app/api/v1/yachts/autocomplete/route.ts — GET

  • Step 1: Tests for each — mirror Task 3.2's pattern. Specifically test that PATCH rejects owner-mutation (use /transfer to change ownership), that POST /transfer requires yachts:transfer (not just :write).

  • Step 2: Implementations.

  • Step 3: Commit

git commit -am "feat(api): yacht detail, patch, archive, transfer, history, autocomplete"

Task 3.4: Company API routes

Files:

  • Create: src/app/api/v1/companies/route.ts

  • Create: src/app/api/v1/companies/autocomplete/route.ts

  • Create: src/app/api/v1/companies/[id]/route.ts

  • Steps: Follow Task 3.2-3.3 pattern for GET list/create, detail/patch/archive, autocomplete.

  • Commit after full file set works.

Task 3.5: Company memberships API

Files:

  • Create: src/app/api/v1/companies/[id]/members/route.ts — GET list, POST add

  • Create: src/app/api/v1/companies/[id]/members/[mid]/route.ts — PATCH, DELETE

  • Steps: POST requires memberships:write. DELETE sets endDate (soft). Test that a non-existent companyId returns 404 before permission check (avoid info leak? Actually 403 vs 404 is fine here; 404 after scoping).

  • Commit.

Task 3.6: Berth reservations API

Files:

  • Create: src/app/api/v1/berths/[id]/reservations/route.ts

  • Create: src/app/api/v1/berth-reservations/[id]/route.ts

  • Steps: POST /berths/:id/reservations creates pending. PATCH /berth-reservations/:id with body { action: 'activate' | 'end' | 'cancel', ...details } performs state transition. Test every transition + invalid transitions → 400.

  • Commit.

Task 3.7: Socket + webhook event wiring

Files:

  • Modify: src/lib/services/webhooks.ts (or wherever the event map lives)

  • Modify: any socket event declaration file

  • Step 1: Add new event names to the webhook event map

Events: 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.

  • Step 2: Update tests/unit/webhook-event-map.test.ts to assert every new event is in the catalog.

  • Step 3: Verify socket events are emitted by services (already implemented in PR 2; spot-check).

  • Step 4: Commit

git commit -am "feat(events): register yacht, company, membership, reservation events"

Task 3.8: Create PR 3

  • Push + PR title: feat(api): new endpoints for yachts, companies, memberships, reservations (PR 3 of 15).

PR 4 — Seeder Rewrite

Goal: pnpm db:seed produces realistic multi-cardinality fixtures that exercise every relationship the refactor introduces.

Task 4.1: Extend factory helpers

Files:

  • Modify: tests/helpers/factories.ts

  • Step 1: Add factories (if not already added in PR 1-2):

export async function makeCompany(args: { portId: string; overrides?: Partial<NewCompany> }) { ... }

export async function makeMembership(args: {
  companyId: string;
  clientId: string;
  role?: string;
  startDate?: Date;
  endDate?: Date | null;
  isPrimary?: boolean;
}) { ... }

export async function makeReservation(args: {
  berthId: string;
  clientId: string;
  yachtId: string;
  status: 'pending' | 'active' | 'ended' | 'cancelled';
  startDate?: Date;
  endDate?: Date | null;
}) { ... }

export async function makeOwnershipTransfer(args: {
  yachtId: string;
  fromOwner: { type; id };
  toOwner: { type; id };
  date: Date;
}) { ... }  // closes existing, opens new
  • Step 2: Commit

Task 4.2: Rewrite src/lib/db/seed.ts

Files:

  • Modify: src/lib/db/seed.ts

  • Step 1: Replace the seed with the scenarios defined in the spec

Creates (per port):

  • 3 companies: Aegean Holdings (active, 3 members), Blue Seas Marine (active, 1 member), Phantom SA (dissolved, all memberships ended)

  • 8 clients: 3 personal-only, 2 members of one company, 2 members of two companies, 1 who's a member of the dissolved company

  • 12 yachts: 7 client-owned (across the 8 clients), 5 company-owned (across 2 active companies). 3 yachts have a completed ownership transfer in history (e.g., from a client to a company).

  • ~15 interests distributed across clients/yachts/berths with pipeline-stage variety

  • 5 active berth reservations (one per owning client); 2 ended reservations with realistic historical dates; 1 cancelled

  • Rich contact data: every client has 1-3 contacts (email + optional phone); every yacht has registration data; every company has a billing address

  • Step 2: Seed uses the factory helpers — no bespoke DB calls in seed.ts.

  • Step 3: Run seed + verify

pnpm db:push && pnpm db:seed
pnpm db:studio
# Eyeball: yachts table has 12 rows across the 3 seeded ports; company_memberships shows realistic distribution
  • Step 4: Commit
git commit -am "feat(seed): rewrite seed for multi-cardinality refactor"

Task 4.3: Create PR 4

  • Push, title: feat(seed): multi-cardinality dummy data (PR 4 of 15).

PR 5 — Yacht UI

Goal: List, detail, create, edit, transfer, archive — all from the UI. Every button has a Playwright test in PR 14.

Task 5.1: Base components — yacht-picker, owner-picker

Files:

  • Create: src/components/shared/owner-picker.tsx

  • Create: src/components/yachts/yacht-picker.tsx

  • Step 1: owner-picker.tsx

Polymorphic combobox. Props: value: { type: 'client' | 'company'; id: string } | null, onChange, portId. Toggle at top: [Client] [Company]. Below: autocomplete backed by /api/v1/clients/autocomplete or /api/v1/companies/autocomplete depending on toggle.

  • Step 2: yacht-picker.tsx

Autocomplete combobox wrapping /api/v1/yachts/autocomplete. Props: value: string | null, onChange, portId, ownerFilter?: { type; id } (for interest form — filter to the interest's client's yachts).

  • Step 3: Simple unit-level sanity: render with empty state, render with a value, change event fires.

  • Step 4: Commit.

Task 5.2: Yacht form + validator wiring

Files:

  • Create: src/components/yachts/yacht-form.tsx

  • Step 1: Implement form using react-hook-form + @hookform/resolvers/zod with createYachtSchema. Fields: name (required), dimensions (ft + m), hull/registration/flag/yearBuilt/builder/model/material, owner (via owner-picker), status, notes.

  • Step 2: Submit handler POSTs to /api/v1/yachts; on success, close dialog / navigate to detail. On 400, show field errors.

  • Step 3: Commit.

Task 5.3: Yacht detail page + tabs + ownership history

Files:

  • Create: src/components/yachts/yacht-detail.tsx

  • Create: src/components/yachts/yacht-detail-header.tsx

  • Create: src/components/yachts/yacht-tabs.tsx

  • Create: src/components/yachts/yacht-ownership-history.tsx

  • Create: src/app/(dashboard)/[portSlug]/yachts/[yachtId]/page.tsx

  • Step 1: Page — server component that fetches yacht detail (plus eager loads: ownershipHistory, reservations, notes, tags, documents, interests). Passes to <YachtDetail />.

  • Step 2: yacht-detail-header — name + dimensions + current-owner link + status badge + Edit / Archive / Transfer buttons.

  • Step 3: yacht-tabs — tabs: Overview, Ownership History, Interests, Reservations, Documents, Notes, Tags. Uses existing shadcn tabs component.

  • Step 4: yacht-ownership-history — table: start date, end date (or "Current"), owner (with link), reason, notes.

  • Step 5: Commit.

Task 5.4: Yacht list page + columns

Files:

  • Create: src/components/yachts/yacht-columns.tsx

  • Create: src/app/(dashboard)/[portSlug]/yachts/page.tsx

  • Step 1: Columns — name, current owner (link, polymorphic), dimensions (LxW ft), status badge, actions (view, archive).

  • Step 2: Page — uses TanStack Table + existing list patterns. Filter UI: owner type select, status select, free-text search.

  • Step 3: Commit.

Task 5.5: Yacht transfer dialog

Files:

  • Create: src/components/yachts/yacht-transfer-dialog.tsx

  • Step 1: Dialog with form: owner-picker, effective date, reason (select), notes (textarea), optional deed/sale-doc upload (file input → MinIO via existing upload flow).

  • Step 2: Submit calls POST /api/v1/yachts/:id/transfer. On success, emits local event to refetch ownership history; toast "Transferred successfully"; closes dialog.

  • Step 3: Commit.

Task 5.6: Nav entry + create-yacht flow

Files:

  • Modify: src/components/layout/sidebar.tsx (or wherever nav is defined)

  • Step 1: Add Yachts sidebar entry with icon (lucide: Anchor) linked to /[portSlug]/yachts.

  • Step 2: On the list page, "Create yacht" button opens a dialog with <YachtForm />.

  • Step 3: Commit.

Task 5.7: Create PR 5


PR 6 — Company UI

Goal: List, detail (with members + owned yachts), create, edit, archive. Add membership dialog. Uses existing shadcn patterns.

Task 6.1: Base — company-picker

Files:

  • Create: src/components/companies/company-picker.tsx

  • Step 1: Autocomplete wrapping /api/v1/companies/autocomplete.

  • Step 2: Commit.

Task 6.2: Company form

Files:

  • Create: src/components/companies/company-form.tsx

  • Step 1: Form fields: name, legalName, taxId, registrationNumber, incorporationCountry, incorporationDate, status, billingEmail, notes, tagIds. Case-insensitive uniqueness is enforced server-side — show the 409 error as a form-level error.

  • Step 2: Commit.

Task 6.3: Company detail + tabs

Files:

  • Create: src/components/companies/company-detail.tsx

  • Create: src/components/companies/company-detail-header.tsx

  • Create: src/components/companies/company-tabs.tsx

  • Create: src/components/companies/company-members-tab.tsx

  • Create: src/components/companies/company-owned-yachts-tab.tsx

  • Create: src/app/(dashboard)/[portSlug]/companies/[companyId]/page.tsx

  • Step 1: Page + detail shell + header.

  • Step 2: Tabs: Overview, Members (active + past toggle), Owned Yachts, Addresses, Documents, Notes, Tags.

  • Step 3: Members tab: list with client link + role + dates. "Add member" button opens add-membership-dialog. "End membership" button on active rows.

  • Step 4: Owned yachts tab: server-queries yachts where currentOwnerType='company' AND currentOwnerId=<this>. Table: yacht name, dimensions, status, link to yacht detail.

  • Step 5: Commit.

Task 6.4: Add-membership dialog

Files:

  • Create: src/components/companies/add-membership-dialog.tsx

  • Step 1: Form: client combobox (existing ClientPicker if present, else implement), role select, roleDetail, startDate, isPrimary toggle, notes.

  • Step 2: Submit → POST /api/v1/companies/:id/members. On 409 (duplicate exact), show "This membership already exists".

  • Step 3: Commit.

Task 6.5: Company list page + columns + nav

Files:

  • Create: src/components/companies/company-columns.tsx

  • Create: src/app/(dashboard)/[portSlug]/companies/page.tsx

  • Modify: sidebar config

  • Step 1: Columns: name, legalName, # active members, # owned yachts, status.

  • Step 2: Page + sidebar entry (Building2 lucide icon).

  • Step 3: Commit.

Task 6.6: Create PR 6


PR 7 — Berth Reservations UI + Ownership Wiring

Goal: Berth detail surfaces reservations; reserve and ownership-transfer dialogs are end-to-end wired.

Task 7.1: reservation-list component

Files:

  • Create: src/components/reservations/reservation-list.tsx

  • Step 1: Table: client link, yacht link, date range, status badge, tenure type, contract file link. Props: reservations: BerthReservation[], showBerth?: boolean.

  • Step 2: Commit.

Task 7.2: berth-reserve-dialog

Files:

  • Create: src/components/reservations/berth-reserve-dialog.tsx

  • Step 1: Form: client combobox, yacht combobox (filtered to client's yachts; includes "Add new yacht" inline shortcut), startDate, endDate, tenureType, contract file upload.

  • Step 2: Submit → POST /api/v1/berths/:id/reservations (creates pending). Second button "Create and activate" → creates pending then immediately PATCHes to active; on 409 (duplicate active), surface the existing active reservation as an error.

  • Step 3: Commit.

Task 7.3: Berth detail — add reservations tab

Files:

  • Modify: src/components/berths/berth-detail.tsx (or equivalent)

  • Modify: src/components/berths/berth-tabs.tsx

  • Step 1: Add "Reservations" tab. Content: active reservation card (or "No active reservation") + "Reserve this berth" button + history table below.

  • Step 2: Commit.

Task 7.4: Create PR 7


PR 8 — Client Form Refactor

Goal: Strip yacht/company/proxy fields from the client form. Clean person-only form. Yacht + company management moves to detail page tabs.

Task 8.1: Update validator

Files:

  • Modify: src/lib/validators/clients.ts

  • Step 1: Remove fields from createClientSchema:

    • companyName, isProxy, proxyType, actualOwnerName, relationshipNotes
    • yachtName, yachtLengthFt/WidthFt/DraftFt, yachtLengthM/WidthM/DraftM, berthSizeDesired
  • Step 2: Update tests in tests/unit/validators.test.ts. Add assertions that deprecated fields are rejected (or just ignored — depends on your preference; z.object.strict() would reject; default z.object ignores unknown). Prefer .strict() for safety.

  • Step 3: Commit.

Task 8.2: Update clients.service.ts

Files:

  • Modify: src/lib/services/clients.service.ts

  • Step 1: Remove all references to the deleted fields in create/update/diff/response shaping.

  • Step 2: Extend getClientById to return yachts (via polymorphic query), companies (via active memberships), activeReservations.

  • Step 3: Update service unit tests to reflect new shape.

  • Step 4: Commit.

Task 8.3: Refactor client form UI

Files:

  • Modify: src/components/clients/client-form.tsx

  • Step 1: Remove fields yacht/company/proxy; keep: name, contacts, nationality, preferred contact method/language, timezone, source, sourceDetails, tagIds.

  • Step 2: Commit.

Task 8.4: Refactor client detail — add new tabs

Files:

  • Modify: src/components/clients/client-detail.tsx

  • Modify: src/components/clients/client-tabs.tsx

  • Step 1: New tabs:

    • Yachts — list of client-owned yachts + "Add yacht" button (opens YachtForm pre-filled owner=this client). Also shows yachts represented via company memberships (read-only link).
    • Companies — list of active memberships: company link, role, since date, "Set primary", "End membership" buttons. "Add membership" opens dialog.
    • Reservations — list of active + historical reservations via reservation-list.
  • Step 2: Remove the old "Yacht details" and "Proxy" sections from Overview tab.

  • Step 3: Update client-columns.tsx: replace yacht/company text columns with "# yachts" and "Primary company" (from active memberships with isPrimary=true).

  • Step 4: Commit.

Task 8.5: Update portal.service.ts + portal UI

Files:

  • Modify: src/lib/services/portal.service.ts

  • Modify: src/app/(portal)/portal/dashboard/page.tsx

  • Create: src/app/(portal)/portal/my-yachts/page.tsx

  • Create: src/app/(portal)/portal/my-reservations/page.tsx

  • Step 1: portal.service.ts — add these functions:

    • getPortalUserYachts(clientId, portId) — returns yachts where currentOwnerType='client' AND currentOwnerId=clientId, PLUS yachts owned by any company where the client has an active company_membership. De-duped.
    • getPortalUserMemberships(clientId, portId) — active memberships for the portal client.
    • getPortalUserReservations(clientId, portId) — active + upcoming reservations.
  • Step 2: Unit tests for each — cover client-owned, company-represented, and dedup cases.

  • Step 3: Dashboard update — add three new cards: "My Yachts", "My Memberships", "My Active Reservations".

  • Step 4: New pagesmy-yachts lists yachts with read-only detail; my-reservations lists reservations.

  • Step 5: E2E test — portal user with one client-owned + one company-owned yacht sees exactly two yachts in "My Yachts", not four.

  • Step 6: Commit.

git commit -am "feat(portal): surface yachts, memberships, reservations for portal users"

Task 8.6: Create PR 8


PR 9 — Interest Form + Public Interest Form + Search + Recommendations

Goal: Interest form requires yachtId. Public interest form creates client + yacht + optional company + membership + interest as a trio.

Task 9.1: Update interest validator + service

Files:

  • Modify: src/lib/validators/interests.ts

  • Modify: src/lib/services/interests.service.ts

  • Step 1: Validator: yachtId — nullable on create (for stage=open), required before leaving stage=open. Service enforces: reject promote-to-next-stage if yachtId is null.

  • Step 2: Service validates yacht belongs to the interest's client OR is owned by a company that the client actively represents. ValidationError otherwise.

  • Step 3: Tests + commit.

Task 9.2: Update interest form UI

Files:

  • Modify: src/components/interests/interest-form.tsx

  • Step 1: Add yacht-picker — filtered to the interest's client (plus company-owned yachts for companies the client represents). Inline "Add new yacht" shortcut opens yacht-form dialog.

  • Step 2: Commit.

Task 9.3: Update public interest form

Files:

  • Modify: src/app/api/public/interests/route.ts

  • Step 1: Extend request body to include yacht fields and optional company fields (separated from client fields).

  • Step 2: Service changes (can live in a helper): in one transaction, createClient + createYacht (owner = the new client) + optional upsertCompany + addMembership + createInterest (with yachtId, berthId if provided). All marked source: 'public_submission'.

  • Step 3: Update public interest form UI to match new body shape.

  • Step 4: E2E test — submit the public form; admin sees four new rows (client + yacht + optional company + interest) in the appropriate lists.

  • Step 5: Commit.

Task 9.4: Update search.service.ts — index yachts and companies

Files:

  • Modify: src/lib/services/search.service.ts

  • Modify: src/hooks/use-search.ts

  • Modify: src/components/search/search-result-item.tsx

  • Modify: src/components/search/command-search.tsx

  • Step 1: Extend search to query yachts (name, hullNumber, registration) and companies (name, legalName, taxId) alongside clients. Each result carries a type: 'client' | 'yacht' | 'company' | 'interest' | 'berth' tag.

  • Step 2: Update UI — result-item variants per type (distinct icon per entity); clicking routes to the correct detail page.

  • Step 3: Unit test search.service.ts — search term matching against seeded yachts and companies returns correct results with type tags.

  • Step 4: E2E test — global search for a yacht name returns yacht result; clicking navigates to yacht detail.

  • Step 5: Commit.

git commit -am "feat(search): index yachts and companies alongside clients"

Task 9.5: Update recommendations.ts — yacht dims from yachts table

Files:

  • Modify: src/lib/services/recommendations.ts

  • Step 1: Update berth-fit logic — currently reads yacht dimensions from clients.yacht* fields. Switch to reading from yachts table via interest.yachtId.

  • Step 2: Unit test — generate berth recommendations for an interest whose yacht has known dimensions; verify the matcher respects them.

  • Step 3: Commit.

git commit -am "feat(recommendations): read yacht dimensions from yachts table"

Task 9.6: Create PR 9


PR 10 — Invoice Billing Entity

Goal: Invoices reference a polymorphic billing entity (client or company). clientName remains as a snapshot.

Task 10.1: Update validators + service

Files:

  • Modify: src/lib/validators/invoices.ts

  • Modify: src/lib/services/invoices.service.ts

  • Step 1: Validator: add billingEntityType, billingEntityId. Remove reliance on clientName for creation (it becomes a snapshot derived from the entity at create time).

  • Step 2: Service: on create, look up entity (client or company), copy its display name into clientName. Validate cross-tenant.

  • Step 3: Tests + commit.

Task 10.2: Update invoice form UI

Files:

  • Modify: src/components/invoices/invoice-form.tsx

  • Step 1: Add billing-entity-picker at the top of the form. Selecting an entity populates clientName + billingEmail + billingAddress from the entity.

  • Step 2: Commit.

Task 10.3: Create PR 10


PR 11 — EOI Dual-Path + Shared Payload Builder

Goal: Both Documenso and in-app paths wired end-to-end; UI picker chooses. Standard EOI template seeded for in-app.

Task 11.1: Audit Documenso template field names

Files:

  • Create: docs/eoi-documenso-field-mapping.md

  • Step 1: Read the old system's client-portal/server/api/eoi/generate-quick-eoi.ts and enumerate every field name the Documenso template expects (look at the POST body of /api/v1/templates/{id}/generate-document).

  • Step 2: Document each field in the new doc: field name → schema source (e.g., clientFullName → client.fullName, yachtLength → yacht.lengthFt).

  • Step 3: Commit this doc — it's the reference for Spec 2's importer too.

git add docs/eoi-documenso-field-mapping.md
git commit -m "docs(eoi): document Documenso template field name mapping"

Task 11.2: Documenso payload builder

Files:

  • Modify: src/lib/services/documenso-client.ts (or create src/lib/services/documenso-payload.ts)

  • Step 1: Write test: given an EoiContext, buildDocumensoPayload(context) returns the flat payload Documenso expects.

  • Step 2: Implement using the field mapping from Task 11.1.

  • Step 3: Integration test (with mocked Documenso API): generate an EOI through the service, assert the POST body contains every mapped field.

  • Step 4: Commit.

Task 11.3: Seed in-app Standard EOI template

Files:

  • Create: src/lib/pdf/templates/eoi-standard-inapp.ts — exports getStandardEoiTemplateHtml()

  • Modify: src/lib/db/seed.ts — also insert a document_templates row per port with this HTML

  • Step 1: Write the HTML template using the token namespace defined in spec: {{client.fullName}}, {{yacht.name}}, {{yacht.lengthFt}}, {{company.name}}, {{company.legalName}}, {{berth.mooringNumber}}, {{interest.dateFirstContact}}, {{port.name}}, {{date.today}}, etc. Mirror the structure of the Documenso EOI (a developer should review it side-by-side with the old EOI).

  • Step 2: Seeder inserts one row per port referencing this template. Template type: 'eoi'. isActive: true. Name: "Standard EOI (in-app)".

  • Step 3: Commit.

Task 11.4: Extend resolveTemplate to new scopes

Files:

  • Modify: src/lib/services/document-templates.ts

  • Step 1: Update MERGE_FIELDS — remove old {{client.yachtName}}, {{client.companyName}}, and yacht dimension tokens; add new yacht, company, owner sections.

  • Step 2: Extend resolveTemplate() — when context includes interestId, resolve via buildEoiContext and substitute {{yacht.*}} / {{company.*}} / {{owner.*}} tokens.

  • Step 3: Unit tests for every new token resolving correctly.

  • Step 4: Commit.

Task 11.5: EOI generation — dual-path service

Files:

  • Modify: src/lib/services/document-templates.tsgenerateAndSign gains a pathway: 'documenso-template' | 'inapp' parameter

  • Step 1: Test — for each pathway, generate an EOI for a seeded interest, verify the resulting file exists in MinIO and the documents row is created with correct metadata.

  • Step 2: Implement — if pathway === 'documenso-template', call Documenso template-generate. Else, resolve in-app template → pdfme → upload PDF → optionally pass to Documenso for signing via existing documensoCreate + documensoSend.

  • Step 3: Commit.

Task 11.6: EOI-generate dialog UI

Files:

  • Modify: (wherever the existing EOI-generate UI lives in src/components/interests/ or src/app/(dashboard)/[portSlug]/interests/)

  • Step 1: Add template dropdown — list: Documenso Standard EOI + seeded in-app templates. Preview shows which context fields will be filled.

  • Step 2: Submit calls the dual-path service with the chosen pathway.

  • Step 3: Commit.

Task 11.7: Create PR 11


PR 12 — Merge-Field Catalog Cleanup

Goal: Any stale references to old tokens ({{client.yachtName}}, etc.) are removed from the codebase, seeded templates, and validator allow-lists. All remaining tokens route through the new schema.

Task 12.1: Remove stale tokens

Files:

  • Modify: anywhere {{client.yachtName|yachtLength|companyName}} appears in code or seed data

  • Step 1: rg -n '{{client\\.(yacht|companyName)' across the repo — verify only historical seed data / test fixtures remain (code paths should be updated).

  • Step 2: Update or remove all matches. Any remaining template in the seeder should use new tokens.

  • Step 3: Commit.

Task 12.2: Validator tightening

Files:

  • Modify: src/lib/validators/document-templates.ts

  • Step 1: If there's a token allow-list, update it to match the new MERGE_FIELDS structure. Unknown tokens rejected at creation time.

  • Step 2: Commit.

Task 12.3: Create PR 12


PR 13 — Drop Old Columns from clients

Goal: With all reads migrated, drop the deprecated columns.

Task 13.1: Drop columns

Files:

  • Modify: src/lib/db/schema/clients.ts

  • Step 1: Remove column definitions: yachtName, yachtLengthFt, yachtWidthFt, yachtDraftFt, yachtLengthM, yachtWidthM, yachtDraftM, berthSizeDesired, companyName, isProxy, proxyType, actualOwnerName, relationshipNotes.

  • Step 2: pnpm db:generate && pnpm db:push — generates a destructive migration (column drops).

  • Step 3: Run full test suite — verify no silent breakage.

pnpm vitest run
pnpm playwright test tests/e2e/scenarios
  • Step 4: Commit.
git commit -am "refactor(clients): drop deprecated yacht/company/proxy columns"

Task 13.2: Verify no callers remain

Files:

  • N/A (exploration)

  • Step 1: rg -n 'yachtName|companyName|isProxy|proxyType|actualOwnerName|relationshipNotes|berthSizeDesired' src/ — should return zero matches in code (tests may reference old column names in assertions that should also have been removed).

  • Step 2: If matches remain, clean them up in this PR.

  • Step 3: Commit.

Task 13.3: Create PR 13


PR 14 — Exhaustive Click-Through Suite (Tier 3.5)

Goal: Every interactive element on every new or changed page has been clicked and verified to not throw.

Task 14.1: click-everything helper

Files:

  • Create: tests/helpers/click-everything.ts

  • Step 1: Implement helper

import { Page } from '@playwright/test';

export async function clickEverythingOnPage(
  page: Page,
  opts?: {
    skip?: string[]; // CSS selectors to skip (destructive actions)
    cleanupBetween?: () => Promise<void>;
  },
): Promise<{ clicked: number; skipped: number; errors: string[] }> {
  // Record starting URL so we can return after click-caused navigation
  const startingUrl = page.url();
  const errors: string[] = [];
  let clicked = 0;
  let skipped = 0;

  // Attach console error listener
  page.on('console', (msg) => {
    if (msg.type() === 'error') errors.push(`[console] ${msg.text()}`);
  });
  page.on('response', async (resp) => {
    if (resp.status() >= 400) errors.push(`[network] ${resp.status()} ${resp.url()}`);
  });

  const elements = await page.locator(':is(button, a, [role="button"])').all();
  for (const el of elements) {
    const selector = (await el.evaluate((n) => (n as HTMLElement).outerHTML)).slice(0, 120);
    if (opts?.skip?.some((s) => selector.includes(s))) {
      skipped++;
      continue;
    }
    try {
      if (await el.isVisible()) {
        await el.click({ timeout: 2000 });
        clicked++;
        await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
        // Close any opened dialog
        const closeBtn = page.locator('[role="dialog"] [aria-label="Close"]').first();
        if (await closeBtn.isVisible()) await closeBtn.click();
        // If navigation happened, return to starting URL
        if (page.url() !== startingUrl) await page.goto(startingUrl);
        if (opts?.cleanupBetween) await opts.cleanupBetween();
      }
    } catch (err) {
      errors.push(`[click] ${selector}${(err as Error).message}`);
    }
  }

  return { clicked, skipped, errors };
}
  • Step 2: Commit.

Task 14.2-14.10: Per-domain exhaustive spec files

For each of: yachts, companies, reservations, client-detail-refactored, eoi-generate, invoice-form, berths-with-reservations, portal, navigation:

  • Step 1: Create tests/e2e/exhaustive/<domain>.spec.ts

Template (instantiate per domain):

import { test, expect } from '@playwright/test';
import { clickEverythingOnPage } from '../../helpers/click-everything';
import { loginAs, seedScenario } from '../../helpers/e2e';

test.describe('exhaustive: yachts', () => {
  test('list page', async ({ page }) => {
    await loginAs(page, 'admin');
    await seedScenario(page, 'multi-yacht-client');
    await page.goto('/port-1/yachts');

    const result = await clickEverythingOnPage(page, {
      skip: ['data-testid="archive"', 'data-testid="delete"'], // destructive
    });

    expect(result.errors).toEqual([]);
    expect(result.clicked).toBeGreaterThanOrEqual(
      (await page.locator(':is(button, a, [role="button"])').count()) - result.skipped,
    );
  });

  test('detail page — every tab and every button', async ({ page }) => {
    /* similar shape, navigate to /yachts/<id>, clickEverything, assert */
  });
});
  • Step 2: Per domain: instantiate for list + detail + every sub-page, including the ownership-transfer dialog (open + fill + cancel — actual transfer happens in a separate destructive test).

  • Step 3: Run all exhaustive specs, confirm zero errors.

  • Step 4: Commit after each domain (not all at once — keep PR digestible).

Task 14.11: Destructive-action narrow tests

Files:

  • Create: tests/e2e/destructive/<several>.spec.ts

  • For each action in the allowlist (yachts.delete, yachts.transfer, companies.delete, memberships.end, reservations.cancel, reservations.end, invoices.delete), write a narrow test that:

    • Creates a throwaway entity via API
    • Performs the destructive action via UI
    • Verifies the effect (archived, status changed, etc.)
  • Commit.

Task 14.12: CI config update

Files:

  • Modify: .github/workflows/* (or whichever CI file runs Playwright)

  • Step 1: Add a separate CI job that runs pnpm playwright test tests/e2e/exhaustive. Sequential with the other Playwright job (shared test DB), but its own pass/fail summary.

  • Step 2: Commit.

Task 14.13: Create PR 14


PR 15 — Documentation + Final Merge

Goal: Update spec files, CLAUDE.md, and open the final merge to main.

Task 15.1: Update numbered spec files

Files:

  • Modify: 01-CONSOLIDATED-SYSTEM-SPEC.md, 02-*.md, … (in repo root)

  • Step 1: Spot-check each numbered spec file for references to the old data model (yacht fields on client, companyName, proxy fields). Update any mentions.

  • Step 2: Add a reference in the system spec to the new yacht/company/reservation tables and their roles.

  • Step 3: Commit.

Task 15.2: Update CLAUDE.md

Files:

  • Modify: CLAUDE.md

  • Step 1: Update the "Conventions" and "Project structure" sections to mention the new schema files (yachts.ts, companies.ts, reservations.ts) and component directories.

  • Step 2: Commit.

Task 15.3: Tier 4 golden-image template regression

Files:

  • Create: tests/e2e/templates/eoi-golden-image.spec.ts

  • Create: tests/e2e/fixtures/eoi-golden/*.pdf

  • Step 1: For each Tier 3 E2E scenario that generates an EOI, render the in-app PDF, compare against a committed reference PDF using a visual-diff library (e.g., pixelmatch or playwright's built-in screenshot comparison). First time through, the PDFs are generated manually and committed as the golden set.

  • Step 2: Commit.

Task 15.4: Merge the feature branch to main

  • Step 1: All PRs 1-14 have been merged into refactor/data-model. CI green.
  • Step 2: Open final PR refactor/data-modelmain, title refactor(data-model): yacht + company + reservation refactor (final merge).
  • Step 3: After approval, squash-merge (or merge-commit, per repo convention).

Task 15.5: Close out

  • Step 1: Delete the feature branch.
  • Step 2: Announce Spec 1 complete. Spec 2 (NocoDB + MinIO importer) can begin — schema is frozen.
  • Step 3: File any follow-ups flagged in the spec's "Open questions" section as issues in the backlog.

Acceptance checklist (must all be true before Spec 1 marked complete)

  • All 15 PRs merged to main
  • CI green on main: unit ≥ 90% on services, validators 100%, API routes ≥ 85%, overall ≥ 85%
  • Exhaustive click-through suite: zero console errors, zero unexpected 4xx/5xx, 100% coverage (minus declared destructive-action allowlist)
  • Tier 4 golden-image PDFs committed; visual-diff tests passing
  • Tier 3 E2E scenarios 1-11 all pass against a freshly seeded dev instance
  • Documenso path and in-app path both produce functional EOIs (manual verification required; visual comparison against old system output)
  • docs/eoi-documenso-field-mapping.md exists and is accurate
  • Numbered spec files (01-15) + CLAUDE.md reflect the new data model
  • No grep hits for yachtName, companyName, isProxy, proxyType, actualOwnerName, relationshipNotes, berthSizeDesired in src/
  • Seeder produces realistic multi-cardinality fixtures; developer can manually walk through every scenario
  • Spec 2 (NocoDB importer) can begin against a frozen schema