feat(proxies): CM-9 backend — polymorphic point-of-contact + resolver

- proxies table (migration 0095, port_id cascade), one per client/interest/yacht
- service: get/set(upsert)/clear + resolveEffectiveProxy (yacht → interest →
  client precedence), port-scoped with entity-in-port guard
- per-entity sub-resource routes (/clients|interests|yachts/[id]/proxy) reusing
  each entity's existing view/edit permission (no new permission resource)
- 3 integration tests (CRUD/upsert, tenant guard, resolution precedence)

Backend only — ProxyCard UI on the 3 detail pages to follow. tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 23:54:47 +02:00
parent 3165ec651f
commit 91703bdb00
10 changed files with 430 additions and 0 deletions

View File

@@ -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));

View File

@@ -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));

View File

@@ -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));

View File

@@ -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 };
}

View File

@@ -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);

View File

@@ -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';

View File

@@ -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;

View File

@@ -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<void> {
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<Proxy | null> {
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<Proxy> {
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<void> {
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<EffectiveProxy | null> {
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;
}

View File

@@ -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<typeof setProxySchema>;

View File

@@ -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();
});
});