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

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