From cae5d396077d0736ec3f5f86525aa5c52f473062 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 26 May 2026 20:06:12 +0200 Subject: [PATCH] feat(documenso): rejection reason + poll fallback + rollback hardening + recipient UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documenso reliability + signer-UX bundle from the 2026-05-26 live UAT. Each piece detailed in docs/superpowers/audits/active-uat.md with full file:line + root cause + alternatives. Webhook + poll convergence - DocumensoRecipient (webhook payload type) gains rejectionReason + declineReason. The DOCUMENT_REJECTED / DOCUMENT_DECLINED handler coalesces them at the boundary so downstream code sees one stable field. Empty/whitespace normalised to null. - DocumensoDocument.recipients[] (normalized client output) gains rejectionReason. normalizeDocument coalesces v2 + v1 field names the same way so poller consumers see identical shape. - handleDocumentRejected signature gains rejectionReason. Stored on document_events.eventData, persisted in audit_logs metadata, quoted inline in the in-CRM rep notification (truncated 120 chars; full reason still on the audit row). New 'transfer' AuditAction added alongside. - signature-poll job now handles REJECTED / DECLINED. Previously only SIGNED / COMPLETED / EXPIRED were reconciled, so a missed rejection webhook (stale tunnel URL is the typical dev cause) left documents stuck in 'sent' forever. The 5-min poll cycle now closes that gap — webhook becomes an optimisation, not a correctness requirement. placeFields rollback gap - custom-document-upload.service moved the synchronous field-placement map() INSIDE the same try/catch that wraps placeFields(). Previously the map's throw bubbled past the catch-and-rollback block, leaving Documenso with a live envelope + recipients but no fields, and the CRM document row stuck in 'sent' with no signing UI for the signers. Logger captures looked-up email + map keys on miss for diagnosis. - Comment documents Documenso's by-email dedupe semantic so future readers don't reintroduce the per-recipient-row map assumption. UploadForSigningDialog recipient UX - New RECIPIENT_ROLE_META + RecipientRoleBadge helpers. Placement-step sidebar list rebuilt as a two-line layout (name + role badge / email on its own line) so duplicate-named recipients are visually distinguishable. FieldSidePanel dropdown SelectItem mirrors the same stacked shape. - "Recipient" label renamed to "Assign this field to" with an explainer paragraph below. SigningProgress copy-link parity - Copy-link button now always renders for pending signers (disabled + explainer tooltip when signingUrl not yet issued). Reps can copy even when the URL hasn't been distributed via email yet. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/webhooks/documenso/route.ts | 16 ++++ src/components/documents/signing-progress.tsx | 16 ++-- .../documents/upload-for-signing-dialog.tsx | 77 +++++++++++++++++-- src/jobs/processors/documenso-poll.ts | 30 ++++++++ src/lib/audit.ts | 5 ++ .../custom-document-upload.service.ts | 65 +++++++++++----- src/lib/services/documenso-client.ts | 43 +++++++---- src/lib/services/documents.service.ts | 29 ++++++- 8 files changed, 231 insertions(+), 50 deletions(-) diff --git a/src/app/api/webhooks/documenso/route.ts b/src/app/api/webhooks/documenso/route.ts index c56ff135..c9ccbb59 100644 --- a/src/app/api/webhooks/documenso/route.ts +++ b/src/app/api/webhooks/documenso/route.ts @@ -97,6 +97,13 @@ type DocumensoRecipient = { token?: string | null; signingToken?: string | null; signingUrl?: string | null; + /** Free-text reason the recipient typed into Documenso's reject dialog. + * v2 payloads carry `rejectionReason`; some 1.x payloads use the + * legacy `declineReason` field name. Either way we surface the + * cleartext to the rep so they don't have to log into Documenso to + * see why a deal stalled. */ + rejectionReason?: string | null; + declineReason?: string | null; }; type DocumensoWebhookBody = { @@ -279,9 +286,18 @@ async function handleDocumensoWebhook(req: NextRequest): Promise { const rejecting = recipients.find( (r) => r.signingStatus === 'REJECTED' || r.signingStatus === 'DECLINED', ); + // Documenso uses two field names across versions: v2 + // `rejectionReason`, some 1.x payloads `declineReason`. Coalesce + // so handlers downstream see one stable field. Empty string + // (vs null) normalised to null so the UI's "no reason given" + // copy fires consistently. + const rawReason = rejecting?.rejectionReason ?? rejecting?.declineReason ?? null; + const rejectionReason = + rawReason && rawReason.trim().length > 0 ? rawReason.trim() : null; await handleDocumentRejected({ documentId: documensoId, recipientEmail: rejecting?.email, + rejectionReason, signatureHash, ...portScope, }); diff --git a/src/components/documents/signing-progress.tsx b/src/components/documents/signing-progress.tsx index 13ab5198..a2f078b2 100644 --- a/src/components/documents/signing-progress.tsx +++ b/src/components/documents/signing-progress.tsx @@ -397,20 +397,26 @@ export function SigningProgress({ documentId, signers }: SigningProgressProps) { • `invitedAt !== null` → "Send reminder" (Documenso-side nudge, rate-limited per cooldown). • Signed/declined → no action buttons. */} - {signer.status === 'pending' && signer.signingUrl ? ( + {signer.status === 'pending' ? (