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>
This commit is contained in:
2026-05-13 13:52:21 +02:00
parent 3dc4c6ff14
commit 33d0426911
3 changed files with 736 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
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/);
});
});