106 lines
3.3 KiB
TypeScript
106 lines
3.3 KiB
TypeScript
import "server-only";
|
|
import { prisma } from "@/lib/db";
|
|
|
|
/**
|
|
* Feature flags with runtime effect.
|
|
*
|
|
* Flags are stored in the `feature_flag` table (admin-toggleable, no deploy) and
|
|
* READ here to actually gate behavior. Known flags have a registry entry that
|
|
* supplies a label, description, and a safe default when the row doesn't exist
|
|
* yet. Admins may also create ad-hoc custom flags from the admin UI.
|
|
*/
|
|
export interface FlagDef {
|
|
key: string;
|
|
label: string;
|
|
description: string;
|
|
default: boolean;
|
|
}
|
|
|
|
export const FLAG_REGISTRY: FlagDef[] = [
|
|
{
|
|
key: "signups_enabled",
|
|
label: "Public sign-ups",
|
|
description: "Allow new users to create an account. Turn off to pause registration.",
|
|
default: true,
|
|
},
|
|
{
|
|
key: "episode_generation_enabled",
|
|
label: "Episode generation",
|
|
description:
|
|
"Master kill-switch for AI generation (new episodes, regenerations, series). Turn off during incidents or provider outages.",
|
|
default: true,
|
|
},
|
|
{
|
|
key: "ai_moderation_enabled",
|
|
label: "AI content moderation",
|
|
description:
|
|
"Screen episode topics and generated scripts with OpenAI moderation; flag violations for review.",
|
|
default: true,
|
|
},
|
|
{
|
|
key: "maintenance_banner",
|
|
label: "Maintenance banner",
|
|
description: "Show a site-wide notice in the app that maintenance is in progress.",
|
|
default: false,
|
|
},
|
|
];
|
|
|
|
const DEFAULTS = new Map(FLAG_REGISTRY.map((f) => [f.key, f.default]));
|
|
|
|
// Short per-process cache so hot paths (layout, generation) don't hit the DB on
|
|
// every request. Admin toggles propagate within TTL_MS; the admin UI itself
|
|
// reads fresh (uncached) data via getAllFlags().
|
|
const TTL_MS = 10_000;
|
|
let cache: { at: number; map: Map<string, boolean> } | null = null;
|
|
|
|
async function loadFlags(): Promise<Map<string, boolean>> {
|
|
if (cache && Date.now() - cache.at < TTL_MS) return cache.map;
|
|
const rows = await prisma.featureFlag.findMany({ select: { key: true, enabled: true } });
|
|
const map = new Map<string, boolean>(rows.map((r) => [r.key, r.enabled]));
|
|
cache = { at: Date.now(), map };
|
|
return map;
|
|
}
|
|
|
|
/** Resolve a flag: DB value if present, else the registry default (else false). */
|
|
export async function isFlagEnabled(key: string): Promise<boolean> {
|
|
const map = await loadFlags();
|
|
if (map.has(key)) return map.get(key)!;
|
|
return DEFAULTS.get(key) ?? false;
|
|
}
|
|
|
|
/** Invalidate the in-process cache (call after an admin toggles a flag). */
|
|
export function bustFlagCache(): void {
|
|
cache = null;
|
|
}
|
|
|
|
export interface FlagView {
|
|
key: string;
|
|
label: string;
|
|
description: string;
|
|
enabled: boolean;
|
|
known: boolean;
|
|
}
|
|
|
|
/** Fresh (uncached) merged view of known + custom flags for the admin screen. */
|
|
export async function getAllFlags(): Promise<FlagView[]> {
|
|
const rows = await prisma.featureFlag.findMany();
|
|
const byKey = new Map(rows.map((r) => [r.key, r.enabled]));
|
|
const known: FlagView[] = FLAG_REGISTRY.map((f) => ({
|
|
key: f.key,
|
|
label: f.label,
|
|
description: f.description,
|
|
enabled: byKey.get(f.key) ?? f.default,
|
|
known: true,
|
|
}));
|
|
const custom: FlagView[] = rows
|
|
.filter((r) => !DEFAULTS.has(r.key))
|
|
.map((r) => ({
|
|
key: r.key,
|
|
label: r.key,
|
|
description: "Custom flag (no registry entry — not read by application code).",
|
|
enabled: r.enabled,
|
|
known: false,
|
|
}));
|
|
return [...known, ...custom];
|
|
}
|