Files
pn-new-crm/src/lib/services/documenso-template-sync.service.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

308 lines
12 KiB
TypeScript

import {
downloadEnvelopeItemPdf,
getTemplate,
type DocumensoTemplate,
} from '@/lib/services/documenso-client';
import { getPortDocumensoConfig } from '@/lib/services/port-config';
import { writeSetting } from '@/lib/settings/resolver';
import { getSetting as getSettingLegacy } from '@/lib/services/settings.service';
import { inspectPdfAcroForm, type AcroFormField } from '@/lib/pdf/inspect-acroform';
import { logger } from '@/lib/logger';
import type { AuditMeta } from '@/lib/audit';
export interface TemplateFieldMap {
[label: string]: number;
}
/**
* Field labels the CRM expects to fill on every EOI. Kept in sync with the
* keys of `formValues` inside `buildDocumensoPayload`. When a template's
* field labels diverge from this set, the unmatched entries are surfaced in
* the sync result so the admin can rename in the Documenso template editor.
*/
const CRM_EXPECTED_EOI_FIELD_LABELS = [
'Name',
'Email',
'Address',
'Yacht Name',
'Length',
'Width',
'Draft',
'Berth Number',
'Lease_10',
'Purchase',
] as const;
export interface TemplateSyncResult {
/** ISO timestamp of when this sync ran. Surfaced as "Last synced X ago"
* so the admin can tell when the cached report is from. */
syncedAt: string;
templateId: number;
title: string;
/** Pre-filled into the matching documenso_*_recipient_id settings. */
recipients: Array<{
role: string;
signingOrder: number;
id: number;
name?: string;
email?: string;
/** Which CRM setting key this row was written to (null = no match). */
mappedSettingKey: string | null;
}>;
/** Stored at `documenso_eoi_field_map` for v2 prefillFields-by-ID. */
fieldMap: TemplateFieldMap;
fieldCount: number;
/**
* Template fields the CRM knows how to fill at send time. The admin doesn't
* need to do anything for these — they'll flow through prefillFields on
* the next EOI send.
*/
matchedFields: Array<{ label: string; fieldId: number }>;
/**
* Template fields discovered on Documenso side but with labels the CRM
* doesn't recognize. These will NOT be filled. The admin should rename
* them in the Documenso template editor to match a CRM expected label.
*/
unmatchedTemplateFields: Array<{ label: string; fieldId: number }>;
/**
* CRM-expected fields that don't exist on the template. The CRM will skip
* them at send time. May be benign (e.g. you removed a field from the
* template intentionally) or a typo (rename a template field to match).
*/
missingFromTemplate: string[];
/**
* The template's stored meta — what every envelope generated from this
* template inherits at creation time. signingOrder is bound to the
* template (v2's /template/use does NOT accept an override), so this
* is the authoritative value the admin sees.
*/
templateMeta: {
signingOrder: 'PARALLEL' | 'SEQUENTIAL' | null;
distributionMethod: 'EMAIL' | 'NONE' | null;
redirectUrl: string | null;
};
/**
* v2 only. Each entry is one underlying PDF file behind the template,
* with its AcroForm field roster and a diff against the CRM's expected
* EOI field labels. The admin uses this to verify their fillable PDF
* actually has the named fields the CRM will fill via `formValues`.
*
* Empty array on v1 or when the PDF download / parse fails — diagnostic
* messages go to pino so the admin still sees the rest of the sync result.
*/
acroForm: Array<{
envelopeItemId: string;
fields: AcroFormField[];
/** Names from the PDF that match a CRM-expected EOI label. */
matchedFieldNames: string[];
/**
* CRM-expected labels missing from the PDF's AcroForm. These won't
* be filled at send time on the AcroForm path. (Independent of the
* Documenso-overlay-field diff above.)
*/
missingFieldNames: string[];
/**
* Names in the PDF AcroForm the CRM has no token for. Usually
* harmless leftover fields from the original PDF authoring tool
* (signature blocks, etc.).
*/
extraFieldNames: string[];
/** Set when download or parse failed — string surfaces in the UI. */
error?: string;
}>;
}
/**
* Map a template recipient to its CRM **recipient-id** setting key by role +
* signing order. These are the Documenso-internal numeric IDs that bind a
* /template/use call's recipients block to the template's slots.
*
* Distinct from `documenso_developer_user_id` / `documenso_approver_user_id`,
* which are CRM user UUIDs for in-CRM notification routing (RBAC binding).
*
* Convention (matches buildDocumensoPayload's signing order):
* signingOrder 1, role=SIGNER → client recipient slot
* signingOrder 2, role=SIGNER → developer recipient slot
* signingOrder 3, role=APPROVER → approver recipient slot
*
* Returns null if the role/order combination doesn't match any known slot —
* useful future-proofing for templates that add CC / VIEWER recipients.
*/
function mapRecipientToSettingKey(role: string, signingOrder: number): string | null {
const r = role.toUpperCase();
if (r === 'SIGNER' && signingOrder === 1) return 'documenso_client_recipient_id';
if (r === 'SIGNER' && signingOrder === 2) return 'documenso_developer_recipient_id';
if (r === 'APPROVER' && signingOrder === 3) return 'documenso_approval_recipient_id';
return null;
}
/**
* Fetch the Documenso template, write the discovered recipient IDs into the
* matching CRM settings, and cache the field name→ID map for v2 prefillFields.
*
* Throws when the template fetch fails (bad credentials, wrong template ID,
* network) — caller surfaces the error to the admin via the form's mutation
* onError + toastError.
*/
export async function syncDocumensoTemplate(
templateId: number,
portId: string,
meta: AuditMeta,
): Promise<TemplateSyncResult> {
const template: DocumensoTemplate = await getTemplate(templateId, portId);
// Build the recipient-slot map and persist to system_settings.
const recipientRows: TemplateSyncResult['recipients'] = [];
for (const rec of template.recipients) {
const settingKey = mapRecipientToSettingKey(rec.role, rec.signingOrder);
recipientRows.push({
role: rec.role,
signingOrder: rec.signingOrder,
id: rec.id,
name: rec.name,
email: rec.email,
mappedSettingKey: settingKey,
});
if (settingKey) {
await writeSetting(settingKey, rec.id, portId, meta);
}
}
// Build the field-label → field-id map. Drives v2's prefillFields. The
// field-map is itself a registry-unaware setting (the registry only knows
// about scalar credentials/URLs, not arbitrary JSON blobs), so write
// through the legacy upsertSetting path to avoid registry validation.
const fieldMap: TemplateFieldMap = {};
for (const f of template.fields) {
if (f.label) fieldMap[f.label] = f.id;
}
await persistFieldMap(portId, fieldMap, meta);
// Persist the canonical template ID so a subsequent send doesn't depend on
// the admin form remembering it after the sync click.
await writeSetting('documenso_eoi_template_id', templateId, portId, meta);
// Diff the discovered field labels against what the CRM expects to fill.
// Drives the matched / unmatched / missing lists the admin sees post-sync.
const expected = new Set<string>(CRM_EXPECTED_EOI_FIELD_LABELS);
const discovered = new Set<string>(Object.keys(fieldMap));
const matchedFields: TemplateSyncResult['matchedFields'] = [];
const unmatchedTemplateFields: TemplateSyncResult['unmatchedTemplateFields'] = [];
for (const [label, fieldId] of Object.entries(fieldMap)) {
if (expected.has(label)) matchedFields.push({ label, fieldId });
else unmatchedTemplateFields.push({ label, fieldId });
}
const missingFromTemplate: string[] = CRM_EXPECTED_EOI_FIELD_LABELS.filter(
(lbl) => !discovered.has(lbl),
);
// v2 only: download each underlying PDF and inspect its AcroForm. v1
// doesn't expose envelope items so the array stays empty there.
const acroForm: TemplateSyncResult['acroForm'] = [];
const cfg = await getPortDocumensoConfig(portId);
if (cfg.apiVersion === 'v2' && template.envelopeItems.length > 0) {
for (const item of template.envelopeItems) {
try {
const pdfBytes = await downloadEnvelopeItemPdf(item.id, portId);
const fields = await inspectPdfAcroForm(pdfBytes);
const pdfNames = new Set(fields.map((f) => f.name));
const matchedFieldNames = CRM_EXPECTED_EOI_FIELD_LABELS.filter((lbl) => pdfNames.has(lbl));
const missingFieldNames = CRM_EXPECTED_EOI_FIELD_LABELS.filter((lbl) => !pdfNames.has(lbl));
const extraFieldNames = fields.map((f) => f.name).filter((n) => !expected.has(n));
acroForm.push({
envelopeItemId: item.id,
fields,
matchedFieldNames,
missingFieldNames,
extraFieldNames,
});
} catch (err) {
// Surface the failure in the UI rather than failing the whole
// sync — the recipient + field-map writes already succeeded
// and an admin can still progress without the AcroForm diff.
logger.warn(
{ err, envelopeItemId: item.id, templateId },
'AcroForm inspection failed during template sync',
);
acroForm.push({
envelopeItemId: item.id,
fields: [],
matchedFieldNames: [],
missingFieldNames: [...CRM_EXPECTED_EOI_FIELD_LABELS],
extraFieldNames: [],
error: err instanceof Error ? err.message : 'PDF inspection failed',
});
}
}
}
const result: TemplateSyncResult = {
syncedAt: new Date().toISOString(),
templateId: template.id || templateId,
title: template.title,
recipients: recipientRows,
fieldMap,
fieldCount: Object.keys(fieldMap).length,
matchedFields,
unmatchedTemplateFields,
missingFromTemplate,
templateMeta: template.meta,
acroForm,
};
// Cache the full report so the admin's status panel survives page reloads.
// Free-form JSON, written via the legacy upsertSetting path (the registry
// resolver only handles scalar credentials/URLs). Last sync wins.
const { upsertSetting } = await import('@/lib/services/settings.service');
await upsertSetting('documenso_eoi_template_sync_report', result, portId, meta);
return result;
}
/**
* Read the cached sync report written on the most recent successful sync.
* Drives the post-reload status panel — returns null when no sync has
* ever run for this port.
*/
export async function getEoiTemplateSyncReport(portId: string): Promise<TemplateSyncResult | null> {
const row = await getSettingLegacy('documenso_eoi_template_sync_report', portId);
const stored = row?.value as unknown;
if (!stored || typeof stored !== 'object') return null;
return stored as TemplateSyncResult;
}
/**
* Read the cached field-name → field-id map for this port. Used by
* `buildDocumensoPayload` when emitting v2's `prefillFields` array.
* Returns null when no sync has run yet — caller falls back to v1's
* `formValues`-by-name shape.
*/
export async function getEoiFieldMap(portId: string): Promise<TemplateFieldMap | null> {
// The field map is a free-form JSON blob that doesn't fit the registry's
// scalar-credential model — read directly via the legacy settings.service
// path so the registry-aware resolver doesn't reject the unknown key.
const row = await getSettingLegacy('documenso_eoi_field_map', portId);
const stored = row?.value;
if (!stored || typeof stored !== 'object') return null;
const out: TemplateFieldMap = {};
for (const [k, v] of Object.entries(stored as Record<string, unknown>)) {
if (typeof v === 'number') out[k] = v;
else if (typeof v === 'string' && /^\d+$/.test(v)) out[k] = Number(v);
}
return Object.keys(out).length > 0 ? out : null;
}
/**
* Write the field map via the legacy settings.service path so we don't have
* to add an arbitrary-JSON entry to the registry. The map is per-port and
* gets blown away + rewritten on every sync click.
*/
async function persistFieldMap(
portId: string,
fieldMap: TemplateFieldMap,
meta: AuditMeta,
): Promise<void> {
const { upsertSetting } = await import('@/lib/services/settings.service');
await upsertSetting('documenso_eoi_field_map', fieldMap, portId, meta);
}