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:
@@ -90,6 +90,13 @@ export const POST = withAuth(async (req, ctx) => {
|
||||
// a per-client reason supplied via reasonsByClientId; the bulk-
|
||||
// archive wizard captures these one at a time before submitting.
|
||||
const dossier = await getClientArchiveDossier(id, ctx.portId);
|
||||
// Idempotent: if a previous request already archived this client
|
||||
// (e.g. a network retry / double-click), treat it as success
|
||||
// rather than letting `archiveClientWithDecisions` throw a
|
||||
// ConflictError that runBulk will surface as a per-row failure.
|
||||
if (dossier.client.archivedAt) {
|
||||
return;
|
||||
}
|
||||
const perClientReason = reasonsByClientId[id];
|
||||
if (dossier.stakeLevel === 'high' && !perClientReason) {
|
||||
throw new Error(
|
||||
@@ -103,20 +110,32 @@ export const POST = withAuth(async (req, ctx) => {
|
||||
(d) => d.status === 'completed' || d.status === 'signed',
|
||||
);
|
||||
const reason = perClientReason ?? 'Bulk archive (low-stakes auto-mode)';
|
||||
// Pick the berth's first linked interest from the dossier
|
||||
// (authoritative interest_berths join). Berths with no linked
|
||||
// interest for this client are dropped — emitting an empty
|
||||
// interestId causes the delete to silently match zero rows
|
||||
// (audit R2-H3).
|
||||
const berthDecisions = dossier.berths
|
||||
.map((b) => {
|
||||
const interestId = b.linkedInterestIds[0];
|
||||
if (!interestId) return null;
|
||||
return {
|
||||
berthId: b.berthId,
|
||||
interestId,
|
||||
action: b.status === 'sold' ? ('retain' as const) : ('release' as const),
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(x): x is { berthId: string; interestId: string; action: 'retain' | 'release' } =>
|
||||
x !== null,
|
||||
);
|
||||
|
||||
const result = await archiveClientWithDecisions({
|
||||
dossier,
|
||||
decisions: {
|
||||
reason,
|
||||
acknowledgedSignedDocuments: hasSignedDocs,
|
||||
berthDecisions: dossier.berths.map((b) => ({
|
||||
berthId: b.berthId,
|
||||
interestId:
|
||||
dossier.interests.find((i) => i.primaryBerthMooring === b.mooringNumber)
|
||||
?.interestId ??
|
||||
dossier.interests[0]?.interestId ??
|
||||
'',
|
||||
action: b.status === 'sold' ? 'retain' : 'release',
|
||||
})),
|
||||
berthDecisions,
|
||||
yachtDecisions: dossier.yachts.map((y) => ({ yachtId: y.yachtId, action: 'retain' })),
|
||||
reservationDecisions: dossier.reservations.map((r) => ({
|
||||
reservationId: r.reservationId,
|
||||
|
||||
Reference in New Issue
Block a user