import { and, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { formatDate } from '@/lib/utils/format-date'; import { documentTemplates, documents, documentSigners, files } from '@/lib/db/schema/documents'; import type { File as DbFile, Document as DbDocument } from '@/lib/db/schema/documents'; import { clients, clientContacts } from '@/lib/db/schema/clients'; import { interests } from '@/lib/db/schema/interests'; import { berths } from '@/lib/db/schema/berths'; import { ports } from '@/lib/db/schema/ports'; import { yachts } from '@/lib/db/schema/yachts'; import { buildListQuery } from '@/lib/db/query-builder'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { diffEntity } from '@/lib/entity-diff'; import { NotFoundError, ValidationError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; import { buildStoragePath } from '@/lib/minio'; import { getStorageBackend } from '@/lib/storage'; import { env } from '@/lib/env'; import { getCountryName } from '@/lib/i18n/countries'; import { createDocument as documensoCreate, sendDocument as documensoSend, generateDocumentFromTemplate as documensoGenerateFromTemplate, } from '@/lib/services/documenso-client'; import { buildDocumensoPayload, getPortEoiSigners } from '@/lib/services/documenso-payload'; import { getPortDocumensoConfig } from '@/lib/services/port-config'; import { generateEoiPdfFromTemplate } from '@/lib/pdf/fill-eoi-form'; import { MERGE_FIELDS, type MergeFieldCatalog } from '@/lib/templates/merge-fields'; import { buildEoiContext } from '@/lib/services/eoi-context'; import { applyEoiOverridesBeforeRender, applyOverridesToContext, persistDocumentOverrides, type EoiOverridesInput, type AppliedOverrides, } from '@/lib/services/eoi-overrides.service'; import { getPrimaryBerth } from '@/lib/services/interest-berths.service'; import type { CreateTemplateInput, UpdateTemplateInput, ListTemplatesInput, GenerateInput, GenerateAndSignInput, } from '@/lib/validators/document-templates'; // ─── Types ──────────────────────────────────────────────────────────────────── // ─── Merge Field Definitions ────────────────────────────────────────────────── export function getMergeFields(): MergeFieldCatalog { return MERGE_FIELDS; } // ─── List ───────────────────────────────────────────────────────────────────── export async function listTemplates(portId: string, query: ListTemplatesInput) { const { page, limit, sort, order, search, templateType, isActive } = query; const filters = []; if (templateType) { filters.push(eq(documentTemplates.templateType, templateType)); } if (isActive !== undefined) { filters.push(eq(documentTemplates.isActive, isActive)); } const sortColumn = sort === 'name' ? documentTemplates.name : sort === 'templateType' ? documentTemplates.templateType : sort === 'createdAt' ? documentTemplates.createdAt : documentTemplates.updatedAt; return buildListQuery({ table: documentTemplates, portIdColumn: documentTemplates.portId, portId, idColumn: documentTemplates.id, updatedAtColumn: documentTemplates.updatedAt, searchColumns: [documentTemplates.name], searchTerm: search, filters, sort: { column: sortColumn, direction: order }, page, pageSize: limit, }); } // ─── Get by ID ──────────────────────────────────────────────────────────────── export async function getTemplateById(id: string, portId: string) { const template = await db.query.documentTemplates.findFirst({ where: eq(documentTemplates.id, id), }); if (!template || template.portId !== portId) { throw new NotFoundError('Document template'); } return template; } // ─── Create ─────────────────────────────────────────────────────────────────── export async function createTemplate(portId: string, data: CreateTemplateInput, meta: AuditMeta) { const [template] = await db .insert(documentTemplates) .values({ portId, name: data.name, description: data.description ?? null, templateType: data.templateType, bodyHtml: data.bodyHtml, mergeFields: data.mergeFields ?? [], isActive: data.isActive ?? true, createdBy: meta.userId, }) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'documentTemplate', entityId: template!.id, newValue: { name: template!.name, templateType: template!.templateType }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'documentTemplate:created', { templateId: template!.id }); return template!; } // ─── Update ─────────────────────────────────────────────────────────────────── export async function updateTemplate( id: string, portId: string, data: UpdateTemplateInput, meta: AuditMeta, ) { const existing = await getTemplateById(id, portId); const { diff } = diffEntity(existing as Record, data as Record); const [updated] = await db .update(documentTemplates) .set({ ...data, updatedAt: new Date() }) .where(and(eq(documentTemplates.id, id), eq(documentTemplates.portId, portId))) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'documentTemplate', entityId: id, oldValue: diff as Record, newValue: data as Record, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'documentTemplate:updated', { templateId: id }); return updated!; } // ─── Delete ─────────────────────────────────────────────────────────────────── export async function deleteTemplate(id: string, portId: string, meta: AuditMeta) { const existing = await getTemplateById(id, portId); await db .delete(documentTemplates) .where(and(eq(documentTemplates.id, id), eq(documentTemplates.portId, portId))); void createAuditLog({ userId: meta.userId, portId, action: 'delete', entityType: 'documentTemplate', entityId: id, oldValue: { name: existing.name, templateType: existing.templateType }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'documentTemplate:deleted', { templateId: id }); } // ─── Resolve Template ───────────────────────────────────────────────────────── /** * Interpolates all {{entity.field}} tokens in the template body HTML. * BR-140: Required merge fields with no value throw ValidationError. */ export async function resolveTemplate( templateId: string, context: { clientId?: string; interestId?: string; berthId?: string; portId: string; }, ): Promise { const template = await getTemplateById(templateId, context.portId); // Build token→value map from context const tokenMap: Record = {}; // Date tokens const now = new Date(); tokenMap['{{date.today}}'] = formatDate(now, 'date.medium'); tokenMap['{{date.year}}'] = String(now.getFullYear()); // Port tokens const port = await db.query.ports.findFirst({ where: eq(ports.id, context.portId) }); if (port) { tokenMap['{{port.name}}'] = port.name; tokenMap['{{port.defaultCurrency}}'] = port.defaultCurrency; } // ─── EOI-style resolution ─────────────────────────────────────────────────── // If an interestId is provided, prefer the shared buildEoiContext payload so // that yacht.*, company.*, owner.*, and berth.* tokens all resolve from the // same denormalised snapshot the PDF/Documenso pipelines use. // Falls back to the legacy path below if the interest isn't EOI-ready // (missing yacht or berth), so non-EOI templates still work. let eoiContextLoaded = false; if (context.interestId) { try { const eoi = await buildEoiContext(context.interestId, context.portId); eoiContextLoaded = true; // Client tokens (from EoiContext) tokenMap['{{client.fullName}}'] = eoi.client.fullName; tokenMap['{{client.email}}'] = eoi.client.primaryEmail ?? ''; tokenMap['{{client.phone}}'] = eoi.client.primaryPhone ?? ''; tokenMap['{{client.nationality}}'] = eoi.client.nationality ?? ''; // Yacht tokens - `eoi.yacht` is null when no yacht is linked // (Section 3 of the EOI is optional). Tokens render as empty strings // in that case so the template still produces output. tokenMap['{{yacht.name}}'] = eoi.yacht?.name ?? ''; tokenMap['{{yacht.hullNumber}}'] = eoi.yacht?.hullNumber ?? ''; tokenMap['{{yacht.flag}}'] = eoi.yacht?.flag ?? ''; tokenMap['{{yacht.yearBuilt}}'] = eoi.yacht?.yearBuilt != null ? String(eoi.yacht.yearBuilt) : ''; tokenMap['{{yacht.lengthFt}}'] = eoi.yacht?.lengthFt ?? ''; tokenMap['{{yacht.widthFt}}'] = eoi.yacht?.widthFt ?? ''; tokenMap['{{yacht.draftFt}}'] = eoi.yacht?.draftFt ?? ''; tokenMap['{{yacht.lengthM}}'] = eoi.yacht?.lengthM ?? ''; tokenMap['{{yacht.widthM}}'] = eoi.yacht?.widthM ?? ''; tokenMap['{{yacht.draftM}}'] = eoi.yacht?.draftM ?? ''; // EoiContext doesn't expose the yacht.registration column - look it up // separately (cheap, indexed fetch) so the token resolves when present. try { const interestRow = await db.query.interests.findFirst({ where: eq(interests.id, context.interestId), columns: { yachtId: true }, }); if (interestRow?.yachtId) { const yachtRow = await db.query.yachts.findFirst({ where: eq(yachts.id, interestRow.yachtId), columns: { registration: true }, }); tokenMap['{{yacht.registration}}'] = yachtRow?.registration ?? ''; } else { tokenMap['{{yacht.registration}}'] = ''; } } catch { tokenMap['{{yacht.registration}}'] = ''; } // Company tokens (only populated when owner is a company) tokenMap['{{company.name}}'] = eoi.company?.name ?? ''; tokenMap['{{company.legalName}}'] = eoi.company?.legalName ?? ''; tokenMap['{{company.taxId}}'] = eoi.company?.taxId ?? ''; tokenMap['{{company.billingAddress}}'] = eoi.company?.billingAddress ?? ''; // Owner tokens tokenMap['{{owner.type}}'] = eoi.owner.type; tokenMap['{{owner.name}}'] = eoi.owner.name; tokenMap['{{owner.legalName}}'] = eoi.owner.legalName ?? ''; // Berth tokens - also optional. Render empty when no berth is linked. tokenMap['{{berth.mooringNumber}}'] = eoi.berth?.mooringNumber ?? ''; tokenMap['{{berth.area}}'] = eoi.berth?.area ?? ''; tokenMap['{{berth.lengthFt}}'] = eoi.berth?.lengthFt ?? ''; tokenMap['{{berth.price}}'] = eoi.berth?.price ?? ''; tokenMap['{{berth.priceCurrency}}'] = eoi.berth?.priceCurrency ?? ''; tokenMap['{{berth.tenureType}}'] = eoi.berth?.tenureType ?? ''; // Interest tokens tokenMap['{{interest.stage}}'] = eoi.interest.stage; tokenMap['{{interest.leadCategory}}'] = eoi.interest.leadCategory ?? ''; tokenMap['{{interest.berthNumber}}'] = eoi.berth?.mooringNumber ?? ''; tokenMap['{{interest.dateFirstContact}}'] = eoi.interest.dateFirstContact ? formatDate(eoi.interest.dateFirstContact, 'date.medium') : ''; tokenMap['{{interest.notes}}'] = eoi.interest.notes ?? ''; } catch (err) { // buildEoiContext throws ValidationError when the EOI's required client // fields (name/email/address - Section 2) are missing. For non-EOI // templates (correspondence, welcome letters, etc.) those gates don't // apply - fall through to the legacy resolution path below. Re-throw // anything else. if ( !(err instanceof ValidationError) || !/missing required client details|interest has no (yacht|berth)/i.test(err.message) ) { throw err; } } } // ─── Legacy / non-EOI fallback ────────────────────────────────────────────── // Client tokens from direct client lookup (welcome letters, correspondence, // or EOI-flow clients where we still want client.source to resolve). if (context.clientId) { const client = await db.query.clients.findFirst({ where: eq(clients.id, context.clientId), }); if (client && client.portId === context.portId) { // Always resolve source from the DB - EoiContext doesn't carry it. if (tokenMap['{{client.source}}'] === undefined) { tokenMap['{{client.source}}'] = client.source ?? ''; } // Only fill client.* tokens if the EOI path didn't already populate them. if (!eoiContextLoaded) { const contactList = await db.query.clientContacts.findMany({ where: eq(clientContacts.clientId, context.clientId), orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)], }); const emailContact = contactList.find((c) => c.channel === 'email'); const phoneContact = contactList.find( (c) => c.channel === 'phone' || c.channel === 'whatsapp', ); tokenMap['{{client.fullName}}'] = client.fullName ?? ''; tokenMap['{{client.email}}'] = emailContact?.value ?? ''; tokenMap['{{client.phone}}'] = phoneContact?.value ?? ''; tokenMap['{{client.nationality}}'] = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : ''; } } } // Interest tokens (legacy path - fills in fields EoiContext doesn't expose, // like eoiStatus / dateEoiSigned / dateContractSigned, or populates the // whole interest.* block when EOI resolution was skipped). if (context.interestId) { const interest = await db.query.interests.findFirst({ where: eq(interests.id, context.interestId), }); if (interest && interest.portId === context.portId) { if (!eoiContextLoaded) { tokenMap['{{interest.stage}}'] = interest.pipelineStage ?? ''; tokenMap['{{interest.leadCategory}}'] = interest.leadCategory ?? ''; tokenMap['{{interest.dateFirstContact}}'] = interest.dateFirstContact ? formatDate(interest.dateFirstContact, 'date.medium', { fallback: '' }) : ''; // `{{interest.notes}}` is now sourced from the threaded // interest_notes timeline via EoiContext.interest.notes; this // shallow-fallback path leaves the token blank if EoiContext // wasn't loaded for this template render. tokenMap['{{interest.notes}}'] = ''; } // These are never populated by EoiContext - always fill them in. tokenMap['{{interest.eoiStatus}}'] = interest.eoiStatus ?? ''; tokenMap['{{interest.dateEoiSigned}}'] = interest.dateEoiSigned ? formatDate(interest.dateEoiSigned, 'date.medium', { fallback: '' }) : ''; tokenMap['{{interest.dateContractSigned}}'] = interest.dateContractSigned ? formatDate(interest.dateContractSigned, 'date.medium', { fallback: '' }) : ''; // Derive berth number from the interest when berthId wasn't passed and // the EOI path didn't already populate it. Resolves through the // interest_berths junction (plan §3.4) - the legacy interest.berth_id // column has been removed. const interestPrimaryBerth = !eoiContextLoaded && !context.berthId ? await getPrimaryBerth(interest.id) : null; if (!eoiContextLoaded && interestPrimaryBerth?.berthId && !context.berthId) { if (interestPrimaryBerth.mooringNumber) { tokenMap['{{interest.berthNumber}}'] = interestPrimaryBerth.mooringNumber; if (!tokenMap['{{berth.mooringNumber}}']) { tokenMap['{{berth.mooringNumber}}'] = interestPrimaryBerth.mooringNumber; } } else { tokenMap['{{interest.berthNumber}}'] ??= ''; } } else if (!eoiContextLoaded) { tokenMap['{{interest.berthNumber}}'] ??= context.berthId ? (tokenMap['{{berth.mooringNumber}}'] ?? '') : ''; } } } // Berth tokens (legacy path - when a berthId is passed directly and EOI // resolution didn't already populate the berth block). if (context.berthId && !eoiContextLoaded) { const berth = await db.query.berths.findFirst({ where: eq(berths.id, context.berthId), }); if (berth && berth.portId === context.portId) { tokenMap['{{berth.mooringNumber}}'] = berth.mooringNumber; tokenMap['{{berth.area}}'] = berth.area ?? ''; tokenMap['{{berth.status}}'] = berth.status; tokenMap['{{berth.price}}'] = berth.price ? String(berth.price) : ''; tokenMap['{{berth.priceCurrency}}'] = berth.priceCurrency; tokenMap['{{berth.lengthFt}}'] = berth.lengthFt ? String(berth.lengthFt) : ''; tokenMap['{{berth.widthFt}}'] = berth.widthFt ? String(berth.widthFt) : ''; tokenMap['{{berth.tenureType}}'] = berth.tenureType; tokenMap['{{berth.tenureYears}}'] = berth.tenureYears ? String(berth.tenureYears) : ''; tokenMap['{{interest.berthNumber}}'] = berth.mooringNumber; } } // BR-140: Check required merge fields have values const missing: string[] = []; for (const [, fields] of Object.entries(MERGE_FIELDS)) { for (const field of fields) { if (field.required) { const value = tokenMap[field.token]; if (value !== undefined && value.trim() === '') { missing.push(field.label); } } } } if (missing.length > 0) { throw new ValidationError(`Missing required merge field values: ${missing.join(', ')}`); } // HTML body is required for the html template format; non-html formats // resolve elsewhere (see template_format dispatch in PR6). if (template.bodyHtml === null) { throw new ValidationError('Template has no HTML body to render'); } // Interpolate all tokens let resolved: string = template.bodyHtml; for (const [token, value] of Object.entries(tokenMap)) { // Escape token for use in regex const escaped = token.replace(/[{}]/g, '\\$&'); resolved = resolved.replace(new RegExp(escaped, 'g'), value); } return resolved; } // ─── Generate from template (REMOVED) ───────────────────────────────────────── // // The in-app TipTap-to-PDF rendering path (`generateFromTemplate` and the // public `generateAndSend` wrapper) was removed in the PDF stack overhaul // (see docs/superpowers/specs/2026-05-12-pdf-stack-overhaul-design.md). // Only the EOI in-app pathway survives, and it renders via pdf-lib AcroForm // fill on the source PDF (`generateEoiFromSourcePdf` below). All other // template types must go through Documenso. // // The old API routes `/api/v1/document-templates/[id]/generate` and // `/api/v1/document-templates/[id]/generate-and-send` have been deleted. // `generateAndSign` (the EOI signing entry point) now throws a clear // ValidationError when a non-EOI template is requested through the in-app // pathway. // ─── EOI from source PDF (in-app pathway, EOI templates only) ───────────────── /** * BR-142: For EOI templates, the in-app pathway uses the same source PDF as * the Documenso template - filled via pdf-lib with values from EoiContext. * Same field names, same legal document; the only difference is who renders * it. The form is left interactive so a recipient can adjust before signing. */ async function generateEoiFromSourcePdf( template: typeof documentTemplates.$inferSelect, portId: string, context: GenerateInput, meta: AuditMeta, options?: { dimensionUnit?: 'ft' | 'm'; includeYachtDetails?: boolean }, applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} }, ): Promise<{ document: DbDocument; file: DbFile }> { if (!context.interestId) { throw new ValidationError('interestId is required for EOI template generation'); } const eoiContext = applyOverridesToContext( await buildEoiContext(context.interestId, portId), applied, ); // Rep opted out of Section 3 — blank the yacht slot so the AcroForm fill // skips writing the yacht.* / owner.* fields (matching the Documenso // pathway). if (options?.includeYachtDetails === false) { eoiContext.yacht = null; } const pdfBytes = await generateEoiPdfFromTemplate(eoiContext, { dimensionUnit: options?.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft', }); const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) }); const fileId = crypto.randomUUID(); const storagePath = buildStoragePath( port?.slug ?? portId, 'document-templates', template.id, fileId, 'pdf', ); { const buffer = Buffer.from(pdfBytes); const backend = await getStorageBackend(); await backend.put(storagePath, buffer, { contentType: 'application/pdf', sizeBytes: buffer.length, }); } const [fileRecord] = await db .insert(files) .values({ portId, clientId: context.clientId ?? null, filename: `${template.name.toLowerCase().replace(/\s+/g, '-')}.pdf`, originalName: `${template.name}.pdf`, mimeType: 'application/pdf', sizeBytes: String(pdfBytes.byteLength), storagePath, storageBucket: env.MINIO_BUCKET, category: 'eoi', uploadedBy: meta.userId, }) .returning(); const [documentRecord] = await db .insert(documents) .values({ portId, clientId: context.clientId ?? null, interestId: context.interestId, documentType: template.templateType, title: template.name, status: 'draft', fileId: fileRecord!.id, isManualUpload: false, createdBy: meta.userId, }) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'document', entityId: documentRecord!.id, newValue: { templateId: template.id, templateName: template.name, source: 'eoi-source-pdf', clientId: context.clientId, interestId: context.interestId, }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'document:created', { documentId: documentRecord!.id }); return { document: documentRecord!, file: fileRecord! }; } // ─── Generate and Sign ──────────────────────────────────────────────────────── /** * BR-142: EOI / NDA signing. Dual pathway: * - `inapp`: produce the PDF locally (EOI templates fill the same source * PDF as Documenso via pdf-lib AcroForm; other template types fall * back to the @react-pdf/renderer path), upload to MinIO, then upload * to Documenso and send for signing. * - `documenso-template`: skip our PDF generation entirely; call Documenso's * template-generate endpoint with the shared EOI context. Documenso owns * the PDF. We still record a `documents` row for tracking. */ export async function generateAndSign( templateId: string | null, portId: string, context: GenerateInput, signers: GenerateAndSignInput['signers'], pathway: 'inapp' | 'documenso-template', meta: AuditMeta, options?: { dimensionUnit?: 'ft' | 'm'; overrides?: EoiOverridesInput; /** False = blank out Section 3 (yacht.* + owner.* merge fields) even * when the interest carries a linked yacht. True (or unset) keeps the * current behaviour (auto-fill from yacht record). */ includeYachtDetails?: boolean; }, ) { // Phase 3b - apply per-field overrides BEFORE either pathway resolves the // EOI context, so any setAsDefault contact promotion is visible to the // buildEoiContext read. The returned `applied.resolved` is layered onto // the in-memory context for useOnlyForThisEoi / fresh-value cases where // the canonical record isn't being touched. const applied = context.interestId ? await applyEoiOverridesBeforeRender(portId, context.interestId, options?.overrides, meta) : { resolved: {}, documentOverrideColumns: {} }; if (pathway === 'documenso-template') { return generateAndSignViaDocumensoTemplate(portId, context, meta, options, applied); } if (!templateId) { throw new ValidationError('templateId is required for inapp pathway'); } return generateAndSignViaInApp(templateId, portId, context, signers, meta, options, applied); } async function generateAndSignViaInApp( templateId: string, portId: string, context: GenerateInput, signers: GenerateAndSignInput['signers'], meta: AuditMeta, options?: { dimensionUnit?: 'ft' | 'm'; includeYachtDetails?: boolean }, applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} }, ) { const template = await getTemplateById(templateId, portId); // For EOI templates, signers default to the same set the Documenso template // pathway uses (interest's client + hardcoded developer + approver), so the // UI doesn't need to collect them. Non-EOI templates still require explicit // signers since they have no canonical recipient list. let resolvedSigners = signers; if ((!resolvedSigners || resolvedSigners.length === 0) && template.templateType === 'eoi') { if (!context.interestId) { throw new ValidationError( 'interestId is required when generating an EOI without explicit signers', ); } const eoiCtx = await buildEoiContext(context.interestId, portId); const signers = await getPortEoiSigners(portId); resolvedSigners = [ { name: eoiCtx.client.fullName, email: eoiCtx.client.primaryEmail ?? '', role: 'signer', signingOrder: 1, }, { name: signers.developer.name, email: signers.developer.email, role: 'signer', signingOrder: 2, }, { name: signers.approver.name, email: signers.approver.email, role: 'approver', signingOrder: 3, }, ]; } if (!resolvedSigners || resolvedSigners.length === 0) { throw new ValidationError('signers are required for inapp pathway'); } // EOI templates fill the same source PDF as the Documenso template (so both // pathways yield the same document). The HTML→pdfme rendering path for // non-EOI templates was removed in the PDF stack overhaul (see the design // spec). Send non-EOI documents via the Documenso pathway, OR - once it // ships - the admin-uploaded AcroForm-fill template feature. if (template.templateType !== 'eoi') { throw new ValidationError( `In-app PDF rendering for templates of type "${template.templateType}" is not supported. ` + 'Use a Documenso template, or upload a custom PDF (AcroForm-fill feature is deferred).', ); } const { document: documentRecord, file } = await generateEoiFromSourcePdf( template, portId, context, meta, options, applied, ); // Phase 3b - record per-document override columns + backfill the // source_document_id on any client_contacts rows inserted during the // override side-effects. await persistDocumentOverrides(documentRecord.id, applied, meta); // Fetch PDF bytes from the active storage backend to send to Documenso. const pdfStream = await (await getStorageBackend()).get(file.storagePath); const chunks: Buffer[] = []; for await (const chunk of pdfStream) { if (Buffer.isBuffer(chunk)) chunks.push(chunk); else if (typeof chunk === 'string') chunks.push(Buffer.from(chunk)); else chunks.push(Buffer.from(chunk as Uint8Array)); } const pdfBase64 = Buffer.concat(chunks).toString('base64'); // Create Documenso document const documensoDoc = await documensoCreate( template.name, pdfBase64, resolvedSigners.map((s) => ({ name: s.name, email: s.email, role: s.role, signingOrder: s.signingOrder, })), ); // Send document for signing await documensoSend(documensoDoc.id); // Update our document record with Documenso ID and status await db .update(documents) .set({ documensoId: documensoDoc.id, documensoNumericId: documensoDoc.numericId, status: 'sent', updatedAt: new Date(), }) .where(eq(documents.id, documentRecord.id)); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'document', entityId: documentRecord.id, newValue: { status: 'sent', documensoId: documensoDoc.id }, metadata: { action: 'generate_and_sign', pathway: 'inapp', signerCount: resolvedSigners.length, }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'document:updated', { documentId: documentRecord.id, changedFields: ['status', 'documensoId'], }); return { document: { ...documentRecord, documensoId: documensoDoc.id, status: 'sent' }, file }; } async function generateAndSignViaDocumensoTemplate( portId: string, context: GenerateInput, meta: AuditMeta, options?: { dimensionUnit?: 'ft' | 'm'; includeYachtDetails?: boolean }, applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} }, ) { if (!context.interestId) { throw new ValidationError('interestId is required for documenso-template pathway'); } const eoiContext = applyOverridesToContext( await buildEoiContext(context.interestId, portId), applied, ); // Rep opted out of Section 3 (yacht details) — blank the yacht slot so // buildDocumensoPayload + the EOI template see "no yacht linked" and // leave yacht.* / owner.* merge fields empty. Persisted in document // metadata below for audit (kind: 'eoi_include_yacht_details=false'). const yachtDeclined = options?.includeYachtDetails === false; if (yachtDeclined) { eoiContext.yacht = null; } const signers = await getPortEoiSigners(portId); // Per-port Documenso template + recipient IDs (with env fallback). Each // tenant pointing at its own Documenso instance has different numeric // template + recipient IDs, so a global env-only setup limits the // platform to one Documenso instance per CRM process. const docCfg = await getPortDocumensoConfig(portId); // v2 prefillFields-by-ID emission requires a field-name → field-ID map // populated by the admin "Sync from Documenso" button. Absent (or partial) // map → payload skips prefillFields and v2 accepts the legacy formValues // shape via backward compat. const { getEoiFieldMap } = await import('@/lib/services/documenso-template-sync.service'); const fieldMap = await getEoiFieldMap(portId); // Pick which side of the yacht's stored dimensions ships to Documenso. // The drawer's toggle drives this; if the caller omitted it, default to // whichever unit the rep originally typed in (yacht.lengthUnit). Legacy // yachts without a unit column default to 'ft'. const dimensionUnit: 'ft' | 'm' = options?.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft'; const payload = buildDocumensoPayload( eoiContext, { interestId: context.interestId, clientRecipientId: docCfg.clientRecipientId, developerRecipientId: docCfg.developerRecipientId, approvalRecipientId: docCfg.approvalRecipientId, developerName: signers.developer.name, developerEmail: signers.developer.email, approverName: signers.approver.name, approverEmail: signers.approver.email, // Prefer per-port post-signing redirect (typically marketing-site // /sign/success on v2). Falls back to APP_URL on v1 / when unset. redirectUrl: docCfg.redirectUrl ?? env.APP_URL, // v2-only signing-order enforcement. v1 instances ignore this key. ...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}), dimensionUnit, }, fieldMap, ); const documensoDoc = await documensoGenerateFromTemplate( docCfg.eoiTemplateId, payload as unknown as Record, portId, ); // Record a documents row referencing the Documenso document. No local file - // Documenso owns the PDF and delivers signed copies via webhook (handled elsewhere). const [documentRecord] = await db .insert(documents) .values({ portId, clientId: context.clientId ?? null, interestId: context.interestId, documentType: 'eoi', title: payload.title, status: 'sent', documensoId: documensoDoc.id, documensoNumericId: documensoDoc.numericId, isManualUpload: false, createdBy: meta.userId, }) .returning(); // Phase 3b - record any per-document override columns + backfill // source_document_id on freshly inserted contact rows. await persistDocumentOverrides(documentRecord!.id, applied, meta); // Persist the per-recipient signer rows from Documenso's create response. // Without these the EOI tab's "Signing progress" panel shows // "No signers loaded" forever (the webhook handler only updates existing // rows by token / email). Each row maps a Documenso recipient slot to // a CRM document-signer record. if (documensoDoc.recipients.length > 0) { await db.insert(documentSigners).values( documensoDoc.recipients.map((r) => { // Strip the `(was: )` suffix that `applyRecipientRedirect` // bakes into recipient names when EMAIL_REDIRECT_TO is on. Without // this, every downstream surface (email greeting, signing-progress // card, document-detail page) leaks "Matt Ciaccio (was: matt@...)" // into reps' faces. Display-only cleanup; the original email is // still recoverable via the redirect helper. const cleanName = (r.name || r.email) .replace(/\s*\(was:[^)]*\)/i, '') .replace(/\s*\(placeholder\)/i, '') .replace(/\s*\(placeholder\b[^)]*\)/i, '') .trim(); // signingOrder 1 with role SIGNER is always the CLIENT in our trio // (Client → Developer → Approver). Without this special-case the // role gets stored as 'signer' for the client too, and the email // template's `isClient` branch wrongly tells the client "you're // the next signer; the client has already signed." const role = r.role.toUpperCase() === 'SIGNER' && r.signingOrder === 1 ? 'client' : normalizeSignerRole(r.role); return { documentId: documentRecord!.id, signerName: cleanName || r.email, signerEmail: r.email, signerRole: role, signingOrder: r.signingOrder, status: 'pending' as const, signingUrl: r.signingUrl ?? null, embeddedUrl: r.embeddedUrl ?? null, signingToken: r.token ?? null, // invitedAt deliberately left null at create time. The // send-invitation route stamps it once the branded invite goes // out. Pre-stamping would mis-label the signer card as // "Invited just now" in manual send mode. invitedAt: null, }; }), ); } // Stamp the interest's EOI milestone so the Overview tab flips the // "Generate EOI" prompt to the "EOI sent / awaiting signatures" state // immediately. Cache-invalidation on the client picks the new shape up // via the document-templates POST's onSuccess. await db .update(interests) .set({ eoiDocStatus: 'sent', dateEoiSent: new Date(), updatedAt: new Date(), }) .where(eq(interests.id, context.interestId)); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'document', entityId: documentRecord!.id, newValue: { documensoId: documensoDoc.id, status: 'sent' }, metadata: { action: 'generate_and_sign', pathway: 'documenso-template', templateId: env.DOCUMENSO_TEMPLATE_ID_EOI, interestId: context.interestId, // Rep's explicit Section-3 choice. Audit-only — Docs row has no // metadata jsonb; the blanked yacht.* / owner.* merge fields on the // generated PDF are the user-visible evidence. includeYachtDetails: !yachtDeclined, }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'document:created', { documentId: documentRecord!.id }); return { document: documentRecord!, file: null }; } /** * Documenso recipient roles arrive as ALL-CAPS strings ('SIGNER' | 'APPROVER' * | 'CC' | 'VIEWER'); the CRM's `document_signers.signer_role` column uses * the lowercase domain vocabulary ('client' | 'developer' | 'approver' | * 'cc' | 'viewer' | 'other'). Map them so the UI's progress panel renders * the right label per row. SIGNER → developer is a safe default because * the client slot is identified positionally elsewhere (signingOrder=1 * always). */ function normalizeSignerRole(documensoRole: string): string { const r = documensoRole.toUpperCase(); if (r === 'APPROVER') return 'approver'; if (r === 'CC') return 'cc'; if (r === 'VIEWER') return 'viewer'; return 'signer'; }