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

@@ -177,6 +177,19 @@ export async function archiveClientWithDecisions(args: {
const berth = dossier.berths.find((b) => b.berthId === d.berthId);
if (!berth) continue;
if (d.action === 'release') {
// Lock the berth row so a concurrent sale can't flip the status
// between our read of dossier.berths (outside the tx) and our
// write below. Without this lock, A archives client X while B
// sells berth A1 to client Y — A's pre-tx read says
// status='under_offer', B commits status='sold', A's update
// would flip it back to 'available'.
const [locked] = await tx
.select({ status: berths.status })
.from(berths)
.where(eq(berths.id, d.berthId))
.for('update');
const lockedStatus = locked?.status ?? berth.status;
// Drop the interest_berths link for this client's interest. Other
// interests on the berth survive (so the next-in-line notification
// can fire).
@@ -186,9 +199,10 @@ export async function archiveClientWithDecisions(args: {
and(eq(interestBerths.berthId, d.berthId), eq(interestBerths.interestId, d.interestId)),
);
// If no remaining interestBerths row marks this berth as
// is_specific_interest, set the berth status back to available
// (sold berths are immutable from this flow per design).
if (berth.status !== 'sold') {
// is_specific_interest, set the berth status back to available.
// Sold berths are immutable from this flow — also re-checked
// against the freshly-locked row, not the pre-tx dossier read.
if (lockedStatus !== 'sold') {
const [stillUnderOffer] = await tx
.select({ count: sql<number>`count(*)::int` })
.from(interestBerths)