R2-H1: smart-restore's berth_released auto-reversal was a no-op while the wizard claimed success. Now uses the persisted interestId from the decision detail to re-insert the interest_berths link and flip the berth status back to under_offer. Verifies the interest still exists and isn't archived before re-linking. R2-H2: smart-archive berth status update had a TOCTOU race — read outside tx, write inside without a lock. Now selects-for-update the berths row inside the tx and re-checks status against the locked row before flipping to available, preventing concurrent archive+sale from un-selling a berth. R2-H3: bulk-archive's berth→interest lookup fell back to dossier.interests[0]?.interestId ?? '' which sent empty-string interestIds that silently matched zero rows. Dossier now exposes linkedInterestIds[] per berth (authoritative interest_berths join); bulk + single-client wizard both use it and skip berths with no linked interest. Affected: - src/lib/services/client-archive-dossier.service.ts (DossierBerth) - src/app/api/v1/clients/bulk/route.ts - src/components/clients/smart-archive-dialog.tsx R2-H4: external-EOI ran storage upload + 4 DB writes outside a transaction. Now wraps file/document/event/interest writes in a single tx; storage upload stays before the tx (S3 isn't transactional), orphan-object on tx failure is acceptable. R2-H5: bulk archive double-submit treated already-archived clients as per-row failures. Bulk callback now early-returns success when the dossier shows archivedAt is set, making the endpoint idempotent. 1175/1175 vitest passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
189 lines
6.2 KiB
TypeScript
189 lines
6.2 KiB
TypeScript
/**
|
|
* External EOI upload — for EOIs signed outside Documenso (paper signing,
|
|
* different e-sign vendor, signed in person, etc).
|
|
*
|
|
* Creates BOTH the document row AND the signed-file record in one shot,
|
|
* then advances the interest stage. Distinct from the existing
|
|
* uploadSignedManually flow which augments a document row that was
|
|
* already created via the Documenso pathway.
|
|
*/
|
|
|
|
import { eq } from 'drizzle-orm';
|
|
|
|
import { db } from '@/lib/db';
|
|
import { interests } from '@/lib/db/schema/interests';
|
|
import { documents, documentEvents, files } from '@/lib/db/schema/documents';
|
|
import { ports } from '@/lib/db/schema/ports';
|
|
import { env } from '@/lib/env';
|
|
import { buildStoragePath } from '@/lib/minio';
|
|
import { getStorageBackend } from '@/lib/storage';
|
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
|
import { CodedError, NotFoundError, ValidationError } from '@/lib/errors';
|
|
import { emitToRoom } from '@/lib/socket/server';
|
|
|
|
export interface ExternalEoiInput {
|
|
interestId: string;
|
|
portId: string;
|
|
/** PDF bytes. */
|
|
fileData: { buffer: Buffer; originalName: string; mimeType: string; size: number };
|
|
/** Free-text title for the document row. Defaults to "External EOI - <date>". */
|
|
title?: string;
|
|
/** When the signing actually happened (the date on the paper / contract). */
|
|
signedAt?: Date;
|
|
/** Names of the people who signed (free-text — we don't manage signer
|
|
* identities for external sigs). Recorded in metadata. */
|
|
signerNames?: string[];
|
|
/** Free-text note (e.g. "signed in person at boat show"). */
|
|
notes?: string;
|
|
meta: AuditMeta;
|
|
}
|
|
|
|
export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
|
|
const { interestId, portId, fileData, meta } = input;
|
|
|
|
if (fileData.size <= 0) throw new ValidationError('Empty file');
|
|
if (fileData.size > 25 * 1024 * 1024) {
|
|
throw new ValidationError('File too large (max 25 MB)');
|
|
}
|
|
if (
|
|
fileData.mimeType !== 'application/pdf' &&
|
|
!fileData.originalName.toLowerCase().endsWith('.pdf')
|
|
) {
|
|
throw new ValidationError('Only PDF uploads are accepted for signed EOIs');
|
|
}
|
|
|
|
const interest = await db.query.interests.findFirst({
|
|
where: eq(interests.id, interestId),
|
|
});
|
|
if (!interest || interest.portId !== portId) throw new NotFoundError('Interest');
|
|
|
|
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
|
if (!port) throw new NotFoundError('Port');
|
|
|
|
const documentId = crypto.randomUUID();
|
|
const fileId = crypto.randomUUID();
|
|
const storagePath = buildStoragePath(port.slug, 'eoi-signed', 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
|
|
// the storage object becomes orphaned (S3 isn't transactional) but
|
|
// the DB stays clean — orphan reaper handles those.
|
|
await (
|
|
await getStorageBackend()
|
|
).put(storagePath, fileData.buffer, {
|
|
contentType: 'application/pdf',
|
|
sizeBytes: fileData.size,
|
|
});
|
|
|
|
const title =
|
|
input.title ?? `External EOI — ${(input.signedAt ?? new Date()).toISOString().slice(0, 10)}`;
|
|
|
|
const result = await db.transaction(async (tx) => {
|
|
const [fileRecord] = await tx
|
|
.insert(files)
|
|
.values({
|
|
portId,
|
|
clientId: interest.clientId,
|
|
filename: fileData.originalName,
|
|
originalName: fileData.originalName,
|
|
mimeType: 'application/pdf',
|
|
sizeBytes: String(fileData.size),
|
|
storagePath,
|
|
storageBucket: env.MINIO_BUCKET,
|
|
category: 'eoi',
|
|
uploadedBy: meta.userId,
|
|
})
|
|
.returning();
|
|
if (!fileRecord) {
|
|
throw new CodedError('INSERT_RETURNING_EMPTY', {
|
|
internalMessage: 'External EOI file insert returned no row',
|
|
});
|
|
}
|
|
|
|
const [doc] = await tx
|
|
.insert(documents)
|
|
.values({
|
|
id: documentId,
|
|
portId,
|
|
interestId,
|
|
clientId: interest.clientId,
|
|
yachtId: interest.yachtId,
|
|
documentType: 'eoi',
|
|
title,
|
|
status: 'completed',
|
|
isManualUpload: true,
|
|
signedFileId: fileRecord.id,
|
|
notes: input.notes ?? null,
|
|
createdBy: meta.userId,
|
|
})
|
|
.returning();
|
|
if (!doc) {
|
|
throw new CodedError('INSERT_RETURNING_EMPTY', {
|
|
internalMessage: 'External EOI document insert returned no row',
|
|
});
|
|
}
|
|
|
|
await tx.insert(documentEvents).values({
|
|
documentId: doc.id,
|
|
eventType: 'completed',
|
|
eventData: {
|
|
isManualUpload: true,
|
|
external: true,
|
|
signerNames: input.signerNames ?? [],
|
|
signedAt: (input.signedAt ?? new Date()).toISOString(),
|
|
fileId: fileRecord.id,
|
|
},
|
|
});
|
|
|
|
// Advance the interest stage to eoi_signed (no-op if already past it).
|
|
// We bypass canTransitionStage explicitly because the operator just
|
|
// brought concrete proof that the EOI is signed — that's higher
|
|
// confidence than a normal forward-jump.
|
|
if (
|
|
interest.pipelineStage === 'open' ||
|
|
interest.pipelineStage === 'details_sent' ||
|
|
interest.pipelineStage === 'in_communication' ||
|
|
interest.pipelineStage === 'eoi_sent'
|
|
) {
|
|
await tx
|
|
.update(interests)
|
|
.set({
|
|
pipelineStage: 'eoi_signed',
|
|
eoiStatus: 'signed',
|
|
dateEoiSigned: input.signedAt ?? new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(interests.id, interestId));
|
|
} else {
|
|
// Past eoi_signed — just record the document, don't touch stage.
|
|
await tx.update(interests).set({ updatedAt: new Date() }).where(eq(interests.id, interestId));
|
|
}
|
|
|
|
return { documentId: doc.id, fileId: fileRecord.id };
|
|
});
|
|
|
|
const { documentId: docId, fileId: fId } = result;
|
|
|
|
void createAuditLog({
|
|
portId,
|
|
userId: meta.userId,
|
|
action: 'create',
|
|
entityType: 'document',
|
|
entityId: docId,
|
|
metadata: {
|
|
kind: 'external_eoi_upload',
|
|
interestId,
|
|
title,
|
|
signerNames: input.signerNames ?? [],
|
|
signedAt: (input.signedAt ?? new Date()).toISOString(),
|
|
fileSizeBytes: fileData.size,
|
|
},
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'document:completed', { documentId: docId });
|
|
|
|
return { documentId: docId, fileId: fId };
|
|
}
|