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

@@ -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' ? (
<Button
variant="ghost"
variant="outline"
size="sm"
className="h-7 shrink-0 gap-1.5 px-2.5 text-xs text-muted-foreground hover:text-foreground [&_svg]:size-3"
className="h-7 shrink-0 gap-1.5 px-2.5 text-xs [&_svg]:size-3"
disabled={!signer.signingUrl}
onClick={async () => {
if (!signer.signingUrl) return;
try {
await navigator.clipboard.writeText(signer.signingUrl!);
await navigator.clipboard.writeText(signer.signingUrl);
toast.success(`Signing link for ${signer.signerName} copied`);
} catch {
toast.error('Could not copy to clipboard');
}
}}
title="Copy this signer's Documenso URL to the clipboard - for QA or manual delivery."
title={
signer.signingUrl
? "Copy this signer's signing link to your clipboard so you can share it directly (Slack, WhatsApp, in person) without going through email."
: 'Signing URL is not available yet — Documenso issues it once the document has been sent.'
}
>
<Link2 />
Copy link

View File

@@ -152,6 +152,45 @@ const RECIPIENT_COLORS = [
'rgb(20 184 166)', // teal-500
];
/**
* Display label + colour tint for the three Documenso recipient roles.
* Surfaced as a small pill next to each recipient in the placement
* step's sidebar AND in the FieldSidePanel's assignment dropdown so
* the rep sees the role context without hunting for it in step 2.
*
* - SIGNER: active responsibility (must sign + fill fields). Blue.
* - APPROVER: gate before signers (approves before the envelope
* reaches the next recipient). Amber to read as oversight, not
* blocker.
* - CC: receives the final document for awareness; no action. Muted.
*/
const RECIPIENT_ROLE_META: Record<Recipient['role'], { label: string; className: string }> = {
SIGNER: {
label: 'Signer',
className: 'bg-blue-100 text-blue-900 border-blue-200',
},
APPROVER: {
label: 'Approver',
className: 'bg-amber-100 text-amber-900 border-amber-200',
},
CC: {
label: 'CC',
className: 'bg-slate-100 text-slate-700 border-slate-200',
},
};
function RecipientRoleBadge({ role }: { role: Recipient['role'] }) {
const meta = RECIPIENT_ROLE_META[role];
return (
<span
className={`inline-flex items-center rounded-full border px-1.5 py-0 text-xs font-medium ${meta.className}`}
title={`Role: ${meta.label}`}
>
{meta.label}
</span>
);
}
export interface UploadForSigningEntity {
type: 'client' | 'company' | 'yacht';
id: string;
@@ -1106,15 +1145,27 @@ function FieldPlacementStep({
)}
<hr className="my-3 border-muted-foreground/20" />
<p className="text-xs font-medium text-muted-foreground mb-2">Recipients</p>
<div className="space-y-1">
<div className="space-y-2">
{recipients.map((r, i) => (
<div key={i} className="text-xs flex items-center gap-2">
<div key={i} className="flex items-start gap-2 text-xs">
<span
className="size-2 rounded-full shrink-0"
className="mt-1 size-2 rounded-full shrink-0"
style={{ backgroundColor: RECIPIENT_COLORS[i % RECIPIENT_COLORS.length] }}
aria-hidden
/>
<span className="truncate">{r.name || r.email || `#${r.signingOrder}`}</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate font-medium">{r.name || `#${r.signingOrder}`}</span>
<RecipientRoleBadge role={r.role} />
</div>
{/* Email shown separately from name so reps with duplicate
names (e.g. multiple "Matt" recipients) can still tell
rows apart. Empty/blank emails fall back to a muted
placeholder so the row doesn't shift size. */}
<p className="truncate text-muted-foreground" title={r.email}>
{r.email || 'no email set'}
</p>
</div>
</div>
))}
</div>
@@ -1396,7 +1447,7 @@ function FieldSidePanel({
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Recipient</Label>
<Label className="text-xs">Assign this field to</Label>
<Select
value={String(field.recipientIndex)}
onValueChange={(v) => onUpdate({ recipientIndex: Number(v) })}
@@ -1407,11 +1458,25 @@ function FieldSidePanel({
<SelectContent>
{recipients.map((r, i) => (
<SelectItem key={i} value={String(i)}>
#{r.signingOrder} {r.name || r.email}
<span className="flex flex-col items-start gap-0.5 text-left">
<span className="flex items-center gap-1.5">
<span className="font-medium">
#{r.signingOrder} {r.name || `Recipient ${r.signingOrder}`}
</span>
<RecipientRoleBadge role={r.role} />
</span>
{r.email ? (
<span className="text-xs text-muted-foreground">{r.email}</span>
) : null}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Whoever is selected here is the only person who will see and fill this field when the
document is sent for signing.
</p>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">