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:
@@ -20,7 +20,7 @@ import { and, eq, isNull, ne, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clients } from '@/lib/db/schema/clients';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { interests, interestBerths } from '@/lib/db/schema/interests';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { portalUsers } from '@/lib/db/schema/portal';
|
||||
@@ -42,6 +42,10 @@ export interface RestoreReversal {
|
||||
label: string;
|
||||
/** Why this is being shown the way it is (e.g. "berth still available"). */
|
||||
reason: string;
|
||||
/** Carries the persisted decision detail through to applyReversal so we
|
||||
* can re-link berths to their original interest, restore yacht owners,
|
||||
* etc. without re-parsing meta.decisions. */
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RestoreDossier {
|
||||
@@ -126,6 +130,7 @@ export async function getRestoreDossier(clientId: string, portId: string): Promi
|
||||
refId: d.refId,
|
||||
label: `Berth ${b.mooringNumber}`,
|
||||
reason: 'still available — re-attaching to the restored client',
|
||||
detail: d.detail,
|
||||
});
|
||||
} else if (b.status === 'sold') {
|
||||
locked.push({
|
||||
@@ -144,6 +149,7 @@ export async function getRestoreDossier(clientId: string, portId: string): Promi
|
||||
refId: d.refId,
|
||||
label: `Berth ${b.mooringNumber}`,
|
||||
reason: 'currently under offer to another client — re-attach as a competing interest?',
|
||||
detail: d.detail,
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -357,19 +363,43 @@ async function applyReversal(
|
||||
clientId: string,
|
||||
): Promise<void> {
|
||||
switch (r.kind) {
|
||||
case 'berth_released':
|
||||
// Re-attach to whichever interest of the restored client originally
|
||||
// owned the link. We don't know that interest id from the reversal
|
||||
// alone, so we pick the most recent active interest on the same
|
||||
// berth from this client; if none exists we skip (the berth is
|
||||
// now genuinely orphaned for this client).
|
||||
// For v1, leave the berth available — operator can re-attach
|
||||
// manually via the interest-berths UI. The restore wizard surfaces
|
||||
// this case as auto-reversible only when the berth is still free,
|
||||
// so the operator can immediately add it back.
|
||||
// (The system MARKS the berth as eligible for re-link; full
|
||||
// automation would require persisting the original interestId.)
|
||||
case 'berth_released': {
|
||||
// Re-link the berth to whichever interest originally owned it
|
||||
// (persisted in d.detail.interestId at archive time). We verify
|
||||
// the interest still belongs to the restored client and isn't
|
||||
// archived — defensive in case the operator deleted the interest
|
||||
// separately while the client was archived.
|
||||
const interestId = (r.detail?.interestId as string | undefined) ?? null;
|
||||
if (!interestId) break;
|
||||
const [iv] = await tx
|
||||
.select({ id: interests.id, archivedAt: interests.archivedAt })
|
||||
.from(interests)
|
||||
.where(and(eq(interests.id, interestId), eq(interests.clientId, clientId)))
|
||||
.limit(1);
|
||||
if (!iv || iv.archivedAt) break;
|
||||
|
||||
// Idempotent re-insert: the unique index on (interestId, berthId)
|
||||
// means a duplicate is a no-op via onConflictDoNothing.
|
||||
await tx
|
||||
.insert(interestBerths)
|
||||
.values({
|
||||
interestId,
|
||||
berthId: r.refId,
|
||||
isPrimary: false,
|
||||
isSpecificInterest: true,
|
||||
isInEoiBundle: false,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
// Flip berth status back to under_offer so the public map reflects
|
||||
// the re-link. Only when berth is currently 'available' (sold
|
||||
// berths are immutable; under_offer to another client is handled
|
||||
// via the prompt branch which the operator may opt into).
|
||||
await tx
|
||||
.update(berths)
|
||||
.set({ status: 'under_offer' })
|
||||
.where(and(eq(berths.id, r.refId), eq(berths.status, 'available')));
|
||||
break;
|
||||
}
|
||||
case 'yacht_transferred': {
|
||||
// Transfer back to the restored client.
|
||||
await tx
|
||||
|
||||
Reference in New Issue
Block a user