feat(documenso): rejection reason + poll fallback + rollback hardening + recipient UX

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 20:06:12 +02:00
parent 2f1eba3e57
commit cae5d39607
8 changed files with 231 additions and 50 deletions

View File

@@ -1920,6 +1920,12 @@ export async function handleDocumentOpened(eventData: {
export async function handleDocumentRejected(eventData: {
documentId: string;
recipientEmail?: string;
/** Free-text reason the rejecting recipient typed into Documenso's
* reject dialog. Coalesced from v2 `rejectionReason` / legacy
* `declineReason` at the webhook boundary. Stored on the rejection
* event row, the audit log, and surfaced in the rep notification so
* the rep doesn't have to bounce to Documenso to read it. */
rejectionReason?: string | null;
signatureHash?: string;
portId?: string;
}) {
@@ -1960,7 +1966,10 @@ export async function handleDocumentRejected(eventData: {
eventType: 'rejected',
signerId,
signatureHash: eventData.signatureHash ?? null,
eventData: { recipientEmail: eventData.recipientEmail ?? null },
eventData: {
recipientEmail: eventData.recipientEmail ?? null,
rejectionReason: eventData.rejectionReason ?? null,
},
})
.onConflictDoNothing();
@@ -1984,14 +1993,25 @@ export async function handleDocumentRejected(eventData: {
const targetUserId = interest?.assignedTo ?? null;
if (targetUserId) {
const { createNotification } = await import('@/lib/services/notifications.service');
// Surface the rejection reason inline when Documenso sent one.
// Truncate aggressively (notification bell tiles cap at ~80 chars
// before wrapping); full reason still lives on the audit row.
const reasonSnippet = eventData.rejectionReason
? eventData.rejectionReason.length > 120
? `${eventData.rejectionReason.slice(0, 117)}`
: eventData.rejectionReason
: null;
const baseDescription = eventData.recipientEmail
? `${eventData.recipientEmail} declined to sign`
: 'A signer declined the EOI';
void createNotification({
portId: doc.portId,
userId: targetUserId,
type: 'document_rejected',
title: 'EOI declined',
description: eventData.recipientEmail
? `${eventData.recipientEmail} declined to sign - review and regenerate.`
: 'A signer declined the EOI - review and regenerate.',
description: reasonSnippet
? `${baseDescription}: "${reasonSnippet}" — review and regenerate.`
: `${baseDescription} review and regenerate.`,
link: `/interests/${doc.interestId}?tab=eoi`,
entityType: 'document',
entityId: doc.id,
@@ -2014,6 +2034,7 @@ export async function handleDocumentRejected(eventData: {
metadata: {
type: 'document_declined',
signerEmail: eventData.recipientEmail ?? null,
rejectionReason: eventData.rejectionReason ?? null,
},
ipAddress: '',
userAgent: '',