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

@@ -1,24 +0,0 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { generateEoi } from '@/lib/services/documents.service';
import { generateEoiSchema } from '@/lib/validators/documents';
export const POST = withAuth(
withPermission('documents', 'create', async (req, ctx) => {
try {
const body = await parseBody(req, generateEoiSchema);
const doc = await generateEoi(body.interestId, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: doc }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -15,13 +15,7 @@ interface ClientDetailHeaderProps {
client: { client: {
id: string; id: string;
fullName: string; fullName: string;
companyName?: string | null;
nationality?: string | null; nationality?: string | null;
isProxy?: boolean;
proxyType?: string | null;
actualOwnerName?: string | null;
yachtName?: string | null;
berthSizeDesired?: string | null;
preferredContactMethod?: string | null; preferredContactMethod?: string | null;
preferredLanguage?: string | null; preferredLanguage?: string | null;
timezone?: string | null; timezone?: string | null;
@@ -36,13 +30,7 @@ interface ClientDetailHeaderProps {
type ClientFormClient = { type ClientFormClient = {
id: string; id: string;
fullName: string; fullName: string;
companyName?: string | null;
nationality?: string | null; nationality?: string | null;
isProxy?: boolean;
proxyType?: string | null;
actualOwnerName?: string | null;
yachtName?: string | null;
berthSizeDesired?: string | null;
preferredContactMethod?: string | null; preferredContactMethod?: string | null;
preferredLanguage?: string | null; preferredLanguage?: string | null;
timezone?: string | null; timezone?: string | null;
@@ -67,8 +55,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
const isArchived = !!client.archivedAt; const isArchived = !!client.archivedAt;
const archiveMutation = useMutation({ const archiveMutation = useMutation({
mutationFn: () => mutationFn: () => apiFetch(`/api/v1/clients/${client.id}`, { method: 'DELETE' }),
apiFetch(`/api/v1/clients/${client.id}`, { method: 'DELETE' }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clients', client.id] }); queryClient.invalidateQueries({ queryKey: ['clients', client.id] });
queryClient.invalidateQueries({ queryKey: ['clients'] }); queryClient.invalidateQueries({ queryKey: ['clients'] });
@@ -77,8 +64,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
}); });
const restoreMutation = useMutation({ const restoreMutation = useMutation({
mutationFn: () => mutationFn: () => apiFetch(`/api/v1/clients/${client.id}/restore`, { method: 'POST' }),
apiFetch(`/api/v1/clients/${client.id}/restore`, { method: 'POST' }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clients', client.id] }); queryClient.invalidateQueries({ queryKey: ['clients', client.id] });
queryClient.invalidateQueries({ queryKey: ['clients'] }); queryClient.invalidateQueries({ queryKey: ['clients'] });
@@ -86,10 +72,12 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
}, },
}); });
const primaryEmail = client.contacts?.find((c) => c.channel === 'email' && c.isPrimary) const primaryEmail =
?? client.contacts?.find((c) => c.channel === 'email'); client.contacts?.find((c) => c.channel === 'email' && c.isPrimary) ??
const primaryPhone = client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) client.contacts?.find((c) => c.channel === 'email');
?? client.contacts?.find((c) => c.channel === 'phone'); const primaryPhone =
client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ??
client.contacts?.find((c) => c.channel === 'phone');
return ( return (
<> <>
@@ -97,23 +85,14 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
<div className="flex items-start gap-3 flex-wrap"> <div className="flex items-start gap-3 flex-wrap">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-bold text-foreground truncate"> <h1 className="text-2xl font-bold text-foreground truncate">{client.fullName}</h1>
{client.fullName}
</h1>
{isArchived && ( {isArchived && (
<Badge variant="secondary" className="text-xs">Archived</Badge> <Badge variant="secondary" className="text-xs">
)} Archived
{client.isProxy && (
<Badge variant="outline" className="text-xs capitalize">
Proxy {client.proxyType ? `(${client.proxyType.replace('_', ' ')})` : ''}
</Badge> </Badge>
)} )}
</div> </div>
{client.companyName && (
<p className="text-muted-foreground mt-0.5">{client.companyName}</p>
)}
<div className="flex items-center gap-4 mt-2 flex-wrap text-sm text-muted-foreground"> <div className="flex items-center gap-4 mt-2 flex-wrap text-sm text-muted-foreground">
{client.source && ( {client.source && (
<span> <span>
@@ -148,11 +127,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
variant="outline"
size="sm"
onClick={() => setEditOpen(true)}
>
<Pencil className="mr-1.5 h-3.5 w-3.5" /> <Pencil className="mr-1.5 h-3.5 w-3.5" />
Edit Edit
</Button> </Button>

View File

@@ -152,7 +152,7 @@ export function CommandSearch() {
id: c.id, id: c.id,
icon: 'client', icon: 'client',
label: c.fullName, label: c.fullName,
sub: c.companyName, sub: null,
}))} }))}
iconMap={iconMap} iconMap={iconMap}
onSelect={(id) => navigate(`/${portSlug}/clients/${id}`)} onSelect={(id) => navigate(`/${portSlug}/clients/${id}`)}

View File

@@ -9,7 +9,6 @@ import { CommandItem } from '@/components/ui/command';
interface ClientItem { interface ClientItem {
id: string; id: string;
fullName: string; fullName: string;
companyName: string | null;
} }
interface InterestItem { interface InterestItem {
@@ -54,12 +53,7 @@ export function SearchResultItem({ type, item, onSelect }: SearchResultItemProps
return ( return (
<CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2"> <CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2">
<User className="h-4 w-4 shrink-0 text-muted-foreground" /> <User className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col"> <span className="text-sm font-medium">{item.fullName}</span>
<span className="text-sm font-medium">{item.fullName}</span>
{item.companyName && (
<span className="text-xs text-muted-foreground">{item.companyName}</span>
)}
</div>
</CommandItem> </CommandItem>
); );
} }

View File

@@ -21,7 +21,6 @@ import { cn } from '@/lib/utils';
interface ClientOption { interface ClientOption {
id: string; id: string;
fullName: string; fullName: string;
companyName?: string | null;
} }
interface ClientPickerProps { interface ClientPickerProps {
@@ -89,12 +88,7 @@ export function ClientPicker({
<Check <Check
className={cn('mr-2 h-4 w-4', value === c.id ? 'opacity-100' : 'opacity-0')} className={cn('mr-2 h-4 w-4', value === c.id ? 'opacity-100' : 'opacity-0')}
/> />
<span> <span>{c.fullName}</span>
{c.fullName}
{c.companyName ? (
<span className="ml-2 text-xs opacity-60">{c.companyName}</span>
) : null}
</span>
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>

View File

@@ -8,7 +8,7 @@ import { useDebounce } from '@/hooks/use-debounce';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
interface SearchResults { interface SearchResults {
clients: Array<{ id: string; fullName: string; companyName: string | null }>; clients: Array<{ id: string; fullName: string }>;
interests: Array<{ interests: Array<{
id: string; id: string;
clientName: string; clientName: string;

View File

@@ -0,0 +1,13 @@
ALTER TABLE "clients" DROP COLUMN "company_name";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "is_proxy";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "proxy_type";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "actual_owner_name";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "relationship_notes";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "yacht_name";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "yacht_length_ft";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "yacht_width_ft";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "yacht_draft_ft";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "yacht_length_m";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "yacht_width_m";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "yacht_draft_m";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "berth_size_desired";

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,13 @@
"when": 1776959993173, "when": 1776959993173,
"tag": "0007_brainy_felicia_hardy", "tag": "0007_brainy_felicia_hardy",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1777204563579,
"tag": "0008_loud_ikaris",
"breakpoints": true
} }
] ]
} }

View File

@@ -3,7 +3,6 @@ import {
text, text,
boolean, boolean,
timestamp, timestamp,
numeric,
jsonb, jsonb,
index, index,
uniqueIndex, uniqueIndex,
@@ -22,20 +21,7 @@ export const clients = pgTable(
.notNull() .notNull()
.references(() => ports.id), .references(() => ports.id),
fullName: text('full_name').notNull(), fullName: text('full_name').notNull(),
companyName: text('company_name'),
nationality: text('nationality'), nationality: text('nationality'),
isProxy: boolean('is_proxy').notNull().default(false),
proxyType: text('proxy_type'), // broker, representative, family_member, legal_counsel, other
actualOwnerName: text('actual_owner_name'),
relationshipNotes: text('relationship_notes'),
yachtName: text('yacht_name'),
yachtLengthFt: numeric('yacht_length_ft'),
yachtWidthFt: numeric('yacht_width_ft'),
yachtDraftFt: numeric('yacht_draft_ft'),
yachtLengthM: numeric('yacht_length_m'),
yachtWidthM: numeric('yacht_width_m'),
yachtDraftM: numeric('yacht_draft_m'),
berthSizeDesired: text('berth_size_desired'),
preferredContactMethod: text('preferred_contact_method'), // email, phone, whatsapp preferredContactMethod: text('preferred_contact_method'), // email, phone, whatsapp
preferredLanguage: text('preferred_language'), preferredLanguage: text('preferred_language'),
timezone: text('timezone'), timezone: text('timezone'),

View File

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

View File

@@ -7,7 +7,7 @@ import { QUEUE_CONFIGS } from '@/lib/queue';
// ─── Email draft generation ─────────────────────────────────────────────────── // ─── Email draft generation ───────────────────────────────────────────────────
const MAX_OUTPUT_BYTES = 10 * 1024; // 10 KB const MAX_OUTPUT_BYTES = 10 * 1024; // 10 KB
const OPENAI_TIMEOUT_MS = 30_000; // 30 s const OPENAI_TIMEOUT_MS = 30_000; // 30 s
interface GenerateEmailDraftPayload { interface GenerateEmailDraftPayload {
interestId: string; interestId: string;
@@ -76,7 +76,12 @@ async function generateEmailDraft(payload: GenerateEmailDraftPayload): Promise<D
if (!apiKey) { if (!apiKey) {
// Fallback: template-based draft // Fallback: template-based draft
return buildTemplateDraft({ clientName: client.fullName, context, berthMooring, pipelineStage: interest.pipelineStage }); return buildTemplateDraft({
clientName: client.fullName,
context,
berthMooring,
pipelineStage: interest.pipelineStage,
});
} }
// Build prompt // Build prompt
@@ -91,8 +96,6 @@ async function generateEmailDraft(payload: GenerateEmailDraftPayload): Promise<D
`Write ${contextDescriptions[context] ?? 'an email'} to a marina berth client.`, `Write ${contextDescriptions[context] ?? 'an email'} to a marina berth client.`,
'', '',
`Client name: ${client.fullName}`, `Client name: ${client.fullName}`,
client.companyName ? `Company: ${client.companyName}` : null,
client.yachtName ? `Yacht: ${client.yachtName}` : null,
berthMooring ? `Berth: ${berthMooring}` : 'Berth: not yet assigned', berthMooring ? `Berth: ${berthMooring}` : 'Berth: not yet assigned',
`Pipeline stage: ${interest.pipelineStage}`, `Pipeline stage: ${interest.pipelineStage}`,
'', '',
@@ -164,7 +167,12 @@ async function generateEmailDraft(payload: GenerateEmailDraftPayload): Promise<D
} catch (err) { } catch (err) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
logger.warn({ err, interestId }, 'OpenAI call failed, falling back to template draft'); logger.warn({ err, interestId }, 'OpenAI call failed, falling back to template draft');
return buildTemplateDraft({ clientName: client.fullName, context, berthMooring, pipelineStage: interest.pipelineStage }); return buildTemplateDraft({
clientName: client.fullName,
context,
berthMooring,
pipelineStage: interest.pipelineStage,
});
} }
return { subject, body, generatedAt: new Date().toISOString() }; return { subject, body, generatedAt: new Date().toISOString() };

View File

@@ -1,4 +1,4 @@
import { and, eq, ilike, inArray, isNull, or } from 'drizzle-orm'; import { and, eq, ilike, inArray, isNull } from 'drizzle-orm';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { clients, clientContacts, clientRelationships, clientTags } from '@/lib/db/schema/clients'; import { clients, clientContacts, clientRelationships, clientTags } from '@/lib/db/schema/clients';
@@ -65,7 +65,7 @@ export async function listClients(portId: string, query: ListClientsInput) {
portId, portId,
idColumn: clients.id, idColumn: clients.id,
updatedAtColumn: clients.updatedAt, updatedAtColumn: clients.updatedAt,
searchColumns: [clients.fullName, clients.companyName], searchColumns: [clients.fullName],
searchTerm: search, searchTerm: search,
filters, filters,
sort: sort ? { column: sortColumn, direction: order } : undefined, sort: sort ? { column: sortColumn, direction: order } : undefined,
@@ -197,7 +197,7 @@ export async function createClient(portId: string, data: CreateClientInput, meta
action: 'create', action: 'create',
entityType: 'client', entityType: 'client',
entityId: result.id, entityId: result.id,
newValue: { fullName: result.fullName, companyName: result.companyName }, newValue: { fullName: result.fullName },
ipAddress: meta.ipAddress, ipAddress: meta.ipAddress,
userAgent: meta.userAgent, userAgent: meta.userAgent,
}); });
@@ -532,9 +532,7 @@ export async function findDuplicates(portId: string, fullName: string) {
export async function listClientOptions(portId: string, search?: string) { export async function listClientOptions(portId: string, search?: string) {
const conditions = [eq(clients.portId, portId)]; const conditions = [eq(clients.portId, portId)];
if (search) { if (search) {
conditions.push( conditions.push(ilike(clients.fullName, `%${search}%`));
or(ilike(clients.fullName, `%${search}%`), ilike(clients.companyName, `%${search}%`))!,
);
} }
return db return db

View File

@@ -4,7 +4,6 @@ import { db } from '@/lib/db';
import { documents, documentSigners, documentEvents, files } from '@/lib/db/schema/documents'; import { documents, documentSigners, documentEvents, files } from '@/lib/db/schema/documents';
import { interests } from '@/lib/db/schema/interests'; import { interests } from '@/lib/db/schema/interests';
import { clients } from '@/lib/db/schema/clients'; import { clients } from '@/lib/db/schema/clients';
import { berths } from '@/lib/db/schema/berths';
import { ports } from '@/lib/db/schema/ports'; import { ports } from '@/lib/db/schema/ports';
import { buildListQuery } from '@/lib/db/query-builder'; import { buildListQuery } from '@/lib/db/query-builder';
import { createAuditLog } from '@/lib/audit'; import { createAuditLog } from '@/lib/audit';
@@ -14,8 +13,6 @@ import { emitToRoom } from '@/lib/socket/server';
import { minioClient, buildStoragePath } from '@/lib/minio'; import { minioClient, buildStoragePath } from '@/lib/minio';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { generatePdf } from '@/lib/pdf/generate';
import { eoiTemplate, buildEoiInputs } from '@/lib/pdf/templates/eoi-template';
import { evaluateRule } from '@/lib/services/berth-rules-engine'; import { evaluateRule } from '@/lib/services/berth-rules-engine';
import { import {
createDocument as documensoCreate, createDocument as documensoCreate,
@@ -50,10 +47,13 @@ export async function listDocuments(portId: string, query: ListDocumentsInput) {
if (status) filters.push(eq(documents.status, status)); if (status) filters.push(eq(documents.status, status));
const sortColumn = const sortColumn =
sort === 'title' ? documents.title : sort === 'title'
sort === 'status' ? documents.status : ? documents.title
sort === 'documentType' ? documents.documentType : : sort === 'status'
documents.createdAt; ? documents.status
: sort === 'documentType'
? documents.documentType
: documents.createdAt;
return buildListQuery({ return buildListQuery({
table: documents, table: documents,
@@ -84,11 +84,7 @@ export async function getDocumentById(id: string, portId: string) {
// ─── Create ─────────────────────────────────────────────────────────────────── // ─── Create ───────────────────────────────────────────────────────────────────
export async function createDocument( export async function createDocument(portId: string, data: CreateDocumentInput, meta: AuditMeta) {
portId: string,
data: CreateDocumentInput,
meta: AuditMeta,
) {
const [doc] = await db const [doc] = await db
.insert(documents) .insert(documents)
.values({ .values({
@@ -169,9 +165,7 @@ export async function deleteDocument(id: string, portId: string, meta: AuditMeta
throw new ConflictError('Cannot delete a document that is currently in signing process'); throw new ConflictError('Cannot delete a document that is currently in signing process');
} }
await db await db.delete(documents).where(and(eq(documents.id, id), eq(documents.portId, portId)));
.delete(documents)
.where(and(eq(documents.id, id), eq(documents.portId, portId)));
void createAuditLog({ void createAuditLog({
userId: meta.userId, userId: meta.userId,
@@ -187,116 +181,6 @@ export async function deleteDocument(id: string, portId: string, meta: AuditMeta
emitToRoom(`port:${portId}`, 'document:deleted', { documentId: id }); emitToRoom(`port:${portId}`, 'document:deleted', { documentId: id });
} }
// ─── Generate EOI (BR-020) ────────────────────────────────────────────────────
export async function generateEoi(interestId: string, portId: string, meta: AuditMeta) {
// Fetch interest + related data
const interest = await db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
});
if (!interest) throw new NotFoundError('Interest');
const client = await db.query.clients.findFirst({
where: eq(clients.id, interest.clientId),
with: { contacts: true },
});
if (!client) throw new NotFoundError('Client');
// BR-020: Check prerequisites
const missing: Array<{ field: string; message: string }> = [];
if (!client.fullName) missing.push({ field: 'client.fullName', message: 'Client must have a full name' });
const emailContact = (client.contacts as Array<{ channel: string; value: string }> | undefined)?.find(
(c) => c.channel === 'email',
);
if (!emailContact?.value) missing.push({ field: 'client.email', message: 'Client must have an email contact' });
if (!client.yachtLengthFt && !client.yachtLengthM) {
missing.push({ field: 'client.yachtDimensions', message: 'Client must have yacht dimensions' });
}
if (!interest.berthId) missing.push({ field: 'interest.berthId', message: 'Interest must have a berth linked' });
if (missing.length > 0) {
throw new ValidationError('Missing prerequisites for EOI generation', missing);
}
const [berth, port] = await Promise.all([
db.query.berths.findFirst({ where: eq(berths.id, interest.berthId!) }),
db.query.ports.findFirst({ where: eq(ports.id, portId) }),
]);
if (!berth) throw new NotFoundError('Berth');
if (!port) throw new NotFoundError('Port');
// Generate PDF
const inputs = buildEoiInputs(
interest as unknown as Record<string, unknown>,
{ ...client, contacts: client.contacts } as unknown as Record<string, unknown>,
berth as unknown as Record<string, unknown>,
port as unknown as Record<string, unknown>,
);
const pdfBytes = await generatePdf(eoiTemplate, [inputs]);
const pdfBuffer = Buffer.from(pdfBytes);
// Store in MinIO
const fileId = crypto.randomUUID();
const storagePath = buildStoragePath(port.slug, 'eoi', interestId, fileId, 'pdf');
await minioClient.putObject(env.MINIO_BUCKET, storagePath, pdfBuffer, pdfBuffer.length, {
'Content-Type': 'application/pdf',
});
// Create files record
const [fileRecord] = await db
.insert(files)
.values({
portId,
clientId: client.id,
filename: `eoi-${interestId}.pdf`,
originalName: `eoi-${interestId}.pdf`,
mimeType: 'application/pdf',
sizeBytes: String(pdfBuffer.length),
storagePath,
storageBucket: env.MINIO_BUCKET,
category: 'eoi',
uploadedBy: meta.userId,
})
.returning();
// Create document record
const [doc] = await db
.insert(documents)
.values({
portId,
interestId,
clientId: client.id,
documentType: 'eoi',
title: `EOI ${client.fullName} / ${berth.mooringNumber}`,
status: 'draft',
fileId: fileRecord!.id,
createdBy: meta.userId,
})
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'document',
entityId: doc!.id,
newValue: { documentType: 'eoi', interestId },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'document:created', { documentId: doc!.id, type: 'eoi' });
return doc!;
}
// ─── Send for Signing (BR-021) ──────────────────────────────────────────────── // ─── Send for Signing (BR-021) ────────────────────────────────────────────────
export async function sendForSigning(documentId: string, portId: string, meta: AuditMeta) { export async function sendForSigning(documentId: string, portId: string, meta: AuditMeta) {
@@ -318,9 +202,9 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
if (!client) throw new ValidationError('Document has no associated client'); if (!client) throw new ValidationError('Document has no associated client');
const emailContact = (client.contacts as Array<{ channel: string; value: string }> | undefined)?.find( const emailContact = (
(c) => c.channel === 'email', client.contacts as Array<{ channel: string; value: string }> | undefined
); )?.find((c) => c.channel === 'email');
if (!emailContact?.value) throw new ValidationError('Client has no email contact'); if (!emailContact?.value) throw new ValidationError('Client has no email contact');
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) }); const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
@@ -373,7 +257,12 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
const documensoDoc = await documensoCreate(doc.title, pdfBase64, [ const documensoDoc = await documensoCreate(doc.title, pdfBase64, [
{ name: client.fullName, email: emailContact.value, role: 'SIGNER', signingOrder: 1 }, { name: client.fullName, email: emailContact.value, role: 'SIGNER', signingOrder: 1 },
{ name: port.name, email: `developer@${port.slug}.com`, role: 'SIGNER', signingOrder: 2 }, { name: port.name, email: `developer@${port.slug}.com`, role: 'SIGNER', signingOrder: 2 },
{ name: `${port.name} Sales`, email: `sales@${port.slug}.com`, role: 'SIGNER', signingOrder: 3 }, {
name: `${port.name} Sales`,
email: `sales@${port.slug}.com`,
role: 'SIGNER',
signingOrder: 3,
},
]); ]);
await documensoSend(documensoDoc.id); await documensoSend(documensoDoc.id);
@@ -432,7 +321,12 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
userAgent: meta.userAgent, userAgent: meta.userAgent,
}); });
emitToRoom(`port:${portId}`, 'document:sent', { documentId, type: doc.documentType, signerCount: 3, documensoId: documensoDoc.id }); emitToRoom(`port:${portId}`, 'document:sent', {
documentId,
type: doc.documentType,
signerCount: 3,
documensoId: documensoDoc.id,
});
return await getDocumentById(documentId, portId); return await getDocumentById(documentId, portId);
} }
@@ -453,13 +347,9 @@ export async function uploadSignedManually(
const fileId = crypto.randomUUID(); const fileId = crypto.randomUUID();
const storagePath = buildStoragePath(port.slug, 'eoi-signed', documentId, fileId, 'pdf'); const storagePath = buildStoragePath(port.slug, 'eoi-signed', documentId, fileId, 'pdf');
await minioClient.putObject( await minioClient.putObject(env.MINIO_BUCKET, storagePath, fileData.buffer, fileData.size, {
env.MINIO_BUCKET, 'Content-Type': fileData.mimeType,
storagePath, });
fileData.buffer,
fileData.size,
{ 'Content-Type': fileData.mimeType },
);
const [fileRecord] = await db const [fileRecord] = await db
.insert(files) .insert(files)
@@ -612,9 +502,7 @@ export async function handleRecipientSigned(eventData: {
}); });
} }
export async function handleDocumentCompleted(eventData: { export async function handleDocumentCompleted(eventData: { documentId: string }) {
documentId: string;
}) {
const doc = await db.query.documents.findFirst({ const doc = await db.query.documents.findFirst({
where: eq(documents.documensoId, eventData.documentId), where: eq(documents.documensoId, eventData.documentId),
}); });
@@ -718,9 +606,7 @@ export async function handleDocumentCompleted(eventData: {
} }
} }
export async function handleDocumentExpired(eventData: { export async function handleDocumentExpired(eventData: { documentId: string }) {
documentId: string;
}) {
const doc = await db.query.documents.findFirst({ const doc = await db.query.documents.findFirst({
where: eq(documents.documensoId, eventData.documentId), where: eq(documents.documensoId, eventData.documentId),
}); });

View File

@@ -66,17 +66,17 @@ async function assertYachtBelongsToClient(
async function resolveLeadCategory( async function resolveLeadCategory(
clientId: string, clientId: string,
leadCategory: string | undefined | null, leadCategory: string | undefined | null,
yachtId?: string | null,
): Promise<string | undefined> { ): Promise<string | undefined> {
if (leadCategory && leadCategory !== 'general_interest') { if (leadCategory && leadCategory !== 'general_interest') {
return leadCategory; return leadCategory;
} }
const client = await db.query.clients.findFirst({ if (yachtId) {
where: eq(clients.id, clientId), const yacht = await db.query.yachts.findFirst({ where: eq(yachts.id, yachtId) });
}); if (yacht && (yacht.lengthFt || yacht.lengthM)) {
return 'specific_qualified';
if (client && (client.yachtLengthFt || client.yachtLengthM)) { }
return 'specific_qualified';
} }
return leadCategory ?? undefined; return leadCategory ?? undefined;
@@ -275,7 +275,11 @@ export async function createInterest(portId: string, data: CreateInterestInput,
const { tagIds, ...interestData } = data; const { tagIds, ...interestData } = data;
// BR-011: auto-promote leadCategory // BR-011: auto-promote leadCategory
const resolvedLeadCategory = await resolveLeadCategory(data.clientId, data.leadCategory); const resolvedLeadCategory = await resolveLeadCategory(
data.clientId,
data.leadCategory,
data.yachtId,
);
const result = await withTransaction(async (tx) => { const result = await withTransaction(async (tx) => {
const [interest] = await tx const [interest] = await tx
@@ -350,6 +354,7 @@ export async function updateInterest(
resolvedLeadCategory = (await resolveLeadCategory( resolvedLeadCategory = (await resolveLeadCategory(
existing.clientId, existing.clientId,
data.leadCategory, data.leadCategory,
data.yachtId ?? existing.yachtId,
)) as typeof data.leadCategory; )) as typeof data.leadCategory;
} }

View File

@@ -1,9 +1,11 @@
import { and, desc, eq, inArray } from 'drizzle-orm'; import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { clients, clientContacts } from '@/lib/db/schema/clients'; import { clients, clientContacts } from '@/lib/db/schema/clients';
import { interests } from '@/lib/db/schema/interests'; import { interests } from '@/lib/db/schema/interests';
import { berths, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths'; import { berths, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
import { yachts } from '@/lib/db/schema/yachts';
import { companyMemberships } from '@/lib/db/schema/companies';
import { auditLogs } from '@/lib/db/schema/system'; import { auditLogs } from '@/lib/db/schema/system';
import { ports } from '@/lib/db/schema/ports'; import { ports } from '@/lib/db/schema/ports';
import { NotFoundError } from '@/lib/errors'; import { NotFoundError } from '@/lib/errors';
@@ -12,10 +14,7 @@ import {
clientSummaryTemplate, clientSummaryTemplate,
buildClientSummaryInputs, buildClientSummaryInputs,
} from '@/lib/pdf/templates/client-summary-template'; } from '@/lib/pdf/templates/client-summary-template';
import { import { berthSpecTemplate, buildBerthSpecInputs } from '@/lib/pdf/templates/berth-spec-template';
berthSpecTemplate,
buildBerthSpecInputs,
} from '@/lib/pdf/templates/berth-spec-template';
import { import {
interestSummaryTemplate, interestSummaryTemplate,
buildInterestSummaryInputs, buildInterestSummaryInputs,
@@ -63,9 +62,7 @@ export async function exportClientPdf(clientId: string, portId: string): Promise
.limit(20); .limit(20);
// Enrich interests with berth mooring numbers // Enrich interests with berth mooring numbers
const berthIds = interestList const berthIds = interestList.map((i) => i.berthId).filter(Boolean) as string[];
.map((i) => i.berthId)
.filter(Boolean) as string[];
let berthsMap: Record<string, string> = {}; let berthsMap: Record<string, string> = {};
if (berthIds.length > 0) { if (berthIds.length > 0) {
@@ -81,7 +78,44 @@ export async function exportClientPdf(clientId: string, portId: string): Promise
berthMooringNumber: i.berthId ? (berthsMap[i.berthId] ?? null) : null, berthMooringNumber: i.berthId ? (berthsMap[i.berthId] ?? null) : null,
})); }));
const inputs = buildClientSummaryInputs(client, contactList, enrichedInterests, activity, port ?? {}); // Yachts owned by the client directly OR by a company they're an active
// member of. Active membership = no end date.
const memberCompanies = await db
.select({ companyId: companyMemberships.companyId })
.from(companyMemberships)
.where(and(eq(companyMemberships.clientId, clientId), isNull(companyMemberships.endDate)));
const companyIds = memberCompanies.map((m) => m.companyId);
const ownerConditions = [
and(eq(yachts.currentOwnerType, 'client'), eq(yachts.currentOwnerId, clientId))!,
];
if (companyIds.length > 0) {
ownerConditions.push(
and(eq(yachts.currentOwnerType, 'company'), inArray(yachts.currentOwnerId, companyIds))!,
);
}
const ownedYachts = await db
.select({
name: yachts.name,
lengthFt: yachts.lengthFt,
widthFt: yachts.widthFt,
draftFt: yachts.draftFt,
lengthM: yachts.lengthM,
widthM: yachts.widthM,
draftM: yachts.draftM,
})
.from(yachts)
.where(and(eq(yachts.portId, portId), isNull(yachts.archivedAt), or(...ownerConditions)));
const inputs = buildClientSummaryInputs(
client,
contactList,
ownedYachts,
enrichedInterests,
activity,
port ?? {},
);
return generatePdf(clientSummaryTemplate, [inputs]); return generatePdf(clientSummaryTemplate, [inputs]);
} }
@@ -143,7 +177,13 @@ export async function exportBerthPdf(berthId: string, portId: string): Promise<U
.orderBy(desc(interests.updatedAt)) .orderBy(desc(interests.updatedAt))
.limit(20); .limit(20);
const inputs = buildBerthSpecInputs(berth, enrichedWaitingList, maintenance, linkedInterests, port ?? {}); const inputs = buildBerthSpecInputs(
berth,
enrichedWaitingList,
maintenance,
linkedInterests,
port ?? {},
);
return generatePdf(berthSpecTemplate, [inputs]); return generatePdf(berthSpecTemplate, [inputs]);
} }
@@ -169,6 +209,11 @@ export async function exportInterestPdf(interestId: string, portId: string): Pro
berth = await db.query.berths.findFirst({ where: eq(berths.id, interest.berthId) }); berth = await db.query.berths.findFirst({ where: eq(berths.id, interest.berthId) });
} }
let yacht = null;
if (interest.yachtId) {
yacht = await db.query.yachts.findFirst({ where: eq(yachts.id, interest.yachtId) });
}
// Audit timeline (last 20 events for this interest) // Audit timeline (last 20 events for this interest)
const timeline = await db const timeline = await db
.select() .select()
@@ -183,7 +228,14 @@ export async function exportInterestPdf(interestId: string, portId: string): Pro
.orderBy(desc(auditLogs.createdAt)) .orderBy(desc(auditLogs.createdAt))
.limit(20); .limit(20);
const inputs = buildInterestSummaryInputs(interest, client ?? {}, berth ?? null, timeline, port ?? {}); const inputs = buildInterestSummaryInputs(
interest,
client ?? {},
yacht ?? null,
berth ?? null,
timeline,
port ?? {},
);
return generatePdf(interestSummaryTemplate, [inputs]); return generatePdf(interestSummaryTemplate, [inputs]);
} }

View File

@@ -8,7 +8,6 @@ import { redis } from '@/lib/redis';
interface ClientResult { interface ClientResult {
id: string; id: string;
fullName: string; fullName: string;
companyName: string | null;
} }
interface InterestResult { interface InterestResult {
@@ -52,15 +51,15 @@ interface SearchResults {
export async function search(portId: string, query: string): Promise<SearchResults> { export async function search(portId: string, query: string): Promise<SearchResults> {
const [clientRows, berthRows, interestRows, yachtRows, companyRows] = await Promise.all([ const [clientRows, berthRows, interestRows, yachtRows, companyRows] = await Promise.all([
// Clients: full-text search via tsvector // Clients: full-text search via tsvector
db.execute<{ id: string; full_name: string; company_name: string | null }>(sql` db.execute<{ id: string; full_name: string }>(sql`
SELECT id, full_name, company_name SELECT id, full_name
FROM clients FROM clients
WHERE port_id = ${portId} WHERE port_id = ${portId}
AND archived_at IS NULL AND archived_at IS NULL
AND to_tsvector('simple', coalesce(full_name, '') || ' ' || coalesce(company_name, '')) AND to_tsvector('simple', coalesce(full_name, ''))
@@ plainto_tsquery('simple', ${query}) @@ plainto_tsquery('simple', ${query})
ORDER BY ts_rank( ORDER BY ts_rank(
to_tsvector('simple', coalesce(full_name, '') || ' ' || coalesce(company_name, '')), to_tsvector('simple', coalesce(full_name, '')),
plainto_tsquery('simple', ${query}) plainto_tsquery('simple', ${query})
) DESC ) DESC
LIMIT 10 LIMIT 10
@@ -157,7 +156,6 @@ export async function search(portId: string, query: string): Promise<SearchResul
clients: Array.from(clientRows).map((r) => ({ clients: Array.from(clientRows).map((r) => ({
id: r.id, id: r.id,
fullName: r.full_name, fullName: r.full_name,
companyName: r.company_name ?? null,
})), })),
berths: Array.from(berthRows).map((r) => ({ berths: Array.from(berthRows).map((r) => ({
id: r.id, id: r.id,

View File

@@ -24,10 +24,6 @@ export const listDocumentsSchema = baseListQuerySchema.extend({
status: z.string().optional(), status: z.string().optional(),
}); });
export const generateEoiSchema = z.object({
interestId: z.string().min(1),
});
export const uploadSignedSchema = z.object({ export const uploadSignedSchema = z.object({
documentId: z.string().min(1), documentId: z.string().min(1),
}); });
@@ -35,4 +31,3 @@ export const uploadSignedSchema = z.object({
export type CreateDocumentInput = z.infer<typeof createDocumentSchema>; export type CreateDocumentInput = z.infer<typeof createDocumentSchema>;
export type UpdateDocumentInput = z.infer<typeof updateDocumentSchema>; export type UpdateDocumentInput = z.infer<typeof updateDocumentSchema>;
export type ListDocumentsInput = z.infer<typeof listDocumentsSchema>; export type ListDocumentsInput = z.infer<typeof listDocumentsSchema>;
export type GenerateEoiInput = z.infer<typeof generateEoiSchema>;

View File

@@ -130,17 +130,6 @@ export const publicInterestSchema = z
source: z.literal('website').default('website'), source: z.literal('website').default('website'),
notes: z.string().max(2000).optional(), notes: z.string().max(2000).optional(),
address: addressSchema.optional(), address: addressSchema.optional(),
// ─── Deprecated flat fields ────────────────────────────────────────────
// Kept in the schema so strict parse does not reject submissions from
// legacy callers, but the route IGNORES them in favor of `yacht` / `company`.
// Remove once all inbound integrations have migrated.
yachtName: z.string().optional(),
yachtLengthFt: z.coerce.number().positive().optional(),
yachtWidthFt: z.coerce.number().positive().optional(),
yachtDraftFt: z.coerce.number().positive().optional(),
preferredBerthSize: z.string().optional(),
companyName: z.string().optional(),
}) })
.refine((data) => data.fullName || (data.firstName && data.lastName), { .refine((data) => data.fullName || (data.firstName && data.lastName), {
message: 'Either fullName or both firstName and lastName are required', message: 'Either fullName or both firstName and lastName are required',

View File

@@ -25,7 +25,6 @@ export interface Client {
id: string; id: string;
portId: string; portId: string;
fullName: string; fullName: string;
companyName?: string | null;
nationality?: string | null; nationality?: string | null;
source?: string | null; source?: string | null;
archivedAt?: Date | null; archivedAt?: Date | null;

View File

@@ -592,7 +592,6 @@ export function makeCreateClientInput(overrides?: { fullName?: string; portId?:
return { return {
fullName: overrides?.fullName ?? 'Test Client', fullName: overrides?.fullName ?? 'Test Client',
contacts: [{ channel: 'email' as const, value: 'test@example.com', isPrimary: true }], contacts: [{ channel: 'email' as const, value: 'test@example.com', isPrimary: true }],
isProxy: false,
tagIds: [] as string[], tagIds: [] as string[],
}; };
} }