refactor(templates): merge-field allow-list rejects unknown tokens

Extracts the MERGE_FIELDS catalog out of the document-templates service
into src/lib/templates/merge-fields.ts so the Zod validator can import
it without circular deps. createTemplateSchema now refines mergeFields
against VALID_MERGE_TOKENS — unknown tokens (including the deprecated
`{{client.yachtName}}` / `{{client.companyName}}` family) are rejected
at template creation time with a message naming the offenders.

Adds the missing `eoi` value to templateType enum so seeded EOI rows
round-trip through the validator. Drops the historical "Removed (PR 11):"
comment from the catalog (per project convention against `// removed`
markers).

6 new validator unit tests; 652/652 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-26 13:48:06 +02:00
parent f4ec51002c
commit 456d399ee2
4 changed files with 158 additions and 70 deletions

View File

@@ -24,6 +24,7 @@ import {
} from '@/lib/services/documenso-client';
import { buildDocumensoPayload } from '@/lib/services/documenso-payload';
import { generateEoiPdfFromTemplate } from '@/lib/pdf/fill-eoi-form';
import { MERGE_FIELDS, type MergeFieldCatalog } from '@/lib/templates/merge-fields';
import { buildEoiContext } from '@/lib/services/eoi-context';
import { sendEmail } from '@/lib/email';
import type {
@@ -45,75 +46,7 @@ interface AuditMeta {
// ─── Merge Field Definitions ──────────────────────────────────────────────────
const MERGE_FIELDS: Record<string, Array<{ token: string; label: string; required: boolean }>> = {
client: [
{ token: '{{client.fullName}}', label: 'Client Full Name', required: true },
{ 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.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 },
{ token: '{{interest.leadCategory}}', label: 'Lead Category', required: false },
{ token: '{{interest.berthNumber}}', label: 'Berth Number', required: false },
{ token: '{{interest.eoiStatus}}', label: 'EOI Status', required: false },
{ token: '{{interest.dateFirstContact}}', label: 'Date First Contact', required: false },
{ token: '{{interest.dateEoiSigned}}', label: 'Date EOI Signed', required: false },
{ token: '{{interest.dateContractSigned}}', label: 'Date Contract Signed', required: false },
{ token: '{{interest.notes}}', label: 'Interest Notes', required: false },
],
berth: [
// 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 },
{ token: '{{berth.priceCurrency}}', label: 'Price Currency', required: false },
{ token: '{{berth.lengthFt}}', label: 'Length (ft)', required: false },
{ token: '{{berth.widthFt}}', label: 'Beam (ft)', required: false },
{ token: '{{berth.tenureType}}', label: 'Tenure Type', required: false },
{ token: '{{berth.tenureYears}}', label: 'Tenure Years', required: false },
],
port: [
{ token: '{{port.name}}', label: 'Port Name', required: false },
{ token: '{{port.defaultCurrency}}', label: 'Default Currency', required: false },
],
date: [
{ token: '{{date.today}}', label: "Today's Date", required: false },
{ token: '{{date.year}}', label: 'Current Year', required: false },
],
};
export function getMergeFields(): typeof MERGE_FIELDS {
export function getMergeFields(): MergeFieldCatalog {
return MERGE_FIELDS;
}