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>
2026-04-26 13:38:02 +02:00
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 < Uint8Array > {
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 < PDFDocument [ 'getForm' ] > , name : string , value : string ) : void {
try {
form . getTextField ( name ) . setText ( value ) ;
} catch {
2026-05-04 22:57:01 +02:00
// Field absent or wrong type - skip silently so a slightly different PDF
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>
2026-04-26 13:38:02 +02:00
// template still produces output. Missing field issues surface in QA, not
// at runtime as a 500.
}
}
function setCheckbox (
form : ReturnType < PDFDocument [ 'getForm' ] > ,
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 < Uint8Array > {
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 ) ) ;
2026-05-04 22:57:01 +02:00
// Yacht + berth (EOI Section 3) are optional - leave the AcroForm fields
2026-05-02 03:11:14 +02:00
// blank when the interest hasn't been linked to either.
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 ? ? '' ) ;
feat(eoi): multi-berth EOI generation + berth-range formatter
Plan §4.6 + §1: a render function that compresses every berth marked
is_in_eoi_bundle=true on an interest into a compact range string
("A1-A3, B5-B7"), wired into both EOI generation paths (the Documenso
template-generate call and the in-app pdf-lib AcroForm fill).
- src/lib/templates/berth-range.ts: pure formatBerthRange() with the
full edge-case set from §4.6 - empty, single, run, gap, multiple
prefixes, sort/dedup, multi-letter prefixes, non-canonical
passthrough, long ranges. Sorts by (prefix, number); dedupes; passes
non-canonical inputs through with a logger warning.
- src/lib/templates/merge-fields.ts: new {{eoi.berthRange}} token
added to VALID_MERGE_TOKENS allow-list under a fresh `eoi` scope so
unknown-token validation at template creation time still rejects
typos.
- src/lib/services/eoi-context.ts: EoiContext gains eoiBerthRange.
Resolved by joining interest_berths (is_in_eoi_bundle=true) →
berths and feeding the mooring numbers through formatBerthRange.
- src/lib/services/documenso-payload.ts: formValues now includes
"Berth Range" alongside the legacy "Berth Number". Multi-berth EOIs
surface here; single-berth EOIs duplicate the primary.
- src/lib/pdf/fill-eoi-form.ts: in-app AcroForm fill mirrors the
Documenso payload by populating "Berth Range". Falls back silently
when older PDFs don't have the field (setText is no-op-on-missing).
15 unit tests on the formatter; existing EoiContext + Documenso
payload tests updated to assert the new field. 1022 -> 1037 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:03:29 +02:00
// Multi-berth EOI: compact range string from the interest's EOI bundle.
// Falls back silently when the AcroForm field doesn't exist (older
// template revisions without the field still fill cleanly).
setText ( form , 'Berth Range' , context . eoiBerthRange ) ;
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>
2026-04-26 13:38:02 +02:00
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 < Uint8Array > {
const bytes = await loadEoiTemplatePdf ( ) ;
return fillEoiFormFields ( bytes , context ) ;
}