import { db } from "~/utils/db.server"; import { FeatureFlagsFilterType } from "../dtos/FeatureFlagsFilterTypes"; import { FeatureFlag, FeatureFlagFilter, Prisma } from "@hivegrid/db/prisma-browser"; import { runWithIsolatedPlatformAnalyticsRlsContext, runWithRlsContext } from "@hivegrid/db/rls-context.server"; import { clearCacheKeyPrefix } from "~/utils/cache.server"; export type FeatureFlagWithDetails = FeatureFlag & { filters: FeatureFlagFilter[]; }; export type FeatureFlagWithEventCount = FeatureFlagWithDetails & { _count: { events: number }; }; export type FeatureFlagScopeLookup = { enabled?: boolean | undefined; forcedFlags?: string[]; tenantId?: string | null; userId?: string | null; scope?: string | null; }; export async function getFeatureFlag(where: { name?: string; id?: string; description?: string; enabled?: boolean | undefined }) { return db.featureFlag.findFirst({ where: { ...where, archivedAt: null }, include: { filters: true, }, }); } export async function getAllFeatureFlags(): Promise { return loadFeatureFlagsWithEventCounts(); } /** * Return the distinct flag identifiers needed by request-time evaluation. * This deliberately avoids the cross-tenant analytics aggregation used by * the admin list projection, because ordinary app requests already run inside * a tenant-bound RLS context. */ export async function getFeatureFlagEvaluationNames(): Promise { const flags = await db.featureFlag.findMany({ where: { archivedAt: null }, select: { name: true, key: true }, orderBy: { createdAt: "desc" }, }); return [...new Set(flags.map((flag) => flag.name ?? flag.key ?? "").filter((name) => name.length > 0))]; } export async function getFeatureFlags(where: FeatureFlagScopeLookup): Promise { const forcedFlags = where.forcedFlags ?? []; const scopedOverrides: Prisma.FeatureFlagWhereInput[] = []; scopedOverrides.push({ scope: "global", tenantId: null, userId: null }); if (where.tenantId) scopedOverrides.push({ scope: "tenant", tenantId: where.tenantId, userId: null }); if (where.userId) scopedOverrides.push({ scope: "user", tenantId: null, userId: where.userId }); if (where.tenantId && where.userId) scopedOverrides.push({ scope: "user", tenantId: where.tenantId, userId: where.userId }); return db.featureFlag.findMany({ where: { AND: [ { archivedAt: null }, { OR: [{ enabled: where.enabled }, { name: { in: forcedFlags } }, ...scopedOverrides], }, { OR: scopedOverrides }, ], }, include: { filters: true, }, orderBy: { createdAt: "desc", }, }); } export async function getFeatureFlagsWithEventCounts(where: { enabled: boolean | undefined }): Promise { return loadFeatureFlagsWithEventCounts({ enabled: where.enabled }); } /** * The legacy editor predates the append-only v2 audit contract. Reserved * operational flags must only be visible and mutable in the audited engine. */ export async function getLegacyFeatureFlagsWithEventCounts(where: { enabled: boolean | undefined }): Promise { return loadFeatureFlagsWithEventCounts({ enabled: where.enabled }); } async function loadFeatureFlagsWithEventCounts(where?: Prisma.FeatureFlagWhereInput): Promise { const flags = await db.featureFlag.findMany({ where: { AND: [{ archivedAt: null }, ...(where ? [where] : [])] }, include: { filters: true, }, orderBy: { createdAt: "desc", }, }); if (flags.length === 0) return []; const eventCounts = await aggregateFeatureFlagEventCounts(flags.map((flag) => flag.id)); return flags.map((flag) => ({ ...flag, _count: { events: eventCounts.get(flag.id) ?? 0 }, })); } async function aggregateFeatureFlagEventCounts(featureFlagIds: string[]): Promise> { const tenants = await db.tenant.findMany({ select: { id: true }, orderBy: { id: "asc" }, }); const counts = new Map(); async function addCountsVisibleInCurrentContext() { const groups = await db.analyticsEvent.groupBy({ by: ["featureFlagId"], where: { featureFlagId: { in: featureFlagIds } }, _count: true, }); for (const group of groups) { if (!group.featureFlagId) continue; counts.set(group.featureFlagId, (counts.get(group.featureFlagId) ?? 0) + group._count); } } for (const tenant of tenants) { await runWithRlsContext({ tenantId: tenant.id }, addCountsVisibleInCurrentContext); } await runWithIsolatedPlatformAnalyticsRlsContext(addCountsVisibleInCurrentContext); return counts; } export async function createFeatureFlag(data: { name: string; description: string; enabled: boolean; filters: { type: FeatureFlagsFilterType; value: string | null; action?: string | null; }[]; }) { return await db.featureFlag .create({ data: { name: data.name, description: data.description, enabled: data.enabled, filters: { create: data.filters.map((f) => ({ type: f.type.toString(), value: f.value ?? null, action: f.action ?? null, })), }, }, }) .then((item) => { clearCacheKeyPrefix(`featureFlags:enabled`); return item; }); } export async function updateFeatureFlag( id: string, { name, description, filters, enabled, }: { name?: string; description?: string; filters?: { type: FeatureFlagsFilterType; value: string | null; action?: string | null }[]; enabled?: boolean; } ) { return await db.featureFlag .update({ where: { id }, data: { name, description, enabled, filters: !filters ? undefined : { deleteMany: {}, create: filters.map((f) => ({ type: f.type.toString(), value: f.value ?? null, action: f.action ?? null, })), }, }, }) .then((flag) => { clearCacheKeyPrefix(`featureFlags:enabled`); return flag; }); } export async function deleteFeatureFlag(id: string) { return await db.featureFlag .delete({ where: { id }, }) .then((item) => { clearCacheKeyPrefix(`featureFlags:enabled`); return item; }); } export async function deleteAllFeatureFlags() { const result = await db.featureFlag.deleteMany(); clearCacheKeyPrefix(`featureFlags:enabled`); return result; }