/** * 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 }>; /** Phase 6 polish — optional rep-authored note inserted above the * CTA in every signing-invitation email for this document. Stored * on documents.invitation_message; falls back to the template * default when null/empty. */ invitationMessage?: string | null; 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, invitationMessage, 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', invitationMessage: invitationMessage?.trim() || null, 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, customMessage: invitationMessage?.trim() || 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;