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:
@@ -24,6 +24,7 @@ import {
|
|||||||
} from '@/lib/services/documenso-client';
|
} from '@/lib/services/documenso-client';
|
||||||
import { buildDocumensoPayload } from '@/lib/services/documenso-payload';
|
import { buildDocumensoPayload } from '@/lib/services/documenso-payload';
|
||||||
import { generateEoiPdfFromTemplate } from '@/lib/pdf/fill-eoi-form';
|
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 { buildEoiContext } from '@/lib/services/eoi-context';
|
||||||
import { sendEmail } from '@/lib/email';
|
import { sendEmail } from '@/lib/email';
|
||||||
import type {
|
import type {
|
||||||
@@ -45,75 +46,7 @@ interface AuditMeta {
|
|||||||
|
|
||||||
// ─── Merge Field Definitions ──────────────────────────────────────────────────
|
// ─── Merge Field Definitions ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const MERGE_FIELDS: Record<string, Array<{ token: string; label: string; required: boolean }>> = {
|
export function getMergeFields(): 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 },
|
|
||||||
// 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 {
|
|
||||||
return MERGE_FIELDS;
|
return MERGE_FIELDS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
80
src/lib/templates/merge-fields.ts
Normal file
80
src/lib/templates/merge-fields.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
export interface MergeField {
|
||||||
|
token: string;
|
||||||
|
label: string;
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MergeFieldCatalog = Record<string, MergeField[]>;
|
||||||
|
|
||||||
|
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<string> = new Set(
|
||||||
|
Object.values(MERGE_FIELDS).flatMap((scope) => scope.map((field) => field.token)),
|
||||||
|
);
|
||||||
@@ -1,11 +1,25 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { baseListQuerySchema } from '@/lib/api/route-helpers';
|
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({
|
export const createTemplateSchema = z.object({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
description: z.string().max(500).optional(),
|
description: z.string().max(500).optional(),
|
||||||
templateType: z.enum([
|
templateType: z.enum([
|
||||||
|
'eoi',
|
||||||
'welcome_letter',
|
'welcome_letter',
|
||||||
'handover_checklist',
|
'handover_checklist',
|
||||||
'acknowledgment',
|
'acknowledgment',
|
||||||
@@ -13,7 +27,7 @@ export const createTemplateSchema = z.object({
|
|||||||
'custom',
|
'custom',
|
||||||
]),
|
]),
|
||||||
bodyHtml: z.string().min(1),
|
bodyHtml: z.string().min(1),
|
||||||
mergeFields: z.array(z.string()).optional().default([]),
|
mergeFields: mergeFieldsSchema,
|
||||||
isActive: z.boolean().default(true),
|
isActive: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
61
tests/unit/validators/document-templates.test.ts
Normal file
61
tests/unit/validators/document-templates.test.ts
Normal file
@@ -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: '<p>x</p>',
|
||||||
|
};
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user