feat: 6 new per-tier feature flags (off by default) + flag-icon locale switcher + hide BYOK when disabled (v0.74.0)

Extends the existing feature-flags system (previously 3 keys, all
default-enabled) with 6 more: recipe_import_url, recipe_import_photo,
nutrition_estimation, markdown_export, weekly_nutrition,
grocery_delivery. Each FEATURE_DEFINITIONS entry now carries its own
defaultEnabled -- the new 6 default to false, the original 3 stay
true -- so no migration/seed was needed for the "off by default"
requirement, just a per-key fallback instead of a blanket true.

Gated server-side (requireFeatureEnabledResponse, a new shared
helper avoiding six copies of the same try/catch) on: import-url,
import-photo, nutrition POST estimate, bulk markdown export, weekly
meal-plan nutrition GET, Instacart export. Gated client-side by
hiding the trigger entirely (not just disabling) on every page that
renders one: recipe detail (meal/drink pairing buttons, nutrition
panel's estimate button, markdown export), recipes list (import-URL
button, including the OS Share Target auto-import path), new-recipe
page (photo import), meal-plan page (markdown export, weekly
nutrition bar), shopping-list/collection/pantry pages (markdown
export), shopping-list page (Instacart button, now gated by both the
existing env-var check AND the tier flag).

