308 lines
12 KiB
TypeScript
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);
|
||
|
|
}
|