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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user