fix(audit): H2/H3 — client merge re-points payments, memberships, yacht & invoice ownership
Merge now re-points the loser's payments, company memberships (deduped against unique_cm_exact), polymorphic yacht ownership, and polymorphic invoice billing-entity to the winner inside the same transaction, before archiving the loser. H2: the winner no longer silently loses those rows. H3: because payments (notNull onDelete:cascade) are moved off the loser, a later hard-delete of the archived loser can no longer cascade-delete the winner's financial history. Counts wired into the merge result + audit row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,10 @@ import {
|
||||
} from '@/lib/db/schema/clients';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { berthTenancies } from '@/lib/db/schema/tenancies';
|
||||
import { payments } from '@/lib/db/schema/pipeline';
|
||||
import { companyMemberships } from '@/lib/db/schema/companies';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { invoices } from '@/lib/db/schema/financial';
|
||||
import { auditLogs } from '@/lib/db/schema/system';
|
||||
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
|
||||
@@ -81,6 +85,10 @@ export interface MergeResult {
|
||||
tags: number;
|
||||
relationships: number;
|
||||
tenancies: number;
|
||||
payments: number;
|
||||
companyMemberships: number;
|
||||
yachts: number;
|
||||
invoices: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -301,6 +309,90 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
|
||||
.returning({ id: clientRelationships.id })
|
||||
).length;
|
||||
|
||||
// Payments: re-point every payment row from the loser to the winner.
|
||||
// Critical: payments.clientId is notNull onDelete:'cascade', so leaving
|
||||
// them on the archived loser would let a later hard-delete cascade away
|
||||
// the winner's financial history. Scoped to the port defensively.
|
||||
const movedPayments = (
|
||||
await tx
|
||||
.update(payments)
|
||||
.set({ clientId: opts.winnerId })
|
||||
.where(and(eq(payments.clientId, opts.loserId), eq(payments.portId, winnerRow.portId)))
|
||||
.returning({ id: payments.id })
|
||||
).length;
|
||||
|
||||
// Company memberships: re-point, but dedup against the unique constraint
|
||||
// `unique_cm_exact` (companyId, clientId, role, startDate). If the winner
|
||||
// already has an equivalent membership, drop the loser's row instead of
|
||||
// re-pointing so we don't violate the unique index.
|
||||
const winnerMemberships = await tx
|
||||
.select({
|
||||
companyId: companyMemberships.companyId,
|
||||
role: companyMemberships.role,
|
||||
startDate: companyMemberships.startDate,
|
||||
})
|
||||
.from(companyMemberships)
|
||||
.where(eq(companyMemberships.clientId, opts.winnerId));
|
||||
const winnerMembershipKeys = new Set(
|
||||
winnerMemberships.map(
|
||||
(m) => `${m.companyId}::${m.role}::${m.startDate ? m.startDate.toISOString() : ''}`,
|
||||
),
|
||||
);
|
||||
const loserMemberships = await tx
|
||||
.select({
|
||||
id: companyMemberships.id,
|
||||
companyId: companyMemberships.companyId,
|
||||
role: companyMemberships.role,
|
||||
startDate: companyMemberships.startDate,
|
||||
})
|
||||
.from(companyMemberships)
|
||||
.where(eq(companyMemberships.clientId, opts.loserId));
|
||||
let movedCompanyMemberships = 0;
|
||||
for (const m of loserMemberships) {
|
||||
const key = `${m.companyId}::${m.role}::${m.startDate ? m.startDate.toISOString() : ''}`;
|
||||
if (winnerMembershipKeys.has(key)) {
|
||||
// Winner already has an equivalent membership - drop the loser's row.
|
||||
await tx.delete(companyMemberships).where(eq(companyMemberships.id, m.id));
|
||||
continue;
|
||||
}
|
||||
await tx
|
||||
.update(companyMemberships)
|
||||
.set({ clientId: opts.winnerId, updatedAt: new Date() })
|
||||
.where(eq(companyMemberships.id, m.id));
|
||||
movedCompanyMemberships += 1;
|
||||
}
|
||||
|
||||
// Yachts: re-point polymorphic ownership on the denormalized owner
|
||||
// columns only. yacht_ownership_history is handled separately.
|
||||
const movedYachts = (
|
||||
await tx
|
||||
.update(yachts)
|
||||
.set({ currentOwnerId: opts.winnerId, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(yachts.currentOwnerType, 'client'),
|
||||
eq(yachts.currentOwnerId, opts.loserId),
|
||||
eq(yachts.portId, winnerRow.portId),
|
||||
),
|
||||
)
|
||||
.returning({ id: yachts.id })
|
||||
).length;
|
||||
|
||||
// Invoices: re-point polymorphic billing entity from loser to winner.
|
||||
const movedInvoices = (
|
||||
await tx
|
||||
.update(invoices)
|
||||
.set({ billingEntityId: opts.winnerId, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(invoices.billingEntityType, 'client'),
|
||||
eq(invoices.billingEntityId, opts.loserId),
|
||||
eq(invoices.portId, winnerRow.portId),
|
||||
),
|
||||
)
|
||||
.returning({ id: invoices.id })
|
||||
).length;
|
||||
|
||||
// ── Archive the loser. Row stays in DB for the undo window;
|
||||
// `mergedIntoClientId` is the redirect pointer for any stragglers
|
||||
// (links / direct queries / saved views). ──────────────────────────
|
||||
@@ -360,6 +452,10 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
|
||||
movedTenancies,
|
||||
movedContacts,
|
||||
movedAddresses,
|
||||
movedPayments,
|
||||
movedCompanyMemberships,
|
||||
movedYachts,
|
||||
movedInvoices,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -373,6 +469,10 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
|
||||
tags: movedTags,
|
||||
relationships: movedRelationships,
|
||||
tenancies: movedTenancies,
|
||||
payments: movedPayments,
|
||||
companyMemberships: movedCompanyMemberships,
|
||||
yachts: movedYachts,
|
||||
invoices: movedInvoices,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user