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:
@@ -42,6 +42,14 @@ vi.mock('@/lib/pdf/generate', () => ({
|
||||
generatePdf: vi.fn().mockResolvedValue(new Uint8Array(Buffer.from('fake-pdf'))),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/pdf/fill-eoi-form', () => ({
|
||||
generateEoiPdfFromTemplate: vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Uint8Array(Buffer.from('fake-eoi-pdf'))),
|
||||
loadEoiTemplatePdf: vi.fn(),
|
||||
fillEoiFormFields: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/audit', () => ({
|
||||
createAuditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -223,6 +231,74 @@ describe('generateAndSign — inapp pathway', () => {
|
||||
),
|
||||
).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('uses the EOI source-PDF path (not pdfme HTML) for templateType=eoi', async () => {
|
||||
const fillModule = await import('@/lib/pdf/fill-eoi-form');
|
||||
const pdfModule = await import('@/lib/pdf/generate');
|
||||
const client = await import('@/lib/services/documenso-client');
|
||||
vi.mocked(client.createDocument).mockResolvedValue({
|
||||
id: 'doc-eoi-pdf',
|
||||
status: 'PENDING',
|
||||
recipients: [],
|
||||
});
|
||||
vi.mocked(client.sendDocument).mockResolvedValue({
|
||||
id: 'doc-eoi-pdf',
|
||||
status: 'PENDING',
|
||||
recipients: [],
|
||||
});
|
||||
|
||||
await generateAndSign(
|
||||
setup.inAppTemplateId,
|
||||
setup.portId,
|
||||
{ clientId: setup.clientId, interestId: setup.interestId },
|
||||
[{ name: 'C', email: 'c@x.com', role: 'signer', signingOrder: 1 }],
|
||||
'inapp',
|
||||
{ ...meta, portId: setup.portId },
|
||||
);
|
||||
|
||||
expect(fillModule.generateEoiPdfFromTemplate).toHaveBeenCalled();
|
||||
expect(pdfModule.generatePdf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to HTML→pdfme for non-EOI template types', async () => {
|
||||
// Create a non-EOI template inline.
|
||||
const [other] = await db
|
||||
.insert(documentTemplates)
|
||||
.values({
|
||||
portId: setup.portId,
|
||||
name: 'Welcome Letter',
|
||||
templateType: 'welcome_letter',
|
||||
bodyHtml: '<p>Welcome {{client.fullName}}</p>',
|
||||
createdBy: 'test',
|
||||
})
|
||||
.returning();
|
||||
|
||||
const fillModule = await import('@/lib/pdf/fill-eoi-form');
|
||||
const pdfModule = await import('@/lib/pdf/generate');
|
||||
const client = await import('@/lib/services/documenso-client');
|
||||
vi.mocked(client.createDocument).mockResolvedValue({
|
||||
id: 'doc-welcome',
|
||||
status: 'PENDING',
|
||||
recipients: [],
|
||||
});
|
||||
vi.mocked(client.sendDocument).mockResolvedValue({
|
||||
id: 'doc-welcome',
|
||||
status: 'PENDING',
|
||||
recipients: [],
|
||||
});
|
||||
|
||||
await generateAndSign(
|
||||
other!.id,
|
||||
setup.portId,
|
||||
{ clientId: setup.clientId },
|
||||
[{ name: 'C', email: 'c@x.com', role: 'signer', signingOrder: 1 }],
|
||||
'inapp',
|
||||
{ ...meta, portId: setup.portId },
|
||||
);
|
||||
|
||||
expect(pdfModule.generatePdf).toHaveBeenCalled();
|
||||
expect(fillModule.generateEoiPdfFromTemplate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Pathway: documenso-template ──────────────────────────────────────────────
|
||||
|
||||
176
tests/unit/pdf/fill-eoi-form.test.ts
Normal file
176
tests/unit/pdf/fill-eoi-form.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user