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