/** * 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, documentSigners, 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'; /** A single signatory on an externally-signed document. */ export interface ExternalSignatory { name: string; email: string; /** Drives auto-fill on the dialog + downstream "Email copy" recipient * list. `cc` recipients aren't signers but show up on send-out lists. */ role: 'client' | 'developer' | 'rep' | 'witness' | 'cc'; } 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; /** * Structured signatory list — preferred over the legacy `signerNames` * CSV. When present, the service inserts one `document_signers` row * per non-CC entry pre-stamped `status='signed'` so the * "X / Y signed" badge renders correctly downstream. */ signatories?: ExternalSignatory[]; /** Legacy CSV form kept for backwards compat with older callers. */ 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', }); } // Backfill document_signers rows for the structured signatory list // so the document-detail "X / Y signed" badge counts correctly. CC // recipients aren't signatories — they're recipients of the email // copy and don't show up in the signed-count denominator. const signedAtMoment = input.signedAt ?? new Date(); const signerRows = (input.signatories ?? []) .filter((s) => s.role !== 'cc') .map((s, i) => ({ documentId: doc.id, signerName: s.name, signerEmail: s.email, signerRole: s.role, signingOrder: i + 1, status: 'signed' as const, signedAt: signedAtMoment, })); if (signerRows.length > 0) { await tx.insert(documentSigners).values(signerRows); } await tx.insert(documentEvents).values({ documentId: doc.id, eventType: 'completed', eventData: { isManualUpload: true, external: true, signerNames: input.signerNames ?? (input.signatories ?? []).map((s) => s.name), signatories: input.signatories ?? null, signedAt: signedAtMoment.toISOString(), fileId: fileRecord.id, }, }); // Two concerns to keep separate: // 1. Document metadata — always write `dateEoiSigned` + `eoiStatus` // from the upload. Even if the rep already advanced the stage // manually, the paper signing event needs a recorded date so // downstream surfaces (SkipAheadBanner, milestone strip, EOI // merge fields) reflect reality. Honour an existing // dateEoiSigned (don't overwrite if already set — covers the // case where the rep is uploading evidence for an event whose // date was already backfilled). // 2. Stage advance — only when the deal hasn't reached eoi_signed // yet. Bypasses canTransitionStage because the operator just // brought concrete proof. const shouldAdvanceStage = interest.pipelineStage === 'open' || interest.pipelineStage === 'details_sent' || interest.pipelineStage === 'in_communication' || interest.pipelineStage === 'eoi_sent'; await tx .update(interests) .set({ dateEoiSigned: interest.dateEoiSigned ?? input.signedAt ?? new Date(), eoiStatus: 'signed', ...(shouldAdvanceStage ? { pipelineStage: 'eoi_signed' as const } : {}), updatedAt: new Date(), }) .where(eq(interests.id, interestId)); return { documentId: doc.id, fileId: fileRecord.id, stageChanged: shouldAdvanceStage, newStage: shouldAdvanceStage ? ('eoi_signed' as const) : interest.pipelineStage, }; }); const { documentId: docId, fileId: fId, stageChanged, newStage } = 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 }); // Berth rules engine: a manually-uploaded external EOI is still an // EOI-signed event for the rules that watch this trigger (e.g. // auto-mark the primary berth Under Offer). Fire via dynamic import // to dodge the circular dep between berth-rules-engine and the // interest services. try { const { evaluateRule } = await import('@/lib/services/berth-rules-engine'); await evaluateRule('eoi_signed', interestId, portId, meta); } catch { // Swallow — rules engine failures should never block the upload // that the rep has already completed end-to-end. The orphan-reaper // path doesn't apply; a missed rule evaluation is a soft failure. } return { documentId: docId, fileId: fId, stageChanged, newStage }; }