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:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
|
...(shouldAdvanceStage ? { pipelineStage: cfg.targetStage } : {}),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
} as Partial<typeof interests.$inferInsert>;
|
||||||
.where(eq(interests.id, interestId));
|
|
||||||
|
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 {
|
||||||
|
if (cfg.rule) {
|
||||||
const { evaluateRule } = await import('@/lib/services/berth-rules-engine');
|
const { evaluateRule } = await import('@/lib/services/berth-rules-engine');
|
||||||
await evaluateRule('eoi_signed', interestId, portId, meta);
|
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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user