import { and, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { documents, documentSigners, documentEvents, files } from '@/lib/db/schema/documents'; import { interests } from '@/lib/db/schema/interests'; import { clients } from '@/lib/db/schema/clients'; 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, ConflictError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; import { minioClient, getPresignedUrl, buildStoragePath } from '@/lib/minio'; import { env } from '@/lib/env'; import { logger } from '@/lib/logger'; import { generatePdf } from '@/lib/pdf/generate'; import { eoiTemplate, buildEoiInputs } from '@/lib/pdf/templates/eoi-template'; import { evaluateRule } from '@/lib/services/berth-rules-engine'; import { createDocument as documensoCreate, sendDocument as documensoSend, getDocument as documensoGet, sendReminder as documensoRemind, downloadSignedPdf, } from '@/lib/services/documenso-client'; import type { CreateDocumentInput, UpdateDocumentInput, ListDocumentsInput, GenerateEoiInput, } from '@/lib/validators/documents'; // ─── Types ──────────────────────────────────────────────────────────────────── interface AuditMeta { userId: string; portId: string; ipAddress: string; userAgent: string; } // ─── List ───────────────────────────────────────────────────────────────────── export async function listDocuments(portId: string, query: ListDocumentsInput) { const { page, limit, sort, order, search, interestId, clientId, documentType, status } = query; const filters = []; if (interestId) filters.push(eq(documents.interestId, interestId)); if (clientId) filters.push(eq(documents.clientId, clientId)); if (documentType) filters.push(eq(documents.documentType, documentType)); if (status) filters.push(eq(documents.status, status)); const sortColumn = sort === 'title' ? documents.title : sort === 'status' ? documents.status : sort === 'documentType' ? documents.documentType : documents.createdAt; return buildListQuery({ table: documents, portIdColumn: documents.portId, portId, idColumn: documents.id, updatedAtColumn: documents.updatedAt, searchColumns: [documents.title], searchTerm: search, filters, sort: sort ? { column: sortColumn, direction: order } : undefined, page, pageSize: limit, }); } // ─── Get by ID ──────────────────────────────────────────────────────────────── export async function getDocumentById(id: string, portId: string) { const doc = await db.query.documents.findFirst({ where: and(eq(documents.id, id), eq(documents.portId, portId)), with: { signers: true }, }); if (!doc) throw new NotFoundError('Document'); return doc; } // ─── Create ─────────────────────────────────────────────────────────────────── export async function createDocument( portId: string, data: CreateDocumentInput, meta: AuditMeta, ) { const [doc] = await db .insert(documents) .values({ portId, interestId: data.interestId ?? null, clientId: data.clientId ?? null, documentType: data.documentType, title: data.title, notes: data.notes ?? null, status: 'draft', createdBy: meta.userId, }) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'document', entityId: doc!.id, newValue: { documentType: doc!.documentType, title: doc!.title }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'document:created', { documentId: doc!.id }); return doc!; } // ─── Update ─────────────────────────────────────────────────────────────────── export async function updateDocument( id: string, portId: string, data: UpdateDocumentInput, meta: AuditMeta, ) { const existing = await getDocumentById(id, portId); const updates: Partial = {}; if (data.title !== undefined) updates.title = data.title; if (data.notes !== undefined) updates.notes = data.notes; if (data.status !== undefined) updates.status = data.status; updates.updatedAt = new Date(); const [updated] = await db .update(documents) .set(updates) .where(and(eq(documents.id, id), eq(documents.portId, portId))) .returning(); const diff = diffEntity(existing, updated!); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'document', entityId: id, oldValue: existing as unknown as Record, newValue: updated as unknown as Record, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'document:updated', { documentId: id }); return updated!; } // ─── Delete ─────────────────────────────────────────────────────────────────── export async function deleteDocument(id: string, portId: string, meta: AuditMeta) { const existing = await getDocumentById(id, portId); if (['sent', 'partially_signed'].includes(existing.status)) { throw new ConflictError('Cannot delete a document that is currently in signing process'); } await db .delete(documents) .where(and(eq(documents.id, id), eq(documents.portId, portId))); void createAuditLog({ userId: meta.userId, portId, action: 'delete', entityType: 'document', entityId: id, oldValue: { title: existing.title, status: existing.status }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'document:deleted', { documentId: id }); } // ─── Generate EOI (BR-020) ──────────────────────────────────────────────────── export async function generateEoi(interestId: string, portId: string, meta: AuditMeta) { // Fetch interest + related data const interest = await db.query.interests.findFirst({ where: and(eq(interests.id, interestId), eq(interests.portId, portId)), }); if (!interest) throw new NotFoundError('Interest'); const client = await db.query.clients.findFirst({ where: eq(clients.id, interest.clientId), with: { contacts: true }, }); if (!client) throw new NotFoundError('Client'); // BR-020: Check prerequisites const missing: Array<{ field: string; message: string }> = []; if (!client.fullName) missing.push({ field: 'client.fullName', message: 'Client must have a full name' }); const emailContact = (client.contacts as Array<{ channel: string; value: string }> | undefined)?.find( (c) => c.channel === 'email', ); if (!emailContact?.value) missing.push({ field: 'client.email', message: 'Client must have an email contact' }); if (!client.yachtLengthFt && !client.yachtLengthM) { missing.push({ field: 'client.yachtDimensions', message: 'Client must have yacht dimensions' }); } if (!interest.berthId) missing.push({ field: 'interest.berthId', message: 'Interest must have a berth linked' }); if (missing.length > 0) { throw new ValidationError('Missing prerequisites for EOI generation', missing); } const [berth, port] = await Promise.all([ db.query.berths.findFirst({ where: eq(berths.id, interest.berthId!) }), db.query.ports.findFirst({ where: eq(ports.id, portId) }), ]); if (!berth) throw new NotFoundError('Berth'); if (!port) throw new NotFoundError('Port'); // Generate PDF const inputs = buildEoiInputs( interest as unknown as Record, { ...client, contacts: client.contacts } as unknown as Record, berth as unknown as Record, port as unknown as Record, ); const pdfBytes = await generatePdf(eoiTemplate, [inputs]); const pdfBuffer = Buffer.from(pdfBytes); // Store in MinIO const fileId = crypto.randomUUID(); const storagePath = buildStoragePath(port.slug, 'eoi', interestId, fileId, 'pdf'); await minioClient.putObject(env.MINIO_BUCKET, storagePath, pdfBuffer, pdfBuffer.length, { 'Content-Type': 'application/pdf', }); // Create files record const [fileRecord] = await db .insert(files) .values({ portId, clientId: client.id, filename: `eoi-${interestId}.pdf`, originalName: `eoi-${interestId}.pdf`, mimeType: 'application/pdf', sizeBytes: String(pdfBuffer.length), storagePath, storageBucket: env.MINIO_BUCKET, category: 'eoi', uploadedBy: meta.userId, }) .returning(); // Create document record const [doc] = await db .insert(documents) .values({ portId, interestId, clientId: client.id, documentType: 'eoi', title: `EOI – ${client.fullName} / ${berth.mooringNumber}`, status: 'draft', fileId: fileRecord!.id, createdBy: meta.userId, }) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'document', entityId: doc!.id, newValue: { documentType: 'eoi', interestId }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'document:created', { documentId: doc!.id, type: 'eoi' }); return doc!; } // ─── Send for Signing (BR-021) ──────────────────────────────────────────────── export async function sendForSigning(documentId: string, portId: string, meta: AuditMeta) { const doc = await getDocumentById(documentId, portId); if (!doc.fileId) throw new ValidationError('Document has no associated file'); if (doc.status !== 'draft') throw new ConflictError('Document is not in draft status'); // Fetch interest + client to build signers const interest = doc.interestId ? await db.query.interests.findFirst({ where: eq(interests.id, doc.interestId) }) : null; const client = doc.clientId ? await db.query.clients.findFirst({ where: eq(clients.id, doc.clientId), with: { contacts: true }, }) : null; if (!client) throw new ValidationError('Document has no associated client'); const emailContact = (client.contacts as Array<{ channel: string; value: string }> | undefined)?.find( (c) => c.channel === 'email', ); if (!emailContact?.value) throw new ValidationError('Client has no email contact'); const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) }); if (!port) throw new NotFoundError('Port'); // BR-021: Create 3 signers — client (1), developer (2), sales/approver (3) const signerRecords = await db .insert(documentSigners) .values([ { documentId, signerName: client.fullName, signerEmail: emailContact.value, signerRole: 'client', signingOrder: 1, status: 'pending', }, { documentId, signerName: port.name, signerEmail: `developer@${port.slug}.com`, signerRole: 'developer', signingOrder: 2, status: 'pending', }, { documentId, signerName: `${port.name} Sales`, signerEmail: `sales@${port.slug}.com`, signerRole: 'approver', signingOrder: 3, status: 'pending', }, ]) .returning(); // Get file from MinIO and base64 encode const fileRecord = await db.query.files.findFirst({ where: eq(files.id, doc.fileId) }); if (!fileRecord) throw new NotFoundError('File'); const fileStream = await minioClient.getObject(env.MINIO_BUCKET, fileRecord.storagePath); const chunks: Buffer[] = []; for await (const chunk of fileStream) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } const pdfBuffer = Buffer.concat(chunks); const pdfBase64 = pdfBuffer.toString('base64'); // Create document in Documenso + send const documensoDoc = await documensoCreate(doc.title, pdfBase64, [ { name: client.fullName, email: emailContact.value, role: 'SIGNER', signingOrder: 1 }, { name: port.name, email: `developer@${port.slug}.com`, role: 'SIGNER', signingOrder: 2 }, { name: `${port.name} Sales`, email: `sales@${port.slug}.com`, role: 'SIGNER', signingOrder: 3 }, ]); await documensoSend(documensoDoc.id); // Update signer records with signing URLs from Documenso response for (const docSigner of documensoDoc.recipients) { const localSigner = signerRecords.find((s) => s.signerEmail === docSigner.email); if (localSigner) { await db .update(documentSigners) .set({ signingUrl: docSigner.signingUrl ?? null, embeddedUrl: docSigner.embeddedUrl ?? null, }) .where(eq(documentSigners.id, localSigner.id)); } } // Update document status await db .update(documents) .set({ status: 'sent', documensoId: documensoDoc.id, updatedAt: new Date() }) .where(eq(documents.id, documentId)); // Update interest if linked if (interest) { await db .update(interests) .set({ documensoId: documensoDoc.id, dateEoiSent: new Date(), eoiStatus: 'waiting_for_signatures', updatedAt: new Date(), }) .where(eq(interests.id, interest.id)); // Trigger berth rules void evaluateRule('eoi_sent', interest.id, portId, meta); } // Create document event await db.insert(documentEvents).values({ documentId, eventType: 'sent', eventData: { documensoId: documensoDoc.id }, }); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'document', entityId: documentId, newValue: { status: 'sent', documensoId: documensoDoc.id }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'document:sent', { documentId, type: doc.documentType, signerCount: 3, documensoId: documensoDoc.id }); return await getDocumentById(documentId, portId); } // ─── Upload Signed Manually (BR-013) ───────────────────────────────────────── export async function uploadSignedManually( documentId: string, portId: string, fileData: { buffer: Buffer; originalName: string; mimeType: string; size: number }, meta: AuditMeta, ) { const doc = await getDocumentById(documentId, portId); const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) }); if (!port) throw new NotFoundError('Port'); // Store the signed file const fileId = crypto.randomUUID(); const storagePath = buildStoragePath(port.slug, 'eoi-signed', documentId, fileId, 'pdf'); await minioClient.putObject( env.MINIO_BUCKET, storagePath, fileData.buffer, fileData.size, { 'Content-Type': fileData.mimeType }, ); const [fileRecord] = await db .insert(files) .values({ portId, clientId: doc.clientId ?? null, filename: fileData.originalName, originalName: fileData.originalName, mimeType: fileData.mimeType, sizeBytes: String(fileData.size), storagePath, storageBucket: env.MINIO_BUCKET, category: 'eoi', uploadedBy: meta.userId, }) .returning(); // Update document await db .update(documents) .set({ signedFileId: fileRecord!.id, status: 'completed', isManualUpload: true, updatedAt: new Date(), }) .where(eq(documents.id, documentId)); // Update interest if linked and type is eoi if (doc.interestId && doc.documentType === 'eoi') { const interest = await db.query.interests.findFirst({ where: eq(interests.id, doc.interestId), }); await db .update(interests) .set({ eoiStatus: 'signed', dateEoiSigned: new Date(), updatedAt: new Date() }) .where(eq(interests.id, doc.interestId)); if (interest) { void evaluateRule('eoi_signed', doc.interestId, portId, meta); } } await db.insert(documentEvents).values({ documentId, eventType: 'completed', eventData: { isManualUpload: true, fileId: fileRecord!.id }, }); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'document', entityId: documentId, newValue: { status: 'completed', isManualUpload: true }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'document:completed', { documentId }); // Notify creator about manual completion void import('@/lib/services/notifications.service').then(({ createNotification }) => createNotification({ portId, userId: meta.userId, type: 'document_signed', title: 'Document marked as signed', description: `"${doc.title}" has been manually uploaded as signed`, link: `/documents/${documentId}`, entityType: 'document', entityId: documentId, dedupeKey: `document:${documentId}:completed`, }), ); return await getDocumentById(documentId, portId); } // ─── List Signers ───────────────────────────────────────────────────────────── export async function listDocumentSigners(documentId: string, portId: string) { await getDocumentById(documentId, portId); // verify access return db.query.documentSigners.findMany({ where: eq(documentSigners.documentId, documentId), orderBy: (ds, { asc }) => [asc(ds.signingOrder)], }); } // ─── List Events ────────────────────────────────────────────────────────────── export async function listDocumentEvents(documentId: string, portId: string) { await getDocumentById(documentId, portId); // verify access return db.query.documentEvents.findMany({ where: eq(documentEvents.documentId, documentId), orderBy: (de, { desc }) => [desc(de.createdAt)], }); } // ─── Webhook Handlers ───────────────────────────────────────────────────────── export async function handleRecipientSigned(eventData: { documentId: string; recipientEmail: string; signatureHash?: string; }) { const doc = await db.query.documents.findFirst({ where: eq(documents.documensoId, eventData.documentId), }); if (!doc) { logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook'); return; } // Update signer status const [signer] = await db .update(documentSigners) .set({ status: 'signed', signedAt: new Date() }) .where( and( eq(documentSigners.documentId, doc.id), eq(documentSigners.signerEmail, eventData.recipientEmail), ), ) .returning(); // Update document to partially_signed if eoi type if (doc.documentType === 'eoi' && doc.status === 'sent') { await db .update(documents) .set({ status: 'partially_signed', updatedAt: new Date() }) .where(eq(documents.id, doc.id)); } await db.insert(documentEvents).values({ documentId: doc.id, eventType: 'signed', signerId: signer?.id ?? null, signatureHash: eventData.signatureHash ?? null, eventData: { recipientEmail: eventData.recipientEmail }, }); emitToRoom(`port:${doc.portId}`, 'document:signer:signed', { documentId: doc.id, signerEmail: eventData.recipientEmail, }); } export async function handleDocumentCompleted(eventData: { documentId: string; }) { const doc = await db.query.documents.findFirst({ where: eq(documents.documensoId, eventData.documentId), }); if (!doc) { logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook'); return; } // BR-022: Download signed PDF and store in MinIO const port = await db.query.ports.findFirst({ where: eq(ports.id, doc.portId) }); if (!port) { logger.error({ portId: doc.portId }, 'Port not found during document completion'); return; } try { const signedPdfBuffer = await downloadSignedPdf(eventData.documentId); const fileId = crypto.randomUUID(); const storagePath = buildStoragePath(port.slug, 'eoi-signed', doc.id, fileId, 'pdf'); await minioClient.putObject( env.MINIO_BUCKET, storagePath, signedPdfBuffer, signedPdfBuffer.length, { 'Content-Type': 'application/pdf' }, ); const [fileRecord] = await db .insert(files) .values({ portId: doc.portId, clientId: doc.clientId ?? null, filename: `signed-${doc.id}.pdf`, originalName: `signed-${doc.id}.pdf`, mimeType: 'application/pdf', sizeBytes: String(signedPdfBuffer.length), storagePath, storageBucket: env.MINIO_BUCKET, category: 'eoi', uploadedBy: 'system', }) .returning(); await db .update(documents) .set({ status: 'completed', signedFileId: fileRecord!.id, updatedAt: new Date() }) .where(eq(documents.id, doc.id)); } catch (err) { logger.error({ err, documentId: doc.id }, 'Failed to download/store signed PDF'); await db .update(documents) .set({ status: 'completed', updatedAt: new Date() }) .where(eq(documents.id, doc.id)); } // Update interest if eoi type if (doc.interestId && doc.documentType === 'eoi') { const interest = await db.query.interests.findFirst({ where: eq(interests.id, doc.interestId), }); await db .update(interests) .set({ eoiStatus: 'signed', dateEoiSigned: new Date(), updatedAt: new Date() }) .where(eq(interests.id, doc.interestId)); if (interest) { void evaluateRule('eoi_signed', doc.interestId, doc.portId, { userId: 'system', portId: doc.portId, ipAddress: '0.0.0.0', userAgent: 'webhook', }); } } await db.insert(documentEvents).values({ documentId: doc.id, eventType: 'completed', eventData: { documensoId: eventData.documentId }, }); emitToRoom(`port:${doc.portId}`, 'document:completed', { documentId: doc.id }); // Notify the document creator about completion if (doc.createdBy && doc.createdBy !== 'system') { void import('@/lib/services/notifications.service').then(({ createNotification }) => createNotification({ portId: doc.portId, userId: doc.createdBy!, type: 'document_signed', title: 'Document fully signed', description: `"${doc.title}" has been signed by all parties`, link: `/documents/${doc.id}`, entityType: 'document', entityId: doc.id, dedupeKey: `document:${doc.id}:completed`, }), ); } } export async function handleDocumentExpired(eventData: { documentId: string; }) { const doc = await db.query.documents.findFirst({ where: eq(documents.documensoId, eventData.documentId), }); if (!doc) { logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook'); return; } await db .update(documents) .set({ status: 'expired', updatedAt: new Date() }) .where(eq(documents.id, doc.id)); if (doc.interestId && doc.documentType === 'eoi') { await db .update(interests) .set({ eoiStatus: 'expired', updatedAt: new Date() }) .where(eq(interests.id, doc.interestId)); } await db.insert(documentEvents).values({ documentId: doc.id, eventType: 'expired', eventData: { documensoId: eventData.documentId }, }); emitToRoom(`port:${doc.portId}`, 'document:expired', { documentId: doc.id }); }