diff --git a/src/lib/services/yachts.service.ts b/src/lib/services/yachts.service.ts index 864083e..7652dcd 100644 --- a/src/lib/services/yachts.service.ts +++ b/src/lib/services/yachts.service.ts @@ -1,4 +1,4 @@ -import { and, eq } from 'drizzle-orm'; +import { and, eq, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { yachts, yachtOwnershipHistory, clients } from '@/lib/db/schema'; import { companies } from '@/lib/db/schema/companies'; @@ -8,7 +8,11 @@ import { emitToRoom } from '@/lib/socket/server'; import { diffEntity } from '@/lib/entity-diff'; import { withTransaction } from '@/lib/db/utils'; import type { z } from 'zod'; -import type { createYachtSchema, UpdateYachtInput } from '@/lib/validators/yachts'; +import type { + createYachtSchema, + UpdateYachtInput, + TransferOwnershipInput, +} from '@/lib/validators/yachts'; type CreateYachtInput = z.input; @@ -184,3 +188,78 @@ export async function archiveYacht(id: string, portId: string, meta: AuditMeta) emitToRoom(`port:${portId}`, 'yacht:archived', { yachtId: id }); } + +export async function transferOwnership( + yachtId: string, + portId: string, + data: TransferOwnershipInput, + meta: AuditMeta, +) { + return await withTransaction(async (tx) => { + const yacht = await tx.query.yachts.findFirst({ + where: and(eq(yachts.id, yachtId), eq(yachts.portId, portId)), + }); + if (!yacht) throw new NotFoundError('Yacht'); + + if ( + yacht.currentOwnerType === data.newOwner.type && + yacht.currentOwnerId === data.newOwner.id + ) { + throw new ValidationError('same owner — nothing to transfer'); + } + + await assertOwnerExists(portId, data.newOwner, tx); + + // 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({ + yachtId, + ownerType: data.newOwner.type, + ownerId: data.newOwner.id, + startDate: data.effectiveDate, + endDate: null, + 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(); + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'update', + entityType: 'yacht', + entityId: yachtId, + newValue: { ownerTransferTo: data.newOwner, reason: data.transferReason }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + emitToRoom(`port:${portId}`, 'yacht:ownership_transferred', { + yachtId, + newOwner: data.newOwner, + }); + + return updated!; + }); +} diff --git a/src/lib/socket/events.ts b/src/lib/socket/events.ts index d43e748..6f6068f 100644 --- a/src/lib/socket/events.ts +++ b/src/lib/socket/events.ts @@ -81,6 +81,10 @@ export interface ServerToClientEvents { 'yacht:created': (payload: { yachtId: string }) => void; 'yacht:updated': (payload: { yachtId: string; changedFields: string[] }) => void; 'yacht:archived': (payload: { yachtId: string }) => void; + 'yacht:ownership_transferred': (payload: { + yachtId: string; + newOwner: { type: 'client' | 'company'; id: string }; + }) => void; // Document events 'document:created': (payload: { documentId: string; type?: string; interestId?: string }) => void; diff --git a/tests/integration/ownership-transfer.test.ts b/tests/integration/ownership-transfer.test.ts new file mode 100644 index 0000000..1684878 --- /dev/null +++ b/tests/integration/ownership-transfer.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { transferOwnership } from '@/lib/services/yachts.service'; +import { makeClient, makePort, makeYacht, makeAuditMeta } from '../helpers/factories'; +import { db } from '@/lib/db'; +import { yachtOwnershipHistory, yachts } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; + +describe('transferOwnership', () => { + it('closes prior history row and opens a new one atomically', async () => { + const port = await makePort(); + const clientA = await makeClient({ portId: port.id }); + const clientB = await makeClient({ portId: port.id }); + const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: clientA.id }); + + await transferOwnership( + yacht.id, + port.id, + { + newOwner: { type: 'client', id: clientB.id }, + effectiveDate: new Date(), + transferReason: 'sale', + }, + makeAuditMeta(), + ); + + const history = await db + .select() + .from(yachtOwnershipHistory) + .where(eq(yachtOwnershipHistory.yachtId, yacht.id)); + expect(history).toHaveLength(2); + const [prior, current] = history.sort((a, b) => a.startDate.getTime() - b.startDate.getTime()); + expect(prior!.endDate).not.toBeNull(); + expect(current!.endDate).toBeNull(); + expect(current!.ownerId).toBe(clientB.id); + + const updatedYacht = await db.query.yachts.findFirst({ where: eq(yachts.id, yacht.id) }); + expect(updatedYacht!.currentOwnerId).toBe(clientB.id); + }); + + it('rejects when newOwner = currentOwner (no-op)', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id }); + + await expect( + transferOwnership( + yacht.id, + port.id, + { newOwner: { type: 'client', id: client.id }, effectiveDate: new Date() }, + makeAuditMeta(), + ), + ).rejects.toThrow(/same owner/i); + }); + + it('partial unique index prevents concurrent double-open ownership rows', async () => { + // Prior to the atomic close-then-open, a naive impl would insert a new open row + // without closing the old one. Verify this would be blocked at the DB level. + const port = await makePort(); + const clientA = await makeClient({ portId: port.id }); + const clientB = await makeClient({ portId: port.id }); + const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: clientA.id }); + + await expect( + db.insert(yachtOwnershipHistory).values({ + yachtId: yacht.id, + ownerType: 'client', + ownerId: clientB.id, + startDate: new Date(), + endDate: null, + createdBy: 'test', + }), + ).rejects.toThrow(/duplicate key/i); + }); +});