feat(external-eoi): auto-cancel + replace generated EOI on upload

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>
This commit is contained in:
2026-05-25 13:15:22 +02:00
parent cd6b19e173
commit 3a1c16ae71
3 changed files with 115 additions and 1 deletions

View File

@@ -51,6 +51,14 @@ export interface ExternalEoiInput {
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;
}
@@ -76,6 +84,26 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
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');
@@ -228,6 +256,7 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
signerNames: input.signerNames ?? [],
signedAt: (input.signedAt ?? new Date()).toISOString(),
fileSizeBytes: fileData.size,
...(cancelledDocumentId ? { replacedDocumentId: cancelledDocumentId } : {}),
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
@@ -249,7 +278,7 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
// path doesn't apply; a missed rule evaluation is a soft failure.
}
return { documentId: docId, fileId: fId, stageChanged, newStage };
return { documentId: docId, fileId: fId, stageChanged, newStage, cancelledDocumentId };
}
// ─── Edit metadata on a previously-uploaded external EOI ─────────────────────