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:
@@ -219,6 +219,72 @@ export async function archiveYacht(id: string, portId: string, meta: AuditMeta)
|
||||
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(
|
||||
yachtId: string,
|
||||
portId: string,
|
||||
@@ -271,40 +337,18 @@ export async function transferOwnership(
|
||||
resolveOwnerName(data.newOwner.type, data.newOwner.id),
|
||||
]);
|
||||
|
||||
// Close the currently-active history row
|
||||
await tx
|
||||
.update(yachtOwnershipHistory)
|
||||
.set({ endDate: data.effectiveDate })
|
||||
.where(
|
||||
and(
|
||||
eq(yachtOwnershipHistory.yachtId, yachtId),
|
||||
sql`${yachtOwnershipHistory.endDate} IS NULL`,
|
||||
),
|
||||
);
|
||||
|
||||
// Open new row
|
||||
await tx.insert(yachtOwnershipHistory).values({
|
||||
// Close the open history row, open the new one, and sync the
|
||||
// denormalized columns — all via the shared tx-aware helper so the
|
||||
// ledger invariant holds for every transfer pathway.
|
||||
const updated = await transferOwnershipTx(tx, {
|
||||
yachtId,
|
||||
ownerType: data.newOwner.type,
|
||||
ownerId: data.newOwner.id,
|
||||
startDate: data.effectiveDate,
|
||||
endDate: null,
|
||||
newOwner: data.newOwner,
|
||||
effectiveDate: data.effectiveDate,
|
||||
transferReason: data.transferReason ?? null,
|
||||
transferNotes: data.transferNotes ?? null,
|
||||
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
|
||||
// formatter: a discrete `transfer` action + human-readable owner
|
||||
// names render as "Matt transferred owner from X to Y" instead of
|
||||
|
||||
Reference in New Issue
Block a user