Files
pn-new-crm/src/lib/pdf/fill-eoi-form.ts
Matt 4b5f85cb7d fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish
Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).

CRITICAL (3):
 - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
   no longer silently drop interest links
 - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
 - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
   callers must go through /stage with the override-guard chain

HIGH (14/15):
 - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
   interests/documents/reservations/reminders/invoices (migration 0070)
 - H-02 login page reads ?redirect= param with same-origin guard
 - H-03 CRM invite token moves to URL fragment so it never lands in
   nginx access logs / Referer headers
 - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
 - H-05 toggleAccount writes an audit row
 - H-06 upsertSetting masks any value whose key ends with _encrypted
 - H-07 archiveClient cascade fires per-interest audit rows
 - H-08 createSalesTransporter applies SMTP_TIMEOUTS
 - H-09 AppShell stable children — viewport flip across breakpoint no
   longer destroys in-progress form drafts
 - H-10 portal documents page swaps Unicode glyph status icons for
   Lucide CheckCircle2/XCircle/Circle + aria-labels
 - H-12 list components swap alert(...) for toast.warning(...)
 - H-13 5 icon-only buttons gain aria-label
 - H-14 parseBody treats empty bodies as {}
 - H-15 admin layout renders a 403 panel instead of silent bounce
 - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet

MEDIUM (28+):
 - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
   WHEREs across custom-fields, notes (all 6 entity types x update +
   delete), client-contacts, yacht ownerClient lookup, webhook reads
 - M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
 - M-EM01 portal-auth emails thread through portId
 - M-EM02 sendEmail accepts cc/bcc params
 - M-EM04 notification_digest catalog key
 - M-IN01 portal presigned download URLs use 4h TTL
 - M-IN02 OpenAI client lazy-instantiated
 - M-IN04 stale pdfme refs updated to pdf-lib AcroForm
 - M-IN05 umami.testConnection returns tagged union
 - M-L01 reservations tenure_type unified with berths
 - M-L02 report-generators canonicalize stage values
 - M-AU01 audit log placeholder copy fixed
 - M-AU04 outcome_set / outcome_cleared distinct audit verbs
 - M-NEW-2 activity feed entity name+type separator
 - M-R01 portal allowlist narrowed + portal_session backstop in proxy
 - M-SC02 companies archived partial index
 - M-SC04 audit_logs.searchText documented as DB-managed
 - M-S01 storage_s3_access_key_encrypted admin field
 - M-U01 audit log empty state uses <EmptyState>
 - M-U09 invoice delete dialog -> <AlertDialog>
 - M-U10 toast.success on ClientForm + InterestForm create/edit
 - M-U11 settings-form-card logo preview alt text
 - M-U14 mobile topbar title on clients/yachts/interests/berths
 - M-U15 Invoices in mobile More-sheet

LOW (6/8):
 - L-AU01 severity defaults for security-relevant verbs
 - L-AU02 +13 missing actions in admin audit filter
 - L-AU03 +7 missing entity types in admin audit filter
 - L-AU04 dead listAuditLogs stubbed
 - L-D02 CLAUDE.md Owner-wins chain tightened

Bonus — Document detail polish (#67 partial, 3/6 deliverables):
 - state-aware action button per signer
 - watcher Add UI with display-name resolution
 - cleanSignerName cleanup

Prior session work bundled in:
 - Documenso v2 webhook + envelope-ID normalization + sequential signing
 - SigningProgress UI redesign (avatars, per-signer state, timestamps)
 - env->admin settings registry + RegistryDrivenForm + encrypted creds
 - Embedded-signing card + Test connection + setup help
 - Dev-mode EMAIL_REDIRECT_TO banner
 - Pipeline rules admin page
 - Sales email config card
 - Audit log details Sheet
 - EOI tab: Finalising badge, absolute timestamps, sequential indicator
 - Notes pipeline_stage_at_creation (migration 0069)
 - Documenso numeric ID dual-key webhook (migration 0068)
 - Dimensions criterion copy (migration 0067)

Tests: 1374/1374 vitest pass. tsc clean. lint clean.

See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +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);
}