feat(documenso-phase-3): custom document upload-to-Documenso
Backend foundation for the Contract + Reservation signing flows. The
existing tab placeholders point at a "send for signing" CTA that had
no code behind it; this commit lands the service + endpoint that the
Phase 4 drag-drop UI will POST to.
Files added:
- lib/services/custom-document-upload.service.ts — orchestrates the
full PDF → Documenso → local-state-update flow:
1. Magic-byte verifies the PDF (defense vs. mislabelled bytes —
same posture as berth-pdf + brochures).
2. Stores the source PDF via getStorageBackend(), works on s3 +
filesystem backends. Auto-files into the client's entity folder
when resolvable.
3. Inserts the documents row (status=draft → sent), with the file
FK + interest link + clientId snapshot.
4. Documenso round-trip via createDocument → sendDocument →
placeFields. Per-port apiVersion drives v1 vs v2 (existing
client handles both — v1: /api/v1/documents; v2: envelope/create
multipart). meta.signingOrder + redirectUrl flow through.
5. Captures recipient signingUrl + token into document_signers so
the Phase 2 cascade picks them up.
6. Auto-send first invitation when port.eoi_send_mode === 'auto';
stamps invitedAt to suppress duplicate cascades.
7. Advances pipeline stage to contract_sent.
- app/api/v1/interests/[id]/upload-for-signing/route.ts — multipart
POST endpoint. Zod-validates recipients (≤20), fields (≤200), PDF
size (≤50MB), all 11 Documenso field types. Permission-gated by
documents.send_for_signing + interests.edit (matches the
external-eoi precedent — the auto-advance side-effect is
interest-mutating).
Files modified: none — keeps the existing tab placeholders as the
entry point; Phase 4 builds the drag-drop UI on top.
Validation contract pinned by 8 unit tests covering: empty recipient
list, empty field list, empty/oversized PDF, non-PDF magic bytes,
out-of-range + negative recipientIndex, duplicate signingOrder.
The heavy paths (storage put, Documenso HTTP, signer update) are
exercised by the existing realapi Playwright project — no new
realapi specs added because the contract-upload UI doesn't exist yet
to drive them.
Verified against Documenso API spec (v1 OpenAPI + v2 docs via
Context7): recipients[].token is on the Recipient model in both
versions; webhook payloads echo the same shape so the Phase 2 token-
match handler works against custom-uploaded docs without changes.
Tests: 1326 → 1334 ✅; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:52:21 +02:00
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
|
|
|
|
|
|
// Validation-path tests for uploadDocumentForSigning. The
|
|
|
|
|
// heavy-integration paths (storage put, Documenso round-trip, signer
|
|
|
|
|
// updates) are exercised by Playwright realapi specs; these tests
|
|
|
|
|
// pin the input-validation contract so a regression in the
|
|
|
|
|
// recipient/field/PDF guards is caught at unit-test time.
|
|
|
|
|
|
|
|
|
|
// Stub the heavy dependencies BEFORE importing the service so its
|
|
|
|
|
// module-level imports resolve to the stubs.
|
|
|
|
|
vi.mock('@/lib/db', () => ({
|
|
|
|
|
db: {
|
|
|
|
|
query: {
|
|
|
|
|
interests: {
|
|
|
|
|
findFirst: vi.fn().mockResolvedValue({ id: 'int-1', portId: 'port-1', clientId: 'c-1' }),
|
|
|
|
|
},
|
|
|
|
|
ports: { findFirst: vi.fn().mockResolvedValue({ id: 'port-1', name: 'Test Port' }) },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock('@/lib/services/berth-pdf-parser', () => ({
|
|
|
|
|
isPdfMagic: (b: Buffer) => b.slice(0, 5).toString() === '%PDF-',
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
uploadDocumentForSigning,
|
|
|
|
|
type CustomDocumentRecipient,
|
|
|
|
|
} from '@/lib/services/custom-document-upload.service';
|
|
|
|
|
|
|
|
|
|
const PDF_HEADER = Buffer.from('%PDF-1.7\n');
|
|
|
|
|
const NON_PDF = Buffer.from('this is not a PDF');
|
|
|
|
|
|
|
|
|
|
const baseArgs = {
|
|
|
|
|
interestId: 'int-1',
|
|
|
|
|
portId: 'port-1',
|
|
|
|
|
portSlug: 'test-port',
|
|
|
|
|
documentType: 'contract' as const,
|
|
|
|
|
title: 'Sales Contract',
|
|
|
|
|
pdfBuffer: PDF_HEADER,
|
|
|
|
|
filename: 'contract.pdf',
|
|
|
|
|
recipients: [
|
|
|
|
|
{ name: 'Buyer', email: 'buyer@example.com', role: 'SIGNER', signingOrder: 1 },
|
|
|
|
|
{ name: 'Seller', email: 'seller@example.com', role: 'SIGNER', signingOrder: 2 },
|
|
|
|
|
] satisfies CustomDocumentRecipient[],
|
|
|
|
|
fields: [
|
|
|
|
|
{
|
|
|
|
|
recipientIndex: 0,
|
|
|
|
|
type: 'SIGNATURE' as const,
|
|
|
|
|
pageNumber: 1,
|
|
|
|
|
pageX: 10,
|
|
|
|
|
pageY: 80,
|
|
|
|
|
pageWidth: 30,
|
|
|
|
|
pageHeight: 5,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
meta: {
|
|
|
|
|
userId: 'user-1',
|
|
|
|
|
portId: 'port-1',
|
|
|
|
|
ipAddress: '127.0.0.1',
|
|
|
|
|
userAgent: 'test',
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
describe('uploadDocumentForSigning validation', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects empty recipient list', async () => {
|
|
|
|
|
await expect(uploadDocumentForSigning({ ...baseArgs, recipients: [] })).rejects.toThrow(
|
|
|
|
|
/at least one recipient/i,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects empty field list', async () => {
|
|
|
|
|
await expect(uploadDocumentForSigning({ ...baseArgs, fields: [] })).rejects.toThrow(
|
|
|
|
|
/at least one field/i,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects empty PDF buffer', async () => {
|
|
|
|
|
await expect(
|
|
|
|
|
uploadDocumentForSigning({ ...baseArgs, pdfBuffer: Buffer.alloc(0) }),
|
|
|
|
|
).rejects.toThrow(/PDF buffer is empty/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects oversized PDF', async () => {
|
|
|
|
|
const oversized = Buffer.alloc(51 * 1024 * 1024, 0x20);
|
|
|
|
|
oversized.write('%PDF-1.7', 0);
|
|
|
|
|
await expect(uploadDocumentForSigning({ ...baseArgs, pdfBuffer: oversized })).rejects.toThrow(
|
|
|
|
|
/exceeds.*MB cap/i,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects non-PDF magic bytes', async () => {
|
|
|
|
|
await expect(uploadDocumentForSigning({ ...baseArgs, pdfBuffer: NON_PDF })).rejects.toThrow(
|
|
|
|
|
/not a PDF/,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects out-of-range recipientIndex on a field', async () => {
|
|
|
|
|
await expect(
|
|
|
|
|
uploadDocumentForSigning({
|
|
|
|
|
...baseArgs,
|
|
|
|
|
fields: [{ ...baseArgs.fields[0]!, recipientIndex: 5 }],
|
|
|
|
|
}),
|
|
|
|
|
).rejects.toThrow(/out of range/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects negative recipientIndex on a field', async () => {
|
|
|
|
|
await expect(
|
|
|
|
|
uploadDocumentForSigning({
|
|
|
|
|
...baseArgs,
|
|
|
|
|
fields: [{ ...baseArgs.fields[0]!, recipientIndex: -1 }],
|
|
|
|
|
}),
|
|
|
|
|
).rejects.toThrow(/out of range/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects duplicate signingOrder across recipients', async () => {
|
|
|
|
|
await expect(
|
|
|
|
|
uploadDocumentForSigning({
|
|
|
|
|
...baseArgs,
|
|
|
|
|
recipients: [
|
|
|
|
|
{ name: 'A', email: 'a@x.com', role: 'SIGNER', signingOrder: 1 },
|
|
|
|
|
{ name: 'B', email: 'b@x.com', role: 'SIGNER', signingOrder: 1 },
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
).rejects.toThrow(/Duplicate signingOrder/);
|
|
|
|
|
});
|
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>
2026-05-26 21:09:50 +02:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
});
|
feat(documenso-phase-3): custom document upload-to-Documenso
Backend foundation for the Contract + Reservation signing flows. The
existing tab placeholders point at a "send for signing" CTA that had
no code behind it; this commit lands the service + endpoint that the
Phase 4 drag-drop UI will POST to.
Files added:
- lib/services/custom-document-upload.service.ts — orchestrates the
full PDF → Documenso → local-state-update flow:
1. Magic-byte verifies the PDF (defense vs. mislabelled bytes —
same posture as berth-pdf + brochures).
2. Stores the source PDF via getStorageBackend(), works on s3 +
filesystem backends. Auto-files into the client's entity folder
when resolvable.
3. Inserts the documents row (status=draft → sent), with the file
FK + interest link + clientId snapshot.
4. Documenso round-trip via createDocument → sendDocument →
placeFields. Per-port apiVersion drives v1 vs v2 (existing
client handles both — v1: /api/v1/documents; v2: envelope/create
multipart). meta.signingOrder + redirectUrl flow through.
5. Captures recipient signingUrl + token into document_signers so
the Phase 2 cascade picks them up.
6. Auto-send first invitation when port.eoi_send_mode === 'auto';
stamps invitedAt to suppress duplicate cascades.
7. Advances pipeline stage to contract_sent.
- app/api/v1/interests/[id]/upload-for-signing/route.ts — multipart
POST endpoint. Zod-validates recipients (≤20), fields (≤200), PDF
size (≤50MB), all 11 Documenso field types. Permission-gated by
documents.send_for_signing + interests.edit (matches the
external-eoi precedent — the auto-advance side-effect is
interest-mutating).
Files modified: none — keeps the existing tab placeholders as the
entry point; Phase 4 builds the drag-drop UI on top.
Validation contract pinned by 8 unit tests covering: empty recipient
list, empty field list, empty/oversized PDF, non-PDF magic bytes,
out-of-range + negative recipientIndex, duplicate signingOrder.
The heavy paths (storage put, Documenso HTTP, signer update) are
exercised by the existing realapi Playwright project — no new
realapi specs added because the contract-upload UI doesn't exist yet
to drive them.
Verified against Documenso API spec (v1 OpenAPI + v2 docs via
Context7): recipients[].token is on the Recipient model in both
versions; webhook payloads echo the same shape so the Phase 2 token-
match handler works against custom-uploaded docs without changes.
Tests: 1326 → 1334 ✅; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:52:21 +02:00
|
|
|
});
|