diff --git a/src/app/api/v1/clients/[id]/proxy/route.ts b/src/app/api/v1/clients/[id]/proxy/route.ts new file mode 100644 index 00000000..23c1aa0d --- /dev/null +++ b/src/app/api/v1/clients/[id]/proxy/route.ts @@ -0,0 +1,8 @@ +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers'; + +const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('client'); + +export const GET = withAuth(withPermission('clients', 'view', getHandler)); +export const PUT = withAuth(withPermission('clients', 'edit', putHandler)); +export const DELETE = withAuth(withPermission('clients', 'edit', deleteHandler)); diff --git a/src/app/api/v1/interests/[id]/proxy/route.ts b/src/app/api/v1/interests/[id]/proxy/route.ts new file mode 100644 index 00000000..d85c43a8 --- /dev/null +++ b/src/app/api/v1/interests/[id]/proxy/route.ts @@ -0,0 +1,8 @@ +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers'; + +const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('interest'); + +export const GET = withAuth(withPermission('interests', 'view', getHandler)); +export const PUT = withAuth(withPermission('interests', 'edit', putHandler)); +export const DELETE = withAuth(withPermission('interests', 'edit', deleteHandler)); diff --git a/src/app/api/v1/yachts/[id]/proxy/route.ts b/src/app/api/v1/yachts/[id]/proxy/route.ts new file mode 100644 index 00000000..71e0630b --- /dev/null +++ b/src/app/api/v1/yachts/[id]/proxy/route.ts @@ -0,0 +1,8 @@ +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers'; + +const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('yacht'); + +export const GET = withAuth(withPermission('yachts', 'view', getHandler)); +export const PUT = withAuth(withPermission('yachts', 'edit', putHandler)); +export const DELETE = withAuth(withPermission('yachts', 'edit', deleteHandler)); diff --git a/src/lib/api/proxy-route-handlers.ts b/src/lib/api/proxy-route-handlers.ts new file mode 100644 index 00000000..5b142a51 --- /dev/null +++ b/src/lib/api/proxy-route-handlers.ts @@ -0,0 +1,56 @@ +/** + * CM-9: shared GET/PUT/DELETE handlers for the per-entity proxy sub-resource + * (`/api/v1/{clients|interests|yachts}/[id]/proxy`). Each entity's route.ts + * binds these with its own permission resource so we reuse existing + * clients/interests/yachts gating instead of a new permission. + */ + +import { NextResponse } from 'next/server'; + +import { type RouteHandler } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { clearProxy, getProxy, setProxy } from '@/lib/services/proxies.service'; +import { setProxySchema, type ProxyEntityType } from '@/lib/validators/proxies'; + +export function makeProxyHandlers(entityType: ProxyEntityType) { + const getHandler: RouteHandler = async (req, ctx, params) => { + try { + const proxy = await getProxy(ctx.portId, entityType, params.id!); + return NextResponse.json({ data: proxy }); + } catch (error) { + return errorResponse(error); + } + }; + + const putHandler: RouteHandler = async (req, ctx, params) => { + try { + const body = await parseBody(req, setProxySchema); + const proxy = await setProxy(ctx.portId, entityType, params.id!, body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: proxy }); + } catch (error) { + return errorResponse(error); + } + }; + + const deleteHandler: RouteHandler = async (req, ctx, params) => { + try { + await clearProxy(ctx.portId, entityType, params.id!, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return new NextResponse(null, { status: 204 }); + } catch (error) { + return errorResponse(error); + } + }; + + return { getHandler, putHandler, deleteHandler }; +} diff --git a/src/lib/db/migrations/0095_proxies.sql b/src/lib/db/migrations/0095_proxies.sql new file mode 100644 index 00000000..d9ba8560 --- /dev/null +++ b/src/lib/db/migrations/0095_proxies.sql @@ -0,0 +1,24 @@ +-- 0095_proxies.sql +-- ---------------------------------------------------------------------------- +-- CM-9: per-entity point-of-contact ("proxy") attachable to a client, interest, +-- or yacht. At most one per entity; outbound comms resolve the most specific +-- via yacht → interest → client. entity_id is polymorphic (no FK; validated in +-- the service against the right table). Idempotent — safe to re-run. + +CREATE TABLE IF NOT EXISTS proxies ( + id text PRIMARY KEY DEFAULT gen_random_uuid()::text, + port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE, + entity_type text NOT NULL, + entity_id text NOT NULL, + name text NOT NULL, + email text, + phone text, + relationship text, + notes text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uniq_proxies_entity ON proxies(port_id, entity_type, entity_id); +CREATE INDEX IF NOT EXISTS idx_proxies_entity ON proxies(entity_type, entity_id); +CREATE INDEX IF NOT EXISTS idx_proxies_port ON proxies(port_id); diff --git a/src/lib/db/schema/index.ts b/src/lib/db/schema/index.ts index eaf99063..105f7665 100644 --- a/src/lib/db/schema/index.ts +++ b/src/lib/db/schema/index.ts @@ -10,6 +10,9 @@ export * from './clients'; // Client groups (CM-1 - mailing/segment groups) export * from './client-groups'; +// Proxies / points-of-contact (CM-9 - polymorphic across client/interest/yacht) +export * from './proxies'; + // Companies export * from './companies'; diff --git a/src/lib/db/schema/proxies.ts b/src/lib/db/schema/proxies.ts new file mode 100644 index 00000000..4bcd1869 --- /dev/null +++ b/src/lib/db/schema/proxies.ts @@ -0,0 +1,48 @@ +/** + * Proxies / points-of-contact (CM-9). + * + * A `proxy` is a designated contact person who acts on behalf of an entity. + * Polymorphic: attachable to a `client` (default), an `interest` (per-deal + * override), or a `yacht` (per-vessel override). At most one proxy per entity + * (unique index). Outbound comms resolve the most specific proxy via the chain + * yacht → interest → client (see resolveEffectiveProxy in proxies.service). + * + * `entity_id` is polymorphic (no FK) — validated against the right table in the + * service, same pattern as polymorphic yacht ownership / notes. + */ + +import { index, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; + +import { ports } from './ports'; + +export const proxies = pgTable( + 'proxies', + { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + portId: text('port_id') + .notNull() + .references(() => ports.id, { onDelete: 'cascade' }), + /** 'client' | 'interest' | 'yacht' */ + entityType: text('entity_type').notNull(), + entityId: text('entity_id').notNull(), + name: text('name').notNull(), + email: text('email'), + phone: text('phone'), + /** Free-form relationship label, e.g. broker / spouse / assistant / legal. */ + relationship: text('relationship'), + notes: text('notes'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + // At most one proxy per entity. + uniqueIndex('uniq_proxies_entity').on(table.portId, table.entityType, table.entityId), + index('idx_proxies_entity').on(table.entityType, table.entityId), + index('idx_proxies_port').on(table.portId), + ], +); + +export type Proxy = typeof proxies.$inferSelect; +export type NewProxy = typeof proxies.$inferInsert; diff --git a/src/lib/services/proxies.service.ts b/src/lib/services/proxies.service.ts new file mode 100644 index 00000000..57d4b33a --- /dev/null +++ b/src/lib/services/proxies.service.ts @@ -0,0 +1,164 @@ +/** + * 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; +} diff --git a/src/lib/validators/proxies.ts b/src/lib/validators/proxies.ts new file mode 100644 index 00000000..200687d5 --- /dev/null +++ b/src/lib/validators/proxies.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +/** CM-9: proxy / point-of-contact. */ + +export const PROXY_ENTITY_TYPES = ['client', 'interest', 'yacht'] as const; +export type ProxyEntityType = (typeof PROXY_ENTITY_TYPES)[number]; + +export const setProxySchema = z.object({ + name: z.string().trim().min(1, 'Name is required').max(200), + // Loose contact fields — empty strings are normalised to null in the service. + email: z.string().trim().max(320).nullish(), + phone: z.string().trim().max(50).nullish(), + relationship: z.string().trim().max(100).nullish(), + notes: z.string().trim().max(2000).nullish(), +}); + +export type SetProxyInput = z.infer; diff --git a/tests/integration/proxies.test.ts b/tests/integration/proxies.test.ts new file mode 100644 index 00000000..ebbb452b --- /dev/null +++ b/tests/integration/proxies.test.ts @@ -0,0 +1,94 @@ +/** + * CM-9: proxy service — per-entity CRUD/upsert, tenant guard, and the + * yacht → interest → client resolution precedence. + */ +import { describe, it, expect } from 'vitest'; + +import { createInterest } from '@/lib/services/interests.service'; +import { + clearProxy, + getProxy, + resolveEffectiveProxy, + setProxy, +} from '@/lib/services/proxies.service'; +import { makeAuditMeta, makeClient, makePort, makeYacht } from '../helpers/factories'; + +describe('proxies.service (CM-9)', () => { + it('sets, reads, upserts and clears a client proxy', async () => { + const port = await makePort(); + const meta = makeAuditMeta({ portId: port.id }); + const client = await makeClient({ portId: port.id }); + + expect(await getProxy(port.id, 'client', client.id)).toBeNull(); + + const p = await setProxy( + port.id, + 'client', + client.id, + { name: 'Broker Bob', email: 'bob@example.com' }, + meta, + ); + expect(p.name).toBe('Broker Bob'); + expect(p.email).toBe('bob@example.com'); + + // Upsert: one proxy per entity — setting again updates the same row. + const p2 = await setProxy( + port.id, + 'client', + client.id, + { name: 'Broker Bob', email: '', phone: '+100' }, + meta, + ); + expect(p2.id).toBe(p.id); + expect(p2.email).toBeNull(); // empty string normalised to null + expect(p2.phone).toBe('+100'); + + await clearProxy(port.id, 'client', client.id, meta); + expect(await getProxy(port.id, 'client', client.id)).toBeNull(); + }); + + it('rejects an entity from a foreign port', async () => { + const portA = await makePort(); + const portB = await makePort(); + const meta = makeAuditMeta({ portId: portA.id }); + const foreign = await makeClient({ portId: portB.id }); + await expect(setProxy(portA.id, 'client', foreign.id, { name: 'X' }, meta)).rejects.toThrow( + /not found in this port/, + ); + }); + + it('resolves the most specific proxy: yacht → interest → client', async () => { + const port = await makePort(); + const meta = makeAuditMeta({ portId: port.id }); + const client = await makeClient({ portId: port.id }); + const interest = await createInterest( + port.id, + { clientId: client.id, pipelineStage: 'enquiry', tagIds: [], reminderEnabled: false }, + meta, + ); + const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id }); + + await setProxy(port.id, 'client', client.id, { name: 'Client PoC' }, meta); + await setProxy(port.id, 'interest', interest.id, { name: 'Deal PoC' }, meta); + await setProxy(port.id, 'yacht', yacht.id, { name: 'Vessel PoC' }, meta); + + const ctx = { + portId: port.id, + clientId: client.id, + interestId: interest.id, + yachtId: yacht.id, + }; + expect((await resolveEffectiveProxy(ctx))?.source).toBe('yacht'); + + await clearProxy(port.id, 'yacht', yacht.id, meta); + expect((await resolveEffectiveProxy(ctx))?.source).toBe('interest'); + + await clearProxy(port.id, 'interest', interest.id, meta); + const eff = await resolveEffectiveProxy(ctx); + expect(eff?.source).toBe('client'); + expect(eff?.proxy.name).toBe('Client PoC'); + + await clearProxy(port.id, 'client', client.id, meta); + expect(await resolveEffectiveProxy(ctx)).toBeNull(); + }); +});