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:
@@ -97,6 +97,13 @@ type DocumensoRecipient = {
|
|||||||
token?: string | null;
|
token?: string | null;
|
||||||
signingToken?: string | null;
|
signingToken?: string | null;
|
||||||
signingUrl?: 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 = {
|
type DocumensoWebhookBody = {
|
||||||
@@ -279,9 +286,18 @@ async function handleDocumensoWebhook(req: NextRequest): Promise<NextResponse> {
|
|||||||
const rejecting = recipients.find(
|
const rejecting = recipients.find(
|
||||||
(r) => r.signingStatus === 'REJECTED' || r.signingStatus === 'DECLINED',
|
(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({
|
await handleDocumentRejected({
|
||||||
documentId: documensoId,
|
documentId: documensoId,
|
||||||
recipientEmail: rejecting?.email,
|
recipientEmail: rejecting?.email,
|
||||||
|
rejectionReason,
|
||||||
signatureHash,
|
signatureHash,
|
||||||
...portScope,
|
...portScope,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -397,20 +397,26 @@ export function SigningProgress({ documentId, signers }: SigningProgressProps) {
|
|||||||
• `invitedAt !== null` → "Send reminder"
|
• `invitedAt !== null` → "Send reminder"
|
||||||
(Documenso-side nudge, rate-limited per cooldown).
|
(Documenso-side nudge, rate-limited per cooldown).
|
||||||
• Signed/declined → no action buttons. */}
|
• Signed/declined → no action buttons. */}
|
||||||
{signer.status === 'pending' && signer.signingUrl ? (
|
{signer.status === 'pending' ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="sm"
|
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 () => {
|
onClick={async () => {
|
||||||
|
if (!signer.signingUrl) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(signer.signingUrl!);
|
await navigator.clipboard.writeText(signer.signingUrl);
|
||||||
toast.success(`Signing link for ${signer.signerName} copied`);
|
toast.success(`Signing link for ${signer.signerName} copied`);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Could not copy to clipboard');
|
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 />
|
<Link2 />
|
||||||
Copy link
|
Copy link
|
||||||
|
|||||||
@@ -152,6 +152,45 @@ const RECIPIENT_COLORS = [
|
|||||||
'rgb(20 184 166)', // teal-500
|
'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 {
|
export interface UploadForSigningEntity {
|
||||||
type: 'client' | 'company' | 'yacht';
|
type: 'client' | 'company' | 'yacht';
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1106,15 +1145,27 @@ function FieldPlacementStep({
|
|||||||
)}
|
)}
|
||||||
<hr className="my-3 border-muted-foreground/20" />
|
<hr className="my-3 border-muted-foreground/20" />
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-2">Recipients</p>
|
<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) => (
|
{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
|
<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] }}
|
style={{ backgroundColor: RECIPIENT_COLORS[i % RECIPIENT_COLORS.length] }}
|
||||||
aria-hidden
|
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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1396,7 +1447,7 @@ function FieldSidePanel({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Recipient</Label>
|
<Label className="text-xs">Assign this field to</Label>
|
||||||
<Select
|
<Select
|
||||||
value={String(field.recipientIndex)}
|
value={String(field.recipientIndex)}
|
||||||
onValueChange={(v) => onUpdate({ recipientIndex: Number(v) })}
|
onValueChange={(v) => onUpdate({ recipientIndex: Number(v) })}
|
||||||
@@ -1407,11 +1458,25 @@ function FieldSidePanel({
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{recipients.map((r, i) => (
|
{recipients.map((r, i) => (
|
||||||
<SelectItem key={i} value={String(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>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
handleRecipientSigned,
|
handleRecipientSigned,
|
||||||
handleDocumentCompleted,
|
handleDocumentCompleted,
|
||||||
handleDocumentExpired,
|
handleDocumentExpired,
|
||||||
|
handleDocumentRejected,
|
||||||
} from '@/lib/services/documents.service';
|
} from '@/lib/services/documents.service';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
@@ -74,6 +75,35 @@ export async function processDocumensoPoll(): Promise<void> {
|
|||||||
'Reconciling expired document from poll',
|
'Reconciling expired document from poll',
|
||||||
);
|
);
|
||||||
await handleDocumentExpired({ documentId: doc.documensoId, portId: doc.portId });
|
await handleDocumentExpired({ documentId: doc.documensoId, portId: doc.portId });
|
||||||
|
} else if (
|
||||||
|
(remoteDoc.status === 'REJECTED' || remoteDoc.status === 'DECLINED') &&
|
||||||
|
doc.status !== 'rejected'
|
||||||
|
) {
|
||||||
|
// Rejection / decline reconcile — mirrors the webhook handler's
|
||||||
|
// logic so a missed `DOCUMENT_REJECTED` / `DOCUMENT_DECLINED`
|
||||||
|
// webhook delivery (stale tunnel URL, signature mismatch, etc.)
|
||||||
|
// still converges within one poll cycle. Finds the rejecting
|
||||||
|
// recipient by status, plucks the free-text reason if Documenso
|
||||||
|
// carried one, hands off to the same handler the webhook uses
|
||||||
|
// — so document_events, audit log, notification, and UI all
|
||||||
|
// see the same shape regardless of delivery path.
|
||||||
|
const rejecting = remoteDoc.recipients.find(
|
||||||
|
(r) => r.status === 'REJECTED' || r.status === 'DECLINED',
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
documentId: doc.id,
|
||||||
|
portId: doc.portId,
|
||||||
|
rejectingEmail: rejecting?.email ?? null,
|
||||||
|
},
|
||||||
|
'Reconciling rejected document from poll',
|
||||||
|
);
|
||||||
|
await handleDocumentRejected({
|
||||||
|
documentId: doc.documensoId,
|
||||||
|
recipientEmail: rejecting?.email,
|
||||||
|
rejectionReason: rejecting?.rejectionReason ?? null,
|
||||||
|
portId: doc.portId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ export type AuditAction =
|
|||||||
// and the FTS GENERATED index missed entirely.
|
// and the FTS GENERATED index missed entirely.
|
||||||
| 'outcome_set'
|
| 'outcome_set'
|
||||||
| 'outcome_cleared'
|
| 'outcome_cleared'
|
||||||
|
// Ownership transfer — yacht/company switches owner. Distinct from
|
||||||
|
// `update` so the EntityActivityFeed renders "transferred ownership"
|
||||||
|
// instead of the generic "updated this record." Old/new owner names
|
||||||
|
// surface via the standard fieldChanged + oldValue + newValue trio.
|
||||||
|
| 'transfer'
|
||||||
// Phase 3 - EOI override / contact promote / yacht spawn from EOI.
|
// Phase 3 - EOI override / contact promote / yacht spawn from EOI.
|
||||||
// The DB column is free-text per migration 0073; these strings just
|
// The DB column is free-text per migration 0073; these strings just
|
||||||
// formalise the catalogue so the audit-log filter dropdown can surface
|
// formalise the catalogue so the audit-log filter dropdown can surface
|
||||||
|
|||||||
@@ -414,32 +414,57 @@ export async function uploadDocumentForSigning(
|
|||||||
|
|
||||||
// Build email→recipientId map. v2 envelope create returns empty
|
// Build email→recipientId map. v2 envelope create returns empty
|
||||||
// recipients; distribute fills them in. v1 already has them on create.
|
// 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>();
|
const emailToRecipientId = new Map<string, string>();
|
||||||
for (const dr of sentDoc.recipients) {
|
for (const dr of sentDoc.recipients) {
|
||||||
if (dr.email) emailToRecipientId.set(dr.email.toLowerCase(), dr.id);
|
if (dr.email) emailToRecipientId.set(dr.email.toLowerCase(), dr.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Place fields (skipped silently when empty - but we validated above).
|
// Build placements + place fields inside a SINGLE try/catch so any
|
||||||
const placements: DocumensoFieldPlacement[] = fields.map((f) => {
|
// failure — including the synchronous `fields.map` throw when a
|
||||||
const recipient = recipients[f.recipientIndex]!;
|
// recipient can't be matched — triggers the rollback path. Previously
|
||||||
const recipientId = emailToRecipientId.get(recipient.email.toLowerCase());
|
// the map's throw bubbled past the try-block wrapping `placeFields()`,
|
||||||
if (!recipientId) {
|
// leaving Documenso with the live envelope + recipients but no fields,
|
||||||
throw new ConflictError(
|
// and the CRM document row stuck in 'sent' with no signing UI for the
|
||||||
`Documenso response missing recipientId for ${recipient.email} - cannot place fields`,
|
// signers (UAT 2026-05-26 — "doc displays in Documenso but is missing
|
||||||
);
|
// all 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 } : {}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
try {
|
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);
|
await placeFields(documensoDoc.id, placements, portId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await db
|
await db
|
||||||
|
|||||||
@@ -178,21 +178,30 @@ function normalizeDocument(raw: unknown): DocumensoDocument {
|
|||||||
(r.recipients as Array<Record<string, unknown>> | undefined) ??
|
(r.recipients as Array<Record<string, unknown>> | undefined) ??
|
||||||
(r.Recipient as Array<Record<string, unknown>> | undefined) ??
|
(r.Recipient as Array<Record<string, unknown>> | undefined) ??
|
||||||
[];
|
[];
|
||||||
const recipients = recipientsRaw.map((rec) => ({
|
const recipients = recipientsRaw.map((rec) => {
|
||||||
id: String(rec.recipientId ?? rec.id ?? ''),
|
// Coalesce the two field names Documenso has used for rejection
|
||||||
name: String(rec.name ?? ''),
|
// reason across versions. Empty string → undefined so consumers
|
||||||
email: String(rec.email ?? ''),
|
// can `?? null`-fallback cleanly without re-checking truthiness.
|
||||||
role: String(rec.role ?? ''),
|
const rawReason = (rec.rejectionReason ?? rec.declineReason) as string | undefined;
|
||||||
signingOrder: Number(rec.signingOrder ?? 0),
|
const rejectionReason =
|
||||||
status: String(rec.signingStatus ?? rec.status ?? 'PENDING'),
|
typeof rawReason === 'string' && rawReason.trim().length > 0 ? rawReason.trim() : undefined;
|
||||||
signingUrl: typeof rec.signingUrl === 'string' ? rec.signingUrl : undefined,
|
return {
|
||||||
embeddedUrl: typeof rec.embeddedUrl === 'string' ? rec.embeddedUrl : undefined,
|
id: String(rec.recipientId ?? rec.id ?? ''),
|
||||||
// Per-recipient signing token - required on the v1 Recipient model,
|
name: String(rec.name ?? ''),
|
||||||
// present on every v2 envelope-distribute response. Documenso uses
|
email: String(rec.email ?? ''),
|
||||||
// it as the URL tail (`/sign/<token>`) so it also matches what we
|
role: String(rec.role ?? ''),
|
||||||
// see on subsequent webhook deliveries.
|
signingOrder: Number(rec.signingOrder ?? 0),
|
||||||
token: typeof rec.token === 'string' ? rec.token : undefined,
|
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 };
|
return { id, numericId, status, recipients };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +233,10 @@ export interface DocumensoDocument {
|
|||||||
* match recipients without leaning on email (which may be reused
|
* match recipients without leaning on email (which may be reused
|
||||||
* across roles). */
|
* across roles). */
|
||||||
token?: string;
|
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: {
|
export async function handleDocumentRejected(eventData: {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
recipientEmail?: 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;
|
signatureHash?: string;
|
||||||
portId?: string;
|
portId?: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -1960,7 +1966,10 @@ export async function handleDocumentRejected(eventData: {
|
|||||||
eventType: 'rejected',
|
eventType: 'rejected',
|
||||||
signerId,
|
signerId,
|
||||||
signatureHash: eventData.signatureHash ?? null,
|
signatureHash: eventData.signatureHash ?? null,
|
||||||
eventData: { recipientEmail: eventData.recipientEmail ?? null },
|
eventData: {
|
||||||
|
recipientEmail: eventData.recipientEmail ?? null,
|
||||||
|
rejectionReason: eventData.rejectionReason ?? null,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
|
|
||||||
@@ -1984,14 +1993,25 @@ export async function handleDocumentRejected(eventData: {
|
|||||||
const targetUserId = interest?.assignedTo ?? null;
|
const targetUserId = interest?.assignedTo ?? null;
|
||||||
if (targetUserId) {
|
if (targetUserId) {
|
||||||
const { createNotification } = await import('@/lib/services/notifications.service');
|
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({
|
void createNotification({
|
||||||
portId: doc.portId,
|
portId: doc.portId,
|
||||||
userId: targetUserId,
|
userId: targetUserId,
|
||||||
type: 'document_rejected',
|
type: 'document_rejected',
|
||||||
title: 'EOI declined',
|
title: 'EOI declined',
|
||||||
description: eventData.recipientEmail
|
description: reasonSnippet
|
||||||
? `${eventData.recipientEmail} declined to sign - review and regenerate.`
|
? `${baseDescription}: "${reasonSnippet}" — review and regenerate.`
|
||||||
: 'A signer declined the EOI - review and regenerate.',
|
: `${baseDescription} — review and regenerate.`,
|
||||||
link: `/interests/${doc.interestId}?tab=eoi`,
|
link: `/interests/${doc.interestId}?tab=eoi`,
|
||||||
entityType: 'document',
|
entityType: 'document',
|
||||||
entityId: doc.id,
|
entityId: doc.id,
|
||||||
@@ -2014,6 +2034,7 @@ export async function handleDocumentRejected(eventData: {
|
|||||||
metadata: {
|
metadata: {
|
||||||
type: 'document_declined',
|
type: 'document_declined',
|
||||||
signerEmail: eventData.recipientEmail ?? null,
|
signerEmail: eventData.recipientEmail ?? null,
|
||||||
|
rejectionReason: eventData.rejectionReason ?? null,
|
||||||
},
|
},
|
||||||
ipAddress: '',
|
ipAddress: '',
|
||||||
userAgent: '',
|
userAgent: '',
|
||||||
|
|||||||
Reference in New Issue
Block a user