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) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 21:28:04 +02:00
parent a7c11f2c51
commit d98aa5cc8a
6 changed files with 202 additions and 48 deletions

View File

@@ -79,6 +79,13 @@ export const POST = withAuth(
(form.get('cancelActiveDocumentId') as string | null) ?? null; (form.get('cancelActiveDocumentId') as string | null) ?? null;
const cancelActiveDocumentId = cancelActiveDocumentIdRaw?.trim() || undefined; 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({ const result = await uploadExternallySignedEoi({
interestId, interestId,
portId: ctx.portId, portId: ctx.portId,
@@ -94,6 +101,7 @@ export const POST = withAuth(
signatories, signatories,
notes, notes,
cancelActiveDocumentId, cancelActiveDocumentId,
docType,
meta: { meta: {
userId: ctx.userId, userId: ctx.userId,
portId: ctx.portId, portId: ctx.portId,

View File

@@ -55,6 +55,10 @@ interface Props {
* from the active Documenso EOI's signers). Falls through to the * from the active Documenso EOI's signers). Falls through to the
* client-only seed when omitted or empty. */ * client-only seed when omitted or empty. */
prefillSignatories?: SignatoryRow[]; 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({ export function ExternalEoiUploadDialog({
@@ -63,7 +67,15 @@ export function ExternalEoiUploadDialog({
interestId, interestId,
onSuccess, onSuccess,
prefillSignatories, prefillSignatories,
docType = 'eoi',
}: Props) { }: Props) {
const DOC_LABEL =
docType === 'reservation'
? 'reservation agreement'
: docType === 'contract'
? 'contract'
: 'EOI';
const isEoi = docType === 'eoi';
const qc = useQueryClient(); const qc = useQueryClient();
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
@@ -173,7 +185,7 @@ export function ExternalEoiUploadDialog({
apiFetch<{ apiFetch<{
data: Array<{ id: string; status: string; title: string; createdAt: string }>; data: Array<{ id: string; status: string; title: string; createdAt: string }>;
}>(`/api/v1/documents?interestId=${interestId}&documentType=eoi`), }>(`/api/v1/documents?interestId=${interestId}&documentType=eoi`),
enabled: open, enabled: open && isEoi,
staleTime: 30_000, staleTime: 30_000,
}); });
const activeEoi = useMemo( const activeEoi = useMemo(
@@ -191,12 +203,12 @@ export function ExternalEoiUploadDialog({
.filter((m): m is string => !!m); .filter((m): m is string => !!m);
const berthLabel = moorings.length > 0 ? formatBerthRange(moorings) : null; const berthLabel = moorings.length > 0 ? formatBerthRange(moorings) : null;
const clientName = interestData?.clientName ?? null; const clientName = interestData?.clientName ?? null;
const parts = ['External EOI']; const parts = [`External ${DOC_LABEL}`];
if (clientName) parts.push(clientName); if (clientName) parts.push(clientName);
if (berthLabel) parts.push(berthLabel); if (berthLabel) parts.push(berthLabel);
parts.push(date); parts.push(date);
return parts.join(' - '); return parts.join(' - ');
}, [interestData, berthsData, signedAt]); }, [interestData, berthsData, signedAt, DOC_LABEL]);
// The title input is controlled with `displayTitle` (derived from // The title input is controlled with `displayTitle` (derived from
// either the rep's typed value or the auto-derived default). Reps // 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 // 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 // Advanced toggle, tell the server to cancel it as part of this
// upload so the deal carries one canonical EOI. // 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); 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`, {
@@ -250,7 +263,7 @@ export function ExternalEoiUploadDialog({
// sync are skipped. Failures here don't undo the upload (the doc // sync are skipped. Failures here don't undo the upload (the doc
// is already filed) but surface as a non-blocking toast so the // is already filed) but surface as a non-blocking toast so the
// rep knows the flag didn't propagate. // rep knows the flag didn't propagate.
if (inBundleBerths.length > 0) { if (isEoi && inBundleBerths.length > 0) {
const targets = inBundleBerths.filter((b) => b.isSpecificInterest !== publicFlagChecked); const targets = inBundleBerths.filter((b) => b.isSpecificInterest !== publicFlagChecked);
if (targets.length > 0) { if (targets.length > 0) {
try { try {
@@ -270,8 +283,8 @@ export function ExternalEoiUploadDialog({
} }
toast.success( toast.success(
stageChanged stageChanged
? 'External EOI uploaded. Stage advanced to EOI Signed.' ? `Signed ${DOC_LABEL} uploaded. Pipeline stage advanced.`
: 'External EOI uploaded. Filed against this deal (stage unchanged).', : `Signed ${DOC_LABEL} uploaded. Filed against this deal (stage unchanged).`,
); );
qc.invalidateQueries({ queryKey: ['interests', interestId] }); qc.invalidateQueries({ queryKey: ['interests', interestId] });
qc.invalidateQueries({ queryKey: ['interests'] }); qc.invalidateQueries({ queryKey: ['interests'] });
@@ -293,16 +306,16 @@ export function ExternalEoiUploadDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl lg:max-w-4xl"> <DialogContent className="sm:max-w-2xl lg:max-w-4xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Upload externally-signed EOI</DialogTitle> <DialogTitle>Upload externally-signed {DOC_LABEL}</DialogTitle>
<DialogDescription> <DialogDescription>
For EOIs signed outside our signing service (paper, in person, alternate e-sign vendor). For a {DOC_LABEL} signed outside our signing service (paper, in person, alternate e-sign
The uploaded PDF is filed against this interest and the pipeline stage is advanced to vendor). The uploaded PDF is filed against this interest and the pipeline stage is
EOI Signed. advanced accordingly.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3 py-2"> <div className="space-y-3 py-2">
{activeEoi ? ( {isEoi && 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"> <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"> <p className="font-medium text-amber-900 dark:text-amber-100">
A generated EOI is already in flight on this deal. A generated EOI is already in flight on this deal.
@@ -466,7 +479,7 @@ export function ExternalEoiUploadDialog({
className="mt-1" className="mt-1"
/> />
</div> </div>
{inBundleBerths.length > 0 ? ( {isEoi && inBundleBerths.length > 0 ? (
<div className="rounded-md border bg-muted/40 p-3 text-sm"> <div className="rounded-md border bg-muted/40 p-3 text-sm">
<label className="flex cursor-pointer items-start gap-2"> <label className="flex cursor-pointer items-start gap-2">
<input <input

View File

@@ -167,17 +167,17 @@ export function InterestContractTab({ interestId, clientId: _clientId }: Interes
</section> </section>
)} )}
{/* Reuses the external-EOI upload dialog. The endpoint {/* Shared upload dialog, parameterised by docType. With
`/api/v1/interests/{id}/external-eoi` is EOI-specific today docType="contract" the service files the PDF as a contract,
- for contract paper-uploads we'll need the equivalent sets contractDocStatus='signed' + contractSignedAt, advances
contract endpoint (deferred to a follow-up; the dialog UI the stage to `contract`, and fires the contract_signed berth
is the pattern we'll clone). For now the flow is documented rule (no EOI-specific banner / public-map flip). */}
as 'coming soon' rather than misrouting through EOI. */}
{uploadSignedOpen && ( {uploadSignedOpen && (
<ExternalEoiUploadDialog <ExternalEoiUploadDialog
open={uploadSignedOpen} open={uploadSignedOpen}
onOpenChange={setUploadSignedOpen} onOpenChange={setUploadSignedOpen}
interestId={interestId} interestId={interestId}
docType="contract"
/> />
)} )}

View File

@@ -170,17 +170,17 @@ export function InterestReservationTab({
</section> </section>
)} )}
{/* Reuses the external-EOI upload dialog. The endpoint {/* Shared upload dialog, parameterised by docType. With
`/api/v1/interests/{id}/external-eoi` is EOI-specific today docType="reservation" the service files the PDF as a
- for reservation paper-uploads we'll need the equivalent reservation_agreement, sets reservationDocStatus='signed' +
reservation endpoint (deferred to a follow-up; the dialog UI reservationSignedAt, and advances the stage to `reservation`
is the pattern we'll clone). For now the flow is documented (no EOI-specific banner / public-map flip). */}
as 'coming soon' rather than misrouting through EOI. */}
{uploadSignedOpen && ( {uploadSignedOpen && (
<ExternalEoiUploadDialog <ExternalEoiUploadDialog
open={uploadSignedOpen} open={uploadSignedOpen}
onOpenChange={setUploadSignedOpen} onOpenChange={setUploadSignedOpen}
interestId={interestId} interestId={interestId}
docType="reservation"
/> />
)} )}

View File

@@ -21,6 +21,63 @@ import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { canonicalizeStage, type PipelineStage } from '@/lib/constants'; import { canonicalizeStage, type PipelineStage } from '@/lib/constants';
import { CodedError, ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; import { CodedError, ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server'; import { emitToRoom } from '@/lib/socket/server';
import type { ExternalSignedDocType } from '@/lib/services/external-signing.service';
/**
* Per-doc-type config for an externally-signed *upload* (file present).
* Mirrors external-signing.service's no-file status/rule maps and adds the
* storage / document-type / stage-advance details the file path needs, so the
* one upload service correctly files an EOI, a reservation agreement, OR a
* contract instead of always filing as an EOI.
*/
const UPLOAD_CONFIG: Record<
ExternalSignedDocType,
{
documentType: 'eoi' | 'reservation_agreement' | 'contract';
fileCategory: 'eoi' | 'contract';
storagePrefix: 'eoi-signed' | 'reservation-signed' | 'contract-signed';
docStatusCol: 'eoiDocStatus' | 'reservationDocStatus' | 'contractDocStatus';
dateCol: 'dateEoiSigned' | 'dateReservationSigned' | 'dateContractSigned';
targetStage: PipelineStage;
advanceFrom: PipelineStage[];
rule: 'eoi_signed' | 'contract_signed' | null;
label: string;
}
> = {
eoi: {
documentType: 'eoi',
fileCategory: 'eoi',
storagePrefix: 'eoi-signed',
docStatusCol: 'eoiDocStatus',
dateCol: 'dateEoiSigned',
targetStage: 'eoi',
advanceFrom: ['enquiry', 'qualified', 'nurturing'],
rule: 'eoi_signed',
label: 'EOI',
},
reservation: {
documentType: 'reservation_agreement',
fileCategory: 'contract',
storagePrefix: 'reservation-signed',
docStatusCol: 'reservationDocStatus',
dateCol: 'dateReservationSigned',
targetStage: 'reservation',
advanceFrom: ['enquiry', 'qualified', 'nurturing', 'eoi'],
rule: null,
label: 'reservation agreement',
},
contract: {
documentType: 'contract',
fileCategory: 'contract',
storagePrefix: 'contract-signed',
docStatusCol: 'contractDocStatus',
dateCol: 'dateContractSigned',
targetStage: 'contract',
advanceFrom: ['enquiry', 'qualified', 'nurturing', 'eoi', 'reservation', 'deposit_paid'],
rule: 'contract_signed',
label: 'contract',
},
};
/** A single signatory on an externally-signed document. */ /** A single signatory on an externally-signed document. */
export interface ExternalSignatory { export interface ExternalSignatory {
@@ -59,11 +116,17 @@ export interface ExternalEoiInput {
* Idempotent — already-cancelled / wrong-port ids are ignored. * Idempotent — already-cancelled / wrong-port ids are ignored.
*/ */
cancelActiveDocumentId?: string; cancelActiveDocumentId?: string;
/** Which signed document this upload represents. Defaults to 'eoi' for
* backward compatibility; reservation/contract tabs pass their own type so
* the file is filed + the interest advanced under the correct kind. */
docType?: ExternalSignedDocType;
meta: AuditMeta; meta: AuditMeta;
} }
export async function uploadExternallySignedEoi(input: ExternalEoiInput) { export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
const { interestId, portId, fileData, meta } = input; const { interestId, portId, fileData, meta } = input;
const docType: ExternalSignedDocType = input.docType ?? 'eoi';
const cfg = UPLOAD_CONFIG[docType];
if (fileData.size <= 0) throw new ValidationError('Empty file'); if (fileData.size <= 0) throw new ValidationError('Empty file');
if (fileData.size > 25 * 1024 * 1024) { if (fileData.size > 25 * 1024 * 1024) {
@@ -73,7 +136,7 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
fileData.mimeType !== 'application/pdf' && fileData.mimeType !== 'application/pdf' &&
!fileData.originalName.toLowerCase().endsWith('.pdf') !fileData.originalName.toLowerCase().endsWith('.pdf')
) { ) {
throw new ValidationError('Only PDF uploads are accepted for signed EOIs'); throw new ValidationError(`Only PDF uploads are accepted for signed ${cfg.label}s`);
} }
const interest = await db.query.interests.findFirst({ const interest = await db.query.interests.findFirst({
@@ -106,7 +169,7 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
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, cfg.storagePrefix, documentId, fileId, 'pdf');
// Upload to storage FIRST so we have a stable key for the DB rows, // Upload to storage FIRST so we have a stable key for the DB rows,
// then commit all four DB writes in one transaction. If the tx fails // then commit all four DB writes in one transaction. If the tx fails
@@ -120,7 +183,8 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
}); });
const title = const title =
input.title ?? `External EOI - ${(input.signedAt ?? new Date()).toISOString().slice(0, 10)}`; input.title ??
`External ${cfg.label} - ${(input.signedAt ?? new Date()).toISOString().slice(0, 10)}`;
const result = await db.transaction(async (tx) => { const result = await db.transaction(async (tx) => {
const [fileRecord] = await tx const [fileRecord] = await tx
@@ -134,7 +198,7 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
sizeBytes: String(fileData.size), sizeBytes: String(fileData.size),
storagePath, storagePath,
storageBucket: env.MINIO_BUCKET, storageBucket: env.MINIO_BUCKET,
category: 'eoi', category: cfg.fileCategory,
uploadedBy: meta.userId, uploadedBy: meta.userId,
}) })
.returning(); .returning();
@@ -152,7 +216,7 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
interestId, interestId,
clientId: interest.clientId, clientId: interest.clientId,
yachtId: interest.yachtId, yachtId: interest.yachtId,
documentType: 'eoi', documentType: cfg.documentType,
title, title,
status: 'completed', status: 'completed',
isManualUpload: true, isManualUpload: true,
@@ -217,27 +281,26 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
// 'deposit_paid' / 'contract' stages (stays put). // 'deposit_paid' / 'contract' stages (stays put).
const stageBeforeAdvance = interest.pipelineStage as PipelineStage | null | undefined; const stageBeforeAdvance = interest.pipelineStage as PipelineStage | null | undefined;
const canonicalStage = canonicalizeStage(stageBeforeAdvance); const canonicalStage = canonicalizeStage(stageBeforeAdvance);
const shouldAdvanceStage = const shouldAdvanceStage = cfg.advanceFrom.includes(canonicalStage);
canonicalStage === 'enquiry' ||
canonicalStage === 'qualified' ||
canonicalStage === 'nurturing';
await tx const existingDate = (interest[cfg.dateCol] as Date | null) ?? null;
.update(interests) const interestPatch = {
.set({ [cfg.dateCol]: existingDate ?? input.signedAt ?? new Date(),
dateEoiSigned: interest.dateEoiSigned ?? input.signedAt ?? new Date(), [cfg.docStatusCol]: 'signed',
eoiStatus: 'signed', // EOI carries a dedicated `eoi_status` lifecycle column; reservation /
eoiDocStatus: 'signed', // contract have only the `*_doc_status` sub-state (mirrors markExternallySigned).
...(shouldAdvanceStage ? { pipelineStage: 'eoi' as const } : {}), ...(docType === 'eoi' ? { eoiStatus: 'signed' as const } : {}),
updatedAt: new Date(), ...(shouldAdvanceStage ? { pipelineStage: cfg.targetStage } : {}),
}) updatedAt: new Date(),
.where(eq(interests.id, interestId)); } as Partial<typeof interests.$inferInsert>;
await tx.update(interests).set(interestPatch).where(eq(interests.id, interestId));
return { return {
documentId: doc.id, documentId: doc.id,
fileId: fileRecord.id, fileId: fileRecord.id,
stageChanged: shouldAdvanceStage, stageChanged: shouldAdvanceStage,
newStage: shouldAdvanceStage ? ('eoi' as const) : canonicalStage, newStage: shouldAdvanceStage ? cfg.targetStage : canonicalStage,
}; };
}); });
@@ -250,7 +313,8 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
entityType: 'document', entityType: 'document',
entityId: docId, entityId: docId,
metadata: { metadata: {
kind: 'external_eoi_upload', kind: `external_${docType}_upload`,
docType,
interestId, interestId,
title, title,
signerNames: input.signerNames ?? [], signerNames: input.signerNames ?? [],
@@ -270,8 +334,10 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
// to dodge the circular dep between berth-rules-engine and the // to dodge the circular dep between berth-rules-engine and the
// interest services. // interest services.
try { try {
const { evaluateRule } = await import('@/lib/services/berth-rules-engine'); if (cfg.rule) {
await evaluateRule('eoi_signed', interestId, portId, meta); const { evaluateRule } = await import('@/lib/services/berth-rules-engine');
await evaluateRule(cfg.rule, interestId, portId, meta);
}
} catch { } catch {
// Swallow - rules engine failures should never block the upload // Swallow - rules engine failures should never block the upload
// that the rep has already completed end-to-end. The orphan-reaper // that the rep has already completed end-to-end. The orphan-reaper

View File

@@ -20,6 +20,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests'; import { interests } from '@/lib/db/schema/interests';
import { documents } from '@/lib/db/schema/documents';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
let uploadExternallySignedEoi: typeof import('@/lib/services/external-eoi.service').uploadExternallySignedEoi; let uploadExternallySignedEoi: typeof import('@/lib/services/external-eoi.service').uploadExternallySignedEoi;
@@ -97,6 +98,7 @@ describe('uploadExternallySignedEoi — stage-advance gate', () => {
interestId: string, interestId: string,
portId: string, portId: string,
meta: ReturnType<typeof makeAuditMeta>, meta: ReturnType<typeof makeAuditMeta>,
docType?: 'eoi' | 'reservation' | 'contract',
) { ) {
return uploadExternallySignedEoi({ return uploadExternallySignedEoi({
interestId, interestId,
@@ -109,6 +111,7 @@ describe('uploadExternallySignedEoi — stage-advance gate', () => {
}, },
signedAt: new Date('2026-05-24T10:00:00Z'), signedAt: new Date('2026-05-24T10:00:00Z'),
signatories: [{ name: 'Client', email: 'client@example.com', role: 'client' }], signatories: [{ name: 'Client', email: 'client@example.com', role: 'client' }],
...(docType ? { docType } : {}),
meta, meta,
}); });
} }
@@ -148,4 +151,68 @@ describe('uploadExternallySignedEoi — stage-advance gate', () => {
expect(row?.dateEoiSigned).toBeTruthy(); expect(row?.dateEoiSigned).toBeTruthy();
}, },
); );
// ── docType routing ────────────────────────────────────────────────────────
// Regression coverage for the paper-upload misroute: the reservation and
// contract tabs reused this dialog, but the service hard-coded the EOI
// documentType / status columns / stage target. A signed contract uploaded
// from the Contract tab filed as an `eoi` and flipped `eoi_status` — wrong
// doc kind, wrong sub-state, wrong stage. The service now keys all of that
// off `docType`.
describe('docType routing (reservation / contract)', () => {
it('reservation: files as reservation_agreement, advances eoi → reservation, leaves eoi_status null', async () => {
const { port, meta, interest } = await makeInterest('eoi');
const result = await uploadFakeEoi(interest.id, port.id, meta, 'reservation');
expect(result.stageChanged).toBe(true);
expect(result.newStage).toBe('reservation');
const row = await db.query.interests.findFirst({ where: eq(interests.id, interest.id) });
expect(row?.pipelineStage).toBe('reservation');
expect(row?.reservationDocStatus).toBe('signed');
expect(row?.dateReservationSigned).toBeTruthy();
// The EOI lifecycle column must NOT be touched by a reservation upload.
expect(row?.eoiStatus).toBeNull();
expect(row?.eoiDocStatus).toBeNull();
const doc = await db.query.documents.findFirst({
where: eq(documents.id, result.documentId),
});
expect(doc?.documentType).toBe('reservation_agreement');
});
it('contract: files as contract, advances reservation → contract, sets contract columns only', async () => {
const { port, meta, interest } = await makeInterest('reservation');
const result = await uploadFakeEoi(interest.id, port.id, meta, 'contract');
expect(result.stageChanged).toBe(true);
expect(result.newStage).toBe('contract');
const row = await db.query.interests.findFirst({ where: eq(interests.id, interest.id) });
expect(row?.pipelineStage).toBe('contract');
expect(row?.contractDocStatus).toBe('signed');
expect(row?.dateContractSigned).toBeTruthy();
expect(row?.eoiStatus).toBeNull();
const doc = await db.query.documents.findFirst({
where: eq(documents.id, result.documentId),
});
expect(doc?.documentType).toBe('contract');
});
it('contract: at-or-past contract stage stays put (idempotent), still files the doc', async () => {
const { port, meta, interest } = await makeInterest('contract');
const result = await uploadFakeEoi(interest.id, port.id, meta, 'contract');
expect(result.stageChanged).toBe(false);
expect(result.newStage).toBe('contract');
const row = await db.query.interests.findFirst({ where: eq(interests.id, interest.id) });
expect(row?.pipelineStage).toBe('contract');
expect(row?.contractDocStatus).toBe('signed');
});
});
}); });