Also: BYOK section on Settings -> AI now hidden entirely for
non-BYOK users (previously showed a locked-and-teased notice, same
inconsistency the Model Prefs fix closed yesterday). Language
switcher shows a flag icon (FlagGB/FlagFR, moved from
components/marketing to components/shared so both the logged-in
settings switcher and the logged-out marketing one can use it)
instead of plain "English"/"Français" text-only options.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-24 11:16:41 +02:00
parent a834695609
commit 55c6fc5ab7
28 changed files with 246 additions and 83 deletions
+12 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.73.1";
export const APP_VERSION = "0.74.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,17 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.74.0",
date: "2026-07-24 10:30",
added: [
"6 new per-tier feature flags, admin-toggleable from Admin -> Tier Limits, all shipped OFF by default: import from URL, import from photo, meal/drink pairings, nutrition estimation, Markdown export, weekly nutrition, and grocery delivery (Instacart). Hidden from the UI entirely (not just locked) until an admin turns them on for a tier.",
"Language switcher now shows a flag icon next to English/Français instead of plain text, in both the logged-in Settings page and the logged-out marketing switcher (shared flag component).",
],
fixed: [
"BYOK section on Settings -> AI is now hidden entirely for non-BYOK users, matching the Model Prefs picker's treatment — was previously shown with a \"locked\" notice teasing a feature most users have no path to unlocking themselves.",
],
},
{
version: "0.73.1",
date: "2026-07-24 09:30",
+66 -2
View File
@@ -1,3 +1,4 @@
import { NextResponse } from "next/server";
import { db, featureFlags, users, eq, and } from "@epicure/db";
export type Tier = "free" | "pro" | "family";
@@ -8,19 +9,64 @@ 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,
},
// 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[];
@@ -41,7 +87,7 @@ export async function getFeatureFlagMatrix(): Promise<Record<FeatureKey, Record<
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;
matrix[key][tier] = overrides.get(`${key}:${tier}`) ?? DEFAULT_ENABLED[key] ?? true;
}
}
return matrix;
@@ -67,7 +113,7 @@ export async function isFeatureEnabledForTier(featureKey: FeatureKey, tier: Tier
.select({ enabled: featureFlags.enabled })
.from(featureFlags)
.where(and(eq(featureFlags.featureKey, featureKey), eq(featureFlags.tier, tier)));
return row ? row.enabled : true;
return row ? row.enabled : (DEFAULT_ENABLED[featureKey] ?? true);
}
/**
@@ -81,3 +127,21 @@ export async function requireFeatureEnabled(userId: string, featureKey: FeatureK
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;
}
}
+6 -6
View File
@@ -288,11 +288,11 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "delete", path: "/api/v1/recipes/bulk", summary: "Bulk delete recipes (owned only)", security, request: { body: { content: { "application/json": { schema: BulkIdsRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "patch", path: "/api/v1/recipes/bulk", summary: "Bulk update visibility/tags (owned only)", security, request: { body: { content: { "application/json": { schema: BulkUpdateRecipesRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request / nothing to update", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/bulk/export", summary: "Export several recipes as one Markdown document", security, request: { body: { content: { "application/json": { schema: BulkIdsRef } }, required: true } }, responses: { 200: { description: "Markdown", content: { "application/json": { schema: z.object({ markdown: z.string() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "None of the ids belong to you", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/bulk/export", summary: "Export several recipes as one Markdown document", description: "Gated by the markdown_export tier feature flag.", security, request: { body: { content: { "application/json": { schema: BulkIdsRef } }, required: true } }, responses: { 200: { description: "Markdown", content: { "application/json": { schema: z.object({ markdown: z.string() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Feature disabled for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "None of the ids belong to you", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/fork", summary: "Fork (or duplicate your own) a recipe", description: "Rate-limited: 20 req/min. Source must be your own, public, or unlisted.", security, request: { params: idParam }, responses: { 201: { description: "New recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Recipe limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/cooked", summary: "Log that you cooked this recipe", description: "Optionally deducts matching ingredients from your pantry.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ servings: z.number().int().min(1).max(1000).optional(), notes: z.string().max(2000).optional(), deductFromPantry: z.boolean().default(true), batchDishId: z.string().optional() }) } } } }, responses: { 201: { description: "Logged", content: { "application/json": { schema: z.object({ logged: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/nutrition", summary: "Get cached nutrition estimate or manually-entered values", security, request: { params: idParam }, responses: { 200: { description: "Nutrition data or null", content: { "application/json": { schema: z.object({ nutrition: z.object({ perServing: NutritionInputRef }).nullable(), manual: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/nutrition", summary: "Compute a fresh AI nutrition estimate (author only)", description: "Rate-limited: 10 req/min. Consumes AI quota. Overwrites any manually-entered nutrition and clears nutritionManual.", security, request: { params: idParam }, responses: { 200: { description: "Nutrition data", content: { "application/json": { schema: z.object({ nutrition: z.object({ perServing: NutritionInputRef }) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/nutrition", summary: "Compute a fresh AI nutrition estimate (author only)", description: "Rate-limited: 10 req/min. Consumes AI quota. Overwrites any manually-entered nutrition and clears nutritionManual. Gated by the nutrition_estimation tier feature flag.", security, request: { params: idParam }, responses: { 200: { description: "Nutrition data", content: { "application/json": { schema: z.object({ nutrition: z.object({ perServing: NutritionInputRef }) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Feature disabled for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/notes", summary: "Get your own private note on a recipe", security, request: { params: idParam }, responses: { 200: { description: "Note or null", content: { "application/json": { schema: z.object({ note: RecipeNoteRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/recipes/{id}/notes", summary: "Set (or clear, with empty content) your private note", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ content: z.string().max(5000) }) } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ note: RecipeNoteRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/reviews", summary: "List reviews with text or a photo (capped at 50)", security, request: { params: idParam }, responses: { 200: { description: "Reviews", content: { "application/json": { schema: z.object({ data: z.array(RecipeReviewRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
@@ -303,7 +303,7 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/comments/{commentId}/reactions", summary: "Toggle a reaction on a comment", description: "Rate-limited: 60 req/min.", security, request: { params: z.object({ id: z.string(), commentId: z.string() }), body: { content: { "application/json": { schema: z.object({ type: z.enum(["like", "love", "laugh", "wow", "sad", "fire"]) }) } }, required: true } }, responses: { 200: { description: "Updated counts", content: { "application/json": { schema: z.object({ counts: z.record(z.string(), z.number()), added: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/generate", summary: "Generate recipe from prompt", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().min(3).max(500), language: z.string().max(10).default("en"), difficulty: z.enum(["easy", "medium", "hard"]).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Generated", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/regenerate", summary: "Revise a recipe draft per a free-text instruction (recipe editor's \"Regenerate with AI\")", description: "Rate-limited: 10 req/min. Consumes AI quota. Stateless — takes the current draft's fields directly (not a recipeId), so it reflects unsaved editor changes; does not write to the database.", security, request: { body: { content: { "application/json": { schema: z.object({ title: z.string().min(1).max(200), description: z.string().max(2000).optional(), baseServings: z.number().int().min(1).max(100), difficulty: z.enum(["easy", "medium", "hard"]).optional(), ingredients: z.array(z.object({ rawName: z.string().min(1).max(200), quantity: z.union([z.string(), z.number()]).optional(), unit: z.string().max(50).optional() })).max(100), steps: z.array(z.object({ instruction: z.string().min(1).max(2000) })).max(100), instruction: z.string().min(1).max(500), language: z.string().max(10).default("en"), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Revised draft", content: { "application/json": { schema: AiGeneratedRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/import-url", summary: "Import recipe from URL", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ url: z.string().url(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Imported", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/import-url", summary: "Import recipe from URL", description: "Rate-limited: 10 req/min. Gated by the recipe_import_url tier feature flag.", security, request: { body: { content: { "application/json": { schema: z.object({ url: z.string().url(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Imported", content: { "application/json": { schema: AiGeneratedRef } } }, 403: { description: "Feature disabled for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
// --- AI (Phase 3): recipe generation, transforms, chat ---
const AiChatMessageRef = registry.register("AiChatMessage", z.object({
@@ -312,7 +312,7 @@ export function generateOpenApiSpec(): object {
}));
registry.registerPath({ method: "post", path: "/api/v1/ai/generate-from-idea", summary: "Generate a full recipe from a short title/idea", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ title: z.string().min(1).max(200), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Created recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/import-photo", summary: "Import a recipe from a photo using AI vision", description: "Rate-limited: 5 req/min. Consumes AI quota. Two AI calls internally — a vision model recognizes the photo, then a text model writes the structured recipe — but only counts as one quota unit. Written in the caller's app language, regardless of the language shown in the photo.", security, request: { body: { content: { "application/json": { schema: z.object({ imageBase64: z.string().max(14_000_000), mimeType: z.enum(["image/jpeg", "image/png", "image/webp"]) }) } }, required: true } }, responses: { 200: { description: "Created recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 422: { description: "No recipe recognized in the photo", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/import-photo", summary: "Import a recipe from a photo using AI vision", description: "Rate-limited: 5 req/min. Consumes AI quota. Two AI calls internally — a vision model recognizes the photo, then a text model writes the structured recipe — but only counts as one quota unit. Written in the caller's app language, regardless of the language shown in the photo. Gated by the recipe_import_photo tier feature flag.", security, request: { body: { content: { "application/json": { schema: z.object({ imageBase64: z.string().max(14_000_000), mimeType: z.enum(["image/jpeg", "image/png", "image/webp"]) }) } }, required: true } }, responses: { 200: { description: "Created recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Feature disabled for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 422: { description: "No recipe recognized in the photo", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/substitute", summary: "Suggest ingredient substitutions", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ ingredient: z.string().min(1).max(200), recipeTitle: z.string().max(200).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Substitutions", content: { "application/json": { schema: z.object({ substitutions: z.array(z.record(z.string(), z.unknown())) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/scale", summary: "Scale a recipe's ingredients to a target serving count", description: "Rate-limited: 20 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ recipeId: z.string(), targetServings: z.number().int().min(1).max(100) }) } }, required: true } }, responses: { 200: { description: "Scaled ingredients", content: { "application/json": { schema: z.object({ ingredients: z.array(z.record(z.string(), z.unknown())) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/adapt/{id}", summary: "Adapt a recipe to exclude ingredients or meet extra constraints", description: "Consumes AI quota. Creates a new private draft recipe.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ excludeIngredients: z.array(z.string()).default([]), extraConstraints: z.string().max(500).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "New recipe id and adaptation notes", content: { "application/json": { schema: z.object({ id: z.string(), adaptationNotes: z.string().optional() }) } } }, 400: { description: "Validation error or no constraint provided", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
@@ -429,7 +429,7 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}/members", summary: "List collaborators on a week's plan (owner only)", security, request: { params: weekStartParam }, responses: { 200: { description: "Members", content: { "application/json": { schema: z.array(MealPlanMemberRef) } } } } });
registry.registerPath({ method: "post", path: "/api/v1/meal-plans/{weekStart}/members", summary: "Invite a collaborator by email or userId (owner only)", description: "Creates the week's plan if it doesn't exist yet.", security, request: { params: weekStartParam, body: { content: { "application/json": { schema: InviteMemberRef } }, required: true } }, responses: { 201: { description: "Member id", content: { "application/json": { schema: z.object({ id: z.string(), mealPlanId: z.string() }) } } }, 400: { description: "Cannot invite yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Already a member", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/meal-plans/{weekStart}/members", summary: "Remove a collaborator (owner or the member themselves)", security, request: { params: weekStartParam, query: z.object({ memberId: z.string() }) }, responses: { 204: { description: "Removed" }, 400: { description: "memberId required", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}/nutrition", summary: "Weekly nutrition summary vs. your daily goals", security, request: { params: weekStartParam }, responses: { 200: { description: "Summary", content: { "application/json": { schema: WeeklyNutritionRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}/nutrition", summary: "Weekly nutrition summary vs. your daily goals", description: "Gated by the weekly_nutrition tier feature flag.", security, request: { params: weekStartParam }, responses: { 200: { description: "Summary", content: { "application/json": { schema: WeeklyNutritionRef } } }, 403: { description: "Feature disabled for your tier", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}/export/ics", summary: "Download the week's plan as a .ics calendar file", security, request: { params: weekStartParam }, responses: { 200: { description: "iCalendar file", content: { "text/calendar": { schema: z.string() } } }, 400: { description: "Invalid weekStart", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/shared/{mealPlanId}", summary: "View a meal plan you're a collaborator on", security, request: { params: z.object({ mealPlanId: z.string() }) }, responses: { 200: { description: "Plan with entries, your role, and the owner's name", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/shared/{mealPlanId}/entries", summary: "List entries on a shared plan (lean — polled for live updates)", security, request: { params: z.object({ mealPlanId: z.string() }) }, responses: { 200: { description: "Entries", content: { "application/json": { schema: z.object({ entries: z.array(MealPlanEntryLeanRef) }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
@@ -592,7 +592,7 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "post", path: "/api/v1/shopping-lists/{id}/members", summary: "Invite a collaborator by email or userId (owner only)", security, request: { params: idParam, body: { content: { "application/json": { schema: InviteMemberRef } }, required: true } }, responses: { 201: { description: "Member id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error or cannot invite yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Already a member", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/shopping-lists/{id}/members", summary: "Remove a collaborator (owner or the member themselves)", security, request: { params: idParam, query: z.object({ memberId: z.string() }) }, responses: { 204: { description: "Removed" }, 400: { description: "memberId required", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/shopping-lists/{id}/export", summary: "Export items in a normalized grocery-provider format", security, request: { params: idParam }, responses: { 200: { description: "Export payload", content: { "application/json": { schema: GroceryExportRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/shopping-lists/{id}/export/instacart", summary: "Create an Instacart shopping-list link", description: "501 if Instacart isn't configured server-side.", security, request: { params: idParam }, responses: { 200: { description: "Link", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 501: { description: "Not configured", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/shopping-lists/{id}/export/instacart", summary: "Create an Instacart shopping-list link", description: "501 if Instacart isn't configured server-side. Gated by the grocery_delivery tier feature flag.", security, request: { params: idParam }, responses: { 200: { description: "Link", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 403: { description: "Feature disabled for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 501: { description: "Not configured", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/api-keys", summary: "List API keys", description: "Requires developer access (an admin must enable it for your account).", security, responses: { 200: { description: "Keys (hash never returned)", content: { "application/json": { schema: z.array(ApiKeyRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/api-keys", summary: "Create API key", description: "Requires developer access (an admin must enable it for your account).", security, request: { body: { content: { "application/json": { schema: z.object({ name: z.string().min(1).max(100), scope: z.enum(["full", "read"]).default("full").describe("read-only keys can only make GET requests") }) } }, required: true } }, responses: { 201: { description: "Key created — shown once", content: { "application/json": { schema: CreateApiKeyResponseRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required, or API key limit reached (max 10)", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/api-keys/{id}", summary: "Revoke API key", description: "Requires developer access (an admin must enable it for your account).", security, request: { params: idParam }, responses: { 204: { description: "Revoked" }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });