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:
@@ -414,32 +414,57 @@ export async function uploadDocumentForSigning(
|
||||
|
||||
// Build email→recipientId map. v2 envelope create returns empty
|
||||
// recipients; distribute fills them in. v1 already has them on create.
|
||||
// Note: Documenso de-dupes by email at the envelope level, so multiple
|
||||
// CRM-side Recipient rows that share an email all map to the same
|
||||
// Documenso recipientId. That's fine for field placement — both rows
|
||||
// simply target the same Documenso recipient.
|
||||
const emailToRecipientId = new Map<string, string>();
|
||||
for (const dr of sentDoc.recipients) {
|
||||
if (dr.email) emailToRecipientId.set(dr.email.toLowerCase(), dr.id);
|
||||
}
|
||||
|
||||
// Place fields (skipped silently when empty - but we validated above).
|
||||
const placements: DocumensoFieldPlacement[] = fields.map((f) => {
|
||||
const recipient = recipients[f.recipientIndex]!;
|
||||
const recipientId = emailToRecipientId.get(recipient.email.toLowerCase());
|
||||
if (!recipientId) {
|
||||
throw new ConflictError(
|
||||
`Documenso response missing recipientId for ${recipient.email} - cannot place fields`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
recipientId,
|
||||
type: f.type,
|
||||
pageNumber: f.pageNumber,
|
||||
pageX: f.pageX,
|
||||
pageY: f.pageY,
|
||||
pageWidth: f.pageWidth,
|
||||
pageHeight: f.pageHeight,
|
||||
...(f.fieldMeta ? { fieldMeta: f.fieldMeta } : {}),
|
||||
};
|
||||
});
|
||||
// Build placements + place fields inside a SINGLE try/catch so any
|
||||
// failure — including the synchronous `fields.map` throw when a
|
||||
// recipient can't be matched — triggers the rollback path. Previously
|
||||
// the map's throw bubbled past the try-block wrapping `placeFields()`,
|
||||
// leaving Documenso with the live envelope + recipients but no fields,
|
||||
// and the CRM document row stuck in 'sent' with no signing UI for the
|
||||
// signers (UAT 2026-05-26 — "doc displays in Documenso but is missing
|
||||
// all fields").
|
||||
try {
|
||||
const placements: DocumensoFieldPlacement[] = fields.map((f) => {
|
||||
const recipient = recipients[f.recipientIndex]!;
|
||||
const recipientId = emailToRecipientId.get(recipient.email.toLowerCase());
|
||||
if (!recipientId) {
|
||||
// Surface the diagnostic state alongside the error so the rep
|
||||
// doesn't have to guess which email failed to match. Logs both
|
||||
// the looked-up email and the keys Documenso DID return so a
|
||||
// future audit can see whether the mismatch is dedupe-related
|
||||
// or a true populate failure.
|
||||
logger.error(
|
||||
{
|
||||
documentId: docRow.id,
|
||||
documensoEnvelopeId: documensoDoc.id,
|
||||
lookedUpEmail: recipient.email,
|
||||
availableEmails: Array.from(emailToRecipientId.keys()),
|
||||
},
|
||||
'Documenso recipient lookup miss during field placement',
|
||||
);
|
||||
throw new ConflictError(
|
||||
`Documenso response missing recipientId for ${recipient.email} - cannot place fields`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
recipientId,
|
||||
type: f.type,
|
||||
pageNumber: f.pageNumber,
|
||||
pageX: f.pageX,
|
||||
pageY: f.pageY,
|
||||
pageWidth: f.pageWidth,
|
||||
pageHeight: f.pageHeight,
|
||||
...(f.fieldMeta ? { fieldMeta: f.fieldMeta } : {}),
|
||||
};
|
||||
});
|
||||
await placeFields(documensoDoc.id, placements, portId);
|
||||
} catch (err) {
|
||||
await db
|
||||
|
||||
@@ -178,21 +178,30 @@ function normalizeDocument(raw: unknown): DocumensoDocument {
|
||||
(r.recipients as Array<Record<string, unknown>> | undefined) ??
|
||||
(r.Recipient 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,
|
||||
// Per-recipient signing token - required on the v1 Recipient model,
|
||||
// present on every v2 envelope-distribute response. Documenso uses
|
||||
// it as the URL tail (`/sign/<token>`) so it also matches what we
|
||||
// see on subsequent webhook deliveries.
|
||||
token: typeof rec.token === 'string' ? rec.token : undefined,
|
||||
}));
|
||||
const recipients = recipientsRaw.map((rec) => {
|
||||
// Coalesce the two field names Documenso has used for rejection
|
||||
// reason across versions. Empty string → undefined so consumers
|
||||
// can `?? null`-fallback cleanly without re-checking truthiness.
|
||||
const rawReason = (rec.rejectionReason ?? rec.declineReason) as string | undefined;
|
||||
const rejectionReason =
|
||||
typeof rawReason === 'string' && rawReason.trim().length > 0 ? rawReason.trim() : undefined;
|
||||
return {
|
||||
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,
|
||||
// Per-recipient signing token - required on the v1 Recipient model,
|
||||
// present on every v2 envelope-distribute response. Documenso uses
|
||||
// it as the URL tail (`/sign/<token>`) so it also matches what we
|
||||
// see on subsequent webhook deliveries.
|
||||
token: typeof rec.token === 'string' ? rec.token : undefined,
|
||||
rejectionReason,
|
||||
};
|
||||
});
|
||||
return { id, numericId, status, recipients };
|
||||
}
|
||||
|
||||
@@ -224,6 +233,10 @@ export interface DocumensoDocument {
|
||||
* match recipients without leaning on email (which may be reused
|
||||
* across roles). */
|
||||
token?: string;
|
||||
/** Free-text rejection reason. v2 payloads use `rejectionReason`;
|
||||
* some 1.x payloads use the legacy `declineReason`. Coalesced
|
||||
* here so downstream poll/webhook consumers see one stable field. */
|
||||
rejectionReason?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
Reference in New Issue
Block a user