import { and, count, eq, ilike, inArray, isNull } from 'drizzle-orm'; import { db } from '@/lib/db'; import { clients, clientContacts, clientRelationships, clientTags, clientAddresses, } from '@/lib/db/schema/clients'; import { companies, companyMemberships } from '@/lib/db/schema/companies'; import { yachts } from '@/lib/db/schema/yachts'; import { berthReservations } from '@/lib/db/schema/reservations'; import { tags } from '@/lib/db/schema/system'; import { createAuditLog } from '@/lib/audit'; import { NotFoundError } from '@/lib/errors'; import { isPortalEnabledForPort } from '@/lib/services/portal-auth.service'; import { emitToRoom } from '@/lib/socket/server'; import { buildListQuery } from '@/lib/db/query-builder'; import { diffEntity } from '@/lib/entity-diff'; import { softDelete, restore, withTransaction } from '@/lib/db/utils'; import type { CreateClientInput, UpdateClientInput, ListClientsInput, } from '@/lib/validators/clients'; // ─── Types ──────────────────────────────────────────────────────────────────── interface AuditMeta { userId: string; portId: string; ipAddress: string; userAgent: string; } // ─── List ───────────────────────────────────────────────────────────────────── export async function listClients(portId: string, query: ListClientsInput) { const { page, limit, sort, order, search, includeArchived, source, nationality, tagIds } = query; const filters = []; if (source) { filters.push(eq(clients.source, source)); } if (nationality) { // Filter accepts an ISO-3166-1 alpha-2 code; legacy free-text matching is // gone after the i18n column drop. filters.push(eq(clients.nationalityIso, nationality.toUpperCase())); } if (tagIds && tagIds.length > 0) { const clientsWithTags = await db .selectDistinct({ clientId: clientTags.clientId }) .from(clientTags) .where(inArray(clientTags.tagId, tagIds)); const matchingIds = clientsWithTags.map((r) => r.clientId); if (matchingIds.length > 0) { filters.push(inArray(clients.id, matchingIds)); } else { // No clients match these tags — return empty return { data: [], total: 0 }; } } let sortColumn: typeof clients.fullName | typeof clients.createdAt | typeof clients.updatedAt = clients.updatedAt; if (sort === 'fullName') sortColumn = clients.fullName; else if (sort === 'createdAt') sortColumn = clients.createdAt; const result = await buildListQuery({ table: clients, portIdColumn: clients.portId, portId, idColumn: clients.id, updatedAtColumn: clients.updatedAt, searchColumns: [clients.fullName], searchTerm: search, filters, sort: sort ? { column: sortColumn, direction: order } : undefined, page, pageSize: limit, includeArchived, archivedAtColumn: clients.archivedAt, }); if (result.data.length === 0) return result; const ids = result.data.map((r) => r.id); const [yachtCounts, companyCounts] = await Promise.all([ db .select({ ownerId: yachts.currentOwnerId, count: count() }) .from(yachts) .where( and( eq(yachts.portId, portId), eq(yachts.currentOwnerType, 'client'), inArray(yachts.currentOwnerId, ids), isNull(yachts.archivedAt), ), ) .groupBy(yachts.currentOwnerId), db .select({ clientId: companyMemberships.clientId, count: count() }) .from(companyMemberships) .where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate))) .groupBy(companyMemberships.clientId), ]); const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count])); const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count])); return { ...result, data: result.data.map((row) => ({ ...row, yachtCount: yachtCountMap.get(row.id) ?? 0, companyCount: companyCountMap.get(row.id) ?? 0, })), }; } // ─── Get by ID ──────────────────────────────────────────────────────────────── export async function getClientById(id: string, portId: string) { const client = await db.query.clients.findFirst({ where: eq(clients.id, id), }); if (!client || client.portId !== portId) { throw new NotFoundError('Client'); } const contacts = await db.query.clientContacts.findMany({ where: eq(clientContacts.clientId, id), orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)], }); const addresses = await db.query.clientAddresses.findMany({ where: eq(clientAddresses.clientId, id), orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)], }); const clientTagRows = await db .select({ tag: tags }) .from(clientTags) .innerJoin(tags, eq(clientTags.tagId, tags.id)) .where(eq(clientTags.clientId, id)); const yachtRows = await db.query.yachts.findMany({ where: and( eq(yachts.portId, portId), eq(yachts.currentOwnerType, 'client'), eq(yachts.currentOwnerId, id), isNull(yachts.archivedAt), ), columns: { id: true, name: true, hullNumber: true, registration: true, lengthFt: true, widthFt: true, status: true, }, }); const membershipRows = await db .select({ membershipId: companyMemberships.id, role: companyMemberships.role, isPrimary: companyMemberships.isPrimary, startDate: companyMemberships.startDate, company: { id: companies.id, name: companies.name, legalName: companies.legalName, status: companies.status, }, }) .from(companyMemberships) .innerJoin(companies, eq(companyMemberships.companyId, companies.id)) .where( and( eq(companyMemberships.clientId, id), eq(companies.portId, portId), isNull(companyMemberships.endDate), ), ); const activeReservations = await db.query.berthReservations.findMany({ where: and( eq(berthReservations.clientId, id), eq(berthReservations.portId, portId), eq(berthReservations.status, 'active'), ), columns: { id: true, berthId: true, yachtId: true, startDate: true, tenureType: true, status: true, }, }); const portalEnabled = await isPortalEnabledForPort(portId); return { ...client, contacts, addresses, tags: clientTagRows.map((r) => r.tag), yachts: yachtRows, companies: membershipRows, activeReservations, clientPortalEnabled: portalEnabled, }; } // ─── Create ─────────────────────────────────────────────────────────────────── export async function createClient(portId: string, data: CreateClientInput, meta: AuditMeta) { const result = await withTransaction(async (tx) => { const { contacts: contactsInput, tagIds, ...clientData } = data; const [client] = await tx .insert(clients) .values({ portId, ...clientData }) .returning(); if (contactsInput.length > 0) { await tx .insert(clientContacts) .values(contactsInput.map((c) => ({ clientId: client!.id, ...c }))); } if (tagIds && tagIds.length > 0) { await tx.insert(clientTags).values(tagIds.map((tagId) => ({ clientId: client!.id, tagId }))); } return client!; }); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'client', entityId: result.id, newValue: { fullName: result.fullName }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'client:created', { clientId: result.id, clientName: result.fullName ?? '', source: result.source ?? '', }); void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) => dispatchWebhookEvent(portId, 'client:created', { clientId: result.id }), ); return result; } // ─── Update ─────────────────────────────────────────────────────────────────── export async function updateClient( id: string, portId: string, data: UpdateClientInput, meta: AuditMeta, ) { const existing = await db.query.clients.findFirst({ where: eq(clients.id, id), }); if (!existing || existing.portId !== portId) { throw new NotFoundError('Client'); } const { diff } = diffEntity(existing as Record, data as Record); const [updated] = await db .update(clients) .set({ ...data, updatedAt: new Date() }) .where(and(eq(clients.id, id), eq(clients.portId, portId))) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'client', entityId: id, oldValue: diff as Record, newValue: data as Record, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'client:updated', { clientId: id, changedFields: Object.keys(diff), }); void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) => dispatchWebhookEvent(portId, 'client:updated', { clientId: id }), ); return updated; } // ─── Archive / Restore ──────────────────────────────────────────────────────── export async function archiveClient(id: string, portId: string, meta: AuditMeta) { const existing = await db.query.clients.findFirst({ where: eq(clients.id, id), }); if (!existing || existing.portId !== portId) { throw new NotFoundError('Client'); } await softDelete(clients, clients.id, id); void createAuditLog({ userId: meta.userId, portId, action: 'archive', entityType: 'client', entityId: id, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'client:archived', { clientId: id }); void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) => dispatchWebhookEvent(portId, 'client:archived', { clientId: id }), ); } export async function restoreClient(id: string, portId: string, meta: AuditMeta) { const existing = await db.query.clients.findFirst({ where: eq(clients.id, id), }); if (!existing || existing.portId !== portId) { throw new NotFoundError('Client'); } await restore(clients, clients.id, id); void createAuditLog({ userId: meta.userId, portId, action: 'restore', entityType: 'client', entityId: id, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'client:restored', { clientId: id }); } // ─── Contacts ───────────────────────────────────────────────────────────────── export async function listContacts(clientId: string, portId: string) { const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId), }); if (!client || client.portId !== portId) throw new NotFoundError('Client'); return db.query.clientContacts.findMany({ where: eq(clientContacts.clientId, clientId), orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)], }); } export async function addContact( clientId: string, portId: string, data: { channel: string; value: string; valueE164?: string | null; valueCountry?: string | null; label?: string; isPrimary?: boolean; notes?: string; }, meta: AuditMeta, ) { const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId), }); if (!client || client.portId !== portId) throw new NotFoundError('Client'); const [contact] = await db .insert(clientContacts) .values({ clientId, ...data }) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'clientContact', entityId: contact!.id, newValue: { clientId, channel: contact!.channel }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] }); return contact!; } export async function updateContact( contactId: string, clientId: string, portId: string, data: Partial<{ channel: string; value: string; valueE164: string | null; valueCountry: string | null; label: string; isPrimary: boolean; notes: string; }>, _meta: AuditMeta, ) { const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId), }); if (!client || client.portId !== portId) throw new NotFoundError('Client'); const contact = await db.query.clientContacts.findFirst({ where: and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)), }); if (!contact) throw new NotFoundError('Contact'); const [updated] = await db .update(clientContacts) .set({ ...data, updatedAt: new Date() }) .where(eq(clientContacts.id, contactId)) .returning(); emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] }); return updated; } export async function removeContact( contactId: string, clientId: string, portId: string, _meta: AuditMeta, ) { const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId), }); if (!client || client.portId !== portId) throw new NotFoundError('Client'); const contact = await db.query.clientContacts.findFirst({ where: and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)), }); if (!contact) throw new NotFoundError('Contact'); await db.delete(clientContacts).where(eq(clientContacts.id, contactId)); emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] }); } // ─── Addresses ──────────────────────────────────────────────────────────────── interface AddressInput { label?: string; streetAddress?: string | null; city?: string | null; subdivisionIso?: string | null; postalCode?: string | null; countryIso?: string | null; isPrimary?: boolean; } export async function listClientAddresses(clientId: string, portId: string) { const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId), }); if (!client || client.portId !== portId) throw new NotFoundError('Client'); return db.query.clientAddresses.findMany({ where: eq(clientAddresses.clientId, clientId), orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)], }); } export async function addClientAddress( clientId: string, portId: string, data: AddressInput, meta: AuditMeta, ) { const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId), }); if (!client || client.portId !== portId) throw new NotFoundError('Client'); // The unique partial index requires us to demote any existing primary // before inserting a new one, in a single transaction. const address = await withTransaction(async (tx) => { const wantsPrimary = data.isPrimary ?? false; if (wantsPrimary) { await tx .update(clientAddresses) .set({ isPrimary: false }) .where(and(eq(clientAddresses.clientId, clientId), eq(clientAddresses.isPrimary, true))); } const [row] = await tx .insert(clientAddresses) .values({ clientId, portId, label: data.label ?? 'Primary', streetAddress: data.streetAddress ?? null, city: data.city ?? null, subdivisionIso: data.subdivisionIso ?? null, postalCode: data.postalCode ?? null, countryIso: data.countryIso ?? null, isPrimary: wantsPrimary, }) .returning(); return row!; }); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'clientAddress', entityId: address.id, newValue: { clientId, label: address.label, countryIso: address.countryIso }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['addresses'] }); return address; } export async function updateClientAddress( addressId: string, clientId: string, portId: string, data: AddressInput, _meta: AuditMeta, ) { const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId), }); if (!client || client.portId !== portId) throw new NotFoundError('Client'); const existing = await db.query.clientAddresses.findFirst({ where: and(eq(clientAddresses.id, addressId), eq(clientAddresses.clientId, clientId)), }); if (!existing) throw new NotFoundError('Address'); const updated = await withTransaction(async (tx) => { if (data.isPrimary === true && !existing.isPrimary) { await tx .update(clientAddresses) .set({ isPrimary: false }) .where(and(eq(clientAddresses.clientId, clientId), eq(clientAddresses.isPrimary, true))); } const [row] = await tx .update(clientAddresses) .set({ ...data, updatedAt: new Date() }) .where(eq(clientAddresses.id, addressId)) .returning(); return row!; }); emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['addresses'] }); return updated; } export async function removeClientAddress( addressId: string, clientId: string, portId: string, _meta: AuditMeta, ) { const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId), }); if (!client || client.portId !== portId) throw new NotFoundError('Client'); const address = await db.query.clientAddresses.findFirst({ where: and(eq(clientAddresses.id, addressId), eq(clientAddresses.clientId, clientId)), }); if (!address) throw new NotFoundError('Address'); await db.delete(clientAddresses).where(eq(clientAddresses.id, addressId)); emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['addresses'] }); } // ─── Tags ───────────────────────────────────────────────────────────────────── export async function setClientTags( clientId: string, portId: string, tagIds: string[], meta: AuditMeta, ) { const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId), }); if (!client || client.portId !== portId) throw new NotFoundError('Client'); await db.delete(clientTags).where(eq(clientTags.clientId, clientId)); if (tagIds.length > 0) { await db.insert(clientTags).values(tagIds.map((tagId) => ({ clientId, tagId }))); } void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'client', entityId: clientId, newValue: { tagIds }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['tags'] }); } // ─── Relationships ──────────────────────────────────────────────────────────── export async function listRelationships(clientId: string, portId: string) { const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId), }); if (!client || client.portId !== portId) throw new NotFoundError('Client'); return db.query.clientRelationships.findMany({ where: (r, { or, eq }) => or(eq(r.clientAId, clientId), eq(r.clientBId, clientId)), }); } export async function createRelationship( clientId: string, portId: string, data: { clientBId: string; relationshipType: string; description?: string }, meta: AuditMeta, ) { const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId), }); if (!client || client.portId !== portId) throw new NotFoundError('Client'); const [rel] = await db .insert(clientRelationships) .values({ portId, clientAId: clientId, ...data }) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'clientRelationship', entityId: rel!.id, newValue: { clientAId: clientId, clientBId: data.clientBId, type: data.relationshipType }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); return rel!; } export async function deleteRelationship( relId: string, clientId: string, portId: string, meta: AuditMeta, ) { const rel = await db.query.clientRelationships.findFirst({ where: eq(clientRelationships.id, relId), }); if (!rel || rel.portId !== portId) throw new NotFoundError('Relationship'); await db.delete(clientRelationships).where(eq(clientRelationships.id, relId)); void createAuditLog({ userId: meta.userId, portId, action: 'delete', entityType: 'clientRelationship', entityId: relId, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); } // ─── Find Duplicates ────────────────────────────────────────────────────────── export async function findDuplicates(portId: string, fullName: string) { return db.query.clients.findMany({ where: (c, { and, eq }) => and(eq(c.portId, portId), ilike(c.fullName, `%${fullName}%`)), limit: 5, }); } // ─── Options (for comboboxes) ───────────────────────────────────────────────── export async function listClientOptions(portId: string, search?: string) { // Pickers only surface active rows. Archived clients are still resolvable // by id (e.g. history views) but should not appear in dropdowns. const conditions = [eq(clients.portId, portId), isNull(clients.archivedAt)]; if (search) { conditions.push(ilike(clients.fullName, `%${search}%`)); } return db .select({ id: clients.id, fullName: clients.fullName }) .from(clients) .where(and(...conditions)) .orderBy(clients.fullName) .limit(50); }