Files
pn-new-crm/src/lib/services/documenso-client.ts

436 lines
14 KiB
TypeScript
Raw Normal View History

import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import { getPortDocumensoConfig, type DocumensoApiVersion } from '@/lib/services/port-config';
feat(admin): per-port email/Documenso/branding/reminder settings + invitations Centralizes everything operators need to configure into the admin panel, each setting per-port with env fallback. New admin pages - /admin landing page linking to every admin section as a card - /admin/email FROM name+address, reply-to, signature/footer HTML, optional SMTP host/port/user/pass override - /admin/documenso API URL+key override, EOI Documenso template ID, default EOI pathway (documenso-template vs inapp), "Test connection" button - /admin/branding logo URL, primary color, app name, email header/footer HTML - /admin/reminders port-level defaults for new interests + port-wide daily-digest delivery window - /admin/invitations send / list / resend / revoke CRM invitations Per-user reminder digest - /notifications/preferences gains a Reminder digest card: immediate / daily / weekly / off, with HH:MM, day-of-week, IANA timezone fields. Stored in user_profiles.preferences.reminders. Plumbing - port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig, getPortBrandingConfig, getPortReminderConfig) — settings → env fallback. - sendEmail accepts optional portId; resolves From/SMTP from settings when supplied. - documensoFetch + downloadSignedPdf accept optional portId; each public function takes it through. checkDocumensoHealth() backs the test button. - crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite with audit-log entries (revoke_invite, resend_invite added to AuditAction). - AdminLandingPage card grid + shared SettingsFormCard component to remove per-page form boilerplate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:54 +02:00
interface DocumensoCreds {
baseUrl: string;
apiKey: string;
apiVersion: DocumensoApiVersion;
feat(admin): per-port email/Documenso/branding/reminder settings + invitations Centralizes everything operators need to configure into the admin panel, each setting per-port with env fallback. New admin pages - /admin landing page linking to every admin section as a card - /admin/email FROM name+address, reply-to, signature/footer HTML, optional SMTP host/port/user/pass override - /admin/documenso API URL+key override, EOI Documenso template ID, default EOI pathway (documenso-template vs inapp), "Test connection" button - /admin/branding logo URL, primary color, app name, email header/footer HTML - /admin/reminders port-level defaults for new interests + port-wide daily-digest delivery window - /admin/invitations send / list / resend / revoke CRM invitations Per-user reminder digest - /notifications/preferences gains a Reminder digest card: immediate / daily / weekly / off, with HH:MM, day-of-week, IANA timezone fields. Stored in user_profiles.preferences.reminders. Plumbing - port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig, getPortBrandingConfig, getPortReminderConfig) — settings → env fallback. - sendEmail accepts optional portId; resolves From/SMTP from settings when supplied. - documensoFetch + downloadSignedPdf accept optional portId; each public function takes it through. checkDocumensoHealth() backs the test button. - crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite with audit-log entries (revoke_invite, resend_invite added to AuditAction). - AdminLandingPage card grid + shared SettingsFormCard component to remove per-page form boilerplate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:54 +02:00
}
async function resolveCreds(portId?: string): Promise<DocumensoCreds> {
if (!portId) {
return {
baseUrl: env.DOCUMENSO_API_URL,
apiKey: env.DOCUMENSO_API_KEY,
apiVersion: env.DOCUMENSO_API_VERSION,
};
}
feat(admin): per-port email/Documenso/branding/reminder settings + invitations Centralizes everything operators need to configure into the admin panel, each setting per-port with env fallback. New admin pages - /admin landing page linking to every admin section as a card - /admin/email FROM name+address, reply-to, signature/footer HTML, optional SMTP host/port/user/pass override - /admin/documenso API URL+key override, EOI Documenso template ID, default EOI pathway (documenso-template vs inapp), "Test connection" button - /admin/branding logo URL, primary color, app name, email header/footer HTML - /admin/reminders port-level defaults for new interests + port-wide daily-digest delivery window - /admin/invitations send / list / resend / revoke CRM invitations Per-user reminder digest - /notifications/preferences gains a Reminder digest card: immediate / daily / weekly / off, with HH:MM, day-of-week, IANA timezone fields. Stored in user_profiles.preferences.reminders. Plumbing - port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig, getPortBrandingConfig, getPortReminderConfig) — settings → env fallback. - sendEmail accepts optional portId; resolves From/SMTP from settings when supplied. - documensoFetch + downloadSignedPdf accept optional portId; each public function takes it through. checkDocumensoHealth() backs the test button. - crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite with audit-log entries (revoke_invite, resend_invite added to AuditAction). - AdminLandingPage card grid + shared SettingsFormCard component to remove per-page form boilerplate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:54 +02:00
const cfg = await getPortDocumensoConfig(portId);
return { baseUrl: cfg.apiUrl, apiKey: cfg.apiKey, apiVersion: cfg.apiVersion };
feat(admin): per-port email/Documenso/branding/reminder settings + invitations Centralizes everything operators need to configure into the admin panel, each setting per-port with env fallback. New admin pages - /admin landing page linking to every admin section as a card - /admin/email FROM name+address, reply-to, signature/footer HTML, optional SMTP host/port/user/pass override - /admin/documenso API URL+key override, EOI Documenso template ID, default EOI pathway (documenso-template vs inapp), "Test connection" button - /admin/branding logo URL, primary color, app name, email header/footer HTML - /admin/reminders port-level defaults for new interests + port-wide daily-digest delivery window - /admin/invitations send / list / resend / revoke CRM invitations Per-user reminder digest - /notifications/preferences gains a Reminder digest card: immediate / daily / weekly / off, with HH:MM, day-of-week, IANA timezone fields. Stored in user_profiles.preferences.reminders. Plumbing - port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig, getPortBrandingConfig, getPortReminderConfig) — settings → env fallback. - sendEmail accepts optional portId; resolves From/SMTP from settings when supplied. - documensoFetch + downloadSignedPdf accept optional portId; each public function takes it through. checkDocumensoHealth() backs the test button. - crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite with audit-log entries (revoke_invite, resend_invite added to AuditAction). - AdminLandingPage card grid + shared SettingsFormCard component to remove per-page form boilerplate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:54 +02:00
}
feat(admin): per-port email/Documenso/branding/reminder settings + invitations Centralizes everything operators need to configure into the admin panel, each setting per-port with env fallback. New admin pages - /admin landing page linking to every admin section as a card - /admin/email FROM name+address, reply-to, signature/footer HTML, optional SMTP host/port/user/pass override - /admin/documenso API URL+key override, EOI Documenso template ID, default EOI pathway (documenso-template vs inapp), "Test connection" button - /admin/branding logo URL, primary color, app name, email header/footer HTML - /admin/reminders port-level defaults for new interests + port-wide daily-digest delivery window - /admin/invitations send / list / resend / revoke CRM invitations Per-user reminder digest - /notifications/preferences gains a Reminder digest card: immediate / daily / weekly / off, with HH:MM, day-of-week, IANA timezone fields. Stored in user_profiles.preferences.reminders. Plumbing - port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig, getPortBrandingConfig, getPortReminderConfig) — settings → env fallback. - sendEmail accepts optional portId; resolves From/SMTP from settings when supplied. - documensoFetch + downloadSignedPdf accept optional portId; each public function takes it through. checkDocumensoHealth() backs the test button. - crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite with audit-log entries (revoke_invite, resend_invite added to AuditAction). - AdminLandingPage card grid + shared SettingsFormCard component to remove per-page form boilerplate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:54 +02:00
async function documensoFetch(
path: string,
options?: RequestInit,
portId?: string,
): Promise<unknown> {
const { baseUrl, apiKey } = await resolveCreds(portId);
const res = await fetch(`${baseUrl}${path}`, {
...options,
headers: {
feat(admin): per-port email/Documenso/branding/reminder settings + invitations Centralizes everything operators need to configure into the admin panel, each setting per-port with env fallback. New admin pages - /admin landing page linking to every admin section as a card - /admin/email FROM name+address, reply-to, signature/footer HTML, optional SMTP host/port/user/pass override - /admin/documenso API URL+key override, EOI Documenso template ID, default EOI pathway (documenso-template vs inapp), "Test connection" button - /admin/branding logo URL, primary color, app name, email header/footer HTML - /admin/reminders port-level defaults for new interests + port-wide daily-digest delivery window - /admin/invitations send / list / resend / revoke CRM invitations Per-user reminder digest - /notifications/preferences gains a Reminder digest card: immediate / daily / weekly / off, with HH:MM, day-of-week, IANA timezone fields. Stored in user_profiles.preferences.reminders. Plumbing - port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig, getPortBrandingConfig, getPortReminderConfig) — settings → env fallback. - sendEmail accepts optional portId; resolves From/SMTP from settings when supplied. - documensoFetch + downloadSignedPdf accept optional portId; each public function takes it through. checkDocumensoHealth() backs the test button. - crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite with audit-log entries (revoke_invite, resend_invite added to AuditAction). - AdminLandingPage card grid + shared SettingsFormCard component to remove per-page form boilerplate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:54 +02:00
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!res.ok) {
const err = await res.text();
feat(admin): per-port email/Documenso/branding/reminder settings + invitations Centralizes everything operators need to configure into the admin panel, each setting per-port with env fallback. New admin pages - /admin landing page linking to every admin section as a card - /admin/email FROM name+address, reply-to, signature/footer HTML, optional SMTP host/port/user/pass override - /admin/documenso API URL+key override, EOI Documenso template ID, default EOI pathway (documenso-template vs inapp), "Test connection" button - /admin/branding logo URL, primary color, app name, email header/footer HTML - /admin/reminders port-level defaults for new interests + port-wide daily-digest delivery window - /admin/invitations send / list / resend / revoke CRM invitations Per-user reminder digest - /notifications/preferences gains a Reminder digest card: immediate / daily / weekly / off, with HH:MM, day-of-week, IANA timezone fields. Stored in user_profiles.preferences.reminders. Plumbing - port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig, getPortBrandingConfig, getPortReminderConfig) — settings → env fallback. - sendEmail accepts optional portId; resolves From/SMTP from settings when supplied. - documensoFetch + downloadSignedPdf accept optional portId; each public function takes it through. checkDocumensoHealth() backs the test button. - crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite with audit-log entries (revoke_invite, resend_invite added to AuditAction). - AdminLandingPage card grid + shared SettingsFormCard component to remove per-page form boilerplate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:54 +02:00
logger.error({ path, status: res.status, err, portId }, 'Documenso API error');
throw new Error(`Documenso API error: ${res.status}`);
}
return res.json();
}
// Documenso 2.x renamed top-level `id` → `documentId` and recipient `id` →
// `recipientId`; v1.13 still uses `id`. Normalize both shapes to the legacy
// `id` form that this codebase consumes everywhere downstream.
function normalizeDocument(raw: unknown): DocumensoDocument {
const r = (raw ?? {}) as Record<string, unknown>;
const id = String(r.documentId ?? r.id ?? '');
const status = String(r.status ?? 'PENDING');
const recipientsRaw = (r.recipients as Array<Record<string, unknown>> | undefined) ?? [];
const recipients = recipientsRaw.map((rec) => ({
id: String(rec.recipientId ?? rec.id ?? ''),
name: String(rec.name ?? ''),
email: String(rec.email ?? ''),
role: String(rec.role ?? ''),
signingOrder: Number(rec.signingOrder ?? 0),
status: String(rec.signingStatus ?? rec.status ?? 'PENDING'),
signingUrl: typeof rec.signingUrl === 'string' ? rec.signingUrl : undefined,
embeddedUrl: typeof rec.embeddedUrl === 'string' ? rec.embeddedUrl : undefined,
}));
return { id, status, recipients };
}
export interface DocumensoRecipient {
name: string;
email: string;
role: string;
signingOrder: number;
}
export interface DocumensoDocument {
id: string;
status: string;
recipients: Array<{
id: string;
name: string;
email: string;
role: string;
signingOrder: number;
status: string;
signingUrl?: string;
embeddedUrl?: string;
}>;
}
feat(safety): EMAIL_REDIRECT_TO now also pauses Documenso + webhooks Closes a gap exposed by the comms safety audit: the existing EMAIL_REDIRECT_TO env var only redirected outbound SMTP via the sendEmail() bottleneck. Two channels still leaked when set: 1. Documenso e-signature recipients — Documenso's own server emails them on our behalf, so SMTP redirect doesn't help. We were sending real client emails to the Documenso REST API, which would then deliver to the real client. 2. Outbound webhooks — fire from the BullMQ worker to user-configured URLs. SSRF guard blocks internal hosts but doesn't pause production endpoints. Documenso (src/lib/services/documenso-client.ts): - createDocument: rewrite every recipient.email to EMAIL_REDIRECT_TO and prefix the recipient.name with the original email so the doc is traceable. - generateDocumentFromTemplate: same treatment for both v1.13 formValues.*Email keys and v2.x recipients[]. The redirect happens BEFORE the API call, so even Documenso's own retry logic can't reach the original recipient. - Both paths log when they redirect so it's visible in dev. Webhooks (src/lib/queue/workers/webhooks.ts): - When EMAIL_REDIRECT_TO is set, short-circuit the dispatch and write a `dead_letter` row with reason "Skipped: EMAIL_REDIRECT_TO is set, outbound comms paused." so the attempt is still visible in the deliveries listing. Doc: docs/operations/outbound-comms-safety.md catalogs every outbound comms channel (email, Documenso, webhooks, WhatsApp/phone deep-links, SMS-not-implemented) and explains how each one respects the env flag. Includes a verification checklist to run before any production data import + cutover steps for going live. Single env var EMAIL_REDIRECT_TO now reliably pauses ALL automated outbound comms. Unset for production. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:24:41 +02:00
/**
* When EMAIL_REDIRECT_TO is set (dev / staging), rewrite every recipient
* email so Documenso doesn't accidentally email real clients during a
* data import / migration dry-run. Names are prefixed with the original
* email so the recipient (you) can tell who would have received the doc.
*
* In production this env var is unset and recipients flow through unchanged.
*/
function applyRecipientRedirect(recipients: DocumensoRecipient[]): DocumensoRecipient[] {
if (!env.EMAIL_REDIRECT_TO) return recipients;
return recipients.map((r) => ({
...r,
name: `${r.name} (was: ${r.email})`,
email: env.EMAIL_REDIRECT_TO!,
}));
}
/**
* Same idea for the template-generate endpoint, which takes a payload
* shape with recipient email/name nested inside `formValues` (Documenso
* v1.13) or `recipients` (Documenso 2.x). We rewrite both shapes.
*/
function applyPayloadRedirect(payload: Record<string, unknown>): Record<string, unknown> {
if (!env.EMAIL_REDIRECT_TO) return payload;
const out: Record<string, unknown> = { ...payload };
// 2.x recipient shape
if (Array.isArray(out.recipients)) {
out.recipients = (out.recipients as Array<Record<string, unknown>>).map((r) => ({
...r,
name: `${String(r.name ?? '')} (was: ${String(r.email ?? '')})`,
email: env.EMAIL_REDIRECT_TO,
}));
}
// v1.13 formValues shape — keys vary per template; key by anything that
// looks like an email field. The conservative approach: only touch keys
// that already hold a string and end with `Email` / `email`.
if (out.formValues && typeof out.formValues === 'object') {
const fv = { ...(out.formValues as Record<string, unknown>) };
for (const key of Object.keys(fv)) {
if (/email$/i.test(key) && typeof fv[key] === 'string') {
fv[key] = env.EMAIL_REDIRECT_TO;
}
}
out.formValues = fv;
}
return out;
}
export async function createDocument(
title: string,
pdfBase64: string,
recipients: DocumensoRecipient[],
feat(admin): per-port email/Documenso/branding/reminder settings + invitations Centralizes everything operators need to configure into the admin panel, each setting per-port with env fallback. New admin pages - /admin landing page linking to every admin section as a card - /admin/email FROM name+address, reply-to, signature/footer HTML, optional SMTP host/port/user/pass override - /admin/documenso API URL+key override, EOI Documenso template ID, default EOI pathway (documenso-template vs inapp), "Test connection" button - /admin/branding logo URL, primary color, app name, email header/footer HTML - /admin/reminders port-level defaults for new interests + port-wide daily-digest delivery window - /admin/invitations send / list / resend / revoke CRM invitations Per-user reminder digest - /notifications/preferences gains a Reminder digest card: immediate / daily / weekly / off, with HH:MM, day-of-week, IANA timezone fields. Stored in user_profiles.preferences.reminders. Plumbing - port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig, getPortBrandingConfig, getPortReminderConfig) — settings → env fallback. - sendEmail accepts optional portId; resolves From/SMTP from settings when supplied. - documensoFetch + downloadSignedPdf accept optional portId; each public function takes it through. checkDocumensoHealth() backs the test button. - crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite with audit-log entries (revoke_invite, resend_invite added to AuditAction). - AdminLandingPage card grid + shared SettingsFormCard component to remove per-page form boilerplate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:54 +02:00
portId?: string,
): Promise<DocumensoDocument> {
feat(safety): EMAIL_REDIRECT_TO now also pauses Documenso + webhooks Closes a gap exposed by the comms safety audit: the existing EMAIL_REDIRECT_TO env var only redirected outbound SMTP via the sendEmail() bottleneck. Two channels still leaked when set: 1. Documenso e-signature recipients — Documenso's own server emails them on our behalf, so SMTP redirect doesn't help. We were sending real client emails to the Documenso REST API, which would then deliver to the real client. 2. Outbound webhooks — fire from the BullMQ worker to user-configured URLs. SSRF guard blocks internal hosts but doesn't pause production endpoints. Documenso (src/lib/services/documenso-client.ts): - createDocument: rewrite every recipient.email to EMAIL_REDIRECT_TO and prefix the recipient.name with the original email so the doc is traceable. - generateDocumentFromTemplate: same treatment for both v1.13 formValues.*Email keys and v2.x recipients[]. The redirect happens BEFORE the API call, so even Documenso's own retry logic can't reach the original recipient. - Both paths log when they redirect so it's visible in dev. Webhooks (src/lib/queue/workers/webhooks.ts): - When EMAIL_REDIRECT_TO is set, short-circuit the dispatch and write a `dead_letter` row with reason "Skipped: EMAIL_REDIRECT_TO is set, outbound comms paused." so the attempt is still visible in the deliveries listing. Doc: docs/operations/outbound-comms-safety.md catalogs every outbound comms channel (email, Documenso, webhooks, WhatsApp/phone deep-links, SMS-not-implemented) and explains how each one respects the env flag. Includes a verification checklist to run before any production data import + cutover steps for going live. Single env var EMAIL_REDIRECT_TO now reliably pauses ALL automated outbound comms. Unset for production. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:24:41 +02:00
const safeRecipients = applyRecipientRedirect(recipients);
if (env.EMAIL_REDIRECT_TO) {
logger.info(
{ redirected: safeRecipients.length, original: recipients.map((r) => r.email) },
'Documenso recipients redirected to EMAIL_REDIRECT_TO',
);
}
feat(admin): per-port email/Documenso/branding/reminder settings + invitations Centralizes everything operators need to configure into the admin panel, each setting per-port with env fallback. New admin pages - /admin landing page linking to every admin section as a card - /admin/email FROM name+address, reply-to, signature/footer HTML, optional SMTP host/port/user/pass override - /admin/documenso API URL+key override, EOI Documenso template ID, default EOI pathway (documenso-template vs inapp), "Test connection" button - /admin/branding logo URL, primary color, app name, email header/footer HTML - /admin/reminders port-level defaults for new interests + port-wide daily-digest delivery window - /admin/invitations send / list / resend / revoke CRM invitations Per-user reminder digest - /notifications/preferences gains a Reminder digest card: immediate / daily / weekly / off, with HH:MM, day-of-week, IANA timezone fields. Stored in user_profiles.preferences.reminders. Plumbing - port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig, getPortBrandingConfig, getPortReminderConfig) — settings → env fallback. - sendEmail accepts optional portId; resolves From/SMTP from settings when supplied. - documensoFetch + downloadSignedPdf accept optional portId; each public function takes it through. checkDocumensoHealth() backs the test button. - crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite with audit-log entries (revoke_invite, resend_invite added to AuditAction). - AdminLandingPage card grid + shared SettingsFormCard component to remove per-page form boilerplate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:54 +02:00
return documensoFetch(
'/api/v1/documents',
{
method: 'POST',
feat(safety): EMAIL_REDIRECT_TO now also pauses Documenso + webhooks Closes a gap exposed by the comms safety audit: the existing EMAIL_REDIRECT_TO env var only redirected outbound SMTP via the sendEmail() bottleneck. Two channels still leaked when set: 1. Documenso e-signature recipients — Documenso's own server emails them on our behalf, so SMTP redirect doesn't help. We were sending real client emails to the Documenso REST API, which would then deliver to the real client. 2. Outbound webhooks — fire from the BullMQ worker to user-configured URLs. SSRF guard blocks internal hosts but doesn't pause production endpoints. Documenso (src/lib/services/documenso-client.ts): - createDocument: rewrite every recipient.email to EMAIL_REDIRECT_TO and prefix the recipient.name with the original email so the doc is traceable. - generateDocumentFromTemplate: same treatment for both v1.13 formValues.*Email keys and v2.x recipients[]. The redirect happens BEFORE the API call, so even Documenso's own retry logic can't reach the original recipient. - Both paths log when they redirect so it's visible in dev. Webhooks (src/lib/queue/workers/webhooks.ts): - When EMAIL_REDIRECT_TO is set, short-circuit the dispatch and write a `dead_letter` row with reason "Skipped: EMAIL_REDIRECT_TO is set, outbound comms paused." so the attempt is still visible in the deliveries listing. Doc: docs/operations/outbound-comms-safety.md catalogs every outbound comms channel (email, Documenso, webhooks, WhatsApp/phone deep-links, SMS-not-implemented) and explains how each one respects the env flag. Includes a verification checklist to run before any production data import + cutover steps for going live. Single env var EMAIL_REDIRECT_TO now reliably pauses ALL automated outbound comms. Unset for production. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:24:41 +02:00
body: JSON.stringify({ title, document: pdfBase64, recipients: safeRecipients }),
feat(admin): per-port email/Documenso/branding/reminder settings + invitations Centralizes everything operators need to configure into the admin panel, each setting per-port with env fallback. New admin pages - /admin landing page linking to every admin section as a card - /admin/email FROM name+address, reply-to, signature/footer HTML, optional SMTP host/port/user/pass override - /admin/documenso API URL+key override, EOI Documenso template ID, default EOI pathway (documenso-template vs inapp), "Test connection" button - /admin/branding logo URL, primary color, app name, email header/footer HTML - /admin/reminders port-level defaults for new interests + port-wide daily-digest delivery window - /admin/invitations send / list / resend / revoke CRM invitations Per-user reminder digest - /notifications/preferences gains a Reminder digest card: immediate / daily / weekly / off, with HH:MM, day-of-week, IANA timezone fields. Stored in user_profiles.preferences.reminders. Plumbing - port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig, getPortBrandingConfig, getPortReminderConfig) — settings → env fallback. - sendEmail accepts optional portId; resolves From/SMTP from settings when supplied. - documensoFetch + downloadSignedPdf accept optional portId; each public function takes it through. checkDocumensoHealth() backs the test button. - crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite with audit-log entries (revoke_invite, resend_invite added to AuditAction). - AdminLandingPage card grid + shared SettingsFormCard component to remove per-page form boilerplate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:54 +02:00
},
portId,
).then(normalizeDocument);
}
export async function generateDocumentFromTemplate(
templateId: number,
payload: Record<string, unknown>,
feat(admin): per-port email/Documenso/branding/reminder settings + invitations Centralizes everything operators need to configure into the admin panel, each setting per-port with env fallback. New admin pages - /admin landing page linking to every admin section as a card - /admin/email FROM name+address, reply-to, signature/footer HTML, optional SMTP host/port/user/pass override - /admin/documenso API URL+key override, EOI Documenso template ID, default EOI pathway (documenso-template vs inapp), "Test connection" button - /admin/branding logo URL, primary color, app name, email header/footer HTML - /admin/reminders port-level defaults for new interests + port-wide daily-digest delivery window - /admin/invitations send / list / resend / revoke CRM invitations Per-user reminder digest - /notifications/preferences gains a Reminder digest card: immediate / daily / weekly / off, with HH:MM, day-of-week, IANA timezone fields. Stored in user_profiles.preferences.reminders. Plumbing - port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig, getPortBrandingConfig, getPortReminderConfig) — settings → env fallback. - sendEmail accepts optional portId; resolves From/SMTP from settings when supplied. - documensoFetch + downloadSignedPdf accept optional portId; each public function takes it through. checkDocumensoHealth() backs the test button. - crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite with audit-log entries (revoke_invite, resend_invite added to AuditAction). - AdminLandingPage card grid + shared SettingsFormCard component to remove per-page form boilerplate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:54 +02:00
portId?: string,
): Promise<DocumensoDocument> {
feat(safety): EMAIL_REDIRECT_TO now also pauses Documenso + webhooks Closes a gap exposed by the comms safety audit: the existing EMAIL_REDIRECT_TO env var only redirected outbound SMTP via the sendEmail() bottleneck. Two channels still leaked when set: 1. Documenso e-signature recipients — Documenso's own server emails them on our behalf, so SMTP redirect doesn't help. We were sending real client emails to the Documenso REST API, which would then deliver to the real client. 2. Outbound webhooks — fire from the BullMQ worker to user-configured URLs. SSRF guard blocks internal hosts but doesn't pause production endpoints. Documenso (src/lib/services/documenso-client.ts): - createDocument: rewrite every recipient.email to EMAIL_REDIRECT_TO and prefix the recipient.name with the original email so the doc is traceable. - generateDocumentFromTemplate: same treatment for both v1.13 formValues.*Email keys and v2.x recipients[]. The redirect happens BEFORE the API call, so even Documenso's own retry logic can't reach the original recipient. - Both paths log when they redirect so it's visible in dev. Webhooks (src/lib/queue/workers/webhooks.ts): - When EMAIL_REDIRECT_TO is set, short-circuit the dispatch and write a `dead_letter` row with reason "Skipped: EMAIL_REDIRECT_TO is set, outbound comms paused." so the attempt is still visible in the deliveries listing. Doc: docs/operations/outbound-comms-safety.md catalogs every outbound comms channel (email, Documenso, webhooks, WhatsApp/phone deep-links, SMS-not-implemented) and explains how each one respects the env flag. Includes a verification checklist to run before any production data import + cutover steps for going live. Single env var EMAIL_REDIRECT_TO now reliably pauses ALL automated outbound comms. Unset for production. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:24:41 +02:00
const safePayload = applyPayloadRedirect(payload);
if (env.EMAIL_REDIRECT_TO) {
logger.info(
{ templateId },
'Documenso template-generate payload redirected to EMAIL_REDIRECT_TO',
);
}
feat(admin): per-port email/Documenso/branding/reminder settings + invitations Centralizes everything operators need to configure into the admin panel, each setting per-port with env fallback. New admin pages - /admin landing page linking to every admin section as a card - /admin/email FROM name+address, reply-to, signature/footer HTML, optional SMTP host/port/user/pass override - /admin/documenso API URL+key override, EOI Documenso template ID, default EOI pathway (documenso-template vs inapp), "Test connection" button - /admin/branding logo URL, primary color, app name, email header/footer HTML - /admin/reminders port-level defaults for new interests + port-wide daily-digest delivery window - /admin/invitations send / list / resend / revoke CRM invitations Per-user reminder digest - /notifications/preferences gains a Reminder digest card: immediate / daily / weekly / off, with HH:MM, day-of-week, IANA timezone fields. Stored in user_profiles.preferences.reminders. Plumbing - port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig, getPortBrandingConfig, getPortReminderConfig) — settings → env fallback. - sendEmail accepts optional portId; resolves From/SMTP from settings when supplied. - documensoFetch + downloadSignedPdf accept optional portId; each public function takes it through. checkDocumensoHealth() backs the test button. - crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite with audit-log entries (revoke_invite, resend_invite added to AuditAction). - AdminLandingPage card grid + shared SettingsFormCard component to remove per-page form boilerplate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:54 +02:00
return documensoFetch(
`/api/v1/templates/${templateId}/generate-document`,
{
method: 'POST',
feat(safety): EMAIL_REDIRECT_TO now also pauses Documenso + webhooks Closes a gap exposed by the comms safety audit: the existing EMAIL_REDIRECT_TO env var only redirected outbound SMTP via the sendEmail() bottleneck. Two channels still leaked when set: 1. Documenso e-signature recipients — Documenso's own server emails them on our behalf, so SMTP redirect doesn't help. We were sending real client emails to the Documenso REST API, which would then deliver to the real client. 2. Outbound webhooks — fire from the BullMQ worker to user-configured URLs. SSRF guard blocks internal hosts but doesn't pause production endpoints. Documenso (src/lib/services/documenso-client.ts): - createDocument: rewrite every recipient.email to EMAIL_REDIRECT_TO and prefix the recipient.name with the original email so the doc is traceable. - generateDocumentFromTemplate: same treatment for both v1.13 formValues.*Email keys and v2.x recipients[]. The redirect happens BEFORE the API call, so even Documenso's own retry logic can't reach the original recipient. - Both paths log when they redirect so it's visible in dev. Webhooks (src/lib/queue/workers/webhooks.ts): - When EMAIL_REDIRECT_TO is set, short-circuit the dispatch and write a `dead_letter` row with reason "Skipped: EMAIL_REDIRECT_TO is set, outbound comms paused." so the attempt is still visible in the deliveries listing. Doc: docs/operations/outbound-comms-safety.md catalogs every outbound comms channel (email, Documenso, webhooks, WhatsApp/phone deep-links, SMS-not-implemented) and explains how each one respects the env flag. Includes a verification checklist to run before any production data import + cutover steps for going live. Single env var EMAIL_REDIRECT_TO now reliably pauses ALL automated outbound comms. Unset for production. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:24:41 +02:00
body: JSON.stringify(safePayload),
feat(admin): per-port email/Documenso/branding/reminder settings + invitations Centralizes everything operators need to configure into the admin panel, each setting per-port with env fallback. New admin pages - /admin landing page linking to every admin section as a card - /admin/email FROM name+address, reply-to, signature/footer HTML, optional SMTP host/port/user/pass override - /admin/documenso API URL+key override, EOI Documenso template ID, default EOI pathway (documenso-template vs inapp), "Test connection" button - /admin/branding logo URL, primary color, app name, email header/footer HTML - /admin/reminders port-level defaults for new interests + port-wide daily-digest delivery window - /admin/invitations send / list / resend / revoke CRM invitations Per-user reminder digest - /notifications/preferences gains a Reminder digest card: immediate / daily / weekly / off, with HH:MM, day-of-week, IANA timezone fields. Stored in user_profiles.preferences.reminders. Plumbing - port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig, getPortBrandingConfig, getPortReminderConfig) — settings → env fallback. - sendEmail accepts optional portId; resolves From/SMTP from settings when supplied. - documensoFetch + downloadSignedPdf accept optional portId; each public function takes it through. checkDocumensoHealth() backs the test button. - crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite with audit-log entries (revoke_invite, resend_invite added to AuditAction). - AdminLandingPage card grid + shared SettingsFormCard component to remove per-page form boilerplate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:54 +02:00
},
portId,
).then(normalizeDocument);
}
feat(admin): per-port email/Documenso/branding/reminder settings + invitations Centralizes everything operators need to configure into the admin panel, each setting per-port with env fallback. New admin pages - /admin landing page linking to every admin section as a card - /admin/email FROM name+address, reply-to, signature/footer HTML, optional SMTP host/port/user/pass override - /admin/documenso API URL+key override, EOI Documenso template ID, default EOI pathway (documenso-template vs inapp), "Test connection" button - /admin/branding logo URL, primary color, app name, email header/footer HTML - /admin/reminders port-level defaults for new interests + port-wide daily-digest delivery window - /admin/invitations send / list / resend / revoke CRM invitations Per-user reminder digest - /notifications/preferences gains a Reminder digest card: immediate / daily / weekly / off, with HH:MM, day-of-week, IANA timezone fields. Stored in user_profiles.preferences.reminders. Plumbing - port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig, getPortBrandingConfig, getPortReminderConfig) — settings → env fallback. - sendEmail accepts optional portId; resolves From/SMTP from settings when supplied. - documensoFetch + downloadSignedPdf accept optional portId; each public function takes it through. checkDocumensoHealth() backs the test button. - crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite with audit-log entries (revoke_invite, resend_invite added to AuditAction). - AdminLandingPage card grid + shared SettingsFormCard component to remove per-page form boilerplate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:54 +02:00
export async function sendDocument(docId: string, portId?: string): Promise<DocumensoDocument> {
return documensoFetch(
`/api/v1/documents/${docId}/send`,
{
method: 'POST',
},
portId,
).then(normalizeDocument);
}
feat(admin): per-port email/Documenso/branding/reminder settings + invitations Centralizes everything operators need to configure into the admin panel, each setting per-port with env fallback. New admin pages - /admin landing page linking to every admin section as a card - /admin/email FROM name+address, reply-to, signature/footer HTML, optional SMTP host/port/user/pass override - /admin/documenso API URL+key override, EOI Documenso template ID, default EOI pathway (documenso-template vs inapp), "Test connection" button - /admin/branding logo URL, primary color, app name, email header/footer HTML - /admin/reminders port-level defaults for new interests + port-wide daily-digest delivery window - /admin/invitations send / list / resend / revoke CRM invitations Per-user reminder digest - /notifications/preferences gains a Reminder digest card: immediate / daily / weekly / off, with HH:MM, day-of-week, IANA timezone fields. Stored in user_profiles.preferences.reminders. Plumbing - port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig, getPortBrandingConfig, getPortReminderConfig) — settings → env fallback. - sendEmail accepts optional portId; resolves From/SMTP from settings when supplied. - documensoFetch + downloadSignedPdf accept optional portId; each public function takes it through. checkDocumensoHealth() backs the test button. - crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite with audit-log entries (revoke_invite, resend_invite added to AuditAction). - AdminLandingPage card grid + shared SettingsFormCard component to remove per-page form boilerplate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:54 +02:00
export async function getDocument(docId: string, portId?: string): Promise<DocumensoDocument> {
return documensoFetch(`/api/v1/documents/${docId}`, undefined, portId).then(normalizeDocument);
}
feat(admin): per-port email/Documenso/branding/reminder settings + invitations Centralizes everything operators need to configure into the admin panel, each setting per-port with env fallback. New admin pages - /admin landing page linking to every admin section as a card - /admin/email FROM name+address, reply-to, signature/footer HTML, optional SMTP host/port/user/pass override - /admin/documenso API URL+key override, EOI Documenso template ID, default EOI pathway (documenso-template vs inapp), "Test connection" button - /admin/branding logo URL, primary color, app name, email header/footer HTML - /admin/reminders port-level defaults for new interests + port-wide daily-digest delivery window - /admin/invitations send / list / resend / revoke CRM invitations Per-user reminder digest - /notifications/preferences gains a Reminder digest card: immediate / daily / weekly / off, with HH:MM, day-of-week, IANA timezone fields. Stored in user_profiles.preferences.reminders. Plumbing - port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig, getPortBrandingConfig, getPortReminderConfig) — settings → env fallback. - sendEmail accepts optional portId; resolves From/SMTP from settings when supplied. - documensoFetch + downloadSignedPdf accept optional portId; each public function takes it through. checkDocumensoHealth() backs the test button. - crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite with audit-log entries (revoke_invite, resend_invite added to AuditAction). - AdminLandingPage card grid + shared SettingsFormCard component to remove per-page form boilerplate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:54 +02:00
export async function sendReminder(
docId: string,
signerId: string,
portId?: string,
): Promise<void> {
await documensoFetch(
`/api/v1/documents/${docId}/recipients/${signerId}/remind`,
{
method: 'POST',
},
portId,
);
}
feat(admin): per-port email/Documenso/branding/reminder settings + invitations Centralizes everything operators need to configure into the admin panel, each setting per-port with env fallback. New admin pages - /admin landing page linking to every admin section as a card - /admin/email FROM name+address, reply-to, signature/footer HTML, optional SMTP host/port/user/pass override - /admin/documenso API URL+key override, EOI Documenso template ID, default EOI pathway (documenso-template vs inapp), "Test connection" button - /admin/branding logo URL, primary color, app name, email header/footer HTML - /admin/reminders port-level defaults for new interests + port-wide daily-digest delivery window - /admin/invitations send / list / resend / revoke CRM invitations Per-user reminder digest - /notifications/preferences gains a Reminder digest card: immediate / daily / weekly / off, with HH:MM, day-of-week, IANA timezone fields. Stored in user_profiles.preferences.reminders. Plumbing - port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig, getPortBrandingConfig, getPortReminderConfig) — settings → env fallback. - sendEmail accepts optional portId; resolves From/SMTP from settings when supplied. - documensoFetch + downloadSignedPdf accept optional portId; each public function takes it through. checkDocumensoHealth() backs the test button. - crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite with audit-log entries (revoke_invite, resend_invite added to AuditAction). - AdminLandingPage card grid + shared SettingsFormCard component to remove per-page form boilerplate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:54 +02:00
export async function downloadSignedPdf(docId: string, portId?: string): Promise<Buffer> {
const { baseUrl, apiKey } = await resolveCreds(portId);
const res = await fetch(`${baseUrl}/api/v1/documents/${docId}/download`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) {
const err = await res.text();
feat(admin): per-port email/Documenso/branding/reminder settings + invitations Centralizes everything operators need to configure into the admin panel, each setting per-port with env fallback. New admin pages - /admin landing page linking to every admin section as a card - /admin/email FROM name+address, reply-to, signature/footer HTML, optional SMTP host/port/user/pass override - /admin/documenso API URL+key override, EOI Documenso template ID, default EOI pathway (documenso-template vs inapp), "Test connection" button - /admin/branding logo URL, primary color, app name, email header/footer HTML - /admin/reminders port-level defaults for new interests + port-wide daily-digest delivery window - /admin/invitations send / list / resend / revoke CRM invitations Per-user reminder digest - /notifications/preferences gains a Reminder digest card: immediate / daily / weekly / off, with HH:MM, day-of-week, IANA timezone fields. Stored in user_profiles.preferences.reminders. Plumbing - port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig, getPortBrandingConfig, getPortReminderConfig) — settings → env fallback. - sendEmail accepts optional portId; resolves From/SMTP from settings when supplied. - documensoFetch + downloadSignedPdf accept optional portId; each public function takes it through. checkDocumensoHealth() backs the test button. - crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite with audit-log entries (revoke_invite, resend_invite added to AuditAction). - AdminLandingPage card grid + shared SettingsFormCard component to remove per-page form boilerplate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:54 +02:00
logger.error({ docId, status: res.status, err, portId }, 'Documenso download error');
throw new Error(`Documenso download error: ${res.status}`);
}
const arrayBuffer = await res.arrayBuffer();
return Buffer.from(arrayBuffer);
}
feat(admin): per-port email/Documenso/branding/reminder settings + invitations Centralizes everything operators need to configure into the admin panel, each setting per-port with env fallback. New admin pages - /admin landing page linking to every admin section as a card - /admin/email FROM name+address, reply-to, signature/footer HTML, optional SMTP host/port/user/pass override - /admin/documenso API URL+key override, EOI Documenso template ID, default EOI pathway (documenso-template vs inapp), "Test connection" button - /admin/branding logo URL, primary color, app name, email header/footer HTML - /admin/reminders port-level defaults for new interests + port-wide daily-digest delivery window - /admin/invitations send / list / resend / revoke CRM invitations Per-user reminder digest - /notifications/preferences gains a Reminder digest card: immediate / daily / weekly / off, with HH:MM, day-of-week, IANA timezone fields. Stored in user_profiles.preferences.reminders. Plumbing - port-config.ts typed accessors (getPortEmailConfig, getPortDocumensoConfig, getPortBrandingConfig, getPortReminderConfig) — settings → env fallback. - sendEmail accepts optional portId; resolves From/SMTP from settings when supplied. - documensoFetch + downloadSignedPdf accept optional portId; each public function takes it through. checkDocumensoHealth() backs the test button. - crm-invite.service gains listCrmInvites / revokeCrmInvite / resendCrmInvite with audit-log entries (revoke_invite, resend_invite added to AuditAction). - AdminLandingPage card grid + shared SettingsFormCard component to remove per-page form boilerplate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:54 +02:00
/** Convenience health-check used by the admin "Test connection" button. */
export async function checkDocumensoHealth(
portId?: string,
): Promise<{ ok: boolean; status?: number; error?: string }> {
try {
const { baseUrl, apiKey } = await resolveCreds(portId);
const res = await fetch(`${baseUrl}/api/v1/health`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
return { ok: res.ok, status: res.status };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' };
}
}
// ─── Version-aware abstractions (Phase A PR2) ─────────────────────────────────
//
// Documenso v1.13 and v2.x diverge on field placement and document deletion:
//
// v1.13: per-field POST /api/v1/documents/{id}/fields with PIXEL coords;
// DELETE /api/v1/documents/{id} for void.
// v2.x: bulk POST /api/v2/envelope/field/create-many with PERCENT
// coords (0-100) and rich `fieldMeta`;
// DELETE /api/v2/envelope/{id} for void.
//
// Callers always work in PERCENT (0-100). For v1 the abstraction multiplies by
// the page dimensions returned by Documenso (cached per docId for the lifetime
// of the process — fields for a given doc usually go in a single batch).
export type DocumensoFieldType = 'SIGNATURE' | 'INITIALS' | 'DATE' | 'TEXT' | 'EMAIL';
export interface DocumensoFieldPlacement {
/** Documenso recipient id; v1 expects number, v2 string — coerced internally. */
recipientId: number | string;
type: DocumensoFieldType;
pageNumber: number;
/** All four are 0-100 percent of page dimensions. */
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
/** Optional v2 fieldMeta — passed through verbatim, ignored on v1. */
fieldMeta?: Record<string, unknown>;
}
export interface DocumensoPageDimensions {
width: number;
height: number;
}
const DEFAULT_PAGE_DIMENSIONS: DocumensoPageDimensions = { width: 595, height: 842 }; // A4 pt
const pageDimensionCache = new Map<string, DocumensoPageDimensions>();
/** Test seam — clears the page-dimension memoization. */
export function __resetDocumensoCachesForTests(): void {
pageDimensionCache.clear();
}
async function getPageDimensions(docId: string, portId?: string): Promise<DocumensoPageDimensions> {
const cached = pageDimensionCache.get(docId);
if (cached) return cached;
// v1 doesn't expose page dimensions cleanly via the public API; the auto-
// placement use case is footer-anchored signature fields, where a default A4
// page rendered by Documenso is a safe assumption. Real page dims can be
// wired in a follow-up by parsing the document/document-data endpoints.
void portId;
pageDimensionCache.set(docId, DEFAULT_PAGE_DIMENSIONS);
return DEFAULT_PAGE_DIMENSIONS;
}
/**
* Place one or more fields on a Documenso document. Coordinates are PERCENT
* (0-100) and converted to pixels for v1 internally.
*
* v1: dispatches one POST per field (no bulk endpoint).
* v2: single bulk POST.
*/
export async function placeFields(
docId: string,
fields: DocumensoFieldPlacement[],
portId?: string,
): Promise<void> {
if (fields.length === 0) return;
const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);
if (apiVersion === 'v2') {
const v2Fields = fields.map((f) => ({
recipientId: String(f.recipientId),
type: f.type,
pageNumber: f.pageNumber,
positionX: f.pageX,
positionY: f.pageY,
width: f.pageWidth,
height: f.pageHeight,
...(f.fieldMeta ? { fieldMeta: f.fieldMeta } : {}),
}));
// Note: v2 endpoint shape (envelopeId/recipientId types) must be
// confirmed against a live Documenso 2.x instance — see PR11 realapi
// suite. Spec risk register flags this drift as the top v2 risk.
const res = await fetch(`${baseUrl}/api/v2/envelope/field/create-many`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ envelopeId: docId, fields: v2Fields }),
});
if (!res.ok) {
const err = await res.text();
logger.error({ docId, status: res.status, err, portId }, 'Documenso v2 placeFields error');
throw new Error(`Documenso v2 placeFields error: ${res.status}`);
}
return;
}
const dims = await getPageDimensions(docId, portId);
for (const f of fields) {
const body = {
recipientId: typeof f.recipientId === 'string' ? Number(f.recipientId) : f.recipientId,
type: f.type,
pageNumber: f.pageNumber,
pageX: Math.round((f.pageX / 100) * dims.width),
pageY: Math.round((f.pageY / 100) * dims.height),
pageWidth: Math.round((f.pageWidth / 100) * dims.width),
pageHeight: Math.round((f.pageHeight / 100) * dims.height),
};
const res = await fetch(`${baseUrl}/api/v1/documents/${docId}/fields`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.text();
logger.error({ docId, status: res.status, err, portId }, 'Documenso v1 placeField error');
throw new Error(`Documenso v1 placeField error: ${res.status}`);
}
}
}
/**
* Auto-position one SIGNATURE field per recipient at the last-page footer,
* staggered horizontally so multiple signers don't overlap. Used by the
* upload-path wizard admins can refine in Documenso afterwards.
*
* Layout (percent of page):
* y = 88 (footer band)
* height = 6
* width = min(20, 80 / N)
* x = i * (80/N) + (40 - 80/N * N / 2) (centered row)
*/
export async function placeDefaultSignatureFields(
docId: string,
recipients: Array<{ id: number | string; pageNumber: number }>,
portId?: string,
): Promise<void> {
if (recipients.length === 0) return;
const fields: DocumensoFieldPlacement[] = computeDefaultSignatureLayout(recipients);
await placeFields(docId, fields, portId);
}
/** Pure function exported for unit testing layout math. */
export function computeDefaultSignatureLayout(
recipients: Array<{ id: number | string; pageNumber: number }>,
): DocumensoFieldPlacement[] {
const n = recipients.length;
if (n === 0) return [];
const slot = Math.min(20, 80 / n); // percent width per signer
const rowWidth = slot * n;
const startX = 50 - rowWidth / 2;
return recipients.map((r, i) => ({
recipientId: r.id,
type: 'SIGNATURE',
pageNumber: r.pageNumber,
pageX: Math.max(0, startX + i * slot),
pageY: 88,
pageWidth: slot,
pageHeight: 6,
}));
}
/**
* Void/cancel a Documenso document.
*
* v1: DELETE /api/v1/documents/{id}
* v2: DELETE /api/v2/envelope/{id}
*
* Idempotent on 404 (already gone) logs and resolves.
*/
export async function voidDocument(docId: string, portId?: string): Promise<void> {
const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);
const path = apiVersion === 'v2' ? `/api/v2/envelope/${docId}` : `/api/v1/documents/${docId}`;
const res = await fetch(`${baseUrl}${path}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${apiKey}` },
});
if (res.status === 404) {
logger.warn({ docId, portId }, 'Documenso voidDocument: already deleted');
return;
}
if (!res.ok) {
const err = await res.text();
logger.error({ docId, status: res.status, err, portId }, 'Documenso voidDocument error');
throw new Error(`Documenso voidDocument error: ${res.status}`);
}
}