fix(audit): reliability HIGHs — smart-restore re-link, TOCTOU lock, bulk wrong-interest, ext-EOI tx, bulk idempotency

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>
This commit is contained in:
Matt Ciaccio
2026-05-06 22:11:00 +02:00
parent 588f8bc43c
commit 94331bd6ec
6 changed files with 208 additions and 115 deletions

View File

@@ -64,6 +64,10 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
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, {
@@ -71,95 +75,101 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
sizeBytes: fileData.size,
});
const [fileRecord] = await db
.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 title =
input.title ?? `External EOI — ${(input.signedAt ?? new Date()).toISOString().slice(0, 10)}`;
const [doc] = await db
.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',
});
}
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',
});
}
await db.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,
},
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 };
});
// 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 db
.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 db.update(interests).set({ updatedAt: new Date() }).where(eq(interests.id, interestId));
}
const { documentId: docId, fileId: fId } = result;
void createAuditLog({
portId,
userId: meta.userId,
action: 'create',
entityType: 'document',
entityId: doc.id,
entityId: docId,
metadata: {
kind: 'external_eoi_upload',
interestId,
@@ -172,7 +182,7 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'document:completed', { documentId: doc.id });
emitToRoom(`port:${portId}`, 'document:completed', { documentId: docId });
return { documentId: doc.id, fileId: fileRecord.id };
return { documentId: docId, fileId: fId };
}