feat(eoi): in-app pathway fills the same source PDF as Documenso

When the in-app pathway is used for EOI templates, we now load the same
source PDF that the Documenso template uploads and fill its AcroForm
fields with values from EoiContext via pdf-lib. Field names mirror the
Documenso template's formValues keys exactly (Name, Email, Address,
Yacht Name, Length, Width, Draft, Berth Number + Lease_10 / Purchase
checkboxes), so both pathways produce equivalent legal documents — only
the renderer differs.

The form is left interactive (not flattened) so a recipient can still
adjust values before signing. Non-EOI templates (welcome letters,
acknowledgments, etc.) keep using the existing HTML→pdfme path.

Adds:
- pdf-lib direct dep
- src/lib/pdf/fill-eoi-form.ts — load + fill helpers, EOI_TEMPLATE_PDF_PATH
  env override
- assets/ + README documenting the expected source PDF
- next.config outputFileTracingIncludes so the asset is bundled in the
  standalone build

Tests: 8 new (4 fill-form unit + 2 source-PDF route + 2 fallback);
645/645 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-26 13:38:02 +02:00
parent f8255cedb8
commit 2ff24a7132
8 changed files with 536 additions and 9 deletions

View File

@@ -0,0 +1,176 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PDFDocument } from 'pdf-lib';
import { fillEoiFormFields, loadEoiTemplatePdf } from '@/lib/pdf/fill-eoi-form';
import type { EoiContext } from '@/lib/services/eoi-context';
// ─── Test PDF builder (synthetic source PDF with the same field names) ───────
async function buildSyntheticEoiPdf(): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const page = doc.addPage([600, 800]);
const form = doc.getForm();
const textFieldNames = [
'Name',
'Email',
'Address',
'Yacht Name',
'Length',
'Width',
'Draft',
'Berth Number',
];
textFieldNames.forEach((name, i) => {
const f = form.createTextField(name);
f.addToPage(page, { x: 50, y: 700 - i * 40, width: 300, height: 24 });
});
for (const name of ['Lease_10', 'Purchase']) {
const cb = form.createCheckBox(name);
cb.addToPage(page, { x: 400, y: 700 - (name === 'Purchase' ? 0 : 40), width: 12, height: 12 });
}
return doc.save();
}
function makeContext(overrides: Partial<EoiContext> = {}): EoiContext {
return {
client: {
fullName: 'Alice Smith',
nationality: 'US',
primaryEmail: 'alice@example.com',
primaryPhone: '+1-555-0100',
address: { street: '123 Main St', city: 'Austin', country: 'USA' },
},
yacht: {
name: 'Sea Breeze',
lengthFt: '45',
widthFt: '14',
draftFt: '6',
lengthM: null,
widthM: null,
draftM: null,
hullNumber: 'HN-1',
flag: 'US',
yearBuilt: 2020,
},
company: null,
owner: { type: 'client', name: 'Alice Smith' },
berth: {
mooringNumber: 'A-12',
area: 'North',
lengthFt: '50',
price: '1000',
priceCurrency: 'USD',
tenureType: 'permanent',
},
interest: { stage: 'open', leadCategory: null, dateFirstContact: null, notes: null },
port: { name: 'Port Nimara', defaultCurrency: 'USD' },
date: { today: '2026-04-26', year: '2026' },
...overrides,
};
}
// ─── fillEoiFormFields ────────────────────────────────────────────────────────
describe('fillEoiFormFields', () => {
it('populates every text field and checkbox using EoiContext', async () => {
const sourcePdf = await buildSyntheticEoiPdf();
const filled = await fillEoiFormFields(sourcePdf, makeContext());
const out = await PDFDocument.load(filled);
const form = out.getForm();
expect(form.getTextField('Name').getText()).toBe('Alice Smith');
expect(form.getTextField('Email').getText()).toBe('alice@example.com');
expect(form.getTextField('Address').getText()).toBe('123 Main St, Austin, USA');
expect(form.getTextField('Yacht Name').getText()).toBe('Sea Breeze');
expect(form.getTextField('Length').getText()).toBe('45');
expect(form.getTextField('Width').getText()).toBe('14');
expect(form.getTextField('Draft').getText()).toBe('6');
expect(form.getTextField('Berth Number').getText()).toBe('A-12');
expect(form.getCheckBox('Purchase').isChecked()).toBe(true);
expect(form.getCheckBox('Lease_10').isChecked()).toBe(false);
});
it('handles null primary email and null address gracefully', async () => {
const sourcePdf = await buildSyntheticEoiPdf();
const filled = await fillEoiFormFields(
sourcePdf,
makeContext({
client: {
fullName: 'Bob',
nationality: null,
primaryEmail: null,
primaryPhone: null,
address: null,
},
}),
);
const out = await PDFDocument.load(filled);
const form = out.getForm();
expect(form.getTextField('Email').getText()).toBe(undefined);
expect(form.getTextField('Address').getText()).toBe(undefined);
expect(form.getTextField('Name').getText()).toBe('Bob');
});
it('leaves the form interactive (not flattened) so values can be edited', async () => {
const sourcePdf = await buildSyntheticEoiPdf();
const filled = await fillEoiFormFields(sourcePdf, makeContext());
const out = await PDFDocument.load(filled);
// Field still present and reachable as a TextField → not flattened.
expect(() => out.getForm().getTextField('Name')).not.toThrow();
});
it('skips fields silently if the source PDF lacks them', async () => {
// Build a PDF with only a subset of fields and ensure no error.
const doc = await PDFDocument.create();
const page = doc.addPage([600, 800]);
const form = doc.getForm();
const f = form.createTextField('Name');
f.addToPage(page, { x: 50, y: 700, width: 300, height: 24 });
const sparse = await doc.save();
await expect(fillEoiFormFields(sparse, makeContext())).resolves.toBeInstanceOf(Uint8Array);
});
});
// ─── loadEoiTemplatePdf ───────────────────────────────────────────────────────
describe('loadEoiTemplatePdf', () => {
let tmpFile: string;
const originalEnv = process.env.EOI_TEMPLATE_PDF_PATH;
beforeAll(async () => {
const sourcePdf = await buildSyntheticEoiPdf();
tmpFile = path.join(os.tmpdir(), `eoi-template-${Date.now()}.pdf`);
await fs.writeFile(tmpFile, sourcePdf);
});
afterAll(async () => {
if (originalEnv === undefined) delete process.env.EOI_TEMPLATE_PDF_PATH;
else process.env.EOI_TEMPLATE_PDF_PATH = originalEnv;
await fs.unlink(tmpFile).catch(() => undefined);
});
it('reads the PDF from EOI_TEMPLATE_PDF_PATH override', async () => {
process.env.EOI_TEMPLATE_PDF_PATH = tmpFile;
const bytes = await loadEoiTemplatePdf();
expect(bytes.byteLength).toBeGreaterThan(100);
// Round-trip: should re-load as a valid PDF.
await expect(PDFDocument.load(bytes)).resolves.toBeInstanceOf(PDFDocument);
});
it('throws a clear error with instructions when the file is missing', async () => {
process.env.EOI_TEMPLATE_PDF_PATH = '/nope/does-not-exist.pdf';
await expect(loadEoiTemplatePdf()).rejects.toThrow(/EOI source PDF not found/);
});
});