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,158 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
import {
uploadDocumentForSigning,
type CustomDocumentType,
type CustomRecipientRole,
} from '@/lib/services/custom-document-upload.service';
import { isPdfMagic } from '@/lib/services/berth-pdf-parser';
/**
* Phase 3 — Custom document upload-to-Documenso endpoint.
*
* POST `/api/v1/interests/[id]/upload-for-signing`
*
* Body: multipart/form-data
* - file: the source PDF (browser-supplied; magic-byte verified)
* - documentType: 'contract' | 'reservation_agreement'
* - title: customer-visible document title
* - recipients: JSON-encoded CustomDocumentRecipient[]
* - fields: JSON-encoded field placement array
*
* The Contract + Reservation tabs (Phase 4) post here from their
* drag-drop UI. Tests can invoke the service directly.
*
* Permission: documents.send_for_signing — sending a document for
* signing is destructive (queues an outbound email + an admin-visible
* Documenso doc). Plus interests.edit because the pipeline-stage
* auto-advance side-effect is interest-mutating (matches the
* external-eoi precedent).
*/
const recipientSchema = z.object({
name: z.string().min(1).max(200),
email: z.string().email(),
role: z.enum(['SIGNER', 'APPROVER', 'CC']),
signingOrder: z.number().int().positive(),
});
const fieldSchema = z.object({
recipientIndex: z.number().int().nonnegative(),
type: z.enum([
'SIGNATURE',
'FREE_SIGNATURE',
'INITIALS',
'DATE',
'EMAIL',
'NAME',
'TEXT',
'NUMBER',
'CHECKBOX',
'DROPDOWN',
'RADIO',
]),
pageNumber: z.number().int().positive(),
pageX: z.number().min(0).max(100),
pageY: z.number().min(0).max(100),
pageWidth: z.number().positive().max(100),
pageHeight: z.number().positive().max(100),
fieldMeta: z.record(z.string(), z.unknown()).optional(),
});
const documentTypeSchema = z.enum(['contract', 'reservation_agreement']);
const MAX_PDF_BYTES = 50 * 1024 * 1024;
function parseJsonField<T>(raw: unknown, schema: z.ZodType<T>, label: string): T {
if (typeof raw !== 'string') {
throw new ValidationError(`Missing or non-string '${label}' field`);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new ValidationError(`'${label}' is not valid JSON`);
}
const result = schema.safeParse(parsed);
if (!result.success) {
throw new ValidationError(`'${label}' validation failed: ${result.error.issues[0]?.message}`);
}
return result.data;
}
export const POST = withAuth(
withPermission('documents', 'send_for_signing', async (req, ctx, params) => {
try {
const interestId = params.id;
if (!interestId) throw new NotFoundError('Interest');
if (!ctx.isSuperAdmin && !ctx.permissions?.interests?.edit) {
throw new ForbiddenError('interests.edit required to upload a document for signing');
}
const form = await req.formData();
// ─── file ──────────────────────────────────────────────────
const file = form.get('file');
if (!file || !(file instanceof File)) {
throw new ValidationError('Missing file');
}
if (file.size > MAX_PDF_BYTES) {
throw new ValidationError(`File exceeds ${MAX_PDF_BYTES / 1024 / 1024} MB cap`);
}
const buffer = Buffer.from(await file.arrayBuffer());
// Magic-byte check at the route boundary too — service repeats it
// as defense in depth but a bad upload should error before we hit
// any side-effecting code.
if (!isPdfMagic(buffer)) {
throw new ValidationError('Uploaded file is not a PDF');
}
// ─── scalar fields ─────────────────────────────────────────
const documentType = documentTypeSchema.parse(form.get('documentType')) as CustomDocumentType;
const title = z.string().min(1).max(255).parse(form.get('title'));
// ─── JSON fields ───────────────────────────────────────────
const recipients = parseJsonField(
form.get('recipients'),
z.array(recipientSchema).min(1).max(20),
'recipients',
);
const fields = parseJsonField(
form.get('fields'),
z.array(fieldSchema).min(1).max(200),
'fields',
);
const result = await uploadDocumentForSigning({
interestId,
portId: ctx.portId,
portSlug: ctx.portSlug,
documentType,
title,
pdfBuffer: buffer,
filename: file.name || `${documentType}.pdf`,
recipients: recipients.map((r) => ({
name: r.name,
email: r.email,
role: r.role as CustomRecipientRole,
signingOrder: r.signingOrder,
})),
fields,
meta: {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
});
return NextResponse.json({ data: result }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);