From 3a1c16ae71cc5bc7019f35dbdf77ae432ebc6362 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 25 May 2026 13:15:22 +0200 Subject: [PATCH] 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) --- .../v1/interests/[id]/external-eoi/route.ts | 5 ++ .../interests/external-eoi-upload-dialog.tsx | 80 +++++++++++++++++++ src/lib/services/external-eoi.service.ts | 31 ++++++- 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/app/api/v1/interests/[id]/external-eoi/route.ts b/src/app/api/v1/interests/[id]/external-eoi/route.ts index 265f1be7..b0261fa7 100644 --- a/src/app/api/v1/interests/[id]/external-eoi/route.ts +++ b/src/app/api/v1/interests/[id]/external-eoi/route.ts @@ -75,6 +75,10 @@ export const POST = withAuth( throw new ValidationError('Invalid signedAt'); } + const cancelActiveDocumentIdRaw = + (form.get('cancelActiveDocumentId') as string | null) ?? null; + const cancelActiveDocumentId = cancelActiveDocumentIdRaw?.trim() || undefined; + const result = await uploadExternallySignedEoi({ interestId, portId: ctx.portId, @@ -89,6 +93,7 @@ export const POST = withAuth( signerNames, signatories, notes, + cancelActiveDocumentId, meta: { userId: ctx.userId, portId: ctx.portId, diff --git a/src/components/interests/external-eoi-upload-dialog.tsx b/src/components/interests/external-eoi-upload-dialog.tsx index e8b78d76..c41374fa 100644 --- a/src/components/interests/external-eoi-upload-dialog.tsx +++ b/src/components/interests/external-eoi-upload-dialog.tsx @@ -74,6 +74,13 @@ export function ExternalEoiUploadDialog({ // the React Compiler bans. const [signatoriesOverride, setSignatoriesOverride] = useState(null); const [notes, setNotes] = useState(''); + // When a non-terminal generated EOI exists on the interest, this drives + // the warning banner + the replace-vs-keep choice. 'replace' (default) + // voids the generated envelope on Documenso + flips the local row to + // cancelled before the new upload lands; 'keep' leaves the prior doc + // alone (legacy behaviour). Hidden behind an Advanced disclosure since + // the replace path is the right default for the vast majority of cases. + const [replaceMode, setReplaceMode] = useState<'replace' | 'keep'>('replace'); // Fetched on open to power the default title: // "External EOI - - - YYYY-MM-DD". Without @@ -125,6 +132,29 @@ export function ExternalEoiUploadDialog({ staleTime: 60_000, }); + // Detect a generated EOI in flight on this interest so the dialog can + // offer "Replace the generated envelope" instead of leaving two parallel + // EOIs on the deal. Only documents in non-terminal status count — already- + // cancelled / completed / rejected EOIs need no reconciliation. + const { data: docsData } = useQuery<{ + data: Array<{ id: string; status: string; title: string; createdAt: string }>; + }>({ + queryKey: ['documents', { interestId, documentType: 'eoi' }], + queryFn: () => + apiFetch<{ + data: Array<{ id: string; status: string; title: string; createdAt: string }>; + }>(`/api/v1/documents?interestId=${interestId}&documentType=eoi`), + enabled: open, + staleTime: 30_000, + }); + const activeEoi = useMemo( + () => + (docsData?.data ?? []).find( + (d) => d.status === 'sent' || d.status === 'partially_signed' || d.status === 'draft', + ) ?? null, + [docsData], + ); + const defaultTitle = useMemo(() => { const date = signedAt || new Date().toISOString().slice(0, 10); const moorings = (berthsData?.data ?? []) @@ -164,6 +194,12 @@ export function ExternalEoiUploadDialog({ form.append('signerNames', cleanSignatories.map((s) => s.name).join(', ')); } if (notes) form.append('notes', notes); + // When a generated EOI is active AND the rep didn't opt out via the + // Advanced toggle, tell the server to cancel it as part of this + // upload so the deal carries one canonical EOI. + if (activeEoi && replaceMode === 'replace') { + form.append('cancelActiveDocumentId', activeEoi.id); + } const res = await fetch(`/api/v1/interests/${interestId}/external-eoi`, { method: 'POST', body: form, @@ -212,6 +248,50 @@ export function ExternalEoiUploadDialog({
+ {activeEoi ? ( +
+

+ A generated EOI is already in flight on this deal. +

+

+ {activeEoi.title} ({activeEoi.status.replace(/_/g, ' ')}, created{' '} + {new Date(activeEoi.createdAt).toLocaleDateString()}). Uploading a signed copy can + cancel the generated envelope so this deal carries one canonical EOI. +

+
+ + +
+
+ ) : null}
diff --git a/src/lib/services/external-eoi.service.ts b/src/lib/services/external-eoi.service.ts index d6e1ca2f..91bd28df 100644 --- a/src/lib/services/external-eoi.service.ts +++ b/src/lib/services/external-eoi.service.ts @@ -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 ─────────────────────