import { createHash } from 'node:crypto'; 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'; import { logger } from '@/lib/logger'; /** * Pinned sha256 of `assets/eoi-template.pdf`. Bump this AND the README * checksum in lockstep when the template is intentionally re-cut. A * mismatch only logs a warning (we don't want a malformed swap to take * the EOI flow offline at boot), but ops monitoring will surface it. */ export const EXPECTED_EOI_SHA256 = 'ba495fd88d99ebe4b7f61acbe397fb2f1cd116e1e1f1b217de93106915c7c44b'; let shaCheckPerformed = false; /** * 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(); let bytes: Buffer; try { bytes = 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}`, ); } // SHA-pin check (M-6): warn once per process when the template's bytes // don't match the committed hash. Skipped entirely when the override // env var is in play (fixture / dev workflows are expected to use // arbitrary PDFs). Only checks the default path so test setups stay // unconstrained. if (!shaCheckPerformed && !process.env.EOI_TEMPLATE_PDF_PATH) { shaCheckPerformed = true; const actual = createHash('sha256').update(bytes).digest('hex'); if (actual !== EXPECTED_EOI_SHA256) { logger.warn( { expected: EXPECTED_EOI_SHA256, actual }, 'EOI source PDF sha256 mismatch - template was modified without an EXPECTED_EOI_SHA256 bump. Update assets/README.md + EXPECTED_EOI_SHA256 in lockstep if this was intentional.', ); } } return bytes; } function formatAddress(address: EoiContext['client']['address']): string { if (!address) return ''; // EOI's Address field renders as: "street, city, REGION, postal, COUNTRY" // with REGION as the ISO-3166-2 suffix (e.g. NY) and COUNTRY as the // alpha-2 code (e.g. US) so the line fits in the PDF box. The separate // `Nationality` PDF field has been retired - the resident's country code // here is the canonical replacement. return [address.street, address.city, address.subdivision, address.postalCode, address.countryIso] .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 so a slightly different PDF // template still produces output, but surface a warning so a re-cut // template with drifted field names is observable in ops logs // instead of shipping silently with empty values. Only warn when // there's a real value to render; an empty value would be a no-op // even if the field existed. if (value && value.trim().length > 0) { logger.warn( { field: name }, `EOI in-app PDF template is missing AcroForm field "${name}" - value was dropped. Update the source template.`, ); } } } function setCheckbox( form: ReturnType, name: string, checked: boolean, ): void { try { const cb = form.getCheckBox(name); if (checked) cb.check(); else cb.uncheck(); } catch { logger.warn( { field: name, checked }, `EOI in-app PDF template is missing checkbox AcroForm field "${name}" - checkbox state was dropped. Update the source template.`, ); } } /** * 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 **flattened** after filling so the recipient can't edit * pre-filled values (yacht dimensions, address, berth number) after the * fact. Documenso pathway flattens server-side; this brings the in-app * pathway to parity. Set metadata so the artifact carries Title/Author/ * Lang for downstream readers and a11y tooling. */ export async function fillEoiFormFields( pdfBytes: Uint8Array, context: EoiContext, options?: { dimensionUnit?: 'ft' | 'm' }, ): 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)); // Yacht + berth (EOI Section 3) are optional - leave the AcroForm fields // blank when the interest hasn't been linked to either. Dimension side // (ft|m) honours the drawer's toggle; legacy callers omit and get ft. setText(form, 'Yacht Name', context.yacht?.name ?? ''); const dimUnit: 'ft' | 'm' = options?.dimensionUnit ?? context.yacht?.lengthUnit ?? 'ft'; const yLen = dimUnit === 'ft' ? context.yacht?.lengthFt : context.yacht?.lengthM; const yWid = dimUnit === 'ft' ? context.yacht?.widthFt : context.yacht?.widthM; const yDra = dimUnit === 'ft' ? context.yacht?.draftFt : context.yacht?.draftM; // Append the unit suffix so the rendered EOI reads "45 ft" / "13.7 m" // rather than the bare number - matches the Documenso pathway. const withDimUnit = (v: string | null | undefined): string => v && String(v).trim() ? `${String(v).trim()} ${dimUnit}` : ''; setText(form, 'Length', withDimUnit(yLen)); setText(form, 'Width', withDimUnit(yWid)); setText(form, 'Draft', withDimUnit(yDra)); // Berth Number = compact range for multi-berth, primary mooring for // single-berth (formatBerthRange(['A1']) === 'A1' so single-berth is // byte-identical to the legacy primary-only path). The dedicated // `Berth Range` AcroForm field was retired 2026-05-14 - the source // PDF only carries `Berth Number`. setText(form, 'Berth Number', context.eoiBerthRange || (context.berth?.mooringNumber ?? '')); setCheckbox(form, 'Purchase', true); setCheckbox(form, 'Lease_10', false); // PDF metadata so the artifact carries Title/Author/Lang downstream. doc.setTitle(`EOI – ${context.client.fullName}`); doc.setAuthor(context.port.name); doc.setSubject('Expression of Interest'); doc.setLanguage('en-GB'); doc.setProducer('Port CRM'); doc.setCreator('Port CRM'); // Flatten so the signer can't edit pre-filled values after the fact. form.flatten(); return doc.save(); } /** * Convenience: loads the source PDF from disk and returns the filled bytes. */ export async function generateEoiPdfFromTemplate( context: EoiContext, options?: { dimensionUnit?: 'ft' | 'm' }, ): Promise { const bytes = await loadEoiTemplatePdf(); return fillEoiFormFields(bytes, context, options); }