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:
@@ -128,4 +128,74 @@ describe('uploadDocumentForSigning validation', () => {
|
||||
}),
|
||||
).rejects.toThrow(/Duplicate signingOrder/);
|
||||
});
|
||||
|
||||
// P1.2 — pre-flight validation extends to recipient email + name
|
||||
// shape. Service mirrors the dialog's pre-Submit checks so direct API
|
||||
// hits also reject early.
|
||||
|
||||
it('rejects recipient with blank email', async () => {
|
||||
await expect(
|
||||
uploadDocumentForSigning({
|
||||
...baseArgs,
|
||||
recipients: [
|
||||
{ name: 'Buyer', email: '', role: 'SIGNER', signingOrder: 1 },
|
||||
{ name: 'Seller', email: 'seller@example.com', role: 'SIGNER', signingOrder: 2 },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(/missing an email/i);
|
||||
});
|
||||
|
||||
it('rejects recipient with whitespace-only email', async () => {
|
||||
await expect(
|
||||
uploadDocumentForSigning({
|
||||
...baseArgs,
|
||||
recipients: [
|
||||
{ name: 'Buyer', email: ' ', role: 'SIGNER', signingOrder: 1 },
|
||||
{ name: 'Seller', email: 'seller@example.com', role: 'SIGNER', signingOrder: 2 },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(/missing an email/i);
|
||||
});
|
||||
|
||||
it('rejects recipient with malformed email', async () => {
|
||||
await expect(
|
||||
uploadDocumentForSigning({
|
||||
...baseArgs,
|
||||
recipients: [
|
||||
{ name: 'Buyer', email: 'not-an-email', role: 'SIGNER', signingOrder: 1 },
|
||||
{ name: 'Seller', email: 'seller@example.com', role: 'SIGNER', signingOrder: 2 },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(/invalid email/i);
|
||||
});
|
||||
|
||||
it('rejects recipient with blank name', async () => {
|
||||
await expect(
|
||||
uploadDocumentForSigning({
|
||||
...baseArgs,
|
||||
recipients: [
|
||||
{ name: '', email: 'buyer@example.com', role: 'SIGNER', signingOrder: 1 },
|
||||
{ name: 'Seller', email: 'seller@example.com', role: 'SIGNER', signingOrder: 2 },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(/missing a name/i);
|
||||
});
|
||||
|
||||
it('accepts duplicate emails across recipients (Documenso dedupes by email)', async () => {
|
||||
// The validation guard does NOT reject same-email recipients — at
|
||||
// the field-placement step the email→recipientId map collapses them
|
||||
// to a single Documenso recipientId by design. Other guards (PDF,
|
||||
// recipient row insert, Documenso round-trip) prevent this test
|
||||
// from reaching success in unit-mode; we only assert that the
|
||||
// recipient-validation block does NOT throw early.
|
||||
await expect(
|
||||
uploadDocumentForSigning({
|
||||
...baseArgs,
|
||||
recipients: [
|
||||
{ name: 'Buyer One', email: 'shared@example.com', role: 'SIGNER', signingOrder: 1 },
|
||||
{ name: 'Buyer Two', email: 'shared@example.com', role: 'SIGNER', signingOrder: 2 },
|
||||
],
|
||||
}),
|
||||
).rejects.not.toThrow(/missing an email|invalid email|missing a name/i);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user