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

@@ -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