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>
This commit is contained in:
Arnaud
2026-07-18 23:35:12 +02:00
parent 12c2ec213a
commit 2f3ba14093
24 changed files with 5945 additions and 19 deletions
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireAdmin } from "@/lib/api-auth";
import { getFeatureFlagMatrix, setFeatureFlag, FEATURE_KEYS, TIERS } from "@/lib/feature-flags";
export async function GET() {
const { response } = await requireAdmin();
if (response) return response;
const matrix = await getFeatureFlagMatrix();
return NextResponse.json(matrix);
}
const UpdateBody = z.object({
featureKey: z.enum(FEATURE_KEYS as [string, ...string[]]),
tier: z.enum(TIERS as [string, ...string[]]),
enabled: z.boolean(),
});
export async function PATCH(req: NextRequest) {
const { session, response } = await requireAdmin();
if (response) return response;
const body = (await req.json()) as unknown;
const parsed = UpdateBody.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
await setFeatureFlag(
parsed.data.featureKey as (typeof FEATURE_KEYS)[number],
parsed.data.tier as (typeof TIERS)[number],
parsed.data.enabled,
session!.user.id
);
return NextResponse.json({ ok: true });
}
@@ -7,6 +7,7 @@ import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { suggestDrinks } from "@/lib/ai/features/suggest-drinks";
import { withUserKey } from "@/lib/ai/resolve-user-key";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
import { requireFeatureEnabled, FeatureDisabledError } from "@/lib/feature-flags";
const Schema = z.object({
count: z.number().int().min(1).max(6).default(4),
@@ -20,6 +21,18 @@ export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
try {
await requireFeatureEnabled(session!.user.id, "drink_pairing");
} 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;
}
const { id } = await params;
const recipe = await db.query.recipes.findFirst({
@@ -7,6 +7,7 @@ import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { suggestPairings } from "@/lib/ai/features/suggest-pairings";
import { withUserKey } from "@/lib/ai/resolve-user-key";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
import { requireFeatureEnabled, FeatureDisabledError } from "@/lib/feature-flags";
const Schema = z.object({
count: z.number().int().min(1).max(6).default(4),
@@ -20,6 +21,18 @@ export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
try {
await requireFeatureEnabled(session!.user.id, "meal_pairing");
} 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;
}
const { id } = await params;
// Allow pairings for own recipes or public recipes
@@ -7,6 +7,7 @@ import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { suggestVariations } from "@/lib/ai/features/suggest-variations";
import { withUserKey } from "@/lib/ai/resolve-user-key";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
import { requireFeatureEnabled, FeatureDisabledError } from "@/lib/feature-flags";
const Schema = z.object({
count: z.number().int().min(1).max(5).default(3),
@@ -21,6 +22,18 @@ export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
try {
await requireFeatureEnabled(session!.user.id, "recipe_variations");
} 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;
}
const { id } = await params;
const recipe = await db.query.recipes.findFirst({
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),