import { and, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { documentTemplates, documents, files } 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 { 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 } from '@/lib/services/documenso-client'; import { sendEmail } from '@/lib/email'; import type { CreateTemplateInput, UpdateTemplateInput, ListTemplatesInput, GenerateInput, GenerateAndSendInput, 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.companyName}}', label: 'Company Name', required: false }, { 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.yachtName}}', label: 'Yacht Name', required: false }, { token: '{{client.yachtLengthFt}}', label: 'Yacht Length (ft)', required: false }, { token: '{{client.yachtLengthM}}', label: 'Yacht Length (m)', required: false }, { token: '{{client.yachtWidthFt}}', label: 'Yacht Beam (ft)', required: false }, { token: '{{client.yachtDraftFt}}', label: 'Yacht Draft (ft)', required: false }, { token: '{{client.source}}', label: 'Lead Source', 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: [ { token: '{{berth.mooringNumber}}', label: 'Mooring Number', required: true }, { 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; } // Client tokens if (context.clientId) { const client = await db.query.clients.findFirst({ where: eq(clients.id, context.clientId), }); if (client && client.portId === context.portId) { 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.companyName}}'] = client.companyName ?? ''; tokenMap['{{client.email}}'] = emailContact?.value ?? ''; tokenMap['{{client.phone}}'] = phoneContact?.value ?? ''; tokenMap['{{client.nationality}}'] = client.nationality ?? ''; tokenMap['{{client.yachtName}}'] = client.yachtName ?? ''; tokenMap['{{client.yachtLengthFt}}'] = client.yachtLengthFt ? String(client.yachtLengthFt) : ''; tokenMap['{{client.yachtLengthM}}'] = client.yachtLengthM ? String(client.yachtLengthM) : ''; tokenMap['{{client.yachtWidthFt}}'] = client.yachtWidthFt ? String(client.yachtWidthFt) : ''; tokenMap['{{client.yachtDraftFt}}'] = client.yachtDraftFt ? String(client.yachtDraftFt) : ''; tokenMap['{{client.source}}'] = client.source ?? ''; } } // Interest tokens if (context.interestId) { const interest = await db.query.interests.findFirst({ where: eq(interests.id, context.interestId), }); if (interest && interest.portId === context.portId) { tokenMap['{{interest.stage}}'] = interest.pipelineStage ?? ''; tokenMap['{{interest.leadCategory}}'] = interest.leadCategory ?? ''; tokenMap['{{interest.eoiStatus}}'] = interest.eoiStatus ?? ''; tokenMap['{{interest.dateFirstContact}}'] = interest.dateFirstContact ? new Date(interest.dateFirstContact).toLocaleDateString('en-GB') : ''; tokenMap['{{interest.dateEoiSigned}}'] = interest.dateEoiSigned ? new Date(interest.dateEoiSigned).toLocaleDateString('en-GB') : ''; tokenMap['{{interest.dateContractSigned}}'] = interest.dateContractSigned ? new Date(interest.dateContractSigned).toLocaleDateString('en-GB') : ''; tokenMap['{{interest.notes}}'] = interest.notes ?? ''; // Berth number from interest if berthId not separately provided if (interest.berthId && !context.berthId) { const interestBerth = await db.query.berths.findFirst({ where: eq(berths.id, interest.berthId), }); tokenMap['{{interest.berthNumber}}'] = interestBerth?.mooringNumber ?? ''; tokenMap['{{berth.mooringNumber}}'] = interestBerth?.mooringNumber ?? ''; } else { tokenMap['{{interest.berthNumber}}'] = context.berthId ? tokenMap['{{berth.mooringNumber}}'] ?? '' : ''; } } } // Berth tokens if (context.berthId) { 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 [_category, 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: any; file: any }> { 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 any, 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 }; } // ─── Generate and Sign ──────────────────────────────────────────────────────── export async function generateAndSign( templateId: string, portId: string, context: GenerateInput, signers: GenerateAndSignInput['signers'], meta: AuditMeta, ) { const { document: documentRecord, file } = await generateFromTemplate( templateId, portId, context, meta, ); const template = await getTemplateById(templateId, portId); // 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', 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 }; }