fix(audit): H5 — keep yacht ownership-history ledger consistent on archive/restore
Extracts transferOwnershipTx (close open yacht_ownership_history row + open a new one + update denormalized owner) from transferOwnership, and uses it in client-archive + client-restore instead of writing only the denormalized columns — which left the ledger showing the old owner as current and let the next real transfer close the wrong row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ import {
|
|||||||
type ClientArchiveDossier,
|
type ClientArchiveDossier,
|
||||||
} from '@/lib/services/client-archive-dossier.service';
|
} from '@/lib/services/client-archive-dossier.service';
|
||||||
import { activeInterestsWhere } from '@/lib/services/active-interest';
|
import { activeInterestsWhere } from '@/lib/services/active-interest';
|
||||||
|
import { transferOwnershipTx } from '@/lib/services/yachts.service';
|
||||||
|
|
||||||
// ─── Decision payload (what the UI sends to the server) ─────────────────────
|
// ─── Decision payload (what the UI sends to the server) ─────────────────────
|
||||||
|
|
||||||
@@ -246,10 +247,17 @@ export async function archiveClientWithDecisions(args: {
|
|||||||
`Yacht ${d.yachtId}: transfer requires newOwnerType + newOwnerId`,
|
`Yacht ${d.yachtId}: transfer requires newOwnerType + newOwnerId`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await tx
|
// Move ownership through the shared ledger-aware helper so the
|
||||||
.update(yachts)
|
// yacht_ownership_history row is closed + reopened in lock-step
|
||||||
.set({ currentOwnerType: d.newOwnerType, currentOwnerId: d.newOwnerId })
|
// with the denormalized columns. Writing only the denorm columns
|
||||||
.where(eq(yachts.id, d.yachtId));
|
// (the prior bug) left the history's open row pointing at the
|
||||||
|
// archived client, corrupting the ledger on the next real transfer.
|
||||||
|
await transferOwnershipTx(tx as unknown as typeof db, {
|
||||||
|
yachtId: d.yachtId,
|
||||||
|
newOwner: { type: d.newOwnerType, id: d.newOwnerId },
|
||||||
|
transferReason: `Smart-archive: ${decisions.reason || 'client archived'}`,
|
||||||
|
createdBy: meta.userId,
|
||||||
|
});
|
||||||
persistedDecisions.push({
|
persistedDecisions.push({
|
||||||
kind: 'yacht_transferred',
|
kind: 'yacht_transferred',
|
||||||
refId: d.yachtId,
|
refId: d.yachtId,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { portalUsers } from '@/lib/db/schema/portal';
|
|||||||
import { documents } from '@/lib/db/schema/documents';
|
import { documents } from '@/lib/db/schema/documents';
|
||||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||||
import { activeInterestsWhere } from '@/lib/services/active-interest';
|
import { activeInterestsWhere } from '@/lib/services/active-interest';
|
||||||
|
import { transferOwnershipTx } from '@/lib/services/yachts.service';
|
||||||
import { ConflictError, NotFoundError } from '@/lib/errors';
|
import { ConflictError, NotFoundError } from '@/lib/errors';
|
||||||
import type { ArchiveMetadata } from '@/lib/services/client-archive.service';
|
import type { ArchiveMetadata } from '@/lib/services/client-archive.service';
|
||||||
|
|
||||||
@@ -310,14 +311,14 @@ export async function restoreClientWithSelections(args: {
|
|||||||
|
|
||||||
// Apply auto-reversals.
|
// Apply auto-reversals.
|
||||||
for (const r of dossier.autoReversible) {
|
for (const r of dossier.autoReversible) {
|
||||||
await applyReversal(tx, r, args.clientId);
|
await applyReversal(tx, r, args.clientId, args.meta.userId);
|
||||||
autoReversed += 1;
|
autoReversed += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply opted-in prompts.
|
// Apply opted-in prompts.
|
||||||
for (const r of dossier.reversibleWithPrompt) {
|
for (const r of dossier.reversibleWithPrompt) {
|
||||||
if (!opted.has(r.id)) continue;
|
if (!opted.has(r.id)) continue;
|
||||||
await applyReversal(tx, r, args.clientId);
|
await applyReversal(tx, r, args.clientId, args.meta.userId);
|
||||||
promptedReversed += 1;
|
promptedReversed += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +358,12 @@ export async function restoreClientWithSelections(args: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyReversal(tx: Tx, r: RestoreReversal, clientId: string): Promise<void> {
|
async function applyReversal(
|
||||||
|
tx: Tx,
|
||||||
|
r: RestoreReversal,
|
||||||
|
clientId: string,
|
||||||
|
actorUserId: string,
|
||||||
|
): Promise<void> {
|
||||||
switch (r.kind) {
|
switch (r.kind) {
|
||||||
case 'berth_released': {
|
case 'berth_released': {
|
||||||
// Re-link the berth to whichever interest originally owned it
|
// Re-link the berth to whichever interest originally owned it
|
||||||
@@ -397,11 +403,18 @@ async function applyReversal(tx: Tx, r: RestoreReversal, clientId: string): Prom
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'yacht_transferred': {
|
case 'yacht_transferred': {
|
||||||
// Transfer back to the restored client.
|
// Transfer back to the restored client through the shared
|
||||||
await tx
|
// ledger-aware helper: closes the open yacht_ownership_history row
|
||||||
.update(yachts)
|
// (whoever the archive transfer pointed it at) and opens a fresh one
|
||||||
.set({ currentOwnerType: 'client', currentOwnerId: clientId })
|
// for the restored client, keeping the ledger and denorm columns in
|
||||||
.where(eq(yachts.id, r.refId));
|
// sync. Writing only the denorm columns (the prior bug) re-corrupted
|
||||||
|
// the history on every restore.
|
||||||
|
await transferOwnershipTx(tx as unknown as typeof db, {
|
||||||
|
yachtId: r.refId,
|
||||||
|
newOwner: { type: 'client', id: clientId },
|
||||||
|
transferReason: 'Smart-restore: ownership returned to restored client',
|
||||||
|
createdBy: actorUserId,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'yacht_marked_sold_away':
|
case 'yacht_marked_sold_away':
|
||||||
|
|||||||
@@ -219,6 +219,72 @@ export async function archiveYacht(id: string, portId: string, meta: AuditMeta)
|
|||||||
emitToRoom(`port:${portId}`, 'yacht:archived', { yachtId: id });
|
emitToRoom(`port:${portId}`, 'yacht:archived', { yachtId: id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaction-aware ownership transfer. Performs the FULL ledger move —
|
||||||
|
* closes the open `yacht_ownership_history` row, opens a new one for the
|
||||||
|
* new owner, and updates the yacht's denormalized current-owner columns —
|
||||||
|
* so the history ledger and the denormalized columns never drift apart.
|
||||||
|
*
|
||||||
|
* Use this from any flow that moves a yacht to a new owner inside a
|
||||||
|
* transaction (smart-archive / restore included). The public
|
||||||
|
* `transferOwnership` wraps this in its own tx + audit/socket emissions.
|
||||||
|
*
|
||||||
|
* NOTE: callers are responsible for any same-owner / owner-exists
|
||||||
|
* validation they need; this helper intentionally does only the ledger
|
||||||
|
* write so it stays composable. `effectiveDate` defaults to now() when
|
||||||
|
* omitted (archive/restore have no operator-chosen date).
|
||||||
|
*/
|
||||||
|
export async function transferOwnershipTx(
|
||||||
|
tx: typeof db,
|
||||||
|
args: {
|
||||||
|
yachtId: string;
|
||||||
|
newOwner: { type: 'client' | 'company'; id: string };
|
||||||
|
effectiveDate?: Date;
|
||||||
|
transferReason?: string | null;
|
||||||
|
transferNotes?: string | null;
|
||||||
|
createdBy: string;
|
||||||
|
},
|
||||||
|
): Promise<Yacht> {
|
||||||
|
const effectiveDate = args.effectiveDate ?? new Date();
|
||||||
|
|
||||||
|
// Close the currently-active history row (endDate IS NULL → guarded by
|
||||||
|
// the idx_yoh_active partial unique index, so there's at most one).
|
||||||
|
await tx
|
||||||
|
.update(yachtOwnershipHistory)
|
||||||
|
.set({ endDate: effectiveDate })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(yachtOwnershipHistory.yachtId, args.yachtId),
|
||||||
|
sql`${yachtOwnershipHistory.endDate} IS NULL`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open the new active row for the incoming owner.
|
||||||
|
await tx.insert(yachtOwnershipHistory).values({
|
||||||
|
yachtId: args.yachtId,
|
||||||
|
ownerType: args.newOwner.type,
|
||||||
|
ownerId: args.newOwner.id,
|
||||||
|
startDate: effectiveDate,
|
||||||
|
endDate: null,
|
||||||
|
transferReason: args.transferReason ?? null,
|
||||||
|
transferNotes: args.transferNotes ?? null,
|
||||||
|
createdBy: args.createdBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update denormalized current-owner columns to match the ledger head.
|
||||||
|
const [updated] = await tx
|
||||||
|
.update(yachts)
|
||||||
|
.set({
|
||||||
|
currentOwnerType: args.newOwner.type,
|
||||||
|
currentOwnerId: args.newOwner.id,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(yachts.id, args.yachtId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
export async function transferOwnership(
|
export async function transferOwnership(
|
||||||
yachtId: string,
|
yachtId: string,
|
||||||
portId: string,
|
portId: string,
|
||||||
@@ -271,40 +337,18 @@ export async function transferOwnership(
|
|||||||
resolveOwnerName(data.newOwner.type, data.newOwner.id),
|
resolveOwnerName(data.newOwner.type, data.newOwner.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Close the currently-active history row
|
// Close the open history row, open the new one, and sync the
|
||||||
await tx
|
// denormalized columns — all via the shared tx-aware helper so the
|
||||||
.update(yachtOwnershipHistory)
|
// ledger invariant holds for every transfer pathway.
|
||||||
.set({ endDate: data.effectiveDate })
|
const updated = await transferOwnershipTx(tx, {
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(yachtOwnershipHistory.yachtId, yachtId),
|
|
||||||
sql`${yachtOwnershipHistory.endDate} IS NULL`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Open new row
|
|
||||||
await tx.insert(yachtOwnershipHistory).values({
|
|
||||||
yachtId,
|
yachtId,
|
||||||
ownerType: data.newOwner.type,
|
newOwner: data.newOwner,
|
||||||
ownerId: data.newOwner.id,
|
effectiveDate: data.effectiveDate,
|
||||||
startDate: data.effectiveDate,
|
|
||||||
endDate: null,
|
|
||||||
transferReason: data.transferReason ?? null,
|
transferReason: data.transferReason ?? null,
|
||||||
transferNotes: data.transferNotes ?? null,
|
transferNotes: data.transferNotes ?? null,
|
||||||
createdBy: meta.userId,
|
createdBy: meta.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update denormalized current-owner columns
|
|
||||||
const [updated] = await tx
|
|
||||||
.update(yachts)
|
|
||||||
.set({
|
|
||||||
currentOwnerType: data.newOwner.type,
|
|
||||||
currentOwnerId: data.newOwner.id,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(yachts.id, yachtId))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
// Audit log shape designed for the EntityActivityFeed sentence
|
// Audit log shape designed for the EntityActivityFeed sentence
|
||||||
// formatter: a discrete `transfer` action + human-readable owner
|
// formatter: a discrete `transfer` action + human-readable owner
|
||||||
// names render as "Matt transferred owner from X to Y" instead of
|
// names render as "Matt transferred owner from X to Y" instead of
|
||||||
|
|||||||
Reference in New Issue
Block a user