- 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>
165 lines
4.4 KiB
TypeScript
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;
|
|
}
|