diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..6364be7 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,48 @@ +# `assets/` + +Server-side runtime assets bundled by Next.js (via `outputFileTracingIncludes` +in `next.config.ts`). These files are read with `fs.readFile` from +`process.cwd()` at runtime, so they are NOT served as public URLs — use +`public/` for that. + +## `eoi-template.pdf` + +The source PDF used by the in-app EOI generation pathway +(`src/lib/pdf/fill-eoi-form.ts`). It must be the **same** PDF that the +Documenso EOI template uploads, so both pathways produce equivalent +documents. + +The PDF must contain AcroForm fields with these exact names (mirroring the +Documenso template's `formValues` keys — see +`docs/eoi-documenso-field-mapping.md`): + +| Field name | Type | Filled with | +| -------------- | -------- | ----------------------------------------------------- | +| `Name` | Text | `EoiContext.client.fullName` | +| `Email` | Text | `EoiContext.client.primaryEmail` | +| `Address` | Text | `street, city, country` | +| `Yacht Name` | Text | `EoiContext.yacht.name` | +| `Length` | Text | `EoiContext.yacht.lengthFt` | +| `Width` | Text | `EoiContext.yacht.widthFt` | +| `Draft` | Text | `EoiContext.yacht.draftFt` | +| `Berth Number` | Text | `EoiContext.berth.mooringNumber` | +| `Lease_10` | Checkbox | always `false` (legacy default — Purchase, not Lease) | +| `Purchase` | Checkbox | always `true` | + +Form fields stay interactive after generation (not flattened), so the +recipient can still tweak values before signing if the in-app pathway is +followed by a Documenso send. + +### Override path + +In dev/test, set `EOI_TEMPLATE_PDF_PATH=/abs/path/to/your/template.pdf` to +point at a different file (e.g. a fixture). + +### How to extract this PDF + +The legacy flow uploads this PDF to Documenso template ID 8. To get the +exact bytes: + +1. In Documenso, open the EOI template. +2. Download the source PDF. +3. Drop it here as `eoi-template.pdf`. diff --git a/next.config.ts b/next.config.ts index 54d7f3f..2104451 100644 --- a/next.config.ts +++ b/next.config.ts @@ -18,6 +18,12 @@ const nextConfig: NextConfig = { experimental: { typedRoutes: true, }, + outputFileTracingIncludes: { + // Bundle the EOI source PDF so the in-app EOI pathway can read it at + // runtime in the standalone build. Reading via fs.readFile from + // process.cwd() requires the file to be traced explicitly. + '/api/v1/document-templates/**': ['./assets/eoi-template.pdf'], + }, }; export default nextConfig; diff --git a/package.json b/package.json index 3c1a2c7..57749c7 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "next-themes": "^0.4.0", "nodemailer": "^6.9.0", "openai": "^6.27.0", + "pdf-lib": "^1.17.1", "pino": "^9.5.0", "pino-pretty": "^13.0.0", "postgres": "^3.4.0", @@ -91,9 +92,9 @@ "@types/react-dom": "^19.0.0", "@vitest/coverage-v8": "^4.1.0", "autoprefixer": "^10.4.27", - "esbuild": "^0.25.0", "dotenv": "^17.3.1", "drizzle-kit": "^0.30.0", + "esbuild": "^0.25.0", "eslint": "^9.0.0", "eslint-config-next": "15.1.0", "eslint-config-prettier": "^9.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02b5fcb..1b248cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,6 +152,9 @@ importers: openai: specifier: ^6.27.0 version: 6.27.0(ws@8.18.3)(zod@3.25.76) + pdf-lib: + specifier: ^1.17.1 + version: 1.17.1 pino: specifier: ^9.5.0 version: 9.14.0 @@ -4417,6 +4420,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pdf-lib@1.17.1: + resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} + peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} @@ -5375,6 +5381,9 @@ packages: tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -9668,6 +9677,13 @@ snapshots: pathe@2.0.3: {} + pdf-lib@1.17.1: + dependencies: + '@pdf-lib/standard-fonts': 1.0.0 + '@pdf-lib/upng': 1.0.1 + pako: 1.0.11 + tslib: 1.14.1 + peberminta@0.9.0: {} performance-now@2.1.0: {} @@ -10843,6 +10859,8 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tslib@1.14.1: {} + tslib@2.8.1: {} tsx@4.21.0: diff --git a/src/lib/pdf/fill-eoi-form.ts b/src/lib/pdf/fill-eoi-form.ts new file mode 100644 index 0000000..20618b9 --- /dev/null +++ b/src/lib/pdf/fill-eoi-form.ts @@ -0,0 +1,101 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { PDFDocument } from 'pdf-lib'; + +import type { EoiContext } from '@/lib/services/eoi-context'; + +/** + * Source PDF for the in-app EOI pathway. Must contain AcroForm fields whose + * names match the Documenso template's `formValues` keys exactly: + * + * Text: Name, Email, Address, Yacht Name, Length, Width, Draft, + * Berth Number + * Checkbox: Lease_10, Purchase + * + * See assets/eoi-template/README.md for full details and the field mapping + * doc at docs/eoi-documenso-field-mapping.md for the canonical list. + */ +const DEFAULT_EOI_TEMPLATE_PATH = path.join(process.cwd(), 'assets', 'eoi-template.pdf'); + +function eoiTemplatePath(): string { + return process.env.EOI_TEMPLATE_PDF_PATH ?? DEFAULT_EOI_TEMPLATE_PATH; +} + +export async function loadEoiTemplatePdf(): Promise { + const filePath = eoiTemplatePath(); + try { + return await fs.readFile(filePath); + } catch (err) { + throw new Error( + `EOI source PDF not found at ${filePath}. Drop the same PDF used by the Documenso template (with AcroForm fields: Name, Email, Address, Yacht Name, Length, Width, Draft, Berth Number, Lease_10, Purchase) at this path, or override via EOI_TEMPLATE_PDF_PATH. Original error: ${(err as Error).message}`, + ); + } +} + +function formatAddress(address: EoiContext['client']['address']): string { + if (!address) return ''; + return [address.street, address.city, address.country].filter(Boolean).join(', '); +} + +function setText(form: ReturnType, name: string, value: string): void { + try { + form.getTextField(name).setText(value); + } catch { + // Field absent or wrong type — skip silently so a slightly different PDF + // template still produces output. Missing field issues surface in QA, not + // at runtime as a 500. + } +} + +function setCheckbox( + form: ReturnType, + name: string, + checked: boolean, +): void { + try { + const cb = form.getCheckBox(name); + if (checked) cb.check(); + else cb.uncheck(); + } catch { + // See comment in setText. + } +} + +/** + * Fills the AcroForm fields of the EOI source PDF with values drawn from + * EoiContext. Field names mirror the Documenso template `formValues` keys so + * a single source PDF can serve both pathways. + * + * The form is left interactive (not flattened) so a recipient can still tweak + * fields if needed before signing. + */ +export async function fillEoiFormFields( + pdfBytes: Uint8Array, + context: EoiContext, +): Promise { + const doc = await PDFDocument.load(pdfBytes); + const form = doc.getForm(); + + setText(form, 'Name', context.client.fullName); + setText(form, 'Email', context.client.primaryEmail ?? ''); + setText(form, 'Address', formatAddress(context.client.address)); + setText(form, 'Yacht Name', context.yacht.name); + setText(form, 'Length', context.yacht.lengthFt ?? ''); + setText(form, 'Width', context.yacht.widthFt ?? ''); + setText(form, 'Draft', context.yacht.draftFt ?? ''); + setText(form, 'Berth Number', context.berth.mooringNumber); + + setCheckbox(form, 'Purchase', true); + setCheckbox(form, 'Lease_10', false); + + return doc.save(); +} + +/** + * Convenience: loads the source PDF from disk and returns the filled bytes. + */ +export async function generateEoiPdfFromTemplate(context: EoiContext): Promise { + const bytes = await loadEoiTemplatePdf(); + return fillEoiFormFields(bytes, context); +} diff --git a/src/lib/services/document-templates.ts b/src/lib/services/document-templates.ts index b3ad273..d9ab274 100644 --- a/src/lib/services/document-templates.ts +++ b/src/lib/services/document-templates.ts @@ -23,6 +23,7 @@ import { generateDocumentFromTemplate as documensoGenerateFromTemplate, } from '@/lib/services/documenso-client'; import { buildDocumensoPayload } from '@/lib/services/documenso-payload'; +import { generateEoiPdfFromTemplate } from '@/lib/pdf/fill-eoi-form'; import { buildEoiContext } from '@/lib/services/eoi-context'; import { sendEmail } from '@/lib/email'; import type { @@ -687,12 +688,107 @@ export async function generateAndSend( return { document, file }; } +// ─── EOI from source PDF (in-app pathway, EOI templates only) ───────────────── + +/** + * BR-142: For EOI templates, the in-app pathway uses the same source PDF as + * the Documenso template — filled via pdf-lib with values from EoiContext. + * Same field names, same legal document; the only difference is who renders + * it. The form is left interactive so a recipient can adjust before signing. + */ +async function generateEoiFromSourcePdf( + template: typeof documentTemplates.$inferSelect, + portId: string, + context: GenerateInput, + meta: AuditMeta, +): Promise<{ document: DbDocument; file: DbFile }> { + if (!context.interestId) { + throw new ValidationError('interestId is required for EOI template generation'); + } + + const eoiContext = await buildEoiContext(context.interestId, portId); + const pdfBytes = await generateEoiPdfFromTemplate(eoiContext); + + const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) }); + + const fileId = crypto.randomUUID(); + const storagePath = buildStoragePath( + port?.slug ?? portId, + 'document-templates', + template.id, + fileId, + 'pdf', + ); + + await minioClient.putObject( + env.MINIO_BUCKET, + storagePath, + Buffer.from(pdfBytes), + pdfBytes.byteLength, + { 'Content-Type': 'application/pdf' }, + ); + + const [fileRecord] = await db + .insert(files) + .values({ + portId, + clientId: context.clientId ?? null, + filename: `${template.name.toLowerCase().replace(/\s+/g, '-')}.pdf`, + originalName: `${template.name}.pdf`, + mimeType: 'application/pdf', + sizeBytes: String(pdfBytes.byteLength), + storagePath, + storageBucket: env.MINIO_BUCKET, + category: 'eoi', + uploadedBy: meta.userId, + }) + .returning(); + + const [documentRecord] = await db + .insert(documents) + .values({ + portId, + clientId: context.clientId ?? null, + interestId: context.interestId, + documentType: template.templateType, + title: template.name, + status: 'draft', + fileId: fileRecord!.id, + isManualUpload: false, + createdBy: meta.userId, + }) + .returning(); + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'create', + entityType: 'document', + entityId: documentRecord!.id, + newValue: { + templateId: template.id, + templateName: template.name, + source: 'eoi-source-pdf', + clientId: context.clientId, + interestId: context.interestId, + }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + emitToRoom(`port:${portId}`, 'document:created', { documentId: documentRecord!.id }); + + return { document: documentRecord!, file: fileRecord! }; +} + // ─── Generate and Sign ──────────────────────────────────────────────────────── /** * BR-142: EOI / NDA signing. Dual pathway: - * - `inapp`: resolve the in-app template → pdfme → upload PDF to MinIO → - * upload to Documenso and send for signing. + * - `inapp`: produce the PDF locally (EOI templates fill the same source + * PDF as Documenso via pdf-lib; other template types fall back to the + * HTML→pdfme path), upload to MinIO, then upload to Documenso and send + * for signing. * - `documenso-template`: skip our PDF generation entirely; call Documenso's * template-generate endpoint with the shared EOI context. Documenso owns * the PDF. We still record a `documents` row for tracking. @@ -724,14 +820,19 @@ async function generateAndSignViaInApp( if (!signers || signers.length === 0) { throw new ValidationError('signers are required for inapp pathway'); } - const { document: documentRecord, file } = (await generateFromTemplate( - templateId, - portId, - context, - meta, - )) as { document: DbDocument; file: DbFile }; const template = await getTemplateById(templateId, portId); + // EOI templates fill the same source PDF as the Documenso template (so both + // pathways yield the same document). Other template types stay on the + // HTML→pdfme rendering path. + const { document: documentRecord, file } = + template.templateType === 'eoi' + ? await generateEoiFromSourcePdf(template, portId, context, meta) + : ((await generateFromTemplate(templateId, portId, context, meta)) as { + document: DbDocument; + file: DbFile; + }); + // Fetch PDF bytes from MinIO to send to Documenso const pdfStream = await minioClient.getObject(env.MINIO_BUCKET, file.storagePath); const chunks: Buffer[] = []; diff --git a/tests/integration/document-templates-generate-and-sign.test.ts b/tests/integration/document-templates-generate-and-sign.test.ts index 75367bb..64f03f7 100644 --- a/tests/integration/document-templates-generate-and-sign.test.ts +++ b/tests/integration/document-templates-generate-and-sign.test.ts @@ -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: '

Welcome {{client.fullName}}

', + 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 ────────────────────────────────────────────── diff --git a/tests/unit/pdf/fill-eoi-form.test.ts b/tests/unit/pdf/fill-eoi-form.test.ts new file mode 100644 index 0000000..49adb24 --- /dev/null +++ b/tests/unit/pdf/fill-eoi-form.test.ts @@ -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 { + 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 { + 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/); + }); +});