From 456d399ee2ce1caed32cf1d4b860b3f3e4b4ac0e Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Sun, 26 Apr 2026 13:48:06 +0200 Subject: [PATCH] refactor(templates): merge-field allow-list rejects unknown tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/lib/services/document-templates.ts | 71 +--------------- src/lib/templates/merge-fields.ts | 80 +++++++++++++++++++ src/lib/validators/document-templates.ts | 16 +++- .../validators/document-templates.test.ts | 61 ++++++++++++++ 4 files changed, 158 insertions(+), 70 deletions(-) create mode 100644 src/lib/templates/merge-fields.ts create mode 100644 tests/unit/validators/document-templates.test.ts diff --git a/src/lib/services/document-templates.ts b/src/lib/services/document-templates.ts index 1d21724..314c382 100644 --- a/src/lib/services/document-templates.ts +++ b/src/lib/services/document-templates.ts @@ -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> = { - 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; } diff --git a/src/lib/templates/merge-fields.ts b/src/lib/templates/merge-fields.ts new file mode 100644 index 0000000..c59882e --- /dev/null +++ b/src/lib/templates/merge-fields.ts @@ -0,0 +1,80 @@ +export interface MergeField { + token: string; + label: string; + required: boolean; +} + +export type MergeFieldCatalog = Record; + +export const MERGE_FIELDS: MergeFieldCatalog = { + 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 }, + ], + 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 }, + ], +}; + +/** + * Flat set of every valid token from the catalog. Used as the validator + * allow-list so unknown tokens are rejected at template creation time. + */ +export const VALID_MERGE_TOKENS: ReadonlySet = new Set( + Object.values(MERGE_FIELDS).flatMap((scope) => scope.map((field) => field.token)), +); diff --git a/src/lib/validators/document-templates.ts b/src/lib/validators/document-templates.ts index 443a028..272b1fb 100644 --- a/src/lib/validators/document-templates.ts +++ b/src/lib/validators/document-templates.ts @@ -1,11 +1,25 @@ import { z } from 'zod'; import { baseListQuerySchema } from '@/lib/api/route-helpers'; +import { VALID_MERGE_TOKENS } from '@/lib/templates/merge-fields'; + +const mergeFieldsSchema = z + .array(z.string()) + .optional() + .default([]) + .refine( + (tokens) => tokens.every((t) => VALID_MERGE_TOKENS.has(t)), + (tokens) => { + const unknown = tokens?.filter((t) => !VALID_MERGE_TOKENS.has(t)) ?? []; + return { message: `Unknown merge tokens: ${unknown.join(', ')}` }; + }, + ); export const createTemplateSchema = z.object({ name: z.string().min(1).max(200), description: z.string().max(500).optional(), templateType: z.enum([ + 'eoi', 'welcome_letter', 'handover_checklist', 'acknowledgment', @@ -13,7 +27,7 @@ export const createTemplateSchema = z.object({ 'custom', ]), bodyHtml: z.string().min(1), - mergeFields: z.array(z.string()).optional().default([]), + mergeFields: mergeFieldsSchema, isActive: z.boolean().default(true), }); diff --git a/tests/unit/validators/document-templates.test.ts b/tests/unit/validators/document-templates.test.ts new file mode 100644 index 0000000..02a9bf4 --- /dev/null +++ b/tests/unit/validators/document-templates.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { createTemplateSchema } from '@/lib/validators/document-templates'; + +const baseInput = { + name: 'Tmpl', + templateType: 'custom' as const, + bodyHtml: '

x

', +}; + +describe('createTemplateSchema — mergeFields allow-list', () => { + it('accepts valid tokens from the catalog', () => { + const parsed = createTemplateSchema.parse({ + ...baseInput, + mergeFields: ['{{client.fullName}}', '{{yacht.name}}', '{{berth.mooringNumber}}'], + }); + expect(parsed.mergeFields).toEqual([ + '{{client.fullName}}', + '{{yacht.name}}', + '{{berth.mooringNumber}}', + ]); + }); + + it('rejects deprecated tokens that lived on `clients` before the refactor', () => { + const result = createTemplateSchema.safeParse({ + ...baseInput, + mergeFields: ['{{client.yachtName}}'], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toMatch(/Unknown merge tokens.*client\.yachtName/); + } + }); + + it('rejects unknown tokens with a helpful message listing them', () => { + const result = createTemplateSchema.safeParse({ + ...baseInput, + mergeFields: ['{{client.fullName}}', '{{not.a.token}}', '{{also.bogus}}'], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toContain('not.a.token'); + expect(result.error.issues[0]?.message).toContain('also.bogus'); + } + }); + + it('defaults mergeFields to an empty array when omitted', () => { + const parsed = createTemplateSchema.parse(baseInput); + expect(parsed.mergeFields).toEqual([]); + }); + + it('accepts an empty mergeFields array', () => { + const parsed = createTemplateSchema.parse({ ...baseInput, mergeFields: [] }); + expect(parsed.mergeFields).toEqual([]); + }); + + it('allows the new `eoi` templateType', () => { + const parsed = createTemplateSchema.parse({ ...baseInput, templateType: 'eoi' }); + expect(parsed.templateType).toBe('eoi'); + }); +});