/** * CM-9: proxy / point-of-contact service. * * A proxy is a designated contact attached to a client, interest, or yacht * (one per entity). `resolveEffectiveProxy` picks the most specific for an * outbound-comms context via the chain yacht → interest → client. All * operations are port-scoped; the entity is verified to belong to the port. */ import { and, eq } from 'drizzle-orm'; import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit'; import { db } from '@/lib/db'; import { clients, interests, proxies, yachts } from '@/lib/db/schema'; import type { Proxy } from '@/lib/db/schema'; import { NotFoundError } from '@/lib/errors'; import type { ProxyEntityType, SetProxyInput } from '@/lib/validators/proxies'; const norm = (v?: string | null): string | null => { const t = v?.trim(); return t ? t : null; }; async function assertEntityInPort( entityType: ProxyEntityType, entityId: string, portId: string, ): Promise { let exists = false; if (entityType === 'client') { const [r] = await db .select({ id: clients.id }) .from(clients) .where(and(eq(clients.id, entityId), eq(clients.portId, portId))) .limit(1); exists = !!r; } else if (entityType === 'interest') { const [r] = await db .select({ id: interests.id }) .from(interests) .where(and(eq(interests.id, entityId), eq(interests.portId, portId))) .limit(1); exists = !!r; } else { const [r] = await db .select({ id: yachts.id }) .from(yachts) .where(and(eq(yachts.id, entityId), eq(yachts.portId, portId))) .limit(1); exists = !!r; } if (!exists) throw new NotFoundError(`${entityType} not found in this port`); } export async function getProxy( portId: string, entityType: ProxyEntityType, entityId: string, ): Promise { const [row] = await db .select() .from(proxies) .where( and( eq(proxies.portId, portId), eq(proxies.entityType, entityType), eq(proxies.entityId, entityId), ), ) .limit(1); return row ?? null; } export async function setProxy( portId: string, entityType: ProxyEntityType, entityId: string, data: SetProxyInput, meta: AuditMeta, ): Promise { await assertEntityInPort(entityType, entityId, portId); const next = { name: data.name.trim(), email: norm(data.email), phone: norm(data.phone), relationship: norm(data.relationship), notes: norm(data.notes), updatedAt: new Date(), }; const [row] = await db .insert(proxies) .values({ portId, entityType, entityId, ...next }) .onConflictDoUpdate({ target: [proxies.portId, proxies.entityType, proxies.entityId], set: next, }) .returning(); if (!row) throw new NotFoundError('Failed to save proxy'); void createAuditLog({ ...meta, action: 'update', entityType: `proxy_${entityType}`, entityId, newValue: toAuditJson(next), }); return row; } export async function clearProxy( portId: string, entityType: ProxyEntityType, entityId: string, meta: AuditMeta, ): Promise { await db .delete(proxies) .where( and( eq(proxies.portId, portId), eq(proxies.entityType, entityType), eq(proxies.entityId, entityId), ), ); void createAuditLog({ ...meta, action: 'delete', entityType: `proxy_${entityType}`, entityId, }); } export interface EffectiveProxy { proxy: Proxy; /** Which level the proxy was resolved from. */ source: ProxyEntityType; } /** * Resolve the most specific proxy for an outbound-comms context. * Precedence: yacht override → interest override → client default. * Returns null when no proxy is set anywhere in the chain (caller falls back * to the client themselves). */ export async function resolveEffectiveProxy(args: { portId: string; clientId?: string | null; interestId?: string | null; yachtId?: string | null; }): Promise { const { portId, clientId, interestId, yachtId } = args; if (yachtId) { const p = await getProxy(portId, 'yacht', yachtId); if (p) return { proxy: p, source: 'yacht' }; } if (interestId) { const p = await getProxy(portId, 'interest', interestId); if (p) return { proxy: p, source: 'interest' }; } if (clientId) { const p = await getProxy(portId, 'client', clientId); if (p) return { proxy: p, source: 'client' }; } return null; }