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:
@@ -21,6 +21,63 @@ import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
import { canonicalizeStage, type PipelineStage } from '@/lib/constants';
|
||||
import { CodedError, ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
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. */
|
||||
export interface ExternalSignatory {
|
||||
@@ -59,11 +116,17 @@ export interface ExternalEoiInput {
|
||||
* Idempotent — already-cancelled / wrong-port ids are ignored.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
|
||||
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 > 25 * 1024 * 1024) {
|
||||
@@ -73,7 +136,7 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
|
||||
fileData.mimeType !== 'application/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({
|
||||
@@ -106,7 +169,7 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
|
||||
|
||||
const documentId = 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,
|
||||
// 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 =
|
||||
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 [fileRecord] = await tx
|
||||
@@ -134,7 +198,7 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
|
||||
sizeBytes: String(fileData.size),
|
||||
storagePath,
|
||||
storageBucket: env.MINIO_BUCKET,
|
||||
category: 'eoi',
|
||||
category: cfg.fileCategory,
|
||||
uploadedBy: meta.userId,
|
||||
})
|
||||
.returning();
|
||||
@@ -152,7 +216,7 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
|
||||
interestId,
|
||||
clientId: interest.clientId,
|
||||
yachtId: interest.yachtId,
|
||||
documentType: 'eoi',
|
||||
documentType: cfg.documentType,
|
||||
title,
|
||||
status: 'completed',
|
||||
isManualUpload: true,
|
||||
@@ -217,27 +281,26 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
|
||||
// 'deposit_paid' / 'contract' stages (stays put).
|
||||
const stageBeforeAdvance = interest.pipelineStage as PipelineStage | null | undefined;
|
||||
const canonicalStage = canonicalizeStage(stageBeforeAdvance);
|
||||
const shouldAdvanceStage =
|
||||
canonicalStage === 'enquiry' ||
|
||||
canonicalStage === 'qualified' ||
|
||||
canonicalStage === 'nurturing';
|
||||
const shouldAdvanceStage = cfg.advanceFrom.includes(canonicalStage);
|
||||
|
||||
await tx
|
||||
.update(interests)
|
||||
.set({
|
||||
dateEoiSigned: interest.dateEoiSigned ?? input.signedAt ?? new Date(),
|
||||
eoiStatus: 'signed',
|
||||
eoiDocStatus: 'signed',
|
||||
...(shouldAdvanceStage ? { pipelineStage: 'eoi' as const } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(interests.id, interestId));
|
||||
const existingDate = (interest[cfg.dateCol] as Date | null) ?? null;
|
||||
const interestPatch = {
|
||||
[cfg.dateCol]: existingDate ?? input.signedAt ?? new Date(),
|
||||
[cfg.docStatusCol]: 'signed',
|
||||
// EOI carries a dedicated `eoi_status` lifecycle column; reservation /
|
||||
// contract have only the `*_doc_status` sub-state (mirrors markExternallySigned).
|
||||
...(docType === 'eoi' ? { eoiStatus: 'signed' as const } : {}),
|
||||
...(shouldAdvanceStage ? { pipelineStage: cfg.targetStage } : {}),
|
||||
updatedAt: new Date(),
|
||||
} as Partial<typeof interests.$inferInsert>;
|
||||
|
||||
await tx.update(interests).set(interestPatch).where(eq(interests.id, interestId));
|
||||
|
||||
return {
|
||||
documentId: doc.id,
|
||||
fileId: fileRecord.id,
|
||||
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',
|
||||
entityId: docId,
|
||||
metadata: {
|
||||
kind: 'external_eoi_upload',
|
||||
kind: `external_${docType}_upload`,
|
||||
docType,
|
||||
interestId,
|
||||
title,
|
||||
signerNames: input.signerNames ?? [],
|
||||
@@ -270,8 +334,10 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
|
||||
// to dodge the circular dep between berth-rules-engine and the
|
||||
// interest services.
|
||||
try {
|
||||
const { evaluateRule } = await import('@/lib/services/berth-rules-engine');
|
||||
await evaluateRule('eoi_signed', interestId, portId, meta);
|
||||
if (cfg.rule) {
|
||||
const { evaluateRule } = await import('@/lib/services/berth-rules-engine');
|
||||
await evaluateRule(cfg.rule, interestId, portId, meta);
|
||||
}
|
||||
} catch {
|
||||
// Swallow - rules engine failures should never block the upload
|
||||
// that the rep has already completed end-to-end. The orphan-reaper
|
||||
|
||||
Reference in New Issue
Block a user