222 lines
6.5 KiB
TypeScript
222 lines
6.5 KiB
TypeScript
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<FeatureFlagWithEventCount[]> {
|
|
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<string[]> {
|
|
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<FeatureFlagWithDetails[]> {
|
|
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<FeatureFlagWithEventCount[]> {
|
|
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<FeatureFlagWithEventCount[]> {
|
|
return loadFeatureFlagsWithEventCounts({ enabled: where.enabled });
|
|
}
|
|
|
|
async function loadFeatureFlagsWithEventCounts(where?: Prisma.FeatureFlagWhereInput): Promise<FeatureFlagWithEventCount[]> {
|
|
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<Map<string, number>> {
|
|
const tenants = await db.tenant.findMany({
|
|
select: { id: true },
|
|
orderBy: { id: "asc" },
|
|
});
|
|
const counts = new Map<string, number>();
|
|
|
|
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;
|
|
}
|