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:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user