1fe379bcdc
Recipe version history is now gated like the other 9 per-tier features (enabled by default, locked-vs-hidden treatment via isFeatureAvailableAnyTier). UpgradeDialog now shows a tagline + concrete bullet list per feature instead of one generic sentence. Also closes a real gap: the AI-generate dialog's Photo tab hit /api/v1/ai/import-photo directly with no tier check in the UI (server route was already correctly gated) — a locked-out user could fill it in and hit a dead-end error instead of an upgrade prompt. Now threaded through the same lock/hide props as the dedicated PhotoImportButton. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
163 lines
5.8 KiB
TypeScript
163 lines
5.8 KiB
TypeScript
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<string, boolean> = 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<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}`) ?? 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<FeatureKey, Record<Tier, boolean>>, key: FeatureKey): boolean {
|
|
return TIERS.some((tier) => matrix[key][tier]);
|
|
}
|
|
|
|
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 : (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<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);
|
|
}
|
|
|
|
/** 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<NextResponse | null> {
|
|
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;
|
|
}
|
|
}
|