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:
@@ -41,6 +41,7 @@ import { KeepScreenAwake } from "@/components/recipe/keep-screen-awake";
|
||||
import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
|
||||
import { recipeToMarkdown } from "@/lib/markdown/recipe";
|
||||
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
||||
import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -70,7 +71,7 @@ export default async function RecipePage({ params }: Params) {
|
||||
const DIETARY_LABELS = m.recipe.dietary;
|
||||
const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric";
|
||||
|
||||
const [recipe, ratingData, favoriteData, myRating, forkedFrom, myNote, dishCookLog] = await Promise.all([
|
||||
const [recipe, ratingData, favoriteData, myRating, forkedFrom, myNote, dishCookLog, featureFlags] = await Promise.all([
|
||||
db.query.recipes.findFirst({
|
||||
where: and(
|
||||
eq(recipes.id, id),
|
||||
@@ -100,10 +101,18 @@ export default async function RecipePage({ params }: Params) {
|
||||
orderBy: desc(cookingHistory.cookedAt),
|
||||
columns: { batchDishId: true, cookedAt: true },
|
||||
}),
|
||||
getFeatureFlagMatrix(),
|
||||
]);
|
||||
|
||||
if (!recipe) notFound();
|
||||
|
||||
const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free";
|
||||
const locked = {
|
||||
variations: !featureFlags.recipe_variations[viewerTier],
|
||||
drinkPairing: !featureFlags.drink_pairing[viewerTier],
|
||||
mealPairing: !featureFlags.meal_pairing[viewerTier],
|
||||
};
|
||||
|
||||
const isOwner = recipe.authorId === session.user.id;
|
||||
|
||||
const dishCookedAtMap = new Map<string, string>();
|
||||
@@ -176,8 +185,8 @@ export default async function RecipePage({ params }: Params) {
|
||||
<FavoriteButton recipeId={id} initialFavorited={isFavorited} />
|
||||
{!recipe.isBatchCook && recipe.recipeType !== "drink" && (
|
||||
<>
|
||||
<MealPairingButton recipeId={id} />
|
||||
<DrinkPairingButton recipeId={id} />
|
||||
<MealPairingButton recipeId={id} locked={locked.mealPairing} />
|
||||
<DrinkPairingButton recipeId={id} locked={locked.drinkPairing} />
|
||||
</>
|
||||
)}
|
||||
{recipe.visibility === "public" && (
|
||||
@@ -229,6 +238,7 @@ export default async function RecipePage({ params }: Params) {
|
||||
timerSeconds: s.timerSeconds,
|
||||
order: s.order,
|
||||
}))}
|
||||
locked={locked.variations}
|
||||
/>
|
||||
<ForkRecipeButton recipeId={id} variant={isOwner ? "duplicate" : "fork"} />
|
||||
<ShareRecipeButton recipeId={id} visibility={recipe.visibility} />
|
||||
|
||||
@@ -5,14 +5,25 @@ import { db, supportTickets, eq, desc } from "@epicure/db";
|
||||
import { SupportManager } from "@/components/support/support-manager";
|
||||
import { getMessages } from "@/lib/i18n/server";
|
||||
import { getPublicUrl } from "@/lib/storage";
|
||||
import { FEATURE_DEFINITIONS } from "@/lib/feature-flags";
|
||||
|
||||
export const metadata: Metadata = {};
|
||||
|
||||
export default async function SupportPage() {
|
||||
export default async function SupportPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ upgrade?: string }>;
|
||||
}) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
const m = getMessages((session.user as { locale?: string }).locale);
|
||||
|
||||
const { upgrade } = await searchParams;
|
||||
const upgradeFeature = FEATURE_DEFINITIONS.find((f) => f.key === upgrade);
|
||||
const prefill = upgradeFeature
|
||||
? { type: "suggestion" as const, title: `Interested in upgrading for: ${upgradeFeature.label}` }
|
||||
: undefined;
|
||||
|
||||
const rows = await db.query.supportTickets.findMany({
|
||||
where: eq(supportTickets.userId, session.user.id),
|
||||
orderBy: desc(supportTickets.createdAt),
|
||||
@@ -26,6 +37,7 @@ export default async function SupportPage() {
|
||||
<p className="text-muted-foreground text-sm mt-1">{m.support.subtitle}</p>
|
||||
</div>
|
||||
<SupportManager
|
||||
prefill={prefill}
|
||||
initialTickets={rows.map((r) => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import { db, tierDefinitions } from "@epicure/db";
|
||||
import { TierLimitsForm } from "@/components/admin/tier-limits-form";
|
||||
import { FeatureFlagsForm } from "@/components/admin/feature-flags-form";
|
||||
import { getFeatureFlagMatrix, FEATURE_DEFINITIONS } from "@/lib/feature-flags";
|
||||
|
||||
export const metadata: Metadata = {};
|
||||
|
||||
export default async function AdminTiersPage() {
|
||||
const tiers = await db.select().from(tierDefinitions);
|
||||
const [tiers, featureFlagMatrix] = await Promise.all([
|
||||
db.select().from(tierDefinitions),
|
||||
getFeatureFlagMatrix(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -19,6 +24,11 @@ export default async function AdminTiersPage() {
|
||||
{tiers.map((tierDefinition) => (
|
||||
<TierLimitsForm key={tierDefinition.tier} tierDefinition={tierDefinition} />
|
||||
))}
|
||||
|
||||
<FeatureFlagsForm
|
||||
features={FEATURE_DEFINITIONS.map((f) => ({ key: f.key, label: f.label, description: f.description }))}
|
||||
initialMatrix={featureFlagMatrix}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user