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:
2026-05-26 21:09:50 +02:00
parent b00cc24565
commit b6c27b506d
10 changed files with 595 additions and 138 deletions

View File

@@ -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);
}
};

View File

@@ -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'}

View File

@@ -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

View File

@@ -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}

View File

@@ -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

View File

@@ -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;
}