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