/** * 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, voidDocument as documensoVoid, 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 / contract / * reservation_agreement each follow the same upload-PDF + * place-fields + send-to-Documenso flow with per-type pipeline stage * + doc-status side effects. `'generic'` is the universal path - * used by the cross-cutting "any uploaded file can be a signing * envelope" feature: no pipeline advance, no doc-status flip, just a * files + documents row marked `sent`. The template-driven EOI * generation lives in `document-templates.ts` and follows a * different route. */ export type CustomDocumentType = 'eoi' | 'contract' | 'reservation_agreement' | 'generic'; /** 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 { /** Optional interest the doc is filed under. Required for eoi / * contract / reservation_agreement (their pipeline-stage side * effects need it); MUST be null for 'generic' (cross-cutting * envelopes that aren't tied to a sales deal). */ interestId: string | null; /** Optional entity context — drives the auto-filed folder + the * file-row FK. Used by the 'generic' path when there's no interest * to derive the client from. Ignored when `interestId` is set * (the service resolves the client off the interest itself). */ entity?: { type: 'client' | 'company' | 'yacht'; id: string } | null; /** Optional explicit folder placement. When set, overrides the * entity-derived folder (e.g. rep dropped the upload into a * specific subfolder from the Documents Hub). */ folderId?: string | null; 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, entity, folderId: explicitFolderId, portId, portSlug, documentType, title, pdfBuffer, filename, recipients, fields, invitationMessage, meta, } = args; // Generic envelopes (no pipeline-stage advance / no interest) MUST // come in with interestId=null; non-generic types MUST carry an // interest. Reject the mismatch here so the rest of the function can // assume the right invariant. if (documentType !== 'generic' && !interestId) { throw new ValidationError( `${documentType} document requires an interestId — only 'generic' documents can be uploaded without one`, ); } if (documentType === 'generic' && interestId) { throw new ValidationError( 'Generic documents cannot carry an interestId — use a type-specific document type instead', ); } // ─── 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 ──────────────────────────────────────────────── // Non-generic types resolve their interest (and derive the client // from there). Generic types skip the interest lookup; entity FK // routing comes from the caller-supplied `entity` arg. const interest = interestId ? await db.query.interests.findFirst({ where: and(eq(interests.id, interestId), eq(interests.portId, portId)), }) : null; if (interestId && !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(); // Storage path category mirrors documentType so admins poking at // the bucket can tell at a glance what each blob is. Generic // envelopes land under `signed-source` (uploaded for signing but no // pipeline-stage context). const storageCategory = documentType === 'contract' ? 'contract-source' : documentType === 'reservation_agreement' ? 'reservation-source' : documentType === 'eoi' ? 'eoi-source' : 'signed-source'; // Storage path groups by interestId when we have one; for generic // uploads the entity id (or a synthetic 'unfiled' bucket) keeps the // namespace tidy. const storageGroupId = interestId ?? entity?.id ?? 'unfiled'; const sourceStoragePath = buildStoragePath( portSlug, storageCategory, storageGroupId, sourceFileId, 'pdf', ); const storage = await getStorageBackend(); await storage.put(sourceStoragePath, pdfBuffer, { contentType: PDF_MIME, sizeBytes: pdfBuffer.length, }); // Folder placement priority: // 1. Caller-supplied `folderId` (rep dropped the upload into a // specific Documents Hub folder). // 2. Interest's primary client folder (legacy path for // EOI/contract/reservation tabs). // 3. Caller-supplied entity (generic path: client/company/yacht // doc tab originated the upload). // 4. Root (fallback). let entityFolderId: string | null = explicitFolderId ?? null; if (entityFolderId === null && 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', ); } } if (entityFolderId === null && entity) { try { const folder = await ensureEntityFolder(portId, entity.type, entity.id, 'system'); entityFolderId = folder.id; } catch (err) { logger.warn( { err, entity }, 'ensureEntityFolder failed for generic upload entity - filing at root', ); } } // Derive the entity-FK fields on the `files` row from whichever // source we have. Interest-derived takes priority; otherwise the // generic `entity` arg maps to its corresponding column. const fileEntityFKs: { clientId: string | null; companyId: string | null; yachtId: string | null; } = { clientId: interest?.clientId ?? (entity?.type === 'client' ? entity.id : null), companyId: entity?.type === 'company' ? entity.id : null, yachtId: entity?.type === 'yacht' ? entity.id : null, }; const [sourceFileRecord] = await db .insert(files) .values({ portId, clientId: fileEntityFKs.clientId, companyId: fileEntityFKs.companyId, yachtId: fileEntityFKs.yachtId, 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: fileEntityFKs.clientId, companyId: fileEntityFKs.companyId, yachtId: fileEntityFKs.yachtId, 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, })); // Documenso round-trip wrapped in try/catch so a failed // create/send/placeFields call doesn't leave a phantom `draft` row // sitting at the top of the Reservation/Contract tab forever. On // failure we mark the local row `cancelled` and (best-effort) void // any envelope we already minted upstream, then re-throw - caller // sees the same DOCUMENSO_UPSTREAM_ERROR as before, but the // dashboard state stays clean. Previously, repeated send failures // accumulated abandoned drafts that masked the rep's real working // document. let documensoDoc: Awaited>; let sentDoc: Awaited>; try { documensoDoc = await documensoCreate(title, pdfBase64, documensoRecipients, portId, { ...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}), ...(docCfg.redirectUrl ? { redirectUrl: docCfg.redirectUrl } : {}), }); } catch (err) { await db .update(documents) .set({ status: 'cancelled', updatedAt: new Date() }) .where(eq(documents.id, docRow.id)); throw err; } // 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). try { sentDoc = await documensoSend(documensoDoc.id, portId); } catch (err) { await db .update(documents) .set({ status: 'cancelled', updatedAt: new Date() }) .where(eq(documents.id, docRow.id)); await documensoVoidSafe(documensoDoc.id, portId); throw err; } // Build email→recipientId map. v2 envelope create returns empty // recipients; distribute fills them in. v1 already has them on create. // Note: Documenso de-dupes by email at the envelope level, so multiple // CRM-side Recipient rows that share an email all map to the same // Documenso recipientId. That's fine for field placement — both rows // simply target the same Documenso recipient. const emailToRecipientId = new Map(); for (const dr of sentDoc.recipients) { if (dr.email) emailToRecipientId.set(dr.email.toLowerCase(), dr.id); } // Build placements + place fields inside a SINGLE try/catch so any // failure — including the synchronous `fields.map` throw when a // recipient can't be matched — triggers the rollback path. Previously // the map's throw bubbled past the try-block wrapping `placeFields()`, // leaving Documenso with the live envelope + recipients but no fields, // and the CRM document row stuck in 'sent' with no signing UI for the // signers (UAT 2026-05-26 — "doc displays in Documenso but is missing // all fields"). try { const placements: DocumensoFieldPlacement[] = fields.map((f) => { const recipient = recipients[f.recipientIndex]!; const recipientId = emailToRecipientId.get(recipient.email.toLowerCase()); if (!recipientId) { // Surface the diagnostic state alongside the error so the rep // doesn't have to guess which email failed to match. Logs both // the looked-up email and the keys Documenso DID return so a // future audit can see whether the mismatch is dedupe-related // or a true populate failure. logger.error( { documentId: docRow.id, documensoEnvelopeId: documensoDoc.id, lookedUpEmail: recipient.email, availableEmails: Array.from(emailToRecipientId.keys()), }, 'Documenso recipient lookup miss during field placement', ); 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); } catch (err) { await db .update(documents) .set({ status: 'cancelled', updatedAt: new Date() }) .where(eq(documents.id, docRow.id)); await documensoVoidSafe(documensoDoc.id, portId); throw err; } // 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: any of the three doc types going out for // signing advances the matching pipeline stage + flips the type's // doc-status sub-state to 'sent' so the badge updates immediately. // EOI here is the upload-draft path (parity with contract/reservation // post-2026-05-22); the template-driven EOI flow stamps from // documents.service.ts. No berth-rules trigger here - the rules // engine fires on `contract_signed` etc. via the webhook handler. // `'generic'` documents skip the pipeline-stage advance + the // per-type doc-status flip - they're cross-cutting envelopes that // happen to be filed against this interest. The eoi / contract / // reservation_agreement branches keep their existing side effects. if (documentType !== 'generic' && interestId) { const stageByType: Record< Exclude, 'eoi' | 'contract' | 'reservation' > = { eoi: 'eoi', contract: 'contract', reservation_agreement: 'reservation', }; const labelByType: Record, string> = { eoi: 'EOI', contract: 'Contract', reservation_agreement: 'Reservation agreement', }; void advanceStageIfBehind( interestId, portId, stageByType[documentType], meta, `${labelByType[documentType]} sent for signing`, ); const interestPatch = documentType === 'contract' ? { contractDocStatus: 'sent' as const, dateContractSent: new Date() } : documentType === 'reservation_agreement' ? { reservationDocStatus: 'sent' as const } : { eoiDocStatus: 'sent' as const, dateEoiSent: new Date() }; await db .update(interests) .set({ ...interestPatch, updatedAt: new Date() }) .where(eq(interests.id, interestId)); } 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; /** Void an envelope upstream when we're rolling back a failed local * insert, swallowing any further upstream error (we've already lost * the original failure and don't want to mask it with a cleanup * exception). */ async function documensoVoidSafe(documensoId: string, portId: string): Promise { try { await documensoVoid(documensoId, portId); } catch (err) { logger.warn( { err, documensoId, portId }, 'Failed to void Documenso envelope during rollback - admin can clean up manually', ); } }