From 33d04269114e067b441cb281b170720a28c28ad5 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 13 May 2026 13:52:21 +0200 Subject: [PATCH] feat(documenso-phase-3): custom document upload-to-Documenso MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../[id]/upload-for-signing/route.ts | 158 +++++++ .../custom-document-upload.service.ts | 447 ++++++++++++++++++ .../services/custom-document-upload.test.ts | 131 +++++ 3 files changed, 736 insertions(+) create mode 100644 src/app/api/v1/interests/[id]/upload-for-signing/route.ts create mode 100644 src/lib/services/custom-document-upload.service.ts create mode 100644 tests/unit/services/custom-document-upload.test.ts diff --git a/src/app/api/v1/interests/[id]/upload-for-signing/route.ts b/src/app/api/v1/interests/[id]/upload-for-signing/route.ts new file mode 100644 index 00000000..6cb59333 --- /dev/null +++ b/src/app/api/v1/interests/[id]/upload-for-signing/route.ts @@ -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(raw: unknown, schema: z.ZodType, 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); + } + }), +); diff --git a/src/lib/services/custom-document-upload.service.ts b/src/lib/services/custom-document-upload.service.ts new file mode 100644 index 00000000..fbcf0595 --- /dev/null +++ b/src/lib/services/custom-document-upload.service.ts @@ -0,0 +1,447 @@ +/** + * Phase 3 — Custom document upload-to-Documenso. + * + * The Contract + Reservation tabs upload a draft PDF, configure + * recipients + fields, and hand the bundle to Documenso for signing. + * This service is the backend foundation; the UI dialog (Phase 4) + * eventually POSTs to /api/v1/interests/[id]/upload-for-signing which + * delegates here. + * + * Flow: + * 1. Magic-byte verify the PDF (defense vs. mislabelled bytes — + * same posture as berth-pdf + brochures). + * 2. Insert a `files` row + push the PDF into storage. The row is + * port-scoped + entity-scoped (interest) so it appears in the + * Documents tab + the interest's entity folder. + * 3. Insert a `documents` row in `draft` status linked to the + * interest + the source file. + * 4. Documenso round-trip: createDocument → placeFields → sendDocument. + * Per-port apiVersion drives v1 vs v2 routing (existing client + * handles both — v1: legacy /api/v1/documents; v2: envelope/create + * multipart). + * 5. Capture per-recipient signingUrl + token into `document_signers` + * so the webhook cascade picks them up (Phase 2). + * 6. If the port's `eoi_send_mode === 'auto'`, fire the branded + * invitation to the first signer immediately + stamp `invitedAt`. + * Manual mode leaves it to the rep's "Send invitation" button. + * + * Multi-tenant guard: the interest is read with both `id` AND `portId` + * filters; cross-port upload attempts return NotFoundError before any + * Documenso traffic. + */ + +import { and, eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { documents, documentSigners, files } from '@/lib/db/schema/documents'; +import { interests } from '@/lib/db/schema/interests'; +import { clients } from '@/lib/db/schema/clients'; +import { ports } from '@/lib/db/schema/ports'; +import { buildStoragePath } from '@/lib/minio'; +import { env } from '@/lib/env'; +import { getStorageBackend } from '@/lib/storage'; +import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; +import { createAuditLog, type AuditMeta } from '@/lib/audit'; +import { isPdfMagic } from '@/lib/services/berth-pdf-parser'; +import { + createDocument as documensoCreate, + sendDocument as documensoSend, + placeFields, + type DocumensoFieldPlacement, + type DocumensoRecipient, +} from '@/lib/services/documenso-client'; +import { getPortDocumensoConfig } from '@/lib/services/port-config'; +import { + sendSigningInvitation, + type SignerRole, +} from '@/lib/services/document-signing-emails.service'; +import { DOC_TYPE_LABEL, extractSigningToken } from '@/lib/services/documenso-signers'; +import { ensureEntityFolder } from '@/lib/services/document-folders.service'; +import { advanceStageIfBehind } from '@/lib/services/interests.service'; +import { emitToRoom } from '@/lib/socket/server'; +import { logger } from '@/lib/logger'; + +/** Document types this service accepts. EOI is template-driven (uses + * the dedicated EOI path); contract + reservation_agreement upload a + * rep-supplied PDF and place fields per-deal. */ +export type CustomDocumentType = 'contract' | 'reservation_agreement'; + +/** Documenso recipient role — narrowed from the full enum to the + * three values the custom-upload flow accepts. APPROVER + CC are + * documented in plan Q4. VIEWER + ASSISTANT are out of scope for + * marina contracts today. */ +export type CustomRecipientRole = 'SIGNER' | 'APPROVER' | 'CC'; + +export interface CustomDocumentRecipient { + name: string; + email: string; + role: CustomRecipientRole; + signingOrder: number; +} + +export interface UploadDocumentForSigningArgs { + interestId: string; + portId: string; + portSlug: string; + documentType: CustomDocumentType; + title: string; + pdfBuffer: Buffer; + filename: string; + recipients: CustomDocumentRecipient[]; + /** Field placements come from Phase 4's drag-drop UI or auto-detect. + * `recipientId` is the INDEX into `recipients` — the service maps + * it to the resolved Documenso recipient id after createDocument + * responds. */ + fields: Array & { recipientIndex: number }>; + meta: AuditMeta; +} + +export interface UploadDocumentForSigningResult { + documentId: string; + documensoDocumentId: string; + /** Map of recipient email → branded embedded signing URL. The UI + * exposes these so a rep can copy a link out for manual delivery in + * manual-send mode. */ + signingUrls: Record; +} + +const PDF_MIME = 'application/pdf'; +const MAX_PDF_BYTES = 50 * 1024 * 1024; // 50 MB — matches MAX_FILE_SIZE default + +export async function uploadDocumentForSigning( + args: UploadDocumentForSigningArgs, +): Promise { + const { + interestId, + portId, + portSlug, + documentType, + title, + pdfBuffer, + filename, + recipients, + fields, + meta, + } = args; + + // ─── Validation ────────────────────────────────────────────────── + if (recipients.length === 0) { + throw new ValidationError('At least one recipient is required'); + } + if (fields.length === 0) { + throw new ValidationError('At least one field placement is required'); + } + if (pdfBuffer.length === 0) { + throw new ValidationError('PDF buffer is empty'); + } + if (pdfBuffer.length > MAX_PDF_BYTES) { + throw new ValidationError(`PDF exceeds ${MAX_PDF_BYTES / 1024 / 1024} MB cap`); + } + if (!isPdfMagic(pdfBuffer)) { + throw new ValidationError('Uploaded file is not a PDF (magic-byte check failed)'); + } + // Every field's recipientIndex must reference a real recipient. Out- + // of-range indexes silently maps to undefined in the recipient lookup + // below — fail loudly here instead. + for (const f of fields) { + if (f.recipientIndex < 0 || f.recipientIndex >= recipients.length) { + throw new ValidationError( + `Field recipientIndex=${f.recipientIndex} is out of range (have ${recipients.length} recipients)`, + ); + } + } + // Defense-in-depth: a duplicate signing-order would let Documenso + // accept the doc but break the cascading-invite logic (next signer + // picker assumes a strict ordering). + const orders = new Set(); + for (const r of recipients) { + if (orders.has(r.signingOrder)) { + throw new ValidationError(`Duplicate signingOrder=${r.signingOrder} in recipients`); + } + orders.add(r.signingOrder); + } + + // ─── Tenant guard ──────────────────────────────────────────────── + const interest = await db.query.interests.findFirst({ + where: and(eq(interests.id, interestId), eq(interests.portId, portId)), + }); + if (!interest) throw new NotFoundError('Interest'); + + const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) }); + if (!port) throw new NotFoundError('Port'); + + // ─── Store source PDF ──────────────────────────────────────────── + // The source PDF needs to live in storage so reps + admins can view + // the pre-signed draft in the Files tab. We also use the resolved + // storage key as the `documents.fileId` reference. + const sourceFileId = crypto.randomUUID(); + const sourceStoragePath = buildStoragePath( + portSlug, + documentType === 'contract' ? 'contract-source' : 'reservation-source', + interestId, + sourceFileId, + 'pdf', + ); + + const storage = await getStorageBackend(); + await storage.put(sourceStoragePath, pdfBuffer, { + contentType: PDF_MIME, + sizeBytes: pdfBuffer.length, + }); + + // Look up the interest's primary client so the auto-filed folder + // ends up under the right entity subfolder. Falls back to root when + // the chain has no resolvable owner. + let entityFolderId: string | null = null; + if (interest.clientId) { + try { + const folder = await ensureEntityFolder(portId, 'client', interest.clientId, 'system'); + entityFolderId = folder.id; + } catch (err) { + logger.warn( + { err, interestId, clientId: interest.clientId }, + 'ensureEntityFolder failed during custom-document-upload — filing at root', + ); + } + } + + const [sourceFileRecord] = await db + .insert(files) + .values({ + portId, + clientId: interest.clientId, + folderId: entityFolderId, + filename, + originalName: filename, + mimeType: PDF_MIME, + sizeBytes: String(pdfBuffer.length), + storagePath: sourceStoragePath, + storageBucket: env.MINIO_BUCKET, + category: documentType, + uploadedBy: meta.userId, + }) + .returning(); + if (!sourceFileRecord) { + // Best-effort compensating delete — we put a blob but the DB row + // failed to land, leaving an orphan otherwise. + await storage.delete(sourceStoragePath).catch(() => {}); + throw new ConflictError('Failed to record source file'); + } + + // ─── Insert the document row (status=draft) ─────────────────────── + const [docRow] = await db + .insert(documents) + .values({ + portId, + interestId, + clientId: interest.clientId, + fileId: sourceFileRecord.id, + documentType, + title, + status: 'draft', + createdBy: meta.userId, + }) + .returning(); + if (!docRow) throw new ConflictError('Failed to insert document row'); + + // ─── Local signer rows (pre-Documenso) ──────────────────────────── + // Insert with status=pending; we'll fill signingUrl + signingToken + // after Documenso responds. + const signerRows = await db + .insert(documentSigners) + .values( + recipients.map((r) => ({ + documentId: docRow.id, + signerName: r.name, + signerEmail: r.email, + // Map Documenso's enum back to our internal role taxonomy. + // APPROVER + CC both render with passive-recipient copy in our + // email templates. + signerRole: documensoRoleToLocal(r.role), + signingOrder: r.signingOrder, + status: 'pending' as const, + })), + ) + .returning(); + + // ─── Documenso round-trip ──────────────────────────────────────── + const docCfg = await getPortDocumensoConfig(portId); + const pdfBase64 = pdfBuffer.toString('base64'); + + const documensoRecipients: DocumensoRecipient[] = recipients.map((r) => ({ + name: r.name, + email: r.email, + role: r.role, + signingOrder: r.signingOrder, + })); + + const documensoDoc = await documensoCreate(title, pdfBase64, documensoRecipients, portId, { + ...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}), + ...(docCfg.redirectUrl ? { redirectUrl: docCfg.redirectUrl } : {}), + }); + + // Map our recipientIndex → resolved Documenso recipient id (number/ + // string). On v2 the envelope/create response doesn't include + // recipient ids; we resolve via the distribute response below + // (sendDocument returns the full doc with recipients). + const sentDoc = await documensoSend(documensoDoc.id, portId); + + // Build email→recipientId map. v2 envelope create returns empty + // recipients; distribute fills them in. v1 already has them on create. + const emailToRecipientId = new Map(); + for (const dr of sentDoc.recipients) { + if (dr.email) emailToRecipientId.set(dr.email.toLowerCase(), dr.id); + } + + // Place fields (skipped silently when empty — but we validated above). + const placements: DocumensoFieldPlacement[] = fields.map((f) => { + const recipient = recipients[f.recipientIndex]!; + const recipientId = emailToRecipientId.get(recipient.email.toLowerCase()); + if (!recipientId) { + throw new ConflictError( + `Documenso response missing recipientId for ${recipient.email} — cannot place fields`, + ); + } + return { + recipientId, + type: f.type, + pageNumber: f.pageNumber, + pageX: f.pageX, + pageY: f.pageY, + pageWidth: f.pageWidth, + pageHeight: f.pageHeight, + ...(f.fieldMeta ? { fieldMeta: f.fieldMeta } : {}), + }; + }); + await placeFields(documensoDoc.id, placements, portId); + + // Update local signers with signingUrl + token from Documenso. + const signingUrls: Record = {}; + for (const dr of sentDoc.recipients) { + const local = signerRows.find((s) => s.signerEmail.toLowerCase() === dr.email?.toLowerCase()); + if (!local) continue; + await db + .update(documentSigners) + .set({ + signingUrl: dr.signingUrl ?? null, + embeddedUrl: dr.embeddedUrl ?? null, + signingToken: dr.token ?? extractSigningToken(dr.signingUrl ?? null), + }) + .where(eq(documentSigners.id, local.id)); + if (dr.signingUrl) signingUrls[dr.email] = dr.signingUrl; + } + + // Promote the local document to `sent` + record the Documenso id so + // the webhook handler can resolve subsequent events back to this row. + await db + .update(documents) + .set({ status: 'sent', documensoId: documensoDoc.id, updatedAt: new Date() }) + .where(eq(documents.id, docRow.id)); + + // Pipeline transition: contract_sent stage when contract or + // reservation_agreement goes out for signing. eoi_sent is reserved + // for the template-driven EOI flow. No berth-rules trigger here — + // the rules engine fires on `contract_signed` (webhook-driven). + void advanceStageIfBehind( + interestId, + portId, + 'contract_sent', + meta, + `${documentType === 'contract' ? 'Contract' : 'Reservation agreement'} sent for signing`, + ); + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'create', + entityType: 'document', + entityId: docRow.id, + newValue: { + documentType, + title, + documensoId: documensoDoc.id, + recipientCount: recipients.length, + fieldCount: fields.length, + }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + emitToRoom(`port:${portId}`, 'document:sent', { + documentId: docRow.id, + type: documentType, + signerCount: recipients.length, + documensoId: documensoDoc.id, + }); + + // ─── Auto-send first invitation ────────────────────────────────── + if (docCfg.sendMode === 'auto') { + const firstByOrder = [...signerRows].sort((a, b) => a.signingOrder - b.signingOrder)[0]; + if (firstByOrder) { + // Re-read the row so we get the freshly-written signingUrl. + const refreshed = await db.query.documentSigners.findFirst({ + where: eq(documentSigners.id, firstByOrder.id), + }); + if (refreshed?.signingUrl) { + await sendSigningInvitation({ + portId, + portName: port.name, + recipient: { name: refreshed.signerName, email: refreshed.signerEmail }, + documensoSigningUrl: refreshed.signingUrl, + documentLabel: DOC_TYPE_LABEL[documentType] ?? 'Sales Contract', + signerRole: (refreshed.signerRole as SignerRole) ?? 'client', + senderName: docCfg.developerName ?? null, + }).catch((err) => { + logger.error( + { err, documentId: docRow.id, signerId: refreshed.id }, + 'Auto-send invitation failed (manual retry via Send button still available)', + ); + }); + await db + .update(documentSigners) + .set({ invitedAt: new Date() }) + .where(eq(documentSigners.id, refreshed.id)); + } + } + } + + return { + documentId: docRow.id, + documensoDocumentId: documensoDoc.id, + signingUrls, + }; +} + +/** + * Map Documenso's recipient role enum to our internal signerRole + * vocabulary (`client | developer | approver | witness | other`). + * + * The custom-upload flow doesn't know which role label fits — the rep + * picks SIGNER/APPROVER/CC in the dialog. We map SIGNER → 'other' (the + * generic case; matches the email template's neutral copy) UNLESS the + * recipient is the first signer in order, in which case the dialog + * defaults to the client (handled at the UI level in Phase 4 — the + * service stays role-blind). + */ +function documensoRoleToLocal(role: CustomRecipientRole): SignerRole { + switch (role) { + case 'APPROVER': + return 'approver'; + case 'CC': + return 'other'; + case 'SIGNER': + default: + return 'other'; + } +} + +// Re-export the client type so callers don't have to import from two +// places when building the field array. +export type { DocumensoFieldPlacement } from '@/lib/services/documenso-client'; + +// Re-export to silence unused-import lint when the union is consumed +// only indirectly via downstream type inference. +export type { CustomDocumentType as _CustomDocumentType }; + +// Keep the clients import referenced — used by future enhancements +// that resolve the client name for default recipient prefill. +void clients; diff --git a/tests/unit/services/custom-document-upload.test.ts b/tests/unit/services/custom-document-upload.test.ts new file mode 100644 index 00000000..7a1f155e --- /dev/null +++ b/tests/unit/services/custom-document-upload.test.ts @@ -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/); + }); +});