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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user