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
|
||||
|
||||
Reference in New Issue
Block a user