Files
pn-new-crm/src/lib/pdf/fill-eoi-form.ts
Matt 221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
2026-05-23 00:52:59 +02:00

187 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Uint8Array> {
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<PDFDocument['getForm']>, 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<PDFDocument['getForm']>,
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<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));
// 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<Uint8Array> {
const bytes = await loadEoiTemplatePdf();
return fillEoiFormFields(bytes, context, options);
}