import { and, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { documentTemplates, documents, 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 } from '@/lib/audit'; import { diffEntity } from '@/lib/entity-diff'; import { NotFoundError, ValidationError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; import { minioClient, buildStoragePath } from '@/lib/minio'; import { env } from '@/lib/env'; import { logger } from '@/lib/logger'; import { generatePdf } from '@/lib/pdf/generate'; import { createDocument as documensoCreate, sendDocument as documensoSend, generateDocumentFromTemplate as documensoGenerateFromTemplate, } from '@/lib/services/documenso-client'; import { buildDocumensoPayload } from '@/lib/services/documenso-payload'; import { generateEoiPdfFromTemplate } from '@/lib/pdf/fill-eoi-form'; import { buildEoiContext } from '@/lib/services/eoi-context'; import { sendEmail } from '@/lib/email'; import type { CreateTemplateInput, UpdateTemplateInput, ListTemplatesInput, GenerateInput, GenerateAndSignInput, } from '@/lib/validators/document-templates'; // ─── Types ──────────────────────────────────────────────────────────────────── interface AuditMeta { userId: string; portId: string; ipAddress: string; userAgent: string; } // ─── Merge Field Definitions ────────────────────────────────────────────────── const MERGE_FIELDS: Record> = { client: [ { token: '{{client.fullName}}', label: 'Client Full Name', required: true }, { token: '{{client.email}}', label: 'Primary Email', required: false }, { token: '{{client.phone}}', label: 'Primary Phone', required: false }, { token: '{{client.nationality}}', label: 'Nationality', required: false }, { token: '{{client.source}}', label: 'Lead Source', required: false }, // Removed (PR 11): {{client.companyName}}, {{client.yachtName}}, // {{client.yachtLengthFt}}, {{client.yachtLengthM}}, {{client.yachtWidthFt}}, // {{client.yachtDraftFt}} — use the dedicated yacht.* / company.* scopes instead. ], yacht: [ { token: '{{yacht.name}}', label: 'Yacht Name', required: false }, { token: '{{yacht.hullNumber}}', label: 'Hull Number', required: false }, { token: '{{yacht.registration}}', label: 'Registration', required: false }, { token: '{{yacht.flag}}', label: 'Flag', required: false }, { token: '{{yacht.yearBuilt}}', label: 'Year Built', required: false }, { token: '{{yacht.lengthFt}}', label: 'Yacht Length (ft)', required: false }, { token: '{{yacht.widthFt}}', label: 'Yacht Beam (ft)', required: false }, { token: '{{yacht.draftFt}}', label: 'Yacht Draft (ft)', required: false }, { token: '{{yacht.lengthM}}', label: 'Yacht Length (m)', required: false }, { token: '{{yacht.widthM}}', label: 'Yacht Beam (m)', required: false }, { token: '{{yacht.draftM}}', label: 'Yacht Draft (m)', required: false }, ], company: [ { token: '{{company.name}}', label: 'Company Name', required: false }, { token: '{{company.legalName}}', label: 'Company Legal Name', required: false }, { token: '{{company.taxId}}', label: 'Company Tax ID', required: false }, { token: '{{company.billingAddress}}', label: 'Company Billing Address', required: false }, ], owner: [ { token: '{{owner.type}}', label: 'Yacht Owner Type', required: false }, { token: '{{owner.name}}', label: 'Yacht Owner Name', required: false }, { token: '{{owner.legalName}}', label: 'Yacht Owner Legal Name', required: false }, ], interest: [ { token: '{{interest.stage}}', label: 'Pipeline Stage', required: false }, { token: '{{interest.leadCategory}}', label: 'Lead Category', required: false }, { token: '{{interest.berthNumber}}', label: 'Berth Number', required: false }, { token: '{{interest.eoiStatus}}', label: 'EOI Status', required: false }, { token: '{{interest.dateFirstContact}}', label: 'Date First Contact', required: false }, { token: '{{interest.dateEoiSigned}}', label: 'Date EOI Signed', required: false }, { token: '{{interest.dateContractSigned}}', label: 'Date Contract Signed', required: false }, { token: '{{interest.notes}}', label: 'Interest Notes', required: false }, ], berth: [ // Non-required so non-EOI templates (welcome letters etc.) don't fail. // EOI-specific required-field enforcement lives in STANDARD_EOI_MERGE_FIELDS. { token: '{{berth.mooringNumber}}', label: 'Mooring Number', required: false }, { token: '{{berth.area}}', label: 'Area', required: false }, { token: '{{berth.status}}', label: 'Berth Status', required: false }, { token: '{{berth.price}}', label: 'Price', required: false }, { token: '{{berth.priceCurrency}}', label: 'Price Currency', required: false }, { token: '{{berth.lengthFt}}', label: 'Length (ft)', required: false }, { token: '{{berth.widthFt}}', label: 'Beam (ft)', required: false }, { token: '{{berth.tenureType}}', label: 'Tenure Type', required: false }, { token: '{{berth.tenureYears}}', label: 'Tenure Years', required: false }, ], port: [ { token: '{{port.name}}', label: 'Port Name', required: false }, { token: '{{port.defaultCurrency}}', label: 'Default Currency', required: false }, ], date: [ { token: '{{date.today}}', label: "Today's Date", required: false }, { token: '{{date.year}}', label: 'Current Year', required: false }, ], }; export function getMergeFields(): typeof MERGE_FIELDS { 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}}'] = now.toLocaleDateString('en-GB'); 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 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 (from EoiContext) 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 ? eoi.interest.dateFirstContact.toLocaleDateString('en-GB') : ''; tokenMap['{{interest.notes}}'] = eoi.interest.notes ?? ''; } catch (err) { // buildEoiContext throws ValidationError when the interest has no yacht // or berth; non-EOI templates don't need those. Fall through to the // legacy resolution path below. Re-throw anything else. if ( !(err instanceof ValidationError) || !/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.nationality ?? ''; } } } // 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 ? new Date(interest.dateFirstContact).toLocaleDateString('en-GB') : ''; tokenMap['{{interest.notes}}'] = interest.notes ?? ''; } // These are never populated by EoiContext — always fill them in. tokenMap['{{interest.eoiStatus}}'] = interest.eoiStatus ?? ''; tokenMap['{{interest.dateEoiSigned}}'] = interest.dateEoiSigned ? new Date(interest.dateEoiSigned).toLocaleDateString('en-GB') : ''; tokenMap['{{interest.dateContractSigned}}'] = interest.dateContractSigned ? new Date(interest.dateContractSigned).toLocaleDateString('en-GB') : ''; // Derive berth number from the interest when berthId wasn't passed and // the EOI path didn't already populate it. if (!eoiContextLoaded && interest.berthId && !context.berthId) { const interestBerth = await db.query.berths.findFirst({ where: eq(berths.id, interest.berthId), }); if (interestBerth) { tokenMap['{{interest.berthNumber}}'] = interestBerth.mooringNumber; if (!tokenMap['{{berth.mooringNumber}}']) { tokenMap['{{berth.mooringNumber}}'] = interestBerth.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(', ')}`); } // Interpolate all tokens let resolved = 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 ─────────────────────────────────────────────────── /** * BR-142: Resolve template → HTML → PDF. Store in MinIO + create file/document records. */ export async function generateFromTemplate( templateId: string, portId: string, context: GenerateInput, meta: AuditMeta, ): Promise<{ document: unknown; file: unknown }> { const template = await getTemplateById(templateId, portId); const resolvedHtml = await resolveTemplate(templateId, { ...context, portId }); const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) }); // Wrap HTML in a minimal full-page document for pdfme text block const wrappedContent = resolvedHtml .replace(/<[^>]+>/g, ' ') // strip HTML tags for plain-text PDF rendering .replace(/\s+/g, ' ') .trim(); // Use a simple single-field pdfme template for the HTML body const pdfTemplate = { basePdf: 'BLANK_PDF' as unknown as string, schemas: [ [ { name: 'portName', type: 'text' as const, position: { x: 20, y: 15 }, width: 170, height: 10, fontSize: 14, }, { name: 'body', type: 'text' as const, position: { x: 20, y: 30 }, width: 170, height: 230, fontSize: 9, }, { name: 'generatedAt', type: 'text' as const, position: { x: 20, y: 275 }, width: 170, height: 6, fontSize: 7, }, ], ], }; const pdfBytes = await generatePdf(pdfTemplate, [ { portName: `${port?.name ?? 'Port Nimara'} — ${template.name}`, body: wrappedContent, generatedAt: `Generated: ${new Date().toLocaleString('en-GB')}`, }, ]); // Store in MinIO const fileId = crypto.randomUUID(); const storagePath = buildStoragePath( port?.slug ?? portId, 'document-templates', templateId, fileId, 'pdf', ); await minioClient.putObject( env.MINIO_BUCKET, storagePath, Buffer.from(pdfBytes), pdfBytes.byteLength, { 'Content-Type': 'application/pdf' }, ); // Create file record 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: 'correspondence', uploadedBy: meta.userId, }) .returning(); // Create document record const [documentRecord] = await db .insert(documents) .values({ portId, clientId: context.clientId ?? null, interestId: context.interestId ?? null, 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, templateName: template.name, clientId: context.clientId, interestId: context.interestId, berthId: context.berthId, }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'document:created', { documentId: documentRecord!.id }); return { document: documentRecord!, file: fileRecord! }; } // ─── Generate and Send ──────────────────────────────────────────────────────── export async function generateAndSend( templateId: string, portId: string, context: GenerateInput, recipientEmail: string, meta: AuditMeta, ) { const { document, file } = await generateFromTemplate(templateId, portId, context, meta); const template = await getTemplateById(templateId, portId); const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) }); // Send email with PDF as attachment (base64 encoded body) try { const resolvedHtml = await resolveTemplate(templateId, { ...context, portId }); await sendEmail( recipientEmail, template.name, `

Please find the attached document: ${template.name}


${resolvedHtml}`, `${port?.name ?? 'Port Nimara'} `, ); } catch (err) { logger.error({ err, templateId, recipientEmail }, 'Failed to send template email'); // Don't throw — document was created successfully; email failure is non-fatal } void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'documentTemplate', entityId: templateId, metadata: { action: 'generate_and_send', recipientEmail }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); return { document, file }; } // ─── 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, ): Promise<{ document: DbDocument; file: DbFile }> { if (!context.interestId) { throw new ValidationError('interestId is required for EOI template generation'); } const eoiContext = await buildEoiContext(context.interestId, portId); const pdfBytes = await generateEoiPdfFromTemplate(eoiContext); 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', ); await minioClient.putObject( env.MINIO_BUCKET, storagePath, Buffer.from(pdfBytes), pdfBytes.byteLength, { 'Content-Type': 'application/pdf' }, ); 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; other template types fall back to the * HTML→pdfme 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, ) { if (pathway === 'documenso-template') { return generateAndSignViaDocumensoTemplate(portId, context, meta); } if (!templateId) { throw new ValidationError('templateId is required for inapp pathway'); } return generateAndSignViaInApp(templateId, portId, context, signers, meta); } async function generateAndSignViaInApp( templateId: string, portId: string, context: GenerateInput, signers: GenerateAndSignInput['signers'], meta: AuditMeta, ) { if (!signers || signers.length === 0) { throw new ValidationError('signers are required for inapp pathway'); } const template = await getTemplateById(templateId, portId); // EOI templates fill the same source PDF as the Documenso template (so both // pathways yield the same document). Other template types stay on the // HTML→pdfme rendering path. const { document: documentRecord, file } = template.templateType === 'eoi' ? await generateEoiFromSourcePdf(template, portId, context, meta) : ((await generateFromTemplate(templateId, portId, context, meta)) as { document: DbDocument; file: DbFile; }); // Fetch PDF bytes from MinIO to send to Documenso const pdfStream = await minioClient.getObject(env.MINIO_BUCKET, file.storagePath); const chunks: Buffer[] = []; for await (const chunk of pdfStream) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as ArrayBuffer)); } const pdfBase64 = Buffer.concat(chunks).toString('base64'); // Create Documenso document const documensoDoc = await documensoCreate( template.name, pdfBase64, signers.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, 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: signers.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, ) { if (!context.interestId) { throw new ValidationError('interestId is required for documenso-template pathway'); } const eoiContext = await buildEoiContext(context.interestId, portId); const payload = buildDocumensoPayload(eoiContext, { interestId: context.interestId, clientRecipientId: env.DOCUMENSO_CLIENT_RECIPIENT_ID, developerRecipientId: env.DOCUMENSO_DEVELOPER_RECIPIENT_ID, approvalRecipientId: env.DOCUMENSO_APPROVAL_RECIPIENT_ID, redirectUrl: env.APP_URL, }); const documensoDoc = await documensoGenerateFromTemplate( env.DOCUMENSO_TEMPLATE_ID_EOI, payload as unknown as Record, ); // 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, isManualUpload: false, createdBy: meta.userId, }) .returning(); 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, }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'document:created', { documentId: documentRecord!.id }); return { document: documentRecord!, file: null }; }