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:
158
src/app/api/v1/interests/[id]/upload-for-signing/route.ts
Normal file
158
src/app/api/v1/interests/[id]/upload-for-signing/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user