import { and, count, desc, eq, ilike, inArray, isNull, sql } 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 { berthTenancies } from '@/lib/db/schema/tenancies'; import { interests, interestBerths } from '@/lib/db/schema/interests'; import { berths } from '@/lib/db/schema/berths'; import { tags } from '@/lib/db/schema/system'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { NotFoundError, ValidationError } from '@/lib/errors'; import { isPortalEnabledForPort } from '@/lib/services/portal-auth.service'; import { setEntityTags } from '@/lib/services/entity-tags.helper'; import { emitToRoom } from '@/lib/socket/server'; import { buildListQuery } from '@/lib/db/query-builder'; import { diffEntity } from '@/lib/entity-diff'; import { restore, withTransaction } from '@/lib/db/utils'; import { logger } from '@/lib/logger'; import { syncEntityFolderName, applyEntityArchivedSuffix, applyEntityRestoredSuffix, } from '@/lib/services/document-folders.service'; import type { CreateClientInput, UpdateClientInput, ListClientsInput, } from '@/lib/validators/clients'; // ─── Types ──────────────────────────────────────────────────────────────────── // ─── 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. 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, interestRows, interestCounts, contactRows, linkedBerthRows] = 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), // Latest interest per client + its primary-berth mooring (resolved via // interest_berths join, plan §3.4). The is_primary filter narrows the // join to ≤1 berth row per interest; non-primary links never surface // through this list-page derivation. db .select({ clientId: interests.clientId, pipelineStage: interests.pipelineStage, updatedAt: interests.updatedAt, mooringNumber: berths.mooringNumber, }) .from(interests) .leftJoin( interestBerths, and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)), ) .leftJoin(berths, eq(berths.id, interestBerths.berthId)) .where( and( eq(interests.portId, portId), inArray(interests.clientId, ids), isNull(interests.archivedAt), ), ) .orderBy(desc(interests.updatedAt)), db .select({ clientId: interests.clientId, count: count() }) .from(interests) .where( and( eq(interests.portId, portId), inArray(interests.clientId, ids), isNull(interests.archivedAt), ), ) .groupBy(interests.clientId), // Pull at most ONE contact per (client_id, channel) for the page. // DISTINCT ON sorted by `is_primary DESC, created_at DESC` keeps // the picker logic identical to the in-memory version it replaced // while bounding the row count to ~2 per client (one email, one // phone) regardless of how many contacts the client has. db.execute<{ clientId: string; channel: string; value: string; valueE164: string | null; isPrimary: boolean; createdAt: Date; }>(sql` SELECT DISTINCT ON (client_id, channel) client_id AS "clientId", channel, value, value_e164 AS "valueE164", is_primary AS "isPrimary", created_at AS "createdAt" FROM client_contacts WHERE ${inArray(clientContacts.clientId, ids)} AND channel IN ('email', 'phone') ORDER BY client_id, channel, is_primary DESC, created_at DESC `), // Berths each client has interests in, with the (most-active) // interest's stage attached so the list-view chip can self-describe // ("E17 · EOI sent") AND deep-link to the interest. DISTINCT ON // collapses (client, berth) when the client has had multiple // historical interests in the same berth - we keep the open-outcome // one if any, otherwise the most recently updated. Excludes archived // interests so closed deals don't crowd the chip row. db.execute<{ clientId: string; berthId: string; mooringNumber: string; interestId: string; pipelineStage: string; outcome: string | null; }>(sql` SELECT DISTINCT ON (i.client_id, b.id) i.client_id AS "clientId", b.id AS "berthId", b.mooring_number AS "mooringNumber", i.id AS "interestId", i.pipeline_stage AS "pipelineStage", i.outcome FROM interests i JOIN interest_berths ib ON ib.interest_id = i.id JOIN berths b ON b.id = ib.berth_id WHERE i.port_id = ${portId} AND i.client_id IN (${sql.join( ids.map((id) => sql`${id}`), sql`, `, )}) AND i.archived_at IS NULL ORDER BY i.client_id, b.id, CASE WHEN i.outcome IS NULL THEN 0 ELSE 1 END, i.updated_at DESC `), ]); const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count])); const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count])); const interestCountMap = new Map(interestCounts.map((r) => [r.clientId, r.count])); // interestRows is sorted desc by updatedAt; first hit per clientId is the latest. const latestInterestMap = new Map(); for (const row of interestRows) { if (!latestInterestMap.has(row.clientId)) { latestInterestMap.set(row.clientId, { stage: row.pipelineStage, mooringNumber: row.mooringNumber, }); } } // Pick the per-client primary email + phone. The SQL DISTINCT ON // returns at most one row per (clientId, channel); the result is // already the picker's "is_primary desc, created_at desc" choice. // We also keep the E.164 form of the phone so the UI can build a // wa.me/ link that doesn't need re-parsing. const primaryEmailMap = new Map(); const primaryPhoneMap = new Map(); const primaryPhoneE164Map = new Map(); type ContactRow = { clientId: string; channel: string; value: string; valueE164: string | null; isPrimary: boolean; createdAt: Date; }; const contactRowList: ContactRow[] = (contactRows as { rows?: ContactRow[] }).rows ?? (contactRows as unknown as ContactRow[]); for (const c of contactRowList) { if (c.channel === 'email') primaryEmailMap.set(c.clientId, c.value); else if (c.channel === 'phone') { primaryPhoneMap.set(c.clientId, c.value); if (c.valueE164) primaryPhoneE164Map.set(c.clientId, c.valueE164); } } // Aggregate berths per client, sorted so the most-action-worthy // interest floats to the top of the chip row. Priority: // 1. open outcome (active deal) before closed (won/lost/cancelled) // 2. within open: most progressed stage first (contract > … > enquiry) // 3. tie-breaker: mooring number alphabetical for stable ordering // The list-view UI shows the top 2 with full labels; the rest fall // through into a "+N more" popover. // // L-001 fix: pre-refactor this map used the 9-stage legacy names // (contract_signed, deposit_10pct, …) and every modern 7-stage value // fell through to rank 0, making the sort effectively random for any // post-refactor interest. Modern values now own the canonical ranks // and legacy keys map to their 7-stage equivalents so historical data // continues to sort correctly. const stageRank: Record = { // modern (post 9→7 refactor) contract: 1, deposit_paid: 2, reservation: 3, eoi: 4, nurturing: 5, qualified: 6, enquiry: 7, // legacy aliases - kept so audit-log + soft-archive data sorts the same contract_signed: 1, contract_sent: 1, completed: 1, deposit_10pct: 2, eoi_signed: 4, eoi_sent: 4, in_communication: 6, details_sent: 7, open: 7, }; type LinkedBerth = { id: string; mooringNumber: string; interestId: string; stage: string; outcome: string | null; }; const linkedBerthsMap = new Map(); type LinkedBerthRow = typeof linkedBerthRows extends Iterable ? T : never; const linkedBerthList: LinkedBerthRow[] = (linkedBerthRows as { rows?: LinkedBerthRow[] }).rows ?? (linkedBerthRows as unknown as LinkedBerthRow[]); for (const r of linkedBerthList) { const list = linkedBerthsMap.get(r.clientId) ?? []; list.push({ id: r.berthId, mooringNumber: r.mooringNumber, interestId: r.interestId, stage: r.pipelineStage, outcome: r.outcome, }); linkedBerthsMap.set(r.clientId, list); } for (const list of linkedBerthsMap.values()) { list.sort((a, b) => { // Open before closed. const openA = a.outcome === null ? 0 : 1; const openB = b.outcome === null ? 0 : 1; if (openA !== openB) return openA - openB; // Within bucket, most-progressed stage first. const rankA = stageRank[a.stage] ?? 99; const rankB = stageRank[b.stage] ?? 99; if (rankA !== rankB) return rankA - rankB; return a.mooringNumber.localeCompare(b.mooringNumber); }); } return { ...result, data: result.data.map((row) => { const latest = latestInterestMap.get(row.id); return { ...row, yachtCount: yachtCountMap.get(row.id) ?? 0, companyCount: companyCountMap.get(row.id) ?? 0, interestCount: interestCountMap.get(row.id) ?? 0, primaryEmail: primaryEmailMap.get(row.id) ?? null, primaryPhone: primaryPhoneMap.get(row.id) ?? null, primaryPhoneE164: primaryPhoneE164Map.get(row.id) ?? null, linkedBerths: linkedBerthsMap.get(row.id) ?? [], latestInterest: latest ? { stage: latest.stage, mooringNumber: latest.mooringNumber, } : null, }; }), }; } // ─── 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), ), ); // Include pending tenancies alongside active ones — a tenancy starts // in `pending` (auto-created from a signed Reservation Agreement, or // manually created via the "Create tenancy" button) and stays pending // until the rep confirms start date + tenure type via the // pending→active activation flow. Reps need to SEE pending rows on // the client tab to act on them; only filtering to `active` hid the // freshly-created tenancy entirely (UAT 2026-05-26). const activeTenancies = await db.query.berthTenancies.findMany({ where: and( eq(berthTenancies.clientId, id), eq(berthTenancies.portId, portId), inArray(berthTenancies.status, ['pending', 'active']), ), columns: { id: true, berthId: true, yachtId: true, startDate: true, tenureType: true, status: true, }, }); const portalEnabled = await isPortalEnabledForPort(portId); // Counts surfaced for tab badges (Interests + Notes - Yachts/Companies/etc // get their counts from the corresponding row arrays we already fetched). const [interestCountRow] = await db .select({ count: count() }) .from(interests) .where( and(eq(interests.portId, portId), eq(interests.clientId, id), isNull(interests.archivedAt)), ); // Aggregated note count — matches what `NotesList` renders below // (direct client notes + interest_notes + yacht_notes for owned // yachts + company_notes for active memberships). Bare clientNotes // count would understate when the rep adds notes to linked entities. const { countForClientAggregated } = await import('@/lib/services/notes.service'); const aggregatedNoteCount = await countForClientAggregated(portId, id); return { ...client, contacts, addresses, tags: clientTagRows.map((r) => r.tag), yachts: yachtRows, companies: membershipRows, activeTenancies, interestCount: interestCountRow?.count ?? 0, noteCount: aggregatedNoteCount, 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 }), ); if (data.fullName !== undefined) { await syncEntityFolderName(portId, 'client', id, meta.userId).catch((err) => { logger.warn({ err, clientId: id }, 'Failed to sync client folder name'); }); } 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'); } // F10: cascade-archive the client's open interests so they don't // dangle in active queries with a shadowed client. Won/lost interests // (outcome IS NOT NULL) are kept as historical records - only IN-FLIGHT // deals get archived. Wrapped in a single transaction so a partial // archive can't leave the system half-cascaded. const archivedInterestIds: string[] = await db.transaction(async (tx) => { await tx .update(clients) .set({ archivedAt: new Date(), updatedAt: new Date() }) .where(eq(clients.id, id)); const cascaded = await tx .update(interests) .set({ archivedAt: new Date(), updatedAt: new Date() }) .where( and( eq(interests.clientId, id), eq(interests.portId, portId), isNull(interests.archivedAt), isNull(interests.outcome), ), ) .returning({ id: interests.id }); return cascaded.map((r) => r.id); }); // fire-and-forget: archive UI does not depend on the folder suffix // being stamped before the HTTP response returns. Task 5 (rename // hook) uses await because the rename should be visible to the // next read; archive does not. void applyEntityArchivedSuffix(portId, 'client', id, meta.userId).catch((err) => { logger.warn({ err, clientId: id, portId }, 'Failed to apply archived suffix to client folder'); }); void createAuditLog({ userId: meta.userId, portId, action: 'archive', entityType: 'client', entityId: id, // Surface the cascade in the audit trail so /admin/audit shows // exactly which interests got swept up. newValue: archivedInterestIds.length > 0 ? { cascadedInterestIds: archivedInterestIds } : undefined, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); // H-07: emit per-interest archive rows so an auditor searching for a // specific archived interest finds it directly - the client-level row's // `cascadedInterestIds` array doesn't participate in audit-log FTS. for (const interestId of archivedInterestIds) { void createAuditLog({ userId: meta.userId, portId, action: 'archive', entityType: 'interest', entityId: interestId, metadata: { cascadeSource: 'client_archive', clientId: id }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); } emitToRoom(`port:${portId}`, 'client:archived', { clientId: id }); for (const interestId of archivedInterestIds) { emitToRoom(`port:${portId}`, 'interest:archived', { interestId }); } 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 applyEntityRestoredSuffix(portId, 'client', id, meta.userId).catch((err) => { logger.warn({ err, clientId: id, portId }, 'Failed to clear archived suffix on client folder'); }); 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() }) // M-MT03: pin the WHERE to (id, clientId) for defense-in-depth. .where(and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId))) .returning(); emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] }); return updated; } /** * Phase 3d - promote a non-primary client_contacts row to primary, * demoting the prior primary for the same channel inside one * transaction. Throws when the contact is already primary or the row * does not exist on the targeted client. * * Used by the EOI dialog's "Set as default for future docs" toggle * (via the eoi-overrides service) and by the client-detail "[EOI] Set * as primary" action. */ export async function promoteContactToPrimary( 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'); if (contact.isPrimary) { // No-op - return the row as-is so callers can be idempotent. return contact; } const updated = await withTransaction(async (tx) => { // Demote the prior primary for the same channel so the partial // unique index doesn't reject the promotion. await tx .update(clientContacts) .set({ isPrimary: false, updatedAt: new Date() }) .where( and( eq(clientContacts.clientId, clientId), eq(clientContacts.channel, contact.channel), eq(clientContacts.isPrimary, true), ), ); const [row] = await tx .update(clientContacts) .set({ isPrimary: true, updatedAt: new Date() }) .where(and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId))) .returning(); return row!; }); void createAuditLog({ userId: meta.userId, portId, action: 'promote_to_primary', entityType: 'client_contact', entityId: contactId, newValue: { clientId, channel: contact.channel, value: contact.value }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); 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'); // M-MT03: pin (id, clientId) for defense-in-depth. await db .delete(clientContacts) .where(and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId))); 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. We grab a row lock on the client to // serialize concurrent primary-toggle requests against the same client - // without this, two simultaneous "isPrimary=true" inserts can both // observe "no existing primary" and one trips the unique index with a // 5xx instead of being safely ordered. const address = await withTransaction(async (tx) => { await tx.select({ id: clients.id }).from(clients).where(eq(clients.id, clientId)).for('update'); 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) => { // Lock the client row to serialize primary-toggle changes - see addClientAddress. await tx.select({ id: clients.id }).from(clients).where(eq(clients.id, clientId)).for('update'); 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 setEntityTags({ joinTable: clientTags, entityColumn: clientTags.clientId, tagColumn: clientTags.tagId, entityId: clientId, portId, tagIds, meta, entityType: 'client', }); } // ─── 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, { and, or, eq }) => and(eq(r.portId, portId), 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, ) { if (data.clientBId === clientId) { throw new ValidationError('A client cannot have a relationship to themselves'); } const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId), }); if (!client || client.portId !== portId) throw new NotFoundError('Client'); // Tenant scope: clientBId arrives from the request body. Without this check // a port-A caller could splice a port-B client UUID onto their own client's // relationship row; the GET handler joins clientRelationships → clients with // no port filter and would surface the foreign client's name + email. const otherClient = await db.query.clients.findFirst({ where: and(eq(clients.id, data.clientBId), eq(clients.portId, portId)), }); if (!otherClient) throw new ValidationError('clientBId not found in this port'); 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(and(eq(clientRelationships.id, relId), eq(clientRelationships.portId, portId))); 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); }