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 { 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(CRM_EXPECTED_EOI_FIELD_LABELS); const discovered = new Set(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 { 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 { // 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)) { 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 { const { upsertSetting } = await import('@/lib/services/settings.service'); await upsertSetting('documenso_eoi_field_map', fieldMap, portId, meta); }