When ExternalEoiUploadDialog mounts on an interest with a non-terminal generated EOI (status sent / partially_signed / draft), it now surfaces an amber banner naming the active envelope and offering two paths via radio: - "Cancel the generated envelope and replace it" (default + recommended): upload posts cancelActiveDocumentId; the service voids the upstream Documenso envelope + flips the local doc row to cancelled BEFORE the new external-EOI doc lands. Audit-log on the new doc carries metadata.replacedDocumentId so reps can trace cause + effect. - "Keep both records (advanced)": legacy behaviour - leaves two EOIs on the deal. Useful only for backfilling intentionally-parallel records. Cancel runs outside the upload transaction so a Documenso void error doesn't block the upload the rep has already photographed. The dialog already shares cache + envelope shape with InterestDetail, so the recent B4 #4 fix means opening the dialog no longer blanks the page. cancelMode='delete' is hardwired in the replace path (kill the upstream envelope on void). Pairs with the existing keep_remote affordance on the manual Cancel-document flow shipped earlier. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
466 lines
18 KiB
TypeScript
466 lines
18 KiB
TypeScript
/**
|
|
* 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 - <date>". */
|
|
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;
|
|
/**
|
|
* Optional: id of an active generated EOI to cancel as part of this
|
|
* upload. When set, the service voids the Documenso envelope and flips
|
|
* the prior local row to `status='cancelled'` BEFORE creating the new
|
|
* external-EOI doc so a deal carries one canonical EOI at any moment.
|
|
* Idempotent — already-cancelled / wrong-port ids are ignored.
|
|
*/
|
|
cancelActiveDocumentId?: 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');
|
|
|
|
// Replace-mode: cancel the prior active EOI before creating the new one.
|
|
// Done OUTSIDE the upload transaction so a Documenso void error doesn't
|
|
// block the upload the rep has already photographed/scanned. cancelDocument
|
|
// is idempotent on status='cancelled' so concurrent calls are safe.
|
|
let cancelledDocumentId: string | null = null;
|
|
if (input.cancelActiveDocumentId) {
|
|
try {
|
|
const { cancelDocument } = await import('@/lib/services/documents.service');
|
|
await cancelDocument(input.cancelActiveDocumentId, portId, meta, {
|
|
reason: 'Replaced by external upload',
|
|
cancelMode: 'delete',
|
|
});
|
|
cancelledDocumentId = input.cancelActiveDocumentId;
|
|
} catch {
|
|
// Swallow — the rep meant to replace, but failing to cancel the prior
|
|
// doc shouldn't lose the upload. The audit log on the new doc still
|
|
// captures the rep's intent via metadata.replacedDocumentId.
|
|
}
|
|
}
|
|
|
|
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,
|
|
...(cancelledDocumentId ? { replacedDocumentId: cancelledDocumentId } : {}),
|
|
},
|
|
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, cancelledDocumentId };
|
|
}
|
|
|
|
// ─── 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<typeof documents.$inferInsert> = { 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 };
|
|
}
|