import { and, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { yachts, yachtOwnershipHistory, yachtTags, clients } from '@/lib/db/schema'; import type { Yacht } from '@/lib/db/schema/yachts'; import { companies } from '@/lib/db/schema/companies'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { NotFoundError, ValidationError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; import { setEntityTags } from '@/lib/services/entity-tags.helper'; 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; 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)), with: { tags: { with: { tag: true } }, }, }); if (!yacht) throw new NotFoundError('Yacht'); const { tags: tagJoins, ...rest } = yacht as typeof yacht & { tags: Array<{ tag: { id: string; name: string; color: string } }>; }; return { ...rest, tags: tagJoins.map((t) => t.tag), }; } 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, }); if (result.data.length === 0) return result; // Resolve current owner names in two parallel batched queries instead of // an N+1 fetch from the client (was 1 round-trip per row from yacht-columns). const clientIds = result.data .filter((y) => y.currentOwnerType === 'client') .map((y) => y.currentOwnerId); const companyIds = result.data .filter((y) => y.currentOwnerType === 'company') .map((y) => y.currentOwnerId); const [clientRows, companyRows] = await Promise.all([ clientIds.length > 0 ? db .select({ id: clients.id, fullName: clients.fullName }) .from(clients) .where(inArray(clients.id, clientIds)) : Promise.resolve([] as { id: string; fullName: string }[]), companyIds.length > 0 ? db .select({ id: companies.id, name: companies.name }) .from(companies) .where(inArray(companies.id, companyIds)) : Promise.resolve([] as { id: string; name: string }[]), ]); const clientNames = new Map(clientRows.map((r) => [r.id, r.fullName])); const companyNames = new Map(companyRows.map((r) => [r.id, r.name])); return { ...result, data: result.data.map((y) => ({ ...y, currentOwnerName: y.currentOwnerType === 'client' ? (clientNames.get(y.currentOwnerId) ?? null) : (companyNames.get(y.currentOwnerId) ?? null), })), }; } // ─── List for owner ─────────────────────────────────────────────────────────── export async function listYachtsForOwner( portId: string, ownerType: 'client' | 'company', ownerId: string, ) { // Owner-detail tabs only surface active yachts. Archived ones live in the // ownership history view and are reachable by id, not via this lister. return await db.query.yachts.findMany({ where: and( eq(yachts.portId, portId), eq(yachts.currentOwnerType, ownerType), eq(yachts.currentOwnerId, ownerId), isNull(yachts.archivedAt), ), 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); } export async function setYachtTags( yachtId: string, portId: string, tagIds: string[], meta: AuditMeta, ) { const yacht = await db.query.yachts.findFirst({ where: eq(yachts.id, yachtId) }); if (!yacht || yacht.portId !== portId) throw new NotFoundError('Yacht'); await setEntityTags({ joinTable: yachtTags, entityColumn: yachtTags.yachtId, tagColumn: yachtTags.tagId, entityId: yachtId, portId, tagIds, meta, entityType: 'yacht', }); }