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:
8
src/app/api/v1/clients/[id]/proxy/route.ts
Normal file
8
src/app/api/v1/clients/[id]/proxy/route.ts
Normal 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));
|
||||
8
src/app/api/v1/interests/[id]/proxy/route.ts
Normal file
8
src/app/api/v1/interests/[id]/proxy/route.ts
Normal 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));
|
||||
8
src/app/api/v1/yachts/[id]/proxy/route.ts
Normal file
8
src/app/api/v1/yachts/[id]/proxy/route.ts
Normal 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));
|
||||
56
src/lib/api/proxy-route-handlers.ts
Normal file
56
src/lib/api/proxy-route-handlers.ts
Normal 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 };
|
||||
}
|
||||
24
src/lib/db/migrations/0095_proxies.sql
Normal file
24
src/lib/db/migrations/0095_proxies.sql
Normal 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);
|
||||
@@ -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';
|
||||
|
||||
|
||||
48
src/lib/db/schema/proxies.ts
Normal file
48
src/lib/db/schema/proxies.ts
Normal 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;
|
||||
164
src/lib/services/proxies.service.ts
Normal file
164
src/lib/services/proxies.service.ts
Normal 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;
|
||||
}
|
||||
17
src/lib/validators/proxies.ts
Normal file
17
src/lib/validators/proxies.ts
Normal 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>;
|
||||
94
tests/integration/proxies.test.ts
Normal file
94
tests/integration/proxies.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user