feat(templates): merge-field resolver supports yacht/company/owner scopes
Task 11.4. Extends resolveTemplate to use buildEoiContext when interestId is provided, populating the new yacht.*, company.*, owner.* token scopes from the shared EOI context. Legacy non-EOI templates still resolve via direct client/berth/port lookups. Deprecated client.yachtName / client.companyName / client.yacht*Ft tokens are removed from the catalog; PR 12 will drop the backing columns. berth.mooringNumber is relaxed to required:false so welcome-letter-style templates without a berth context no longer trip the required-merge-field check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { clients, clientContacts } from '@/lib/db/schema/clients';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { buildListQuery } from '@/lib/db/query-builder';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { diffEntity } from '@/lib/entity-diff';
|
||||
@@ -16,7 +17,11 @@ import { minioClient, buildStoragePath } from '@/lib/minio';
|
||||
import { env } from '@/lib/env';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { generatePdf } from '@/lib/pdf/generate';
|
||||
import { createDocument as documensoCreate, sendDocument as documensoSend } from '@/lib/services/documenso-client';
|
||||
import {
|
||||
createDocument as documensoCreate,
|
||||
sendDocument as documensoSend,
|
||||
} from '@/lib/services/documenso-client';
|
||||
import { buildEoiContext } from '@/lib/services/eoi-context';
|
||||
import { sendEmail } from '@/lib/email';
|
||||
import type {
|
||||
CreateTemplateInput,
|
||||
@@ -40,16 +45,37 @@ interface AuditMeta {
|
||||
const MERGE_FIELDS: Record<string, Array<{ token: string; label: string; required: boolean }>> = {
|
||||
client: [
|
||||
{ token: '{{client.fullName}}', label: 'Client Full Name', required: true },
|
||||
{ token: '{{client.companyName}}', label: 'Company Name', required: false },
|
||||
{ token: '{{client.email}}', label: 'Primary Email', required: false },
|
||||
{ token: '{{client.phone}}', label: 'Primary Phone', required: false },
|
||||
{ token: '{{client.nationality}}', label: 'Nationality', required: false },
|
||||
{ token: '{{client.yachtName}}', label: 'Yacht Name', required: false },
|
||||
{ token: '{{client.yachtLengthFt}}', label: 'Yacht Length (ft)', required: false },
|
||||
{ token: '{{client.yachtLengthM}}', label: 'Yacht Length (m)', required: false },
|
||||
{ token: '{{client.yachtWidthFt}}', label: 'Yacht Beam (ft)', required: false },
|
||||
{ token: '{{client.yachtDraftFt}}', label: 'Yacht Draft (ft)', required: false },
|
||||
{ token: '{{client.source}}', label: 'Lead Source', required: false },
|
||||
// Removed (PR 11): {{client.companyName}}, {{client.yachtName}},
|
||||
// {{client.yachtLengthFt}}, {{client.yachtLengthM}}, {{client.yachtWidthFt}},
|
||||
// {{client.yachtDraftFt}} — use the dedicated yacht.* / company.* scopes instead.
|
||||
],
|
||||
yacht: [
|
||||
{ token: '{{yacht.name}}', label: 'Yacht Name', required: false },
|
||||
{ token: '{{yacht.hullNumber}}', label: 'Hull Number', required: false },
|
||||
{ token: '{{yacht.registration}}', label: 'Registration', required: false },
|
||||
{ token: '{{yacht.flag}}', label: 'Flag', required: false },
|
||||
{ token: '{{yacht.yearBuilt}}', label: 'Year Built', required: false },
|
||||
{ token: '{{yacht.lengthFt}}', label: 'Yacht Length (ft)', required: false },
|
||||
{ token: '{{yacht.widthFt}}', label: 'Yacht Beam (ft)', required: false },
|
||||
{ token: '{{yacht.draftFt}}', label: 'Yacht Draft (ft)', required: false },
|
||||
{ token: '{{yacht.lengthM}}', label: 'Yacht Length (m)', required: false },
|
||||
{ token: '{{yacht.widthM}}', label: 'Yacht Beam (m)', required: false },
|
||||
{ token: '{{yacht.draftM}}', label: 'Yacht Draft (m)', required: false },
|
||||
],
|
||||
company: [
|
||||
{ token: '{{company.name}}', label: 'Company Name', required: false },
|
||||
{ token: '{{company.legalName}}', label: 'Company Legal Name', required: false },
|
||||
{ token: '{{company.taxId}}', label: 'Company Tax ID', required: false },
|
||||
{ token: '{{company.billingAddress}}', label: 'Company Billing Address', required: false },
|
||||
],
|
||||
owner: [
|
||||
{ token: '{{owner.type}}', label: 'Yacht Owner Type', required: false },
|
||||
{ token: '{{owner.name}}', label: 'Yacht Owner Name', required: false },
|
||||
{ token: '{{owner.legalName}}', label: 'Yacht Owner Legal Name', required: false },
|
||||
],
|
||||
interest: [
|
||||
{ token: '{{interest.stage}}', label: 'Pipeline Stage', required: false },
|
||||
@@ -62,7 +88,9 @@ const MERGE_FIELDS: Record<string, Array<{ token: string; label: string; require
|
||||
{ token: '{{interest.notes}}', label: 'Interest Notes', required: false },
|
||||
],
|
||||
berth: [
|
||||
{ token: '{{berth.mooringNumber}}', label: 'Mooring Number', required: true },
|
||||
// Non-required so non-EOI templates (welcome letters etc.) don't fail.
|
||||
// EOI-specific required-field enforcement lives in STANDARD_EOI_MERGE_FIELDS.
|
||||
{ token: '{{berth.mooringNumber}}', label: 'Mooring Number', required: false },
|
||||
{ token: '{{berth.area}}', label: 'Area', required: false },
|
||||
{ token: '{{berth.status}}', label: 'Berth Status', required: false },
|
||||
{ token: '{{berth.price}}', label: 'Price', required: false },
|
||||
@@ -101,10 +129,13 @@ export async function listTemplates(portId: string, query: ListTemplatesInput) {
|
||||
}
|
||||
|
||||
const sortColumn =
|
||||
sort === 'name' ? documentTemplates.name :
|
||||
sort === 'templateType' ? documentTemplates.templateType :
|
||||
sort === 'createdAt' ? documentTemplates.createdAt :
|
||||
documentTemplates.updatedAt;
|
||||
sort === 'name'
|
||||
? documentTemplates.name
|
||||
: sort === 'templateType'
|
||||
? documentTemplates.templateType
|
||||
: sort === 'createdAt'
|
||||
? documentTemplates.createdAt
|
||||
: documentTemplates.updatedAt;
|
||||
|
||||
return buildListQuery({
|
||||
table: documentTemplates,
|
||||
@@ -178,10 +209,7 @@ export async function updateTemplate(
|
||||
) {
|
||||
const existing = await getTemplateById(id, portId);
|
||||
|
||||
const { diff } = diffEntity(
|
||||
existing as Record<string, unknown>,
|
||||
data as Record<string, unknown>,
|
||||
);
|
||||
const { diff } = diffEntity(existing as Record<string, unknown>, data as Record<string, unknown>);
|
||||
|
||||
const [updated] = await db
|
||||
.update(documentTemplates)
|
||||
@@ -261,69 +289,179 @@ export async function resolveTemplate(
|
||||
tokenMap['{{port.defaultCurrency}}'] = port.defaultCurrency;
|
||||
}
|
||||
|
||||
// Client tokens
|
||||
// ─── EOI-style resolution ───────────────────────────────────────────────────
|
||||
// If an interestId is provided, prefer the shared buildEoiContext payload so
|
||||
// that yacht.*, company.*, owner.*, and berth.* tokens all resolve from the
|
||||
// same denormalised snapshot the PDF/Documenso pipelines use.
|
||||
// Falls back to the legacy path below if the interest isn't EOI-ready
|
||||
// (missing yacht or berth), so non-EOI templates still work.
|
||||
let eoiContextLoaded = false;
|
||||
if (context.interestId) {
|
||||
try {
|
||||
const eoi = await buildEoiContext(context.interestId, context.portId);
|
||||
eoiContextLoaded = true;
|
||||
|
||||
// Client tokens (from EoiContext)
|
||||
tokenMap['{{client.fullName}}'] = eoi.client.fullName;
|
||||
tokenMap['{{client.email}}'] = eoi.client.primaryEmail ?? '';
|
||||
tokenMap['{{client.phone}}'] = eoi.client.primaryPhone ?? '';
|
||||
tokenMap['{{client.nationality}}'] = eoi.client.nationality ?? '';
|
||||
|
||||
// Yacht tokens
|
||||
tokenMap['{{yacht.name}}'] = eoi.yacht.name;
|
||||
tokenMap['{{yacht.hullNumber}}'] = eoi.yacht.hullNumber ?? '';
|
||||
tokenMap['{{yacht.flag}}'] = eoi.yacht.flag ?? '';
|
||||
tokenMap['{{yacht.yearBuilt}}'] =
|
||||
eoi.yacht.yearBuilt != null ? String(eoi.yacht.yearBuilt) : '';
|
||||
tokenMap['{{yacht.lengthFt}}'] = eoi.yacht.lengthFt ?? '';
|
||||
tokenMap['{{yacht.widthFt}}'] = eoi.yacht.widthFt ?? '';
|
||||
tokenMap['{{yacht.draftFt}}'] = eoi.yacht.draftFt ?? '';
|
||||
tokenMap['{{yacht.lengthM}}'] = eoi.yacht.lengthM ?? '';
|
||||
tokenMap['{{yacht.widthM}}'] = eoi.yacht.widthM ?? '';
|
||||
tokenMap['{{yacht.draftM}}'] = eoi.yacht.draftM ?? '';
|
||||
|
||||
// EoiContext doesn't expose the yacht.registration column — look it up
|
||||
// separately (cheap, indexed fetch) so the token resolves when present.
|
||||
try {
|
||||
const interestRow = await db.query.interests.findFirst({
|
||||
where: eq(interests.id, context.interestId),
|
||||
columns: { yachtId: true },
|
||||
});
|
||||
if (interestRow?.yachtId) {
|
||||
const yachtRow = await db.query.yachts.findFirst({
|
||||
where: eq(yachts.id, interestRow.yachtId),
|
||||
columns: { registration: true },
|
||||
});
|
||||
tokenMap['{{yacht.registration}}'] = yachtRow?.registration ?? '';
|
||||
} else {
|
||||
tokenMap['{{yacht.registration}}'] = '';
|
||||
}
|
||||
} catch {
|
||||
tokenMap['{{yacht.registration}}'] = '';
|
||||
}
|
||||
|
||||
// Company tokens (only populated when owner is a company)
|
||||
tokenMap['{{company.name}}'] = eoi.company?.name ?? '';
|
||||
tokenMap['{{company.legalName}}'] = eoi.company?.legalName ?? '';
|
||||
tokenMap['{{company.taxId}}'] = eoi.company?.taxId ?? '';
|
||||
tokenMap['{{company.billingAddress}}'] = eoi.company?.billingAddress ?? '';
|
||||
|
||||
// Owner tokens
|
||||
tokenMap['{{owner.type}}'] = eoi.owner.type;
|
||||
tokenMap['{{owner.name}}'] = eoi.owner.name;
|
||||
tokenMap['{{owner.legalName}}'] = eoi.owner.legalName ?? '';
|
||||
|
||||
// Berth tokens (from EoiContext)
|
||||
tokenMap['{{berth.mooringNumber}}'] = eoi.berth.mooringNumber;
|
||||
tokenMap['{{berth.area}}'] = eoi.berth.area ?? '';
|
||||
tokenMap['{{berth.lengthFt}}'] = eoi.berth.lengthFt ?? '';
|
||||
tokenMap['{{berth.price}}'] = eoi.berth.price ?? '';
|
||||
tokenMap['{{berth.priceCurrency}}'] = eoi.berth.priceCurrency;
|
||||
tokenMap['{{berth.tenureType}}'] = eoi.berth.tenureType;
|
||||
|
||||
// Interest tokens
|
||||
tokenMap['{{interest.stage}}'] = eoi.interest.stage;
|
||||
tokenMap['{{interest.leadCategory}}'] = eoi.interest.leadCategory ?? '';
|
||||
tokenMap['{{interest.berthNumber}}'] = eoi.berth.mooringNumber;
|
||||
tokenMap['{{interest.dateFirstContact}}'] = eoi.interest.dateFirstContact
|
||||
? eoi.interest.dateFirstContact.toLocaleDateString('en-GB')
|
||||
: '';
|
||||
tokenMap['{{interest.notes}}'] = eoi.interest.notes ?? '';
|
||||
} catch (err) {
|
||||
// buildEoiContext throws ValidationError when the interest has no yacht
|
||||
// or berth; non-EOI templates don't need those. Fall through to the
|
||||
// legacy resolution path below. Re-throw anything else.
|
||||
if (
|
||||
!(err instanceof ValidationError) ||
|
||||
!/interest has no (yacht|berth)/i.test(err.message)
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Legacy / non-EOI fallback ──────────────────────────────────────────────
|
||||
|
||||
// Client tokens from direct client lookup (welcome letters, correspondence,
|
||||
// or EOI-flow clients where we still want client.source to resolve).
|
||||
if (context.clientId) {
|
||||
const client = await db.query.clients.findFirst({
|
||||
where: eq(clients.id, context.clientId),
|
||||
});
|
||||
if (client && client.portId === context.portId) {
|
||||
const contactList = await db.query.clientContacts.findMany({
|
||||
where: eq(clientContacts.clientId, context.clientId),
|
||||
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
|
||||
});
|
||||
const emailContact = contactList.find((c) => c.channel === 'email');
|
||||
const phoneContact = contactList.find((c) => c.channel === 'phone' || c.channel === 'whatsapp');
|
||||
// Always resolve source from the DB — EoiContext doesn't carry it.
|
||||
if (tokenMap['{{client.source}}'] === undefined) {
|
||||
tokenMap['{{client.source}}'] = client.source ?? '';
|
||||
}
|
||||
|
||||
tokenMap['{{client.fullName}}'] = client.fullName ?? '';
|
||||
tokenMap['{{client.companyName}}'] = client.companyName ?? '';
|
||||
tokenMap['{{client.email}}'] = emailContact?.value ?? '';
|
||||
tokenMap['{{client.phone}}'] = phoneContact?.value ?? '';
|
||||
tokenMap['{{client.nationality}}'] = client.nationality ?? '';
|
||||
tokenMap['{{client.yachtName}}'] = client.yachtName ?? '';
|
||||
tokenMap['{{client.yachtLengthFt}}'] = client.yachtLengthFt ? String(client.yachtLengthFt) : '';
|
||||
tokenMap['{{client.yachtLengthM}}'] = client.yachtLengthM ? String(client.yachtLengthM) : '';
|
||||
tokenMap['{{client.yachtWidthFt}}'] = client.yachtWidthFt ? String(client.yachtWidthFt) : '';
|
||||
tokenMap['{{client.yachtDraftFt}}'] = client.yachtDraftFt ? String(client.yachtDraftFt) : '';
|
||||
tokenMap['{{client.source}}'] = client.source ?? '';
|
||||
// Only fill client.* tokens if the EOI path didn't already populate them.
|
||||
if (!eoiContextLoaded) {
|
||||
const contactList = await db.query.clientContacts.findMany({
|
||||
where: eq(clientContacts.clientId, context.clientId),
|
||||
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
|
||||
});
|
||||
const emailContact = contactList.find((c) => c.channel === 'email');
|
||||
const phoneContact = contactList.find(
|
||||
(c) => c.channel === 'phone' || c.channel === 'whatsapp',
|
||||
);
|
||||
|
||||
tokenMap['{{client.fullName}}'] = client.fullName ?? '';
|
||||
tokenMap['{{client.email}}'] = emailContact?.value ?? '';
|
||||
tokenMap['{{client.phone}}'] = phoneContact?.value ?? '';
|
||||
tokenMap['{{client.nationality}}'] = client.nationality ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Interest tokens
|
||||
// Interest tokens (legacy path — fills in fields EoiContext doesn't expose,
|
||||
// like eoiStatus / dateEoiSigned / dateContractSigned, or populates the
|
||||
// whole interest.* block when EOI resolution was skipped).
|
||||
if (context.interestId) {
|
||||
const interest = await db.query.interests.findFirst({
|
||||
where: eq(interests.id, context.interestId),
|
||||
});
|
||||
if (interest && interest.portId === context.portId) {
|
||||
tokenMap['{{interest.stage}}'] = interest.pipelineStage ?? '';
|
||||
tokenMap['{{interest.leadCategory}}'] = interest.leadCategory ?? '';
|
||||
if (!eoiContextLoaded) {
|
||||
tokenMap['{{interest.stage}}'] = interest.pipelineStage ?? '';
|
||||
tokenMap['{{interest.leadCategory}}'] = interest.leadCategory ?? '';
|
||||
tokenMap['{{interest.dateFirstContact}}'] = interest.dateFirstContact
|
||||
? new Date(interest.dateFirstContact).toLocaleDateString('en-GB')
|
||||
: '';
|
||||
tokenMap['{{interest.notes}}'] = interest.notes ?? '';
|
||||
}
|
||||
// These are never populated by EoiContext — always fill them in.
|
||||
tokenMap['{{interest.eoiStatus}}'] = interest.eoiStatus ?? '';
|
||||
tokenMap['{{interest.dateFirstContact}}'] = interest.dateFirstContact
|
||||
? new Date(interest.dateFirstContact).toLocaleDateString('en-GB')
|
||||
: '';
|
||||
tokenMap['{{interest.dateEoiSigned}}'] = interest.dateEoiSigned
|
||||
? new Date(interest.dateEoiSigned).toLocaleDateString('en-GB')
|
||||
: '';
|
||||
tokenMap['{{interest.dateContractSigned}}'] = interest.dateContractSigned
|
||||
? new Date(interest.dateContractSigned).toLocaleDateString('en-GB')
|
||||
: '';
|
||||
tokenMap['{{interest.notes}}'] = interest.notes ?? '';
|
||||
// Berth number from interest if berthId not separately provided
|
||||
if (interest.berthId && !context.berthId) {
|
||||
// Derive berth number from the interest when berthId wasn't passed and
|
||||
// the EOI path didn't already populate it.
|
||||
if (!eoiContextLoaded && interest.berthId && !context.berthId) {
|
||||
const interestBerth = await db.query.berths.findFirst({
|
||||
where: eq(berths.id, interest.berthId),
|
||||
});
|
||||
tokenMap['{{interest.berthNumber}}'] = interestBerth?.mooringNumber ?? '';
|
||||
tokenMap['{{berth.mooringNumber}}'] = interestBerth?.mooringNumber ?? '';
|
||||
} else {
|
||||
tokenMap['{{interest.berthNumber}}'] = context.berthId
|
||||
? tokenMap['{{berth.mooringNumber}}'] ?? ''
|
||||
if (interestBerth) {
|
||||
tokenMap['{{interest.berthNumber}}'] = interestBerth.mooringNumber;
|
||||
if (!tokenMap['{{berth.mooringNumber}}']) {
|
||||
tokenMap['{{berth.mooringNumber}}'] = interestBerth.mooringNumber;
|
||||
}
|
||||
} else {
|
||||
tokenMap['{{interest.berthNumber}}'] ??= '';
|
||||
}
|
||||
} else if (!eoiContextLoaded) {
|
||||
tokenMap['{{interest.berthNumber}}'] ??= context.berthId
|
||||
? (tokenMap['{{berth.mooringNumber}}'] ?? '')
|
||||
: '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Berth tokens
|
||||
if (context.berthId) {
|
||||
// Berth tokens (legacy path — when a berthId is passed directly and EOI
|
||||
// resolution didn't already populate the berth block).
|
||||
if (context.berthId && !eoiContextLoaded) {
|
||||
const berth = await db.query.berths.findFirst({
|
||||
where: eq(berths.id, context.berthId),
|
||||
});
|
||||
@@ -355,9 +493,7 @@ export async function resolveTemplate(
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new ValidationError(
|
||||
`Missing required merge field values: ${missing.join(', ')}`,
|
||||
);
|
||||
throw new ValidationError(`Missing required merge field values: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
// Interpolate all tokens
|
||||
@@ -558,12 +694,12 @@ export async function generateAndSign(
|
||||
signers: GenerateAndSignInput['signers'],
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const { document: documentRecord, file } = await generateFromTemplate(
|
||||
const { document: documentRecord, file } = (await generateFromTemplate(
|
||||
templateId,
|
||||
portId,
|
||||
context,
|
||||
meta,
|
||||
) as { document: DbDocument; file: DbFile };
|
||||
)) as { document: DbDocument; file: DbFile };
|
||||
const template = await getTemplateById(templateId, portId);
|
||||
|
||||
// Fetch PDF bytes from MinIO to send to Documenso
|
||||
@@ -611,7 +747,10 @@ export async function generateAndSign(
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'document:updated', { documentId: documentRecord.id, changedFields: ['status', 'documensoId'] });
|
||||
emitToRoom(`port:${portId}`, 'document:updated', {
|
||||
documentId: documentRecord.id,
|
||||
changedFields: ['status', 'documensoId'],
|
||||
});
|
||||
|
||||
return { document: { ...documentRecord, documensoId: documensoDoc.id, status: 'sent' }, file };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user