fix(audit): berth rules/recommender — M4 (bundle-wide status), M5 (berth_unlinked target), M20/L27 (interest_berths invariant + cross-port guard), L3 (recommender stage-scale), L4 (dead branch)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:59:12 +02:00
parent 4084029962
commit 70bf26aea1
5 changed files with 163 additions and 58 deletions

View File

@@ -21,7 +21,7 @@ import { and, eq, ne, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import type { Tx } from '@/lib/db/utils';
import { clients } from '@/lib/db/schema/clients';
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { interests } 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';
@@ -29,6 +29,7 @@ import { documents } from '@/lib/db/schema/documents';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { activeInterestsWhere } from '@/lib/services/active-interest';
import { transferOwnershipTx } from '@/lib/services/yachts.service';
import { upsertInterestBerthTx } from '@/lib/services/interest-berths.service';
import { ConflictError, NotFoundError } from '@/lib/errors';
import type { ArchiveMetadata } from '@/lib/services/client-archive.service';
@@ -380,18 +381,17 @@ async function applyReversal(
.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();
// Idempotent re-link via the canonical junction helper (audit L27):
// routes through `upsertInterestBerthTx` so the cross-port guard runs
// (the prior raw insert bypassed it) and the unique (interestId, berthId)
// index keeps a duplicate a benign merge. This row is a non-primary
// re-attach, so the primary↔bundle invariant doesn't force the bundle
// flag on — it stays an EOI-only/legal link as before.
await upsertInterestBerthTx(tx, interestId, r.refId, {
isPrimary: false,
isSpecificInterest: true,
isInEoiBundle: false,
});
// 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