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');
|
throw new ValidationError('Invalid signedAt');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cancelActiveDocumentIdRaw =
|
||||||
|
(form.get('cancelActiveDocumentId') as string | null) ?? null;
|
||||||
|
const cancelActiveDocumentId = cancelActiveDocumentIdRaw?.trim() || undefined;
|
||||||
|
|
||||||
const result = await uploadExternallySignedEoi({
|
const result = await uploadExternallySignedEoi({
|
||||||
interestId,
|
interestId,
|
||||||
portId: ctx.portId,
|
portId: ctx.portId,
|
||||||
@@ -89,6 +93,7 @@ export const POST = withAuth(
|
|||||||
signerNames,
|
signerNames,
|
||||||
signatories,
|
signatories,
|
||||||
notes,
|
notes,
|
||||||
|
cancelActiveDocumentId,
|
||||||
meta: {
|
meta: {
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
portId: ctx.portId,
|
portId: ctx.portId,
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ export function ExternalEoiUploadDialog({
|
|||||||
// the React Compiler bans.
|
// the React Compiler bans.
|
||||||
const [signatoriesOverride, setSignatoriesOverride] = useState<SignatoryRow[] | null>(null);
|
const [signatoriesOverride, setSignatoriesOverride] = useState<SignatoryRow[] | null>(null);
|
||||||
const [notes, setNotes] = useState('');
|
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:
|
// Fetched on open to power the default title:
|
||||||
// "External EOI - <Client> - <berth range> - YYYY-MM-DD". Without
|
// "External EOI - <Client> - <berth range> - YYYY-MM-DD". Without
|
||||||
@@ -125,6 +132,29 @@ export function ExternalEoiUploadDialog({
|
|||||||
staleTime: 60_000,
|
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 defaultTitle = useMemo(() => {
|
||||||
const date = signedAt || new Date().toISOString().slice(0, 10);
|
const date = signedAt || new Date().toISOString().slice(0, 10);
|
||||||
const moorings = (berthsData?.data ?? [])
|
const moorings = (berthsData?.data ?? [])
|
||||||
@@ -164,6 +194,12 @@ export function ExternalEoiUploadDialog({
|
|||||||
form.append('signerNames', cleanSignatories.map((s) => s.name).join(', '));
|
form.append('signerNames', cleanSignatories.map((s) => s.name).join(', '));
|
||||||
}
|
}
|
||||||
if (notes) form.append('notes', notes);
|
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`, {
|
const res = await fetch(`/api/v1/interests/${interestId}/external-eoi`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: form,
|
body: form,
|
||||||
@@ -212,6 +248,50 @@ export function ExternalEoiUploadDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3 py-2">
|
<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>
|
<div>
|
||||||
<Label>PDF file *</Label>
|
<Label>PDF file *</Label>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ export interface ExternalEoiInput {
|
|||||||
signerNames?: string[];
|
signerNames?: string[];
|
||||||
/** Free-text note (e.g. "signed in person at boat show"). */
|
/** Free-text note (e.g. "signed in person at boat show"). */
|
||||||
notes?: string;
|
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;
|
meta: AuditMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +84,26 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
|
|||||||
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
||||||
if (!port) throw new NotFoundError('Port');
|
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 documentId = crypto.randomUUID();
|
||||||
const fileId = crypto.randomUUID();
|
const fileId = crypto.randomUUID();
|
||||||
const storagePath = buildStoragePath(port.slug, 'eoi-signed', documentId, fileId, 'pdf');
|
const storagePath = buildStoragePath(port.slug, 'eoi-signed', documentId, fileId, 'pdf');
|
||||||
@@ -228,6 +256,7 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
|
|||||||
signerNames: input.signerNames ?? [],
|
signerNames: input.signerNames ?? [],
|
||||||
signedAt: (input.signedAt ?? new Date()).toISOString(),
|
signedAt: (input.signedAt ?? new Date()).toISOString(),
|
||||||
fileSizeBytes: fileData.size,
|
fileSizeBytes: fileData.size,
|
||||||
|
...(cancelledDocumentId ? { replacedDocumentId: cancelledDocumentId } : {}),
|
||||||
},
|
},
|
||||||
ipAddress: meta.ipAddress,
|
ipAddress: meta.ipAddress,
|
||||||
userAgent: meta.userAgent,
|
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.
|
// 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 ─────────────────────
|
// ─── Edit metadata on a previously-uploaded external EOI ─────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user