import { NextResponse } from "next/server"; 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.).", defaultEnabled: true, }, { key: "drink_pairing", label: "Drink pairing", description: "AI-suggested drink pairings for a recipe.", defaultEnabled: true, }, { key: "meal_pairing", label: "Meal pairing", description: "AI-suggested side dish / meal pairings for a recipe.", defaultEnabled: true, }, { key: "version_history", label: "Recipe version history", description: "Snapshots of a recipe's prior versions, with diff/compare and restore.", defaultEnabled: true, }, // The following ship disabled by default (2026-07-24) — turn on per tier // from this page once ready, rather than a code change. { key: "recipe_import_url", label: "Import from URL", description: "Import a recipe by pasting a link to another site.", defaultEnabled: false, }, { key: "recipe_import_photo", label: "Import from photo", description: "Import a recipe by photographing a cookbook page or handwritten card.", defaultEnabled: false, }, { key: "nutrition_estimation", label: "Nutrition estimation", description: "AI/USDA-estimated nutrition facts for a recipe.", defaultEnabled: false, }, { key: "markdown_export", label: "Markdown export", description: "Export a recipe, collection, meal plan, or pantry list as Markdown.", defaultEnabled: false, }, { key: "weekly_nutrition", label: "Weekly nutrition", description: "Nutrition rollup across a whole meal-plan week.", defaultEnabled: false, }, { key: "grocery_delivery", label: "Grocery delivery integration", description: "Export a shopping list to a grocery delivery provider (e.g. Instacart).", defaultEnabled: false, }, ] as const; const DEFAULT_ENABLED: Record = Object.fromEntries( FEATURE_DEFINITIONS.map((f) => [f.key, f.defaultEnabled]) ); 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>> { const rows = await db.select().from(featureFlags); const overrides = new Map(rows.map((r) => [`${r.featureKey}:${r.tier}`, r.enabled])); const matrix = {} as Record>; for (const key of FEATURE_KEYS) { matrix[key] = {} as Record; for (const tier of TIERS) { matrix[key][tier] = overrides.get(`${key}:${tier}`) ?? DEFAULT_ENABLED[key] ?? true; } } return matrix; } /** True if a feature is enabled for at least one tier — i.e. it's a real * upgrade path, not shut off entirely. UI uses this to decide whether a * locked feature should still show (with a "Pro" upsell) or hide outright: * showing an upsell for something no tier can ever unlock would be a dead * end, so those hide instead. */ export function isFeatureAvailableAnyTier(matrix: Record>, key: FeatureKey): boolean { return TIERS.some((tier) => matrix[key][tier]); } export async function setFeatureFlag( featureKey: FeatureKey, tier: Tier, enabled: boolean, updatedById: string ): Promise { 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 { const [row] = await db .select({ enabled: featureFlags.enabled }) .from(featureFlags) .where(and(eq(featureFlags.featureKey, featureKey), eq(featureFlags.tier, tier))); return row ? row.enabled : (DEFAULT_ENABLED[featureKey] ?? 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 { 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); } /** Route-level convenience: returns the 403 response to return early, or * null if the feature is enabled — avoids repeating the same try/catch * around requireFeatureEnabled in every gated route. */ export async function requireFeatureEnabledResponse(userId: string, featureKey: FeatureKey): Promise { try { await requireFeatureEnabled(userId, featureKey); return null; } catch (err) { if (err instanceof FeatureDisabledError) { return NextResponse.json( { error: "This feature isn't available on your plan", code: "FEATURE_DISABLED", featureKey: err.featureKey }, { status: 403 } ); } throw err; } }