110 lines
3.8 KiB
TypeScript
110 lines
3.8 KiB
TypeScript
// P4 EntityLink (AK-P4b) §5c — Gate W1 generic cross-entity link service.
|
|
//
|
|
// Tenant-scoped, idempotent CRUD over the polymorphic `EntityLink` table. Both
|
|
// sides (source + target) are polymorphic, so any module docks onto this one
|
|
// service instead of building its own. No CRM coupling here — wiring concrete
|
|
// entities (Task<->Contact, etc.) happens in P6. NO React/UI imports.
|
|
//
|
|
// RLS-ready (AK-P4b "Teil A", mirrors fileService): every EntityLink access runs
|
|
// inside withTenantRLS() so the tenant GUC is set before the query. Functional
|
|
// no-op until the EntityLink RLS policy is activated (prisma/rls/task-isolation.sql,
|
|
// P5 cutover). EntityLink is the only table this service touches, so each function
|
|
// is a single withTenantRLS() block (no nested transactions to worry about).
|
|
|
|
import { withTenantRLS } from "~/utils/db/.server/tenantRls.server";
|
|
|
|
export type LinkableEntityType = "contact" | "company" | "lead" | "deal" | "project" | "task" | "file" | "user" | "order" | "appointment" | "form_submission";
|
|
|
|
export type EntityLinkRow = {
|
|
id: string;
|
|
sourceType: LinkableEntityType;
|
|
sourceId: string;
|
|
targetType: LinkableEntityType;
|
|
targetId: string;
|
|
linkRole: string | null;
|
|
createdAt: Date;
|
|
};
|
|
|
|
export type EntityRef = { entityType: LinkableEntityType; entityId: string };
|
|
|
|
/**
|
|
* Create (or update the role of) a directed link between two entities. Idempotent
|
|
* on the unique tuple (tenantId, source*, target*) — a repeated call only
|
|
* refreshes `linkRole`.
|
|
*/
|
|
export async function linkEntities(input: {
|
|
tenantId: string;
|
|
userId: string;
|
|
sourceType: LinkableEntityType;
|
|
sourceId: string;
|
|
targetType: LinkableEntityType;
|
|
targetId: string;
|
|
linkRole?: string | null;
|
|
}): Promise<void> {
|
|
await withTenantRLS(input.tenantId, (tx) =>
|
|
tx.entityLink.upsert({
|
|
where: {
|
|
tenantId_sourceType_sourceId_targetType_targetId: {
|
|
tenantId: input.tenantId,
|
|
sourceType: input.sourceType,
|
|
sourceId: input.sourceId,
|
|
targetType: input.targetType,
|
|
targetId: input.targetId,
|
|
},
|
|
},
|
|
update: { linkRole: input.linkRole ?? null },
|
|
create: {
|
|
tenantId: input.tenantId,
|
|
createdByUserId: input.userId,
|
|
sourceType: input.sourceType,
|
|
sourceId: input.sourceId,
|
|
targetType: input.targetType,
|
|
targetId: input.targetId,
|
|
linkRole: input.linkRole ?? null,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** Remove a directed link between two entities (no-op if absent). */
|
|
export async function unlinkEntities(input: {
|
|
tenantId: string;
|
|
sourceType: LinkableEntityType;
|
|
sourceId: string;
|
|
targetType: LinkableEntityType;
|
|
targetId: string;
|
|
}): Promise<void> {
|
|
await withTenantRLS(input.tenantId, (tx) =>
|
|
tx.entityLink.deleteMany({
|
|
where: {
|
|
tenantId: input.tenantId,
|
|
sourceType: input.sourceType,
|
|
sourceId: input.sourceId,
|
|
targetType: input.targetType,
|
|
targetId: input.targetId,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* List all links touching an entity (as either source or target), tenant-scoped.
|
|
* Returns rows oriented as stored; callers normalize direction as needed.
|
|
*/
|
|
export async function listLinksFor(input: { tenantId: string; entityType: LinkableEntityType; entityId: string }): Promise<EntityLinkRow[]> {
|
|
const rows = await withTenantRLS(input.tenantId, (tx) =>
|
|
tx.entityLink.findMany({
|
|
where: {
|
|
tenantId: input.tenantId,
|
|
OR: [
|
|
{ sourceType: input.entityType, sourceId: input.entityId },
|
|
{ targetType: input.entityType, targetId: input.entityId },
|
|
],
|
|
},
|
|
select: { id: true, sourceType: true, sourceId: true, targetType: true, targetId: true, linkRole: true, createdAt: true },
|
|
orderBy: { createdAt: "asc" },
|
|
}),
|
|
);
|
|
return rows as EntityLinkRow[];
|
|
}
|