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:
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ─────────────────────
|
||||
|
||||
Reference in New Issue
Block a user