Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
754
src/lib/services/documents.service.ts
Normal file
754
src/lib/services/documents.service.ts
Normal file
@@ -0,0 +1,754 @@
|
||||
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<typeof documents.$inferInsert> = {};
|
||||
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<string, unknown>,
|
||||
newValue: updated as unknown as Record<string, unknown>,
|
||||
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<string, unknown>,
|
||||
{ ...client, contacts: client.contacts } as unknown as Record<string, unknown>,
|
||||
berth as unknown as Record<string, unknown>,
|
||||
port as unknown as Record<string, unknown>,
|
||||
);
|
||||
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user