Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/integrations/entityLinkService.server.ts
T

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[];
}