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

View File

@@ -4,7 +4,6 @@ import { db } from '@/lib/db';
import { documents, documentSigners, documentEvents, files } from '@/lib/db/schema/documents';
import { interests } from '@/lib/db/schema/interests';
import { clients } from '@/lib/db/schema/clients';
import { berths } from '@/lib/db/schema/berths';
import { ports } from '@/lib/db/schema/ports';
import { buildListQuery } from '@/lib/db/query-builder';
import { createAuditLog } from '@/lib/audit';
@@ -14,8 +13,6 @@ import { emitToRoom } from '@/lib/socket/server';
import { minioClient, buildStoragePath } from '@/lib/minio';
import { env } from '@/lib/env';
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 {
createDocument as documensoCreate,
@@ -50,10 +47,13 @@ export async function listDocuments(portId: string, query: ListDocumentsInput) {
if (status) filters.push(eq(documents.status, status));
const sortColumn =
sort === 'title' ? documents.title :
sort === 'status' ? documents.status :
sort === 'documentType' ? documents.documentType :
documents.createdAt;
sort === 'title'
? documents.title
: sort === 'status'
? documents.status
: sort === 'documentType'
? documents.documentType
: documents.createdAt;
return buildListQuery({
table: documents,
@@ -84,11 +84,7 @@ export async function getDocumentById(id: string, portId: string) {
// ─── Create ───────────────────────────────────────────────────────────────────
export async function createDocument(
portId: string,
data: CreateDocumentInput,
meta: AuditMeta,
) {
export async function createDocument(portId: string, data: CreateDocumentInput, meta: AuditMeta) {
const [doc] = await db
.insert(documents)
.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');
}
await db
.delete(documents)
.where(and(eq(documents.id, id), eq(documents.portId, portId)));
await db.delete(documents).where(and(eq(documents.id, id), eq(documents.portId, portId)));
void createAuditLog({
userId: meta.userId,
@@ -187,116 +181,6 @@ export async function deleteDocument(id: string, portId: string, meta: AuditMeta
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) ────────────────────────────────────────────────
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');
const emailContact = (client.contacts as Array<{ channel: string; value: string }> | undefined)?.find(
(c) => c.channel === 'email',
);
const emailContact = (
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');
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, [
{ 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} 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);
@@ -432,7 +321,12 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
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);
}
@@ -453,13 +347,9 @@ export async function uploadSignedManually(
const fileId = crypto.randomUUID();
const storagePath = buildStoragePath(port.slug, 'eoi-signed', documentId, fileId, 'pdf');
await minioClient.putObject(
env.MINIO_BUCKET,
storagePath,
fileData.buffer,
fileData.size,
{ 'Content-Type': fileData.mimeType },
);
await minioClient.putObject(env.MINIO_BUCKET, storagePath, fileData.buffer, fileData.size, {
'Content-Type': fileData.mimeType,
});
const [fileRecord] = await db
.insert(files)
@@ -612,9 +502,7 @@ export async function handleRecipientSigned(eventData: {
});
}
export async function handleDocumentCompleted(eventData: {
documentId: string;
}) {
export async function handleDocumentCompleted(eventData: { documentId: string }) {
const doc = await db.query.documents.findFirst({
where: eq(documents.documensoId, eventData.documentId),
});
@@ -718,9 +606,7 @@ export async function handleDocumentCompleted(eventData: {
}
}
export async function handleDocumentExpired(eventData: {
documentId: string;
}) {
export async function handleDocumentExpired(eventData: { documentId: string }) {
const doc = await db.query.documents.findFirst({
where: eq(documents.documensoId, eventData.documentId),
});

View File

@@ -66,17 +66,17 @@ async function assertYachtBelongsToClient(
async function resolveLeadCategory(
clientId: string,
leadCategory: string | undefined | null,
yachtId?: string | null,
): Promise<string | undefined> {
if (leadCategory && leadCategory !== 'general_interest') {
return leadCategory;
}
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (client && (client.yachtLengthFt || client.yachtLengthM)) {
return 'specific_qualified';
if (yachtId) {
const yacht = await db.query.yachts.findFirst({ where: eq(yachts.id, yachtId) });
if (yacht && (yacht.lengthFt || yacht.lengthM)) {
return 'specific_qualified';
}
}
return leadCategory ?? undefined;
@@ -275,7 +275,11 @@ export async function createInterest(portId: string, data: CreateInterestInput,
const { tagIds, ...interestData } = data;
// 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 [interest] = await tx
@@ -350,6 +354,7 @@ export async function updateInterest(
resolvedLeadCategory = (await resolveLeadCategory(
existing.clientId,
data.leadCategory,
data.yachtId ?? existing.yachtId,
)) 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 { clients, clientContacts } from '@/lib/db/schema/clients';
import { interests } from '@/lib/db/schema/interests';
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 { ports } from '@/lib/db/schema/ports';
import { NotFoundError } from '@/lib/errors';
@@ -12,10 +14,7 @@ import {
clientSummaryTemplate,
buildClientSummaryInputs,
} from '@/lib/pdf/templates/client-summary-template';
import {
berthSpecTemplate,
buildBerthSpecInputs,
} from '@/lib/pdf/templates/berth-spec-template';
import { berthSpecTemplate, buildBerthSpecInputs } from '@/lib/pdf/templates/berth-spec-template';
import {
interestSummaryTemplate,
buildInterestSummaryInputs,
@@ -63,9 +62,7 @@ export async function exportClientPdf(clientId: string, portId: string): Promise
.limit(20);
// Enrich interests with berth mooring numbers
const berthIds = interestList
.map((i) => i.berthId)
.filter(Boolean) as string[];
const berthIds = interestList.map((i) => i.berthId).filter(Boolean) as string[];
let berthsMap: Record<string, string> = {};
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,
}));
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]);
}
@@ -143,7 +177,13 @@ export async function exportBerthPdf(berthId: string, portId: string): Promise<U
.orderBy(desc(interests.updatedAt))
.limit(20);
const inputs = buildBerthSpecInputs(berth, enrichedWaitingList, maintenance, linkedInterests, port ?? {});
const inputs = buildBerthSpecInputs(
berth,
enrichedWaitingList,
maintenance,
linkedInterests,
port ?? {},
);
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) });
}
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)
const timeline = await db
.select()
@@ -183,7 +228,14 @@ export async function exportInterestPdf(interestId: string, portId: string): Pro
.orderBy(desc(auditLogs.createdAt))
.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]);
}

View File

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