import { and, eq, ilike, or, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { yachts, yachtOwnershipHistory, clients } from '@/lib/db/schema'; import type { Yacht } from '@/lib/db/schema/yachts'; 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 { buildListQuery } from '@/lib/db/query-builder'; import { withTransaction } from '@/lib/db/utils'; import type { z } from 'zod'; import type { createYachtSchema, UpdateYachtInput, TransferOwnershipInput, ListYachtsInput, } 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 }, tx: typeof db, ): Promise { if (owner.type === 'client') { const client = await tx.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 tx.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 withTransaction(async (tx) => { await assertOwnerExists(portId, data.owner, tx); 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; } 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 }); } 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!; }); } // ─── List ───────────────────────────────────────────────────────────────────── export async function listYachts(portId: string, query: ListYachtsInput) { const { page, limit, sort, order, search, includeArchived, ownerType, ownerId, status } = query; const filters = []; if (ownerType) filters.push(eq(yachts.currentOwnerType, ownerType)); if (ownerId) filters.push(eq(yachts.currentOwnerId, ownerId)); if (status) filters.push(eq(yachts.status, status)); let sortColumn: typeof yachts.name | typeof yachts.createdAt | typeof yachts.updatedAt = yachts.updatedAt; if (sort === 'name') sortColumn = yachts.name; else if (sort === 'createdAt') sortColumn = yachts.createdAt; const result = await buildListQuery({ table: yachts, portIdColumn: yachts.portId, portId, idColumn: yachts.id, updatedAtColumn: yachts.updatedAt, searchColumns: [yachts.name, yachts.hullNumber, yachts.registration], searchTerm: search, filters, sort: sort ? { column: sortColumn, direction: order } : undefined, page, pageSize: limit, includeArchived, archivedAtColumn: yachts.archivedAt, }); return result; } // ─── List for owner ─────────────────────────────────────────────────────────── export async function listYachtsForOwner( portId: string, ownerType: 'client' | 'company', ownerId: string, ) { return await db.query.yachts.findMany({ where: and( eq(yachts.portId, portId), eq(yachts.currentOwnerType, ownerType), eq(yachts.currentOwnerId, ownerId), ), orderBy: (t, { desc }) => [desc(t.updatedAt)], }); } // ─── Ownership history ──────────────────────────────────────────────────────── export async function listOwnershipHistory(yachtId: string, portId: string) { // First scope-check the yacht (throws NotFoundError if cross-tenant) await getYachtById(yachtId, portId); return await db.query.yachtOwnershipHistory.findMany({ where: eq(yachtOwnershipHistory.yachtId, yachtId), orderBy: (t, { desc }) => [desc(t.startDate)], }); } // ─── Autocomplete ───────────────────────────────────────────────────────────── export async function autocomplete(portId: string, q: string) { const pattern = `%${q}%`; return await db .select() .from(yachts) .where( and( eq(yachts.portId, portId), or( ilike(yachts.name, pattern), ilike(yachts.hullNumber, pattern), ilike(yachts.registration, pattern), ), ), ) .limit(10); }