From d98aa5cc8a07b59dea5e2fed9e2d66720397f523 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 1 Jun 2026 21:28:04 +0200 Subject: [PATCH] fix(signing): route paper-signed reservation/contract uploads to the right doc type The Reservation and Contract tabs reused ExternalEoiUploadDialog, but the service hard-coded the EOI document type, status columns, stage target, and berth rule. A signed contract uploaded from the Contract tab filed as an `eoi`, flipped `eoi_status`, and advanced the stage to `eoi` - wrong doc kind, wrong sub-state, wrong stage. - external-eoi.service: UPLOAD_CONFIG keyed off docType (eoi | reservation | contract) parameterises documentType, file category, storage prefix, doc-status column, signed-date column, target stage, advance-from set, and berth rule. eoi_status is written only for docType=eoi. - route: parse docType from the form (default eoi). - dialog: docType prop; generalised copy; EOI-only UI (active-EOI replace banner, public-map flip, cancelActiveDocumentId) gated to docType=eoi. - reservation/contract tabs: pass docType; drop the coming-soon comments. - test: docType routing cases (reservation -> reservation_agreement + reservation cols; contract -> contract + contract cols; eoi_status stays null on both; contract idempotent at/past contract stage). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../v1/interests/[id]/external-eoi/route.ts | 8 ++ .../interests/external-eoi-upload-dialog.tsx | 39 ++++-- .../interests/interest-contract-tab.tsx | 12 +- .../interests/interest-reservation-tab.tsx | 12 +- src/lib/services/external-eoi.service.ts | 112 ++++++++++++++---- .../external-eoi-stage-advance.test.ts | 67 +++++++++++ 6 files changed, 202 insertions(+), 48 deletions(-) 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 b0261fa7..1ad770a0 100644 --- a/src/app/api/v1/interests/[id]/external-eoi/route.ts +++ b/src/app/api/v1/interests/[id]/external-eoi/route.ts @@ -79,6 +79,13 @@ export const POST = withAuth( (form.get('cancelActiveDocumentId') as string | null) ?? null; const cancelActiveDocumentId = cancelActiveDocumentIdRaw?.trim() || undefined; + // Which signed doc this is. Defaults to 'eoi' (legacy callers); the + // reservation/contract tabs post their own type so it files correctly. + const docTypeRaw = (form.get('docType') as string | null)?.trim() ?? 'eoi'; + const docType = ( + ['eoi', 'reservation', 'contract'].includes(docTypeRaw) ? docTypeRaw : 'eoi' + ) as 'eoi' | 'reservation' | 'contract'; + const result = await uploadExternallySignedEoi({ interestId, portId: ctx.portId, @@ -94,6 +101,7 @@ export const POST = withAuth( signatories, notes, cancelActiveDocumentId, + docType, 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 cdccbfab..4ac1028a 100644 --- a/src/components/interests/external-eoi-upload-dialog.tsx +++ b/src/components/interests/external-eoi-upload-dialog.tsx @@ -55,6 +55,10 @@ interface Props { * from the active Documenso EOI's signers). Falls through to the * client-only seed when omitted or empty. */ prefillSignatories?: SignatoryRow[]; + /** Which signed document this upload is for. Defaults to 'eoi'; the + * reservation/contract tabs pass their own type so the file is filed + + * the deal advanced under the right kind (EOI-specific UI is hidden). */ + docType?: 'eoi' | 'reservation' | 'contract'; } export function ExternalEoiUploadDialog({ @@ -63,7 +67,15 @@ export function ExternalEoiUploadDialog({ interestId, onSuccess, prefillSignatories, + docType = 'eoi', }: Props) { + const DOC_LABEL = + docType === 'reservation' + ? 'reservation agreement' + : docType === 'contract' + ? 'contract' + : 'EOI'; + const isEoi = docType === 'eoi'; const qc = useQueryClient(); const [file, setFile] = useState(null); const [title, setTitle] = useState(''); @@ -173,7 +185,7 @@ export function ExternalEoiUploadDialog({ apiFetch<{ data: Array<{ id: string; status: string; title: string; createdAt: string }>; }>(`/api/v1/documents?interestId=${interestId}&documentType=eoi`), - enabled: open, + enabled: open && isEoi, staleTime: 30_000, }); const activeEoi = useMemo( @@ -191,12 +203,12 @@ export function ExternalEoiUploadDialog({ .filter((m): m is string => !!m); const berthLabel = moorings.length > 0 ? formatBerthRange(moorings) : null; const clientName = interestData?.clientName ?? null; - const parts = ['External EOI']; + const parts = [`External ${DOC_LABEL}`]; if (clientName) parts.push(clientName); if (berthLabel) parts.push(berthLabel); parts.push(date); return parts.join(' - '); - }, [interestData, berthsData, signedAt]); + }, [interestData, berthsData, signedAt, DOC_LABEL]); // The title input is controlled with `displayTitle` (derived from // either the rep's typed value or the auto-derived default). Reps @@ -226,7 +238,8 @@ export function ExternalEoiUploadDialog({ // 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('docType', docType); + if (isEoi && activeEoi && replaceMode === 'replace') { form.append('cancelActiveDocumentId', activeEoi.id); } const res = await fetch(`/api/v1/interests/${interestId}/external-eoi`, { @@ -250,7 +263,7 @@ export function ExternalEoiUploadDialog({ // sync are skipped. Failures here don't undo the upload (the doc // is already filed) but surface as a non-blocking toast so the // rep knows the flag didn't propagate. - if (inBundleBerths.length > 0) { + if (isEoi && inBundleBerths.length > 0) { const targets = inBundleBerths.filter((b) => b.isSpecificInterest !== publicFlagChecked); if (targets.length > 0) { try { @@ -270,8 +283,8 @@ export function ExternalEoiUploadDialog({ } toast.success( stageChanged - ? 'External EOI uploaded. Stage advanced to EOI Signed.' - : 'External EOI uploaded. Filed against this deal (stage unchanged).', + ? `Signed ${DOC_LABEL} uploaded. Pipeline stage advanced.` + : `Signed ${DOC_LABEL} uploaded. Filed against this deal (stage unchanged).`, ); qc.invalidateQueries({ queryKey: ['interests', interestId] }); qc.invalidateQueries({ queryKey: ['interests'] }); @@ -293,16 +306,16 @@ export function ExternalEoiUploadDialog({ - Upload externally-signed EOI + Upload externally-signed {DOC_LABEL} - For EOIs signed outside our signing service (paper, in person, alternate e-sign vendor). - The uploaded PDF is filed against this interest and the pipeline stage is advanced to - EOI Signed. + For a {DOC_LABEL} signed outside our signing service (paper, in person, alternate e-sign + vendor). The uploaded PDF is filed against this interest and the pipeline stage is + advanced accordingly.
- {activeEoi ? ( + {isEoi && activeEoi ? (

A generated EOI is already in flight on this deal. @@ -466,7 +479,7 @@ export function ExternalEoiUploadDialog({ className="mt-1" />

- {inBundleBerths.length > 0 ? ( + {isEoi && inBundleBerths.length > 0 ? (