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>
187 lines
7.5 KiB
TypeScript
187 lines
7.5 KiB
TypeScript
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);
|
||
}
|