Files
pn-new-crm/src/lib/services/proxies.service.ts
Matt 91703bdb00 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>
2026-06-18 23:54:47 +02:00

165 lines
4.4 KiB
TypeScript

/**
* 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;
}