/** * External EOI upload — for EOIs signed outside Documenso (paper signing, * different e-sign vendor, signed in person, etc). * * Creates BOTH the document row AND the signed-file record in one shot, * then advances the interest stage. Distinct from the existing * uploadSignedManually flow which augments a document row that was * already created via the Documenso pathway. */ import { eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { interests } from '@/lib/db/schema/interests'; import { documents, documentEvents, files } from '@/lib/db/schema/documents'; import { ports } from '@/lib/db/schema/ports'; import { env } from '@/lib/env'; import { buildStoragePath } from '@/lib/minio'; import { getStorageBackend } from '@/lib/storage'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { CodedError, NotFoundError, ValidationError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; export interface ExternalEoiInput { interestId: string; portId: string; /** PDF bytes. */ fileData: { buffer: Buffer; originalName: string; mimeType: string; size: number }; /** Free-text title for the document row. Defaults to "External EOI - ". */ title?: string; /** When the signing actually happened (the date on the paper / contract). */ signedAt?: Date; /** Names of the people who signed (free-text — we don't manage signer * identities for external sigs). Recorded in metadata. */ signerNames?: string[]; /** Free-text note (e.g. "signed in person at boat show"). */ notes?: string; meta: AuditMeta; } export async function uploadExternallySignedEoi(input: ExternalEoiInput) { const { interestId, portId, fileData, meta } = input; if (fileData.size <= 0) throw new ValidationError('Empty file'); if (fileData.size > 25 * 1024 * 1024) { throw new ValidationError('File too large (max 25 MB)'); } if ( fileData.mimeType !== 'application/pdf' && !fileData.originalName.toLowerCase().endsWith('.pdf') ) { throw new ValidationError('Only PDF uploads are accepted for signed EOIs'); } const interest = await db.query.interests.findFirst({ where: eq(interests.id, interestId), }); if (!interest || interest.portId !== portId) throw new NotFoundError('Interest'); const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) }); if (!port) throw new NotFoundError('Port'); const documentId = crypto.randomUUID(); const fileId = crypto.randomUUID(); const storagePath = buildStoragePath(port.slug, 'eoi-signed', documentId, fileId, 'pdf'); // Upload to storage FIRST so we have a stable key for the DB rows, // then commit all four DB writes in one transaction. If the tx fails // the storage object becomes orphaned (S3 isn't transactional) but // the DB stays clean — orphan reaper handles those. await ( await getStorageBackend() ).put(storagePath, fileData.buffer, { contentType: 'application/pdf', sizeBytes: fileData.size, }); const title = input.title ?? `External EOI — ${(input.signedAt ?? new Date()).toISOString().slice(0, 10)}`; const result = await db.transaction(async (tx) => { const [fileRecord] = await tx .insert(files) .values({ portId, clientId: interest.clientId, filename: fileData.originalName, originalName: fileData.originalName, mimeType: 'application/pdf', sizeBytes: String(fileData.size), storagePath, storageBucket: env.MINIO_BUCKET, category: 'eoi', uploadedBy: meta.userId, }) .returning(); if (!fileRecord) { throw new CodedError('INSERT_RETURNING_EMPTY', { internalMessage: 'External EOI file insert returned no row', }); } const [doc] = await tx .insert(documents) .values({ id: documentId, portId, interestId, clientId: interest.clientId, yachtId: interest.yachtId, documentType: 'eoi', title, status: 'completed', isManualUpload: true, signedFileId: fileRecord.id, notes: input.notes ?? null, createdBy: meta.userId, }) .returning(); if (!doc) { throw new CodedError('INSERT_RETURNING_EMPTY', { internalMessage: 'External EOI document insert returned no row', }); } await tx.insert(documentEvents).values({ documentId: doc.id, eventType: 'completed', eventData: { isManualUpload: true, external: true, signerNames: input.signerNames ?? [], signedAt: (input.signedAt ?? new Date()).toISOString(), fileId: fileRecord.id, }, }); // Advance the interest stage to eoi_signed (no-op if already past it). // We bypass canTransitionStage explicitly because the operator just // brought concrete proof that the EOI is signed — that's higher // confidence than a normal forward-jump. if ( interest.pipelineStage === 'open' || interest.pipelineStage === 'details_sent' || interest.pipelineStage === 'in_communication' || interest.pipelineStage === 'eoi_sent' ) { await tx .update(interests) .set({ pipelineStage: 'eoi_signed', eoiStatus: 'signed', dateEoiSigned: input.signedAt ?? new Date(), updatedAt: new Date(), }) .where(eq(interests.id, interestId)); } else { // Past eoi_signed — just record the document, don't touch stage. await tx.update(interests).set({ updatedAt: new Date() }).where(eq(interests.id, interestId)); } return { documentId: doc.id, fileId: fileRecord.id }; }); const { documentId: docId, fileId: fId } = result; void createAuditLog({ portId, userId: meta.userId, action: 'create', entityType: 'document', entityId: docId, metadata: { kind: 'external_eoi_upload', interestId, title, signerNames: input.signerNames ?? [], signedAt: (input.signedAt ?? new Date()).toISOString(), fileSizeBytes: fileData.size, }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'document:completed', { documentId: docId }); return { documentId: docId, fileId: fId }; }