Files

106 lines
3.3 KiB
TypeScript
Raw Permalink Normal View History

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