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:
@@ -47,6 +47,11 @@ export interface DossierBerth {
|
||||
berthId: string;
|
||||
mooringNumber: string;
|
||||
status: string; // 'available' | 'under_offer' | 'sold'
|
||||
/** Every interest of THIS client that links to the berth. The bulk
|
||||
* wizard uses this to pick the right interestId per berth instead of
|
||||
* guessing by primary-mooring (which fails when multiple interests
|
||||
* share a primary or when none is primary). */
|
||||
linkedInterestIds: string[];
|
||||
/** Other interests still actively expressing interest in this berth
|
||||
* (so the next-in-line notification can list them). */
|
||||
otherInterests: Array<{
|
||||
@@ -267,10 +272,18 @@ export async function getClientArchiveDossier(
|
||||
.orderBy(desc(interests.updatedAt))
|
||||
.limit(10);
|
||||
|
||||
// Every linked interest belonging to THIS client (multiple
|
||||
// interests can share a berth — primary flag is at most one per
|
||||
// interest, not per berth).
|
||||
const linkedInterestIds = Array.from(
|
||||
new Set(interestBerthRows.filter((r) => r.berthId === berthId).map((r) => r.interestId)),
|
||||
);
|
||||
|
||||
dossierBerths.push({
|
||||
berthId,
|
||||
mooringNumber: berth.mooringNumber,
|
||||
status: berth.berthStatus,
|
||||
linkedInterestIds,
|
||||
otherInterests: others.map((o) => ({
|
||||
interestId: o.interestId,
|
||||
clientId: o.clientId,
|
||||
|
||||
Reference in New Issue
Block a user