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

@@ -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,

View File

@@ -74,6 +74,13 @@ export function ExternalEoiUploadDialog({
// the React Compiler bans.
const [signatoriesOverride, setSignatoriesOverride] = useState<SignatoryRow[] | null>(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 - <Client> - <berth range> - 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({
</DialogHeader>
<div className="space-y-3 py-2">
{activeEoi ? (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm dark:border-amber-900 dark:bg-amber-950/40">
<p className="font-medium text-amber-900 dark:text-amber-100">
A generated EOI is already in flight on this deal.
</p>
<p className="mt-1 text-amber-800 dark:text-amber-200">
{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.
</p>
<div
role="radiogroup"
aria-label="What to do with the generated EOI"
className="mt-2 space-y-1"
>
<label className="flex cursor-pointer items-start gap-2">
<input
type="radio"
name="replace-mode"
value="replace"
checked={replaceMode === 'replace'}
onChange={() => setReplaceMode('replace')}
className="mt-1 h-4 w-4 cursor-pointer accent-amber-700"
/>
<span className="text-amber-900 dark:text-amber-100">
Cancel the generated envelope and replace it with this upload (recommended).
</span>
</label>
<label className="flex cursor-pointer items-start gap-2">
<input
type="radio"
name="replace-mode"
value="keep"
checked={replaceMode === 'keep'}
onChange={() => setReplaceMode('keep')}
className="mt-1 h-4 w-4 cursor-pointer accent-amber-700"
/>
<span className="text-amber-900 dark:text-amber-100">
Keep both records (advanced - leaves two EOIs on the deal).
</span>
</label>
</div>
</div>
) : null}
<div>
<Label>PDF file *</Label>
<div className="mt-1">

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 ─────────────────────