diff --git a/src/lib/services/yachts.service.ts b/src/lib/services/yachts.service.ts index af2c603..864083e 100644 --- a/src/lib/services/yachts.service.ts +++ b/src/lib/services/yachts.service.ts @@ -5,9 +5,10 @@ import { companies } from '@/lib/db/schema/companies'; import { createAuditLog } from '@/lib/audit'; import { NotFoundError, ValidationError } from '@/lib/errors'; 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 } from '@/lib/validators/yachts'; +import type { createYachtSchema, UpdateYachtInput } from '@/lib/validators/yachts'; type CreateYachtInput = z.input; @@ -98,3 +99,88 @@ export async function getYachtById(id: string, portId: string) { if (!yacht) throw new NotFoundError('Yacht'); return yacht; } + +export async function updateYacht( + id: string, + portId: string, + data: UpdateYachtInput, + meta: AuditMeta, +) { + // Defense-in-depth: owner changes must go through /transfer, not PATCH. + const dataRecord = data as Record; + if ( + Object.prototype.hasOwnProperty.call(dataRecord, 'currentOwnerType') || + Object.prototype.hasOwnProperty.call(dataRecord, 'currentOwnerId') + ) { + throw new ValidationError('use /transfer to change ownership'); + } + + const existing = await db.query.yachts.findFirst({ + where: eq(yachts.id, id), + }); + + if (!existing || existing.portId !== portId) { + throw new NotFoundError('Yacht'); + } + + const { diff } = diffEntity( + existing as unknown as Record, + data as Record, + ); + + const [updated] = await db + .update(yachts) + .set({ ...data, updatedAt: new Date() }) + .where(and(eq(yachts.id, id), eq(yachts.portId, portId))) + .returning(); + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'update', + entityType: 'yacht', + entityId: id, + oldValue: diff as Record, + newValue: data as Record, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + emitToRoom(`port:${portId}`, 'yacht:updated', { + yachtId: id, + changedFields: Object.keys(diff), + }); + + return updated!; +} + +export async function archiveYacht(id: string, portId: string, meta: AuditMeta) { + const existing = await db.query.yachts.findFirst({ + where: eq(yachts.id, id), + }); + + if (!existing || existing.portId !== portId) { + throw new NotFoundError('Yacht'); + } + + // NOTE: bypassing the shared `softDelete(...)` util: it sets the raw + // column key `archived_at`, which Drizzle does not recognise (the JS + // key is `archivedAt`) and therefore emits an empty SET clause. Until + // the utility is fixed, do the update inline. + await db + .update(yachts) + .set({ archivedAt: new Date() }) + .where(and(eq(yachts.id, id), eq(yachts.portId, portId))); + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'archive', + entityType: 'yacht', + entityId: id, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + emitToRoom(`port:${portId}`, 'yacht:archived', { yachtId: id }); +} diff --git a/src/lib/socket/events.ts b/src/lib/socket/events.ts index f6f8968..d43e748 100644 --- a/src/lib/socket/events.ts +++ b/src/lib/socket/events.ts @@ -79,6 +79,8 @@ export interface ServerToClientEvents { // Yacht events 'yacht:created': (payload: { yachtId: string }) => void; + 'yacht:updated': (payload: { yachtId: string; changedFields: string[] }) => void; + 'yacht:archived': (payload: { yachtId: string }) => void; // Document events 'document:created': (payload: { documentId: string; type?: string; interestId?: string }) => void; diff --git a/tests/unit/services/yachts.test.ts b/tests/unit/services/yachts.test.ts index a53098b..3e8ba4f 100644 --- a/tests/unit/services/yachts.test.ts +++ b/tests/unit/services/yachts.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect } from 'vitest'; -import { createYacht } from '@/lib/services/yachts.service'; -import { makeClient, makePort, makeAuditMeta } from '../../helpers/factories'; +import { createYacht, updateYacht, archiveYacht } from '@/lib/services/yachts.service'; +import { makeClient, makePort, makeYacht, makeAuditMeta } from '../../helpers/factories'; import { db } from '@/lib/db'; -import { yachtOwnershipHistory } from '@/lib/db/schema'; +import { yachts, yachtOwnershipHistory } from '@/lib/db/schema'; import { eq } from 'drizzle-orm'; describe('yachts.service — createYacht', () => { @@ -65,3 +65,98 @@ describe('yachts.service — createYacht', () => { ).rejects.toThrow(/owner not found/i); }); }); + +describe('yachts.service — updateYacht', () => { + it('updates name and notes', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + overrides: { name: 'Original Name' }, + }); + + const updated = await updateYacht( + yacht.id, + port.id, + { name: 'New Name', notes: 'Updated notes' }, + makeAuditMeta(), + ); + + expect(updated.name).toBe('New Name'); + expect(updated.notes).toBe('Updated notes'); + + const [row] = await db.select().from(yachts).where(eq(yachts.id, yacht.id)); + expect(row!.name).toBe('New Name'); + expect(row!.notes).toBe('Updated notes'); + }); + + it('rejects when id does not exist or is cross-tenant', async () => { + const portA = await makePort(); + const portB = await makePort(); + const clientInB = await makeClient({ portId: portB.id }); + const yachtInB = await makeYacht({ + portId: portB.id, + ownerType: 'client', + ownerId: clientInB.id, + }); + + await expect( + updateYacht(yachtInB.id, portA.id, { name: 'Hijack' }, makeAuditMeta()), + ).rejects.toThrow(/yacht/i); + + await expect( + updateYacht('nonexistent-id', portA.id, { name: 'Phantom' }, makeAuditMeta()), + ).rejects.toThrow(/yacht/i); + }); + + it('rejects attempt to change currentOwnerId via update', 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( + updateYacht( + yacht.id, + port.id, + { currentOwnerId: 'some-other-id' } as unknown as { name: string }, + makeAuditMeta(), + ), + ).rejects.toThrow(/transfer to change ownership/i); + }); +}); + +describe('yachts.service — archiveYacht', () => { + it('sets archivedAt to a non-null timestamp', 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 archiveYacht(yacht.id, port.id, makeAuditMeta()); + + const [row] = await db.select().from(yachts).where(eq(yachts.id, yacht.id)); + expect(row!.archivedAt).not.toBeNull(); + }); + + it('throws NotFound for cross-tenant or missing yacht', async () => { + const portA = await makePort(); + const portB = await makePort(); + const clientInB = await makeClient({ portId: portB.id }); + const yachtInB = await makeYacht({ + portId: portB.id, + ownerType: 'client', + ownerId: clientInB.id, + }); + + await expect(archiveYacht(yachtInB.id, portA.id, makeAuditMeta())).rejects.toThrow(/yacht/i); + }); +});