refactor(clients): drop deprecated yacht/company/proxy columns

PR 13: now that all reads are migrated to the dedicated yacht / company
/ membership entities, drop the columns that mirrored them on `clients`:
companyName, isProxy, proxyType, actualOwnerName, relationshipNotes,
yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M},
berthSizeDesired.

Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE
DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to
apply.

Caller cleanup (zero behavioral change to remaining flows):

- Drops the legacy `generateEoi` flow entirely (route, service function,
  pdfme template, validator schema). The dual-path generate-and-sign
  service from PR 11 has fully replaced it; the route was no longer
  wired to the UI.
- `clients.service`: company-name search column / WHERE / audit value
  removed; search now ranks by full name only.
- `interests.service`: `resolveLeadCategory` reads dimensions from
  `yachts` via `interest.yachtId` instead of the dropped
  `client.yachtLength{Ft,M}`.
- `record-export`: client-summary now lists yachts via owner-side
  lookup (direct + active company memberships); interest-summary fetches
  yacht via `interest.yachtId`. Both PDF templates updated to read
  yacht details from the new entity.
- `client-detail-header`, `client-picker`, `command-search`,
  `search-result-item`, `use-search` hook, `types/domain.ts`,
  `search.service` — drop the companyName badge / sub-label / typed
  field everywhere it was rendered or fetched.
- `ai.ts` worker: drop the company / yacht context lines from the
  prompt (will be re-added later sourced from the new entities).
- `validators/interests.ts`: remove the deprecated public-form flat
  yacht/company fields. The route already ignores them.
- `factories.ts`: drop the `isProxy: false` default.

Tests: 652/652 green; type-check clean. The
`security-sensitive-data` tests use `companyName` / `isProxy` as
arbitrary record keys for a generic util — left unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-26 13:57:54 +02:00
parent 456d399ee2
commit 0ed401d083
23 changed files with 8871 additions and 383 deletions

View File

@@ -4,60 +4,126 @@ export const clientSummaryTemplate: Template = {
basePdf: 'BLANK_PDF' as unknown as string,
schemas: [
[
{ name: 'portName', type: 'text', position: { x: 20, y: 15 }, width: 100, height: 10, fontSize: 16 },
{ name: 'title', type: 'text', position: { x: 20, y: 30 }, width: 170, height: 8, fontSize: 14 },
{ name: 'clientInfo', type: 'text', position: { x: 20, y: 45 }, width: 80, height: 40, fontSize: 9 },
{ name: 'contacts', type: 'text', position: { x: 110, y: 45 }, width: 80, height: 40, fontSize: 9 },
{ name: 'vesselInfo', type: 'text', position: { x: 20, y: 90 }, width: 170, height: 20, fontSize: 9 },
{ name: 'interests', type: 'text', position: { x: 20, y: 115 }, width: 170, height: 80, fontSize: 8 },
{ name: 'recentActivity', type: 'text', position: { x: 20, y: 200 }, width: 170, height: 60, fontSize: 8 },
{ name: 'generatedAt', type: 'text', position: { x: 20, y: 275 }, width: 170, height: 6, fontSize: 7 },
{
name: 'portName',
type: 'text',
position: { x: 20, y: 15 },
width: 100,
height: 10,
fontSize: 16,
},
{
name: 'title',
type: 'text',
position: { x: 20, y: 30 },
width: 170,
height: 8,
fontSize: 14,
},
{
name: 'clientInfo',
type: 'text',
position: { x: 20, y: 45 },
width: 80,
height: 40,
fontSize: 9,
},
{
name: 'contacts',
type: 'text',
position: { x: 110, y: 45 },
width: 80,
height: 40,
fontSize: 9,
},
{
name: 'yachts',
type: 'text',
position: { x: 20, y: 90 },
width: 170,
height: 25,
fontSize: 9,
},
{
name: 'interests',
type: 'text',
position: { x: 20, y: 120 },
width: 170,
height: 80,
fontSize: 8,
},
{
name: 'recentActivity',
type: 'text',
position: { x: 20, y: 205 },
width: 170,
height: 60,
fontSize: 8,
},
{
name: 'generatedAt',
type: 'text',
position: { x: 20, y: 275 },
width: 170,
height: 6,
fontSize: 7,
},
],
],
};
export interface YachtSummary {
name: string;
lengthFt: string | null;
widthFt: string | null;
draftFt: string | null;
lengthM: string | null;
widthM: string | null;
draftM: string | null;
}
export function buildClientSummaryInputs(
client: Record<string, unknown>,
contacts: Record<string, unknown>[],
yachtList: YachtSummary[],
interestList: Record<string, unknown>[],
activity: Record<string, unknown>[],
port: Record<string, unknown>,
): Record<string, string> {
const clientInfo = [
`Name: ${client.fullName ?? 'N/A'}`,
client.companyName ? `Company: ${client.companyName}` : null,
client.nationality ? `Nationality: ${client.nationality}` : null,
client.source ? `Source: ${client.source}` : null,
client.isProxy ? `Proxy: Yes${client.proxyType ? ` (${client.proxyType})` : ''}` : null,
`Added: ${new Date(client.createdAt as string | Date).toLocaleDateString('en-GB')}`,
]
.filter(Boolean)
.join('\n');
const contactsText = contacts.length > 0
? contacts
.map(
(c) =>
`${(c.channel as string).charAt(0).toUpperCase() + (c.channel as string).slice(1)}${c.isPrimary ? ' (primary)' : ''}: ${c.value}${c.label ? ` [${c.label}]` : ''}`,
)
.join('\n')
: 'No contacts on file';
const contactsText =
contacts.length > 0
? contacts
.map(
(c) =>
`${(c.channel as string).charAt(0).toUpperCase() + (c.channel as string).slice(1)}${c.isPrimary ? ' (primary)' : ''}: ${c.value}${c.label ? ` [${c.label}]` : ''}`,
)
.join('\n')
: 'No contacts on file';
const vesselInfo = [
client.yachtName ? `Yacht: ${client.yachtName}` : null,
client.yachtLengthFt
? `Length: ${client.yachtLengthFt}ft${client.yachtLengthM ? ` / ${client.yachtLengthM}m` : ''}`
: null,
client.yachtWidthFt
? `Beam: ${client.yachtWidthFt}ft${client.yachtWidthM ? ` / ${client.yachtWidthM}m` : ''}`
: null,
client.yachtDraftFt
? `Draft: ${client.yachtDraftFt}ft${client.yachtDraftM ? ` / ${client.yachtDraftM}m` : ''}`
: null,
client.berthSizeDesired ? `Desired berth size: ${client.berthSizeDesired}` : null,
]
.filter(Boolean)
.join(' | ') || 'No vessel information on file';
const yachtsText =
yachtList.length > 0
? `Owned/Linked Yachts:\n${yachtList
.map((y) => {
const dims = [
y.lengthFt ? `${y.lengthFt}ft` : y.lengthM ? `${y.lengthM}m` : null,
y.widthFt ? `${y.widthFt}ft beam` : null,
y.draftFt ? `${y.draftFt}ft draft` : null,
]
.filter(Boolean)
.join(' · ');
return `${y.name}${dims ? ` (${dims})` : ''}`;
})
.join('\n')}`
: 'No yachts linked to this client';
const interestsText =
interestList.length > 0
@@ -84,7 +150,7 @@ export function buildClientSummaryInputs(
title: `Client Summary — ${client.fullName ?? ''}`,
clientInfo,
contacts: contactsText,
vesselInfo,
yachts: yachtsText,
interests: `Pipeline Interests:\n${interestsText}`,
recentActivity: `Recent Activity:\n${activityText}`,
generatedAt: `Generated: ${new Date().toLocaleString('en-GB')}`,

View File

@@ -1,45 +0,0 @@
import type { Template } from '@pdfme/common';
export const eoiTemplate: Template = {
basePdf: 'BLANK_PDF' as unknown as string,
schemas: [
[
{ name: 'portName', type: 'text', position: { x: 20, y: 20 }, width: 170, height: 10, fontSize: 18 },
{ name: 'title', type: 'text', position: { x: 20, y: 40 }, width: 170, height: 8, fontSize: 14 },
{ name: 'clientName', type: 'text', position: { x: 20, y: 60 }, width: 80, height: 6 },
{ name: 'clientEmail', type: 'text', position: { x: 20, y: 68 }, width: 80, height: 6 },
{ name: 'yachtName', type: 'text', position: { x: 20, y: 80 }, width: 80, height: 6 },
{ name: 'yachtDimensions', type: 'text', position: { x: 20, y: 88 }, width: 80, height: 6 },
{ name: 'berthNumber', type: 'text', position: { x: 110, y: 60 }, width: 80, height: 6 },
{ name: 'berthDimensions', type: 'text', position: { x: 110, y: 68 }, width: 80, height: 6 },
{ name: 'berthPrice', type: 'text', position: { x: 110, y: 76 }, width: 80, height: 6 },
{ name: 'date', type: 'text', position: { x: 20, y: 110 }, width: 80, height: 6 },
{ name: 'terms', type: 'text', position: { x: 20, y: 130 }, width: 170, height: 100, fontSize: 9 },
],
],
};
export function buildEoiInputs(
interest: Record<string, unknown>,
client: Record<string, unknown>,
berth: Record<string, unknown>,
port: Record<string, unknown>,
): Record<string, string> {
const contacts = (client.contacts as Array<{ channel: string; value: string }> | undefined) ?? [];
const emailContact = contacts.find((c) => c.channel === 'email');
return {
portName: (port.name as string) ?? 'Port Nimara',
title: 'Expression of Interest',
clientName: `Client: ${client.fullName as string}`,
clientEmail: `Email: ${emailContact?.value ?? 'N/A'}`,
yachtName: `Yacht: ${(client.yachtName as string) ?? 'N/A'}`,
yachtDimensions: `LOA: ${(client.yachtLengthFt as string) ?? '?'}ft × Beam: ${(client.yachtWidthFt as string) ?? '?'}ft × Draft: ${(client.yachtDraftFt as string) ?? '?'}ft`,
berthNumber: `Berth: ${berth.mooringNumber as string}`,
berthDimensions: `${(berth.lengthFt as string) ?? '?'}ft × ${(berth.widthFt as string) ?? '?'}ft`,
berthPrice: `Price: ${(berth.priceCurrency as string) ?? 'USD'} ${(berth.price as string) ?? 'TBD'}`,
date: `Date: ${new Date().toLocaleDateString('en-GB')}`,
terms:
"This Expression of Interest confirms the above-named client's interest in the specified berth. This document is non-binding until signed by all parties. Upon signing, the client agrees to proceed with the berth acquisition process as outlined in the full terms and conditions provided separately.",
};
}

View File

@@ -4,15 +4,78 @@ export const interestSummaryTemplate: Template = {
basePdf: 'BLANK_PDF' as unknown as string,
schemas: [
[
{ name: 'portName', type: 'text', position: { x: 20, y: 15 }, width: 100, height: 10, fontSize: 16 },
{ name: 'title', type: 'text', position: { x: 20, y: 30 }, width: 170, height: 8, fontSize: 14 },
{ name: 'clientInfo', type: 'text', position: { x: 20, y: 45 }, width: 80, height: 30, fontSize: 9 },
{ name: 'berthInfo', type: 'text', position: { x: 110, y: 45 }, width: 80, height: 30, fontSize: 9 },
{ name: 'stageAndCategory', type: 'text', position: { x: 20, y: 80 }, width: 170, height: 15, fontSize: 9 },
{ name: 'milestones', type: 'text', position: { x: 20, y: 100 }, width: 170, height: 40, fontSize: 8 },
{ name: 'notes', type: 'text', position: { x: 20, y: 145 }, width: 170, height: 30, fontSize: 9 },
{ name: 'recentTimeline', type: 'text', position: { x: 20, y: 180 }, width: 170, height: 85, fontSize: 8 },
{ name: 'generatedAt', type: 'text', position: { x: 20, y: 275 }, width: 170, height: 6, fontSize: 7 },
{
name: 'portName',
type: 'text',
position: { x: 20, y: 15 },
width: 100,
height: 10,
fontSize: 16,
},
{
name: 'title',
type: 'text',
position: { x: 20, y: 30 },
width: 170,
height: 8,
fontSize: 14,
},
{
name: 'clientInfo',
type: 'text',
position: { x: 20, y: 45 },
width: 80,
height: 30,
fontSize: 9,
},
{
name: 'berthInfo',
type: 'text',
position: { x: 110, y: 45 },
width: 80,
height: 30,
fontSize: 9,
},
{
name: 'stageAndCategory',
type: 'text',
position: { x: 20, y: 80 },
width: 170,
height: 15,
fontSize: 9,
},
{
name: 'milestones',
type: 'text',
position: { x: 20, y: 100 },
width: 170,
height: 40,
fontSize: 8,
},
{
name: 'notes',
type: 'text',
position: { x: 20, y: 145 },
width: 170,
height: 30,
fontSize: 9,
},
{
name: 'recentTimeline',
type: 'text',
position: { x: 20, y: 180 },
width: 170,
height: 85,
fontSize: 8,
},
{
name: 'generatedAt',
type: 'text',
position: { x: 20, y: 275 },
width: 170,
height: 6,
fontSize: 7,
},
],
],
};
@@ -25,16 +88,16 @@ function formatDate(d: Date | string | null | undefined): string {
export function buildInterestSummaryInputs(
interest: Record<string, unknown>,
client: Record<string, unknown>,
yacht: Record<string, unknown> | null,
berth: Record<string, unknown> | null,
timeline: Record<string, unknown>[],
port: Record<string, unknown>,
): Record<string, string> {
const clientInfo = [
`Name: ${client?.fullName ?? 'N/A'}`,
client?.companyName ? `Company: ${client.companyName}` : null,
client?.yachtName ? `Yacht: ${client.yachtName}` : null,
client?.yachtLengthFt
? `Length: ${client.yachtLengthFt}ft${client.yachtLengthM ? ` / ${client.yachtLengthM}m` : ''}`
yacht?.name ? `Yacht: ${yacht.name}` : null,
yacht?.lengthFt
? `Length: ${yacht.lengthFt}ft${yacht.lengthM ? ` / ${yacht.lengthM}m` : ''}`
: null,
]
.filter(Boolean)
@@ -45,7 +108,9 @@ export function buildInterestSummaryInputs(
`Mooring: ${berth.mooringNumber}`,
berth.area ? `Area: ${berth.area}` : null,
berth.lengthFt ? `Length: ${berth.lengthFt}ft` : null,
berth.price ? `Price: ${berth.priceCurrency ?? 'USD'} ${Number(berth.price).toLocaleString()}` : null,
berth.price
? `Price: ${berth.priceCurrency ?? 'USD'} ${Number(berth.price).toLocaleString()}`
: null,
`Status: ${berth.status ?? 'available'}`,
]
.filter(Boolean)
@@ -73,9 +138,7 @@ export function buildInterestSummaryInputs(
`Deposit received: ${formatDate(interest.dateDepositReceived as Date | string | null | undefined)}`,
].join('\n');
const notesText = interest.notes
? `Notes:\n${interest.notes}`
: 'No notes';
const notesText = interest.notes ? `Notes:\n${interest.notes}` : 'No notes';
const timelineText =
timeline.length > 0