/** * 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 { and, eq, inArray } 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 { canonicalizeStage, type PipelineStage } from '@/lib/constants'; import { CodedError, ConflictError, 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, and // eoiDocStatus from the upload. Even if the rep already advanced // the stage manually, the paper signing event needs a recorded // date + doc-status badge so downstream surfaces (SkipAheadBanner, // milestone strip, EOI merge fields, signing-progress chip) reflect // reality. Honour an existing dateEoiSigned - covers the case where // the rep is uploading evidence for an event whose date was already // backfilled. // 2. Stage advance - in the 7-stage pipeline (PIPELINE_STAGES), every // pre-EOI stage (enquiry / qualified / nurturing) should flip to // 'eoi' on signing. The doc-status 'signed' carries the within-stage // sub-state. Bypasses canTransitionStage because the operator just // brought concrete proof. Idempotent at the 'eoi' / 'reservation' / // 'deposit_paid' / 'contract' stages (stays put). const stageBeforeAdvance = interest.pipelineStage as PipelineStage | null | undefined; const canonicalStage = canonicalizeStage(stageBeforeAdvance); const shouldAdvanceStage = canonicalStage === 'enquiry' || canonicalStage === 'qualified' || canonicalStage === 'nurturing'; await tx .update(interests) .set({ dateEoiSigned: interest.dateEoiSigned ?? input.signedAt ?? new Date(), eoiStatus: 'signed', eoiDocStatus: 'signed', ...(shouldAdvanceStage ? { pipelineStage: 'eoi' as const } : {}), updatedAt: new Date(), }) .where(eq(interests.id, interestId)); return { documentId: doc.id, fileId: fileRecord.id, stageChanged: shouldAdvanceStage, newStage: shouldAdvanceStage ? ('eoi' as const) : canonicalStage, }; }); 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 }; } // ─── Edit metadata on a previously-uploaded external EOI ───────────────────── /** Signatory row in an edit payload. `id` distinguishes update vs insert. */ export interface ExternalEoiSignatoryEdit { /** Present on existing rows; omitted to insert a new signer. */ id?: string; name: string; email: string; role: 'client' | 'developer' | 'rep' | 'witness' | 'cc'; } export interface ExternalEoiMetadataPatch { documentId: string; portId: string; /** Undefined = leave unchanged; null is not allowed (title is NOT NULL). */ title?: string; /** Undefined = leave unchanged; null clears `interests.dateEoiSigned`. */ signedAt?: Date | null; /** Undefined = leave unchanged; '' clears the column. */ notes?: string; /** * Full replacement set when present. Rows with an id are UPDATEd; rows * without are INSERTed; existing rows whose id isn't in the array are * DELETEd. CC entries are stored but excluded from the X/Y signed * count (status stays signed=signedAt to match the upload-time * pre-stamp). */ signatories?: ExternalEoiSignatoryEdit[]; meta: AuditMeta; } /** * Update title / notes / signed-date / signatories on a previously-uploaded * external EOI. Refuses to touch Documenso-managed documents because their * signer rows are the vendor's source of truth - any edit would drift from * the upstream envelope. * * Mirrors the upload service's invariants: * - `signedAt` updates BOTH `documents` events and `interests.dateEoiSigned` * (and the per-signer `signedAt` stamp for the structured signatories). * - Document_signers writes are full-replacement when `signatories` is * present (insert / update / delete by id-presence). Same shape as the * upload-time insert: CC entries persisted but not counted as signers. * * Stage advance is NOT re-evaluated - that fires once at upload and shouldn't * be reversed by a metadata edit. If the rep needs to roll a stage back, * they do it through the stage-change UI directly. */ export async function updateExternalEoiMetadata(input: ExternalEoiMetadataPatch) { const { documentId, portId, meta } = input; const document = await db.query.documents.findFirst({ where: and(eq(documents.id, documentId), eq(documents.portId, portId)), }); if (!document) throw new NotFoundError('Document'); if (!document.isManualUpload) { throw new ConflictError( 'Only manually-uploaded documents can have their metadata edited. Documenso-managed documents inherit their signers and signing date from the upstream envelope.', ); } if (document.documentType !== 'eoi') { // The form only knows EOI shape today; widen later when other doc // types grow their own external-upload pathways. throw new ConflictError( 'Metadata edit is currently supported only for EOI documents. Open a ticket if you need it for contracts or reservations.', ); } // Capture before-state for the audit log. const beforeSigners = await db.query.documentSigners.findMany({ where: eq(documentSigners.documentId, documentId), }); const result = await db.transaction(async (tx) => { // 1) Patch the document row itself. const docPatch: Partial = { updatedAt: new Date() }; if (input.title !== undefined) docPatch.title = input.title; if (input.notes !== undefined) docPatch.notes = input.notes === '' ? null : input.notes; await tx.update(documents).set(docPatch).where(eq(documents.id, documentId)); // 2) Sync the interest's `dateEoiSigned` when signedAt is being // edited. Honour the upload-side rule: a metadata edit IS the // canonical source for the date, so we overwrite even when the // column already has a value (the rep is presumably fixing it). if (input.signedAt !== undefined && document.interestId) { await tx .update(interests) .set({ dateEoiSigned: input.signedAt, updatedAt: new Date(), }) .where(eq(interests.id, document.interestId)); } // 3) Replace the signatories list when provided. Mirror the upload // invariants: status='signed' on every non-CC row (the doc has // already been signed externally), signedAt stamped from the // edit's effective signing date. if (input.signatories !== undefined) { const signedAtMoment = input.signedAt !== undefined ? (input.signedAt ?? new Date()) : (beforeSigners[0]?.signedAt ?? new Date()); const submittedIds = new Set(input.signatories.filter((s) => s.id).map((s) => s.id!)); const existingIds = beforeSigners.map((s) => s.id); const toDelete = existingIds.filter((id) => !submittedIds.has(id)); if (toDelete.length > 0) { await tx.delete(documentSigners).where(inArray(documentSigners.id, toDelete)); } for (let i = 0; i < input.signatories.length; i++) { const s = input.signatories[i]!; const isSigner = s.role !== 'cc'; if (s.id && existingIds.includes(s.id)) { await tx .update(documentSigners) .set({ signerName: s.name, signerEmail: s.email, signerRole: s.role, signingOrder: i + 1, status: isSigner ? 'signed' : 'pending', signedAt: isSigner ? signedAtMoment : null, }) .where(eq(documentSigners.id, s.id)); } else { await tx.insert(documentSigners).values({ documentId, signerName: s.name, signerEmail: s.email, signerRole: s.role, signingOrder: i + 1, status: isSigner ? 'signed' : 'pending', signedAt: isSigner ? signedAtMoment : null, }); } } } // 4) Trail in document_events so the activity timeline reflects the // edit alongside the original 'completed' row. await tx.insert(documentEvents).values({ documentId, eventType: 'metadata_updated', eventData: { editedBy: meta.userId, fields: { title: input.title !== undefined, signedAt: input.signedAt !== undefined, notes: input.notes !== undefined, signatories: input.signatories !== undefined, }, }, }); return { documentId }; }); void createAuditLog({ portId, userId: meta.userId, action: 'update', entityType: 'document', entityId: documentId, metadata: { kind: 'external_eoi_metadata_edit', fieldsChanged: { title: input.title !== undefined, signedAt: input.signedAt !== undefined, notes: input.notes !== undefined, signatories: input.signatories !== undefined, }, }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'document:updated', { documentId: result.documentId }); return { documentId: result.documentId }; }