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>
2679 lines
92 KiB
Markdown
2679 lines
92 KiB
Markdown
# 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**
|
|
|
|
```typescript
|
|
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`:
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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`:
|
|
|
|
```typescript
|
|
export * from './companies';
|
|
```
|
|
|
|
- [ ] **Step 3: Generate + apply migration**
|
|
|
|
```bash
|
|
pnpm db:generate
|
|
pnpm db:push
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```typescript
|
|
yachtId: text('yacht_id'), // FK added via relation; nullable until pipeline leaves 'open'
|
|
```
|
|
|
|
Add an index:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
yachtId: text('yacht_id'), // FK added via relation; nullable
|
|
```
|
|
|
|
- [ ] **Step 3: Generate + apply migration, commit**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
index('idx_invoices_billing_entity').on(table.portId, table.billingEntityType, table.billingEntityId),
|
|
```
|
|
|
|
- [ ] **Step 2: Generate + apply migration, commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```typescript
|
|
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`**
|
|
|
|
```typescript
|
|
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`**
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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`**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
pnpm vitest run tests/integration/schema-constraints.test.ts
|
|
```
|
|
|
|
Expected: 3 passed.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
// 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**
|
|
|
|
```bash
|
|
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`**
|
|
|
|
```typescript
|
|
// 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**
|
|
|
|
```bash
|
|
pnpm vitest run tests/unit/services/yachts.test.ts
|
|
```
|
|
|
|
- [ ] **Step 3: Implement `createYacht`**
|
|
|
|
```typescript
|
|
// 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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
// 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`**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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: Validators** — `createCompanySchema`, `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**
|
|
|
|
```bash
|
|
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: Validators** — `addMembershipSchema` (companyId via path, body: clientId, role, roleDetail?, startDate, isPrimary?, notes?). `updateMembershipSchema`. `endMembershipSchema` (endDate).
|
|
|
|
- [ ] **Step 2: Tests** — `addMembership` 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**
|
|
|
|
```bash
|
|
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: Validators** — `createPendingSchema`, `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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
// 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**
|
|
|
|
```typescript
|
|
// 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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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):**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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 pages** — `my-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.**
|
|
|
|
```bash
|
|
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.**
|
|
|
|
```bash
|
|
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.**
|
|
|
|
```bash
|
|
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.
|
|
|
|
```bash
|
|
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.ts` — `generateAndSign` 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.
|
|
|
|
```bash
|
|
pnpm vitest run
|
|
pnpm playwright test tests/e2e/scenarios
|
|
```
|
|
|
|
- [ ] **Step 4: Commit.**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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):
|
|
|
|
```typescript
|
|
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-model` → `main`, 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
|