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:
@@ -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),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user