Files
Epicure/apps/web/lib/feature-flags.ts
T
Arnaud 2f3ba14093 feat: per-tier feature toggles for recipe variations/pairings (v0.50.0)
Admins can now disable specific AI features per tier from Admin > Tier
Limits — new feature_flags table (feature x tier -> enabled, defaulting
to true so adding a new gated feature never needs a backfill).

Covers recipe variations, drink pairing, and meal pairing to start.
When disabled for a user's tier, the button stays visible (with a small
lock badge) but opens an upgrade dialog instead of running; the API
route rejects the call server-side either way (requireFeatureEnabled,
re-reads tier from the DB rather than trusting the session's cache,
same rationale as checkAndIncrementTierLimit).

The upgrade dialog is informational only — no Stripe checkout exists
yet (STRIPE_PLAN.md is still just a plan) — its CTA links to /support
prefilled as an upgrade-interest suggestion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 23:35:12 +02:00

84 lines
2.9 KiB
TypeScript

import { db, featureFlags, users, eq, and } from "@epicure/db";
export type Tier = "free" | "pro" | "family";
export const TIERS: Tier[] = ["free", "pro", "family"];
export const FEATURE_DEFINITIONS = [
{
key: "recipe_variations",
label: "Recipe variations",
description: "AI-generated variations of a recipe (dietary swaps, flavor twists, etc.).",
},
{
key: "drink_pairing",
label: "Drink pairing",
description: "AI-suggested drink pairings for a recipe.",
},
{
key: "meal_pairing",
label: "Meal pairing",
description: "AI-suggested side dish / meal pairings for a recipe.",
},
] as const;
export type FeatureKey = (typeof FEATURE_DEFINITIONS)[number]["key"];
export const FEATURE_KEYS = FEATURE_DEFINITIONS.map((f) => f.key) as FeatureKey[];
export class FeatureDisabledError extends Error {
constructor(public readonly featureKey: FeatureKey) {
super(`Feature disabled for your tier: ${featureKey}`);
this.name = "FeatureDisabledError";
}
}
/** Full (feature x tier) matrix, defaulting every cell to enabled=true unless
* a row overrides it. Used by the admin toggle UI. */
export async function getFeatureFlagMatrix(): Promise<Record<FeatureKey, Record<Tier, boolean>>> {
const rows = await db.select().from(featureFlags);
const overrides = new Map(rows.map((r) => [`${r.featureKey}:${r.tier}`, r.enabled]));
const matrix = {} as Record<FeatureKey, Record<Tier, boolean>>;
for (const key of FEATURE_KEYS) {
matrix[key] = {} as Record<Tier, boolean>;
for (const tier of TIERS) {
matrix[key][tier] = overrides.get(`${key}:${tier}`) ?? true;
}
}
return matrix;
}
export async function setFeatureFlag(
featureKey: FeatureKey,
tier: Tier,
enabled: boolean,
updatedById: string
): Promise<void> {
await db
.insert(featureFlags)
.values({ featureKey, tier, enabled, updatedAt: new Date(), updatedById })
.onConflictDoUpdate({
target: [featureFlags.featureKey, featureFlags.tier],
set: { enabled, updatedAt: new Date(), updatedById },
});
}
export async function isFeatureEnabledForTier(featureKey: FeatureKey, tier: Tier): Promise<boolean> {
const [row] = await db
.select({ enabled: featureFlags.enabled })
.from(featureFlags)
.where(and(eq(featureFlags.featureKey, featureKey), eq(featureFlags.tier, tier)));
return row ? row.enabled : true;
}
/**
* Server-side enforcement for API routes — never trust the session's tier
* (5-minute cookieCache, see lib/auth/server.ts), re-read it from the DB,
* same rationale as checkAndIncrementTierLimit.
*/
export async function requireFeatureEnabled(userId: string, featureKey: FeatureKey): Promise<void> {
const [dbUser] = await db.select({ tier: users.tier }).from(users).where(eq(users.id, userId));
const tier = (dbUser?.tier ?? "free") as Tier;
const enabled = await isFeatureEnabledForTier(featureKey, tier);
if (!enabled) throw new FeatureDisabledError(featureKey);
}