feat(documenso-audit-phase-1): persist documensoId early + preflight + state machine + reconciliation + tests
Phase 1 of the comprehensive Documenso upload audit per the 2026-05-26 locked-decisions block in docs/superpowers/audits/active-uat.md. P1.1 — persist documensoId immediately after create Was set only at the late `status: 'sent'` commit. Any throw between documensoCreate and the late update left an orphaned Documenso envelope the CRM had no link to. Now the UPDATE runs right after documensoCreate succeeds; rollback paths can find and void the envelope. P1.2 — pre-flight validation hard-blocks Submit UploadForSigningDialog computes a submissionErrors memo over recipients + fields. Submit button disabled when errors > 0. Inline amber summary lists every issue (missing email, invalid email, missing name, field assigned to non-existent recipient, no fields placed). Service layer mirrors the same email + name checks so direct API hits reject early. No override path per locked decision. P1.3 — cancel/delete affordance audit + sweep Document-list per-row Delete + Send for Signing actions now: - Wrapped in PermissionGate (documents.delete + send_for_signing). - Surface toast on success + toastError on failure (were silently swallowing errors). - Use a broader predicate-based query invalidation so every doc list across the app refreshes, not just the local key. EOI tab Regenerate + Cancel EOI buttons + reservation/contract tab Cancel buttons wrapped in PermissionGate (documents.edit, the cancel route's auth check). P1.4 — Documenso webhook URL auto-PATCH (env-gated) scripts/update-documenso-webhook.ts written. Reads DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK env flag (when 1, runs; otherwise no-op). Lists every webhook on the Documenso instance via v2 (with v1 fallback), identifies webhooks pointing at trycloudflare.com hosts OR /api/webhooks/documenso paths, PATCHes them to the new tunnel URL. scripts/tunnel-url.sh chains the script after the URL print so a re-tunnel auto-rotates the webhook (when flag set). P1.5 — state-machine refactor with rollbackTo() helper custom-document-upload.service.ts: - Single try around create → send → place steps. - state.step tracks which step is current; state.documensoDocId records the envelope id once we have it. - rollbackTo(reason) composes the recovery: status='cancelled' on the CRM row, documensoVoidSafe on the envelope when applicable. Idempotent — calling twice is safe. - Removes three independent try/catches. P1.6 — recipient ↔ Documenso identity reconciliation After documensoSend, validates every distinct email we sent appears in sentDoc.recipients. If Documenso silently dropped one, a ConflictError fires before field placement so the rollback path triggers. Explicit message names the missing emails for the rep. P1.7 — vitest extension + per-failure audit-log entries - 5 new vitest cases (blank email, whitespace email, malformed email, blank name, duplicate-emails-OK semantic). - rollbackTo writes a structured audit_log entry with failedStep, documensoEnvelopeId, errorClass, errorMessage. Post-mortem investigation has structured data instead of just logger lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,9 @@
|
||||
import { useState } from 'react';
|
||||
import { Download, FolderInput } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -126,7 +129,9 @@ function DocRow({ doc, onDelete, onSend, onPreview }: DocRowProps) {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{doc.status === 'draft' && (
|
||||
<DropdownMenuItem onClick={() => onSend(doc)}>Send for Signing</DropdownMenuItem>
|
||||
<PermissionGate resource="documents" action="send_for_signing">
|
||||
<DropdownMenuItem onClick={() => onSend(doc)}>Send for Signing</DropdownMenuItem>
|
||||
</PermissionGate>
|
||||
)}
|
||||
<PermissionGate resource="documents" action="manage_folders">
|
||||
<DropdownMenuItem onSelect={() => setMoveOpen(true)}>
|
||||
@@ -134,12 +139,14 @@ function DocRow({ doc, onDelete, onSend, onPreview }: DocRowProps) {
|
||||
Move to folder…
|
||||
</DropdownMenuItem>
|
||||
</PermissionGate>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(doc)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
<PermissionGate resource="documents" action="delete">
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(doc)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</PermissionGate>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
@@ -179,18 +186,23 @@ export function DocumentList({ interestId, clientId, emptyState }: DocumentListP
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiFetch(`/api/v1/documents/${doc.id}`, { method: 'DELETE' });
|
||||
queryClient.invalidateQueries({ queryKey: ['documents', { interestId, clientId }] });
|
||||
} catch {
|
||||
// silent
|
||||
// Broader predicate matches every documents query (interest tab
|
||||
// counter, doc detail, hub root, etc.) so the row disappears
|
||||
// everywhere without requiring per-key invalidation knowledge.
|
||||
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
|
||||
toast.success('Document deleted');
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async (doc: DocumentRow) => {
|
||||
try {
|
||||
await apiFetch(`/api/v1/documents/${doc.id}/send`, { method: 'POST' });
|
||||
queryClient.invalidateQueries({ queryKey: ['documents', { interestId, clientId }] });
|
||||
} catch {
|
||||
// silent
|
||||
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
|
||||
toast.success('Sent for signing');
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -543,6 +543,46 @@ function DialogBody({
|
||||
},
|
||||
});
|
||||
|
||||
// Pre-flight validation — surfaces every reason Submit would fail
|
||||
// BEFORE the rep clicks, so they don't hit a partial-state Documenso
|
||||
// failure. Mirrors the service-side validation in
|
||||
// custom-document-upload.service.ts so the UI rejection set always
|
||||
// matches what the server would reject. Hard block: Submit stays
|
||||
// disabled until this returns []. Per the 2026-05-26 decision, no
|
||||
// override path — Documenso's API can't recover from missing emails.
|
||||
const submissionErrors = useMemo<string[]>(() => {
|
||||
const errors: string[] = [];
|
||||
if (recipients.length === 0) {
|
||||
errors.push('Add at least one recipient before sending.');
|
||||
}
|
||||
const emailRegex = /^\S+@\S+\.\S+$/;
|
||||
recipients.forEach((r) => {
|
||||
const label = `Recipient #${r.signingOrder}`;
|
||||
const name = (r.name ?? '').trim();
|
||||
const email = (r.email ?? '').trim();
|
||||
if (!name) {
|
||||
errors.push(`${label} is missing a name.`);
|
||||
}
|
||||
if (!email) {
|
||||
errors.push(`${label} is missing an email address.`);
|
||||
} else if (!emailRegex.test(email)) {
|
||||
errors.push(`${label} has an invalid email address (${email}).`);
|
||||
}
|
||||
});
|
||||
if (fields.length === 0) {
|
||||
errors.push('Place at least one field on the document.');
|
||||
}
|
||||
fields.forEach((f, idx) => {
|
||||
const r = recipients[f.recipientIndex];
|
||||
if (!r) {
|
||||
errors.push(
|
||||
`Field #${idx + 1} (${FIELD_DEFAULTS[f.type].label} on page ${f.pageNumber}) is assigned to a recipient that no longer exists. Re-assign or delete it.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
return errors;
|
||||
}, [recipients, fields]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -714,6 +754,30 @@ function DialogBody({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pre-flight validation summary. Surfaces every reason Submit
|
||||
would fail BEFORE the rep clicks, so a missing email or a
|
||||
field assigned to a deleted recipient is caught here instead
|
||||
of via a Documenso silent-failure round-trip. Renders only on
|
||||
the place-fields step (where Submit lives) and only when
|
||||
there are issues to call out. */}
|
||||
{step === 'place-fields' && submissionErrors.length > 0 && (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="mx-6 mb-2 flex-shrink-0 rounded-md border border-amber-300 bg-amber-50 p-3 text-xs"
|
||||
>
|
||||
<p className="font-semibold text-amber-900">
|
||||
Resolve {submissionErrors.length} issue
|
||||
{submissionErrors.length === 1 ? '' : 's'} before sending:
|
||||
</p>
|
||||
<ul className="mt-1 list-inside list-disc space-y-0.5 text-amber-900">
|
||||
{submissionErrors.map((e, i) => (
|
||||
<li key={i}>{e}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="px-6 py-4 border-t flex-shrink-0 flex items-center gap-2">
|
||||
<StepIndicator step={step} />
|
||||
<div className="ml-auto flex gap-2">
|
||||
@@ -758,7 +822,12 @@ function DialogBody({
|
||||
{step === 'place-fields' && (
|
||||
<Button
|
||||
onClick={() => sendMutation.mutate()}
|
||||
disabled={sendMutation.isPending || fields.length === 0}
|
||||
disabled={sendMutation.isPending || submissionErrors.length > 0}
|
||||
title={
|
||||
submissionErrors.length > 0
|
||||
? `Resolve ${submissionErrors.length} issue${submissionErrors.length === 1 ? '' : 's'} before sending`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{sendMutation.isPending && <Loader2 className="size-4 mr-2 animate-spin" />}
|
||||
{defaults?.data?.sendMode === 'auto' ? 'Send for signing' : 'Send'}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { toast } from 'sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
|
||||
import { MarkExternallySignedDialog } from '@/components/interests/mark-externally-signed-dialog';
|
||||
import { SigningProgress } from '@/components/documents/signing-progress';
|
||||
@@ -342,17 +343,19 @@ function ActiveContractCard({
|
||||
<Upload />
|
||||
Upload paper-signed copy
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={cancelMutation.isPending}
|
||||
onClick={() => setCancelDialogOpen(true)}
|
||||
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
|
||||
>
|
||||
<XCircle />
|
||||
Cancel contract
|
||||
</Button>
|
||||
<PermissionGate resource="documents" action="edit">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={cancelMutation.isPending}
|
||||
onClick={() => setCancelDialogOpen(true)}
|
||||
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
|
||||
>
|
||||
<XCircle />
|
||||
Cancel contract
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
</footer>
|
||||
<CancelDocumentDialog
|
||||
|
||||
@@ -29,6 +29,7 @@ import { MarkExternallySignedDialog } from '@/components/interests/mark-external
|
||||
import { SigningProgress } from '@/components/documents/signing-progress';
|
||||
import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog';
|
||||
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
import { useConfirmation } from '@/hooks/use-confirmation';
|
||||
@@ -513,51 +514,57 @@ function ActiveEoiCard({
|
||||
{/* Regenerate is only safe when no one has signed yet - once
|
||||
signatures are on the doc, the rep must go through the
|
||||
cancel-with-notify path so collaborators learn about the
|
||||
discard. */}
|
||||
discard. Wrapped in PermissionGate so reps without the
|
||||
cancel perm don't see the action at all (matches the
|
||||
Cancel EOI button gating below). */}
|
||||
{signedCount === 0 ? (
|
||||
<PermissionGate resource="documents" action="edit">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Regenerate this EOI?',
|
||||
description:
|
||||
'The current envelope will be voided silently - no recipients will be notified - and the generate dialog will re-open so you can rebuild.',
|
||||
confirmLabel: 'Regenerate',
|
||||
});
|
||||
if (ok) {
|
||||
try {
|
||||
await apiFetch(`/api/v1/documents/${doc.id}/cancel`, {
|
||||
method: 'POST',
|
||||
body: { reason: 'regenerated', notifyRecipients: [] },
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (q) => q.queryKey[0] === 'documents',
|
||||
});
|
||||
toast.success('EOI voided. Regenerate now.');
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="h-7 gap-1.5 text-xs [&_svg]:size-3"
|
||||
title="Void the current envelope (no notifications) and rebuild from scratch."
|
||||
>
|
||||
<RefreshCw />
|
||||
Regenerate
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
) : null}
|
||||
<PermissionGate resource="documents" action="edit">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Regenerate this EOI?',
|
||||
description:
|
||||
'The current envelope will be voided silently - no recipients will be notified - and the generate dialog will re-open so you can rebuild.',
|
||||
confirmLabel: 'Regenerate',
|
||||
});
|
||||
if (ok) {
|
||||
try {
|
||||
await apiFetch(`/api/v1/documents/${doc.id}/cancel`, {
|
||||
method: 'POST',
|
||||
body: { reason: 'regenerated', notifyRecipients: [] },
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (q) => q.queryKey[0] === 'documents',
|
||||
});
|
||||
toast.success('EOI voided. Regenerate now.');
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="h-7 gap-1.5 text-xs [&_svg]:size-3"
|
||||
title="Void the current envelope (no notifications) and rebuild from scratch."
|
||||
onClick={() => setCancelOpen(true)}
|
||||
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
|
||||
>
|
||||
<RefreshCw />
|
||||
Regenerate
|
||||
<XCircle />
|
||||
Cancel EOI
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCancelOpen(true)}
|
||||
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
|
||||
>
|
||||
<XCircle />
|
||||
Cancel EOI
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
</footer>
|
||||
) : null}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { toast } from 'sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
|
||||
import { MarkExternallySignedDialog } from '@/components/interests/mark-externally-signed-dialog';
|
||||
import { SigningProgress } from '@/components/documents/signing-progress';
|
||||
@@ -338,17 +339,19 @@ function ActiveReservationCard({
|
||||
<Upload />
|
||||
Upload paper-signed copy
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={cancelMutation.isPending}
|
||||
onClick={() => setCancelDialogOpen(true)}
|
||||
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
|
||||
>
|
||||
<XCircle />
|
||||
Cancel reservation
|
||||
</Button>
|
||||
<PermissionGate resource="documents" action="edit">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={cancelMutation.isPending}
|
||||
onClick={() => setCancelDialogOpen(true)}
|
||||
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
|
||||
>
|
||||
<XCircle />
|
||||
Cancel reservation
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
</footer>
|
||||
<CancelDocumentDialog
|
||||
|
||||
@@ -193,6 +193,27 @@ export async function uploadDocumentForSigning(
|
||||
);
|
||||
}
|
||||
}
|
||||
// Recipient-level validation — emails and names. Documenso's API
|
||||
// can't recover from a missing/invalid email (it'll silently drop the
|
||||
// recipient or accept the envelope without distribution), so hard
|
||||
// block at the service boundary. The UI mirrors this validation
|
||||
// pre-Submit so the rep sees the issue before the round-trip.
|
||||
const recipientEmailRegex = /^\S+@\S+\.\S+$/;
|
||||
for (const r of recipients) {
|
||||
const email = (r.email ?? '').trim();
|
||||
if (!email) {
|
||||
throw new ValidationError(`Recipient #${r.signingOrder} is missing an email address`);
|
||||
}
|
||||
if (!recipientEmailRegex.test(email)) {
|
||||
throw new ValidationError(
|
||||
`Recipient #${r.signingOrder} has an invalid email address: ${email}`,
|
||||
);
|
||||
}
|
||||
const name = (r.name ?? '').trim();
|
||||
if (!name) {
|
||||
throw new ValidationError(`Recipient #${r.signingOrder} is missing a name`);
|
||||
}
|
||||
}
|
||||
// Defense-in-depth: a duplicate signing-order would let Documenso
|
||||
// accept the doc but break the cascading-invite logic (next signer
|
||||
// picker assumes a strict ordering).
|
||||
@@ -373,74 +394,143 @@ export async function uploadDocumentForSigning(
|
||||
signingOrder: r.signingOrder,
|
||||
}));
|
||||
|
||||
// Documenso round-trip wrapped in try/catch so a failed
|
||||
// create/send/placeFields call doesn't leave a phantom `draft` row
|
||||
// sitting at the top of the Reservation/Contract tab forever. On
|
||||
// failure we mark the local row `cancelled` and (best-effort) void
|
||||
// any envelope we already minted upstream, then re-throw - caller
|
||||
// sees the same DOCUMENSO_UPSTREAM_ERROR as before, but the
|
||||
// dashboard state stays clean. Previously, repeated send failures
|
||||
// accumulated abandoned drafts that masked the rep's real working
|
||||
// document.
|
||||
// ─── State machine: create → send → place → promote ────────────
|
||||
// Every step that can fail registers its rollback contribution on
|
||||
// `state`. The single catch at the end runs `rollbackTo(state)`,
|
||||
// which composes the recovery: status='cancelled' on the local row
|
||||
// always, void the Documenso envelope only when we created one.
|
||||
// Idempotent — calling it twice is safe (status flip is a no-op the
|
||||
// second time, voidDocument treats 404 as success).
|
||||
type UploadStep = 'create' | 'send' | 'place' | 'promote';
|
||||
interface UploadState {
|
||||
step: UploadStep | null;
|
||||
documensoDocId: string | null;
|
||||
}
|
||||
const state: UploadState = { step: null, documensoDocId: null };
|
||||
// Cache the row id in a local const so the closure below doesn't fight
|
||||
// TypeScript's `Row | undefined` narrowing through the closure
|
||||
// boundary. The if (!docRow) guard above already established it's
|
||||
// defined here.
|
||||
const docRowId = docRow.id;
|
||||
|
||||
async function rollbackTo(reason: unknown): Promise<void> {
|
||||
logger.warn(
|
||||
{
|
||||
documentId: docRowId,
|
||||
documensoEnvelopeId: state.documensoDocId,
|
||||
failedStep: state.step,
|
||||
err: reason instanceof Error ? { message: reason.message, name: reason.name } : reason,
|
||||
},
|
||||
'Rolling back custom document upload',
|
||||
);
|
||||
await db
|
||||
.update(documents)
|
||||
.set({ status: 'cancelled', updatedAt: new Date() })
|
||||
.where(eq(documents.id, docRowId));
|
||||
if (state.documensoDocId) {
|
||||
await documensoVoidSafe(state.documensoDocId, portId);
|
||||
}
|
||||
// Failure audit-log entry — captures which step failed, the
|
||||
// Documenso envelope id (if any), and the error class/message so
|
||||
// post-mortem investigation doesn't have to dig through structured
|
||||
// logs. Success-path audit is at the end of the function; this is
|
||||
// the failure-path counterpart.
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'document',
|
||||
entityId: docRowId,
|
||||
newValue: {
|
||||
status: 'cancelled',
|
||||
failedStep: state.step ?? 'unknown',
|
||||
documensoEnvelopeId: state.documensoDocId,
|
||||
errorClass: reason instanceof Error ? reason.name : null,
|
||||
errorMessage: reason instanceof Error ? reason.message : String(reason),
|
||||
},
|
||||
metadata: {
|
||||
type: 'upload_for_signing_rollback',
|
||||
},
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
}
|
||||
|
||||
let documensoDoc: Awaited<ReturnType<typeof documensoCreate>>;
|
||||
let sentDoc: Awaited<ReturnType<typeof documensoSend>>;
|
||||
|
||||
try {
|
||||
// Step 1 — create envelope in Documenso.
|
||||
state.step = 'create';
|
||||
documensoDoc = await documensoCreate(title, pdfBase64, documensoRecipients, portId, {
|
||||
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
|
||||
...(docCfg.redirectUrl ? { redirectUrl: docCfg.redirectUrl } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
state.documensoDocId = documensoDoc.id;
|
||||
|
||||
// Persist documensoId IMMEDIATELY so any subsequent failure leaves
|
||||
// the CRM row pointing at the envelope. Without this, a throw
|
||||
// between documensoCreate and the late `status: 'sent'` update
|
||||
// would orphan the envelope in Documenso (CRM has the local row but
|
||||
// no link to find the upstream envelope to void). UAT 2026-05-26
|
||||
// hit exactly this: CRM document row stuck in 'draft' status,
|
||||
// documensoId=NULL, Documenso hosting a live envelope nothing
|
||||
// referenced. The late `status: 'sent'` UPDATE still runs and the
|
||||
// idempotent re-write of documensoId is fine.
|
||||
await db
|
||||
.update(documents)
|
||||
.set({ status: 'cancelled', updatedAt: new Date() })
|
||||
.set({ documensoId: documensoDoc.id, updatedAt: new Date() })
|
||||
.where(eq(documents.id, docRow.id));
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Map our recipientIndex → resolved Documenso recipient id (number/
|
||||
// string). On v2 the envelope/create response doesn't include
|
||||
// recipient ids; we resolve via the distribute response below
|
||||
// (sendDocument returns the full doc with recipients).
|
||||
try {
|
||||
// Step 2 — distribute (Documenso v2) / send (v1). Resolves the
|
||||
// recipient ids that we need for field placement next.
|
||||
state.step = 'send';
|
||||
sentDoc = await documensoSend(documensoDoc.id, portId);
|
||||
} catch (err) {
|
||||
await db
|
||||
.update(documents)
|
||||
.set({ status: 'cancelled', updatedAt: new Date() })
|
||||
.where(eq(documents.id, docRow.id));
|
||||
await documensoVoidSafe(documensoDoc.id, portId);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// Step 3 — recipient identity reconciliation + field placement.
|
||||
// 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 target the same Documenso recipient).
|
||||
state.step = 'place';
|
||||
const emailToRecipientId = new Map<string, string>();
|
||||
for (const dr of sentDoc.recipients) {
|
||||
if (dr.email) emailToRecipientId.set(dr.email.toLowerCase(), dr.id);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Reconciliation guard: every distinct CRM email we sent must
|
||||
// appear in sentDoc.recipients. If Documenso silently dropped one
|
||||
// (invalid email format that passed our regex, etc.), we want a
|
||||
// loud failure that triggers the rollback path — NOT a half-placed
|
||||
// doc that ships to signers with missing fields.
|
||||
const sentEmails = new Set(Array.from(emailToRecipientId.keys()).map((k) => k.toLowerCase()));
|
||||
const missingFromDocumenso = recipients
|
||||
.map((r) => r.email.trim().toLowerCase())
|
||||
.filter((email, idx, arr) => arr.indexOf(email) === idx) // dedupe
|
||||
.filter((email) => !sentEmails.has(email));
|
||||
if (missingFromDocumenso.length > 0) {
|
||||
logger.error(
|
||||
{
|
||||
documentId: docRow.id,
|
||||
documensoEnvelopeId: documensoDoc.id,
|
||||
missingFromDocumenso,
|
||||
documensoReturned: Array.from(emailToRecipientId.keys()),
|
||||
},
|
||||
'Recipient reconciliation: Documenso response missing emails the CRM sent',
|
||||
);
|
||||
throw new ConflictError(
|
||||
`Documenso accepted the envelope but didn't echo recipient(s) for: ${missingFromDocumenso.join(', ')}. ` +
|
||||
`Cannot place fields — recipients aren't reachable.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Build placements + place fields inside the same try block so the
|
||||
// synchronous map() throw (when a recipient can't be matched)
|
||||
// triggers rollback alongside any async placeFields() throw.
|
||||
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,
|
||||
@@ -467,11 +557,7 @@ export async function uploadDocumentForSigning(
|
||||
});
|
||||
await placeFields(documensoDoc.id, placements, portId);
|
||||
} catch (err) {
|
||||
await db
|
||||
.update(documents)
|
||||
.set({ status: 'cancelled', updatedAt: new Date() })
|
||||
.where(eq(documents.id, docRow.id));
|
||||
await documensoVoidSafe(documensoDoc.id, portId);
|
||||
await rollbackTo(err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user