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:
@@ -1,5 +1,5 @@
|
||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||
export const APP_VERSION = "0.49.1";
|
||||
export const APP_VERSION = "0.50.0";
|
||||
|
||||
export type ChangelogEntry = {
|
||||
version: string;
|
||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
||||
};
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.50.0",
|
||||
date: "2026-07-18 14:20",
|
||||
added: [
|
||||
"Per-tier feature toggles, managed from Admin > Tier Limits. Recipe variations, drink pairing, and meal pairing can each be disabled for a tier (e.g. Free) — the buttons stay visible but show an upgrade prompt instead of running, and the API rejects the call server-side either way.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.49.1",
|
||||
date: "2026-07-18 13:10",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
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);
|
||||
}
|
||||
@@ -814,6 +814,19 @@ export function generateOpenApiSpec(): object {
|
||||
|
||||
registry.registerPath({ method: "patch", path: "/api/v1/admin/tiers/{tier}", summary: "Update numeric limits for a tier definition", description: "Admin only.", security: adminSecurity, request: { params: tierParam, body: { content: { "application/json": { schema: UpdateTierDefinitionBodyRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ tierDefinition: TierDefinitionRef }) } } }, 400: { description: "Invalid tier or field value", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Tier not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
|
||||
const FeatureFlagMatrixRef = registry.register("FeatureFlagMatrix", z.record(
|
||||
z.string(),
|
||||
z.object({ free: z.boolean(), pro: z.boolean(), family: z.boolean() })
|
||||
).describe("Feature key -> per-tier enabled state. A feature/tier pair defaults to enabled=true until explicitly disabled."));
|
||||
const UpdateFeatureFlagRef = registry.register("UpdateFeatureFlag", z.object({
|
||||
featureKey: z.enum(["recipe_variations", "drink_pairing", "meal_pairing"]),
|
||||
tier: z.enum(["free", "pro", "family"]),
|
||||
enabled: z.boolean(),
|
||||
}));
|
||||
|
||||
registry.registerPath({ method: "get", path: "/api/v1/admin/feature-flags", summary: "Get the full feature x tier toggle matrix", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Matrix", content: { "application/json": { schema: FeatureFlagMatrixRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "patch", path: "/api/v1/admin/feature-flags", summary: "Enable or disable a feature for a tier", description: "Admin only. Disabling a feature doesn't hide its button client-side — the corresponding AI route (variations/drinks/pairings) returns 403 with code FEATURE_DISABLED for users on that tier, and the UI shows an upgrade prompt instead.", security: adminSecurity, request: { body: { content: { "application/json": { schema: UpdateFeatureFlagRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
|
||||
registry.registerPath({ method: "post", path: "/api/v1/admin/users", summary: "Create a user directly (bypasses open/closed signup state via an internal one-time invite)", description: "Admin only. The new user is created email-verified with a random unusable password, then sent a password-reset email so they can set their own.", security: adminSecurity, request: { body: { content: { "application/json": { schema: AdminCreateUserBodyRef } }, required: true } }, responses: { 200: { description: "Created", content: { "application/json": { schema: AdminCreatedUserRef } } }, 400: { description: "Missing fields, invalid role/tier, or Better Auth signup error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "A user with this email already exists", content: { "application/json": { schema: ApiErrorRef } } }, 500: { description: "User creation failed", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "patch", path: "/api/v1/admin/users/{id}", summary: "Update a user's role and/or tier", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: AdminUpdateUserBodyRef } } } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: AdminUpdatedUserRef } } }, 400: { description: "Invalid role or tier", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "patch", path: "/api/v1/admin/users/{id}/usage", summary: "Reset a user's usage counters for the current month", description: "Admin only.", security: adminSecurity, request: { params: idParam }, responses: { 200: { description: "Reset usage (zeros, whether or not a usage row already existed)", content: { "application/json": { schema: z.object({ usage: AdminUserUsageRef }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
|
||||
Reference in New Issue
Block a user