From 2f2ad4452f0714bf79b2909a1fd2deddef29fac7 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Thu, 23 Apr 2026 23:40:56 +0200 Subject: [PATCH] feat(yachts): createYacht + getYachtById services with tests --- src/lib/services/yachts.service.ts | 98 +++++++++++++++++ src/lib/socket/events.ts | 169 +++++++++++++++++++++++++---- tests/unit/services/yachts.test.ts | 67 ++++++++++++ 3 files changed, 310 insertions(+), 24 deletions(-) create mode 100644 src/lib/services/yachts.service.ts create mode 100644 tests/unit/services/yachts.test.ts diff --git a/src/lib/services/yachts.service.ts b/src/lib/services/yachts.service.ts new file mode 100644 index 0000000..5f21206 --- /dev/null +++ b/src/lib/services/yachts.service.ts @@ -0,0 +1,98 @@ +import { and, eq } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { yachts, yachtOwnershipHistory, clients } from '@/lib/db/schema'; +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 type { z } from 'zod'; +import type { createYachtSchema } from '@/lib/validators/yachts'; + +type CreateYachtInput = z.input; + +interface AuditMeta { + userId: string; + portId: string; + ipAddress: string; + userAgent: string; +} + +async function assertOwnerExists( + portId: string, + owner: { type: 'client' | 'company'; id: string }, +): Promise { + if (owner.type === 'client') { + const client = await db.query.clients.findFirst({ + where: and(eq(clients.id, owner.id), eq(clients.portId, portId)), + }); + if (!client) throw new ValidationError('owner not found'); + } else { + const company = await db.query.companies.findFirst({ + where: and(eq(companies.id, owner.id), eq(companies.portId, portId)), + }); + if (!company) throw new ValidationError('owner not found'); + } +} + +export async function createYacht(portId: string, data: CreateYachtInput, meta: AuditMeta) { + return await db.transaction(async (tx) => { + await assertOwnerExists(portId, data.owner); + + const [yacht] = await tx + .insert(yachts) + .values({ + portId, + name: data.name, + hullNumber: data.hullNumber ?? null, + registration: data.registration ?? null, + flag: data.flag ?? null, + yearBuilt: data.yearBuilt ?? null, + builder: data.builder ?? null, + model: data.model ?? null, + hullMaterial: data.hullMaterial ?? null, + lengthFt: data.lengthFt ?? null, + widthFt: data.widthFt ?? null, + draftFt: data.draftFt ?? null, + lengthM: data.lengthM ?? null, + widthM: data.widthM ?? null, + draftM: data.draftM ?? null, + currentOwnerType: data.owner.type, + currentOwnerId: data.owner.id, + status: data.status ?? 'active', + notes: data.notes ?? null, + }) + .returning(); + + await tx.insert(yachtOwnershipHistory).values({ + yachtId: yacht!.id, + ownerType: data.owner.type, + ownerId: data.owner.id, + startDate: new Date(), + endDate: null, + createdBy: meta.userId, + }); + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'create', + entityType: 'yacht', + entityId: yacht!.id, + newValue: { name: yacht!.name, owner: data.owner }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + emitToRoom(`port:${portId}`, 'yacht:created', { yachtId: yacht!.id }); + + return yacht!; + }); +} + +export async function getYachtById(id: string, portId: string) { + const yacht = await db.query.yachts.findFirst({ + where: and(eq(yachts.id, id), eq(yachts.portId, portId)), + }); + if (!yacht) throw new NotFoundError('Yacht'); + return yacht; +} diff --git a/src/lib/socket/events.ts b/src/lib/socket/events.ts index a12f262..f6f8968 100644 --- a/src/lib/socket/events.ts +++ b/src/lib/socket/events.ts @@ -1,9 +1,19 @@ // Server → Client events export interface ServerToClientEvents { // Berth events - 'berth:statusChanged': (payload: { berthId: string; oldStatus?: string; newStatus: string; triggeredBy: string; trigger?: string }) => void; + 'berth:statusChanged': (payload: { + berthId: string; + oldStatus?: string; + newStatus: string; + triggeredBy: string; + trigger?: string; + }) => void; 'berth:updated': (payload: { berthId: string; changedFields: string[] }) => void; - 'berth:waitingListChanged': (payload: { berthId: string; action: string; entry: unknown }) => void; + 'berth:waitingListChanged': (payload: { + berthId: string; + action: string; + entry: unknown; + }) => void; 'berth:maintenanceAdded': (payload: { berthId: string; logEntry: unknown }) => void; // Client events @@ -12,58 +22,159 @@ export interface ServerToClientEvents { 'client:archived': (payload: { clientId: string }) => void; 'client:restored': (payload: { clientId: string }) => void; 'client:merged': (payload: { survivingId: string; mergedId: string }) => void; - 'client:noteAdded': (payload: { clientId: string; noteId: string; authorName: string; preview: string }) => void; - 'client:duplicateDetected': (payload: { clientAId: string; clientBId: string; score: number; reason: string }) => void; + 'client:noteAdded': (payload: { + clientId: string; + noteId: string; + authorName: string; + preview: string; + }) => void; + 'client:duplicateDetected': (payload: { + clientAId: string; + clientBId: string; + score: number; + reason: string; + }) => void; // Interest events - 'interest:created': (payload: { interestId: string; clientId: string; berthId: string | null; source: string }) => void; + 'interest:created': (payload: { + interestId: string; + clientId: string; + berthId: string | null; + source: string; + }) => void; 'interest:updated': (payload: { interestId: string; changedFields: string[] }) => void; - 'interest:stageChanged': (payload: { interestId: string; oldStage: string; newStage: string; clientName: string; berthNumber: string }) => void; + 'interest:stageChanged': (payload: { + interestId: string; + oldStage: string; + newStage: string; + clientName: string; + berthNumber: string; + }) => void; 'interest:berthLinked': (payload: { interestId: string; berthId: string }) => void; 'interest:berthUnlinked': (payload: { interestId: string; berthId: string }) => void; 'interest:archived': (payload: { interestId: string }) => void; - 'interest:noteAdded': (payload: { interestId: string; noteId: string; authorName: string; preview: string }) => void; - 'interest:recommendationsGenerated': (payload: { interestId: string; count: number; topBerthId: string }) => void; - 'interest:recommendationAdded': (payload: { interestId: string; berthId: string; source: string; matchScore: number }) => void; - 'interest:leadCategoryChanged': (payload: { interestId: string; oldCategory: string; newCategory: string; auto: boolean }) => void; + 'interest:noteAdded': (payload: { + interestId: string; + noteId: string; + authorName: string; + preview: string; + }) => void; + 'interest:recommendationsGenerated': (payload: { + interestId: string; + count: number; + topBerthId: string; + }) => void; + 'interest:recommendationAdded': (payload: { + interestId: string; + berthId: string; + source: string; + matchScore: number; + }) => void; + 'interest:leadCategoryChanged': (payload: { + interestId: string; + oldCategory: string; + newCategory: string; + auto: boolean; + }) => void; + + // Yacht events + 'yacht:created': (payload: { yachtId: string }) => void; // Document events 'document:created': (payload: { documentId: string; type?: string; interestId?: string }) => void; 'document:updated': (payload: { documentId: string; changedFields?: string[] }) => void; 'document:deleted': (payload: { documentId: string }) => void; - 'document:sent': (payload: { documentId: string; type?: string; signerCount?: number; documensoId?: string }) => void; - 'document:signed': (payload: { documentId: string; signerName: string; signerRole: string; remainingSigners: number }) => void; - 'document:signer:signed': (payload: { documentId: string; signerName?: string; signerEmail?: string; signerRole?: string; order?: number }) => void; - 'document:completed': (payload: { documentId: string; type?: string; interestId?: string; clientName?: string }) => void; + 'document:sent': (payload: { + documentId: string; + type?: string; + signerCount?: number; + documensoId?: string; + }) => void; + 'document:signed': (payload: { + documentId: string; + signerName: string; + signerRole: string; + remainingSigners: number; + }) => void; + 'document:signer:signed': (payload: { + documentId: string; + signerName?: string; + signerEmail?: string; + signerRole?: string; + order?: number; + }) => void; + 'document:completed': (payload: { + documentId: string; + type?: string; + interestId?: string; + clientName?: string; + }) => void; 'document:expired': (payload: { documentId: string }) => void; 'document:reminderSent': (payload: { documentId: string; recipientEmail: string }) => void; // Document template events - 'documentTemplate:created': (payload: { templateId: string; name?: string; type?: string }) => void; + 'documentTemplate:created': (payload: { + templateId: string; + name?: string; + type?: string; + }) => void; 'documentTemplate:updated': (payload: { templateId: string; changedFields?: string[] }) => void; 'documentTemplate:deleted': (payload: { templateId: string }) => void; // Financial events - 'expense:created': (payload: { expenseId: string; amount: number; currency: string; category: string }) => void; + 'expense:created': (payload: { + expenseId: string; + amount: number; + currency: string; + category: string; + }) => void; 'expense:updated': (payload: { expenseId: string; changedFields: string[] }) => void; 'expense:archived': (payload: { expenseId: string }) => void; - 'invoice:created': (payload: { invoiceId: string; invoiceNumber: string; total: number; clientName: string }) => void; + 'invoice:created': (payload: { + invoiceId: string; + invoiceNumber: string; + total: number; + clientName: string; + }) => void; 'invoice:updated': (payload: { invoiceId: string; changedFields: string[] }) => void; - 'invoice:sent': (payload: { invoiceId: string; invoiceNumber: string; recipientEmail: string }) => void; + 'invoice:sent': (payload: { + invoiceId: string; + invoiceNumber: string; + recipientEmail: string; + }) => void; 'invoice:paid': (payload: { invoiceId: string; invoiceNumber: string; amount: number }) => void; - 'invoice:overdue': (payload: { invoiceId: string; invoiceNumber: string; daysPastDue: number }) => void; + 'invoice:overdue': (payload: { + invoiceId: string; + invoiceNumber: string; + daysPastDue: number; + }) => void; // Reminder & Calendar events - 'reminder:created': (payload: { reminderId: string; title: string; assignedTo: string; dueAt: string }) => void; + 'reminder:created': (payload: { + reminderId: string; + title: string; + assignedTo: string; + dueAt: string; + }) => void; 'reminder:updated': (payload: { reminderId: string; changedFields: string[] }) => void; - 'reminder:completed': (payload: { reminderId: string; title: string; completedBy: string }) => void; + 'reminder:completed': (payload: { + reminderId: string; + title: string; + completedBy: string; + }) => void; 'reminder:overdue': (payload: { reminderId: string; title: string; dueAt: string }) => void; 'reminder:snoozed': (payload: { reminderId: string; snoozedUntil: string }) => void; 'calendar:synced': (payload: { eventCount: number; lastSyncAt: string }) => void; 'calendar:disconnected': (payload: { reason: string }) => void; // Notification events - 'notification:new': (payload: { notificationId: string; type: string; title: string; description: string; link: string }) => void; + 'notification:new': (payload: { + notificationId: string; + type: string; + title: string; + description: string; + link: string; + }) => void; 'notification:unreadCount': (payload: { count: number }) => void; // Report events @@ -74,10 +185,20 @@ export interface ServerToClientEvents { // System events 'system:alert': (payload: { alertType: string; message: string; severity: string }) => void; 'system:jobFailed': (payload: { queueName: string; jobId: string; error: string }) => void; - 'registration:new': (payload: { clientId: string; interestId: string; clientName: string; berthNumber: string }) => void; + 'registration:new': (payload: { + clientId: string; + interestId: string; + clientName: string; + berthNumber: string; + }) => void; // File events - 'file:uploaded': (payload: { fileId: string; filename: string; clientId?: string; category?: string }) => void; + 'file:uploaded': (payload: { + fileId: string; + filename: string; + clientId?: string; + category?: string; + }) => void; 'file:updated': (payload: { fileId: string; changedFields?: string[] }) => void; 'file:deleted': (payload: { fileId: string; filename?: string }) => void; } diff --git a/tests/unit/services/yachts.test.ts b/tests/unit/services/yachts.test.ts new file mode 100644 index 0000000..a53098b --- /dev/null +++ b/tests/unit/services/yachts.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { createYacht } from '@/lib/services/yachts.service'; +import { makeClient, makePort, makeAuditMeta } from '../../helpers/factories'; +import { db } from '@/lib/db'; +import { yachtOwnershipHistory } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; + +describe('yachts.service — createYacht', () => { + it('creates a yacht with a client owner and opens an ownership history row', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + + const yacht = await createYacht( + port.id, + { + name: 'Sea Breeze', + owner: { type: 'client', id: client.id }, + }, + makeAuditMeta(), + ); + + expect(yacht.currentOwnerType).toBe('client'); + expect(yacht.currentOwnerId).toBe(client.id); + + const history = await db + .select() + .from(yachtOwnershipHistory) + .where(eq(yachtOwnershipHistory.yachtId, yacht.id)); + expect(history).toHaveLength(1); + expect(history[0]!.endDate).toBeNull(); + }); + + it('rejects when ownerType=client but ownerId does not exist', async () => { + const port = await makePort(); + await expect( + createYacht( + port.id, + { name: 'Phantom', owner: { type: 'client', id: 'nonexistent' } }, + makeAuditMeta(), + ), + ).rejects.toThrow(/owner not found/i); + }); + + it('rejects when ownerType=company but ownerId does not exist', async () => { + const port = await makePort(); + await expect( + createYacht( + port.id, + { name: 'Phantom', owner: { type: 'company', id: 'nonexistent' } }, + makeAuditMeta(), + ), + ).rejects.toThrow(/owner not found/i); + }); + + it('rejects owner from a different tenant (cross-tenant guard)', async () => { + const portA = await makePort(); + const portB = await makePort(); + const clientInB = await makeClient({ portId: portB.id }); + await expect( + createYacht( + portA.id, + { name: 'Wrong Port', owner: { type: 'client', id: clientInB.id } }, + makeAuditMeta(), + ), + ).rejects.toThrow(/owner not found/i); + }); +});