diff --git a/CHANGELOG.md b/CHANGELOG.md index a1c1f81..698dbf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.74.0 — 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. + ## 0.73.1 — 2026-07-24 09:30 ### Fixed diff --git a/FEATURE_AUDIT.md b/FEATURE_AUDIT.md index b8a79af..fc02ad3 100644 --- a/FEATURE_AUDIT.md +++ b/FEATURE_AUDIT.md @@ -111,7 +111,7 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real | Stripe webhook (checkout/cancel) | Exists | Signature-verified, replay-protected, event-deduped — production quality | `apps/web/app/api/webhooks/stripe/route.ts` | | Stripe checkout + self-serve billing portal (2026-07-23) | Exists | `POST /api/v1/billing/checkout` creates a subscription Checkout Session (promotion codes enabled); `POST /api/v1/billing/portal` opens Stripe's hosted Customer Portal for self-serve cancel/upgrade/card-update. Webhook route rewritten with the real `stripe` SDK (`stripe.webhooks.constructEvent`, replacing the hand-rolled HMAC verifier) and now handles the full event set: `checkout.session.completed`, `customer.subscription.{updated,deleted}`, `invoice.{payment_failed,paid}` — `past_due` deliberately doesn't downgrade tier (Stripe retries the card first). `/settings/billing` shows plan cards, usage, and a "Manage billing" button; `/admin/billing` shows connection status, subscriber counts, past-due list, recent billing audit events. Cancel/downgrade is at period end (Stripe Portal default); no trial period. **Not yet built:** family-group multi-user sharing (Family tier is purchasable solo, but the plan's per-account member invite/join/tier-resolution piece is deliberately deferred — see `plans/STRIPE_PLAN.md` §1a, flagged there as the most novel/error-prone piece, intentionally shipped after solo billing is proven). | `apps/web/lib/stripe.ts`, `apps/web/app/api/webhooks/stripe/route.ts`, `apps/web/app/api/v1/billing/**`, `apps/web/app/(app)/settings/billing/page.tsx`, `apps/web/app/admin/billing/page.tsx` | | Admin dashboard | Exists | 15 sections (was 13, missing Billing until this pass): overview, insights/analytics, users, invites, recipe moderation, reports, support, tier limits, **billing**, webhooks, audit logs, storage, AI config, site settings, changelog | `apps/web/app/admin/layout.tsx` (`adminNav`) | -| Feature flags (per-tier) vs feature prefs (per-user cosmetic) | Exists, two distinct systems | Flags gate 3 AI capabilities by tier; prefs let users hide 7 nav sections, no billing implication | `apps/web/lib/{feature-flags,feature-prefs}.ts` | +| Feature flags (per-tier) vs feature prefs (per-user cosmetic) | Exists, two distinct systems | Flags now gate 9 capabilities by tier (was 3): recipe_variations/drink_pairing/meal_pairing (default enabled) plus recipe_import_url, recipe_import_photo, nutrition_estimation, markdown_export, weekly_nutrition, grocery_delivery (2026-07-24, default **disabled** for all tiers — admin turns on per tier from `/admin/tiers`). Each key's own `defaultEnabled` governs the fallback when no admin override row exists, not a blanket true. Prefs let users hide 7 nav sections, no billing implication, unrelated system. | `apps/web/lib/{feature-flags,feature-prefs}.ts` | | **Developer permission** (2026-07-22, split 2026-07-23) | Exists | Two separate, orthogonal permissions — not one. `users.isDeveloper` gates webhooks + self-serve API keys: admin-toggled always, *and* self-serve toggleable by the user themselves once on a paid tier (no added fee) via `PATCH /api/v1/users/me/developer-access`; free-tier users still need an admin grant. `users.isByokEnabled` gates BYOK separately, admin-only, no self-serve — routing real AI spend through Epicure on the user's own key warrants a manual check-in. Previously all three (webhooks/API keys/BYOK) shared one flag with zero self-serve path. Existing users with a webhook/API key were already grandfathered for `isDeveloper`; a second migration grandfathered existing BYOK users into `isByokEnabled` specifically. | `apps/web/lib/permissions.ts` (`hasDeveloperAccess`, `hasByokAccess`, `canSelfServeDeveloperAccess`), `apps/web/lib/api-auth.ts` (`requireDeveloper`, `requireByok`) | | User webhooks (personal automation) | Exists | 7 events, Zapier-style, requires developer access | `apps/web/lib/webhooks.ts` | | Admin ops webhooks (site-wide) | Exists | 3 events (signup, ticket, report) — admin-only, unrelated to developer access | `apps/web/lib/admin-webhooks.ts` | diff --git a/apps/web/app/(app)/collections/[id]/page.tsx b/apps/web/app/(app)/collections/[id]/page.tsx index e669821..8302120 100644 --- a/apps/web/app/(app)/collections/[id]/page.tsx +++ b/apps/web/app/(app)/collections/[id]/page.tsx @@ -18,6 +18,7 @@ import { buttonVariants } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { ExportMarkdownButton } from "@/components/shared/export-markdown-button"; +import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags"; import { EmptyState } from "@/components/shared/empty-state"; import { collectionToMarkdown } from "@/lib/markdown/collection"; import { getMessages } from "@/lib/i18n/server"; @@ -31,6 +32,8 @@ export default async function CollectionPage({ params }: Params) { const session = await auth.api.getSession({ headers: await headers() }); if (!session) return null; const m = getMessages((session.user as { locale?: string }).locale); + const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free"; + const canExportMarkdown = (await getFeatureFlagMatrix()).markdown_export[viewerTier]; const col = await db.query.collections.findFirst({ where: and(eq(collections.id, id), collectionVisibleToViewer(session.user.id)), @@ -82,14 +85,14 @@ export default async function CollectionPage({ params }: Params) { } /> {m.collections.exportPdf} - + />} )} {isOwner && } diff --git a/apps/web/app/(app)/meal-plan/page.tsx b/apps/web/app/(app)/meal-plan/page.tsx index ee57e0a..83e3508 100644 --- a/apps/web/app/(app)/meal-plan/page.tsx +++ b/apps/web/app/(app)/meal-plan/page.tsx @@ -12,6 +12,7 @@ import { NewShoppingListButton } from "@/components/meal-plan/new-shopping-list- import { WeeklyNutritionBar } from "@/components/nutrition/weekly-nutrition-bar"; import { cn } from "@/lib/utils"; import { ExportMarkdownButton } from "@/components/shared/export-markdown-button"; +import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags"; import { mealPlanToMarkdown } from "@/lib/markdown/meal-plan"; import { getMessages, formatMessage } from "@/lib/i18n/server"; @@ -59,6 +60,10 @@ export default async function MealPlanPage({ const session = await auth.api.getSession({ headers: await headers() }); if (!session) return null; const msgs = getMessages((session.user as { locale?: string }).locale); + const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free"; + const featureFlags = await getFeatureFlagMatrix(); + const canExportMarkdown = featureFlags.markdown_export[viewerTier]; + const canSeeWeeklyNutrition = featureFlags.weekly_nutrition[viewerTier]; const monday = getMonday(week); const weekStart = toDateStr(monday); @@ -157,10 +162,12 @@ export default async function MealPlanPage({ } /> {msgs.common.print} - + {canExportMarkdown && ( + + )} @@ -173,7 +180,7 @@ export default async function MealPlanPage({ - + {canSeeWeeklyNutrition && } {sharedMemberships.length > 0 && ( diff --git a/apps/web/app/(app)/pantry/page.tsx b/apps/web/app/(app)/pantry/page.tsx index 4d65af1..7720688 100644 --- a/apps/web/app/(app)/pantry/page.tsx +++ b/apps/web/app/(app)/pantry/page.tsx @@ -10,12 +10,15 @@ import { ExpiringLeftovers } from "@/components/pantry/expiring-leftovers"; import { scoreRecipesAgainstPantry } from "@/lib/pantry-match"; import { dishExpiresAt, daysUntil, isLeftoverExpiringSoon } from "@/lib/leftover-match"; import { getPublicUrl } from "@/lib/storage"; +import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags"; export const metadata: Metadata = {}; export default async function PantryPage() { const session = await auth.api.getSession({ headers: await headers() }); if (!session) return null; + const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free"; + const canExportMarkdown = (await getFeatureFlagMatrix()).markdown_export[viewerTier]; const [items, candidateRecipes, cookedDishes] = await Promise.all([ db.query.pantryItems.findMany({ @@ -75,7 +78,7 @@ export default async function PantryPage() { return (
- + i.id).join(",")} initialItems={mappedItems} /> diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx index 0b8bcd2..7b12248 100644 --- a/apps/web/app/(app)/recipes/[id]/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/page.tsx @@ -114,6 +114,8 @@ export default async function RecipePage({ params }: Params) { variations: !featureFlags.recipe_variations[viewerTier], drinkPairing: !featureFlags.drink_pairing[viewerTier], mealPairing: !featureFlags.meal_pairing[viewerTier], + nutritionEstimation: !featureFlags.nutrition_estimation[viewerTier], + markdownExport: !featureFlags.markdown_export[viewerTier], }; const isOwner = recipe.authorId === session.user.id; @@ -188,8 +190,8 @@ export default async function RecipePage({ params }: Params) { {!recipe.isBatchCook && recipe.recipeType !== "drink" && ( <> - - + {!locked.mealPairing && } + {!locked.drinkPairing && } )} {recipe.visibility === "public" && ( @@ -247,22 +249,24 @@ export default async function RecipePage({ params }: Params) { - + {!locked.markdownExport && ( + + )} {isOwner && ( <> - +
)} diff --git a/apps/web/app/(app)/recipes/new/page.tsx b/apps/web/app/(app)/recipes/new/page.tsx index 7775f91..24289fa 100644 --- a/apps/web/app/(app)/recipes/new/page.tsx +++ b/apps/web/app/(app)/recipes/new/page.tsx @@ -1,16 +1,24 @@ import type { Metadata } from "next"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth/server"; import { RecipeForm } from "@/components/recipe/recipe-form"; import { NewRecipeHeader } from "@/components/recipe/new-recipe-header"; import { PhotoImportButton } from "@/components/recipe/photo-import-button"; +import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags"; export const metadata: Metadata = {}; -export default function NewRecipePage() { +export default async function NewRecipePage() { + const session = await auth.api.getSession({ headers: await headers() }); + const viewerTier = (session?.user as { tier?: string } | undefined)?.tier as Tier | undefined ?? "free"; + const featureFlags = await getFeatureFlagMatrix(); + const canImportPhoto = featureFlags.recipe_import_photo[viewerTier]; + return (
- + {canImportPhoto && }
diff --git a/apps/web/app/(app)/recipes/page.tsx b/apps/web/app/(app)/recipes/page.tsx index ec69919..a9ba815 100644 --- a/apps/web/app/(app)/recipes/page.tsx +++ b/apps/web/app/(app)/recipes/page.tsx @@ -10,6 +10,7 @@ import { RecipesGrid } from "@/components/recipe/recipes-grid"; import { CookingAssistantPanel } from "@/components/recipe/cooking-assistant-panel"; import { getMessages } from "@/lib/i18n/server"; import { getFeaturePrefs } from "@/lib/feature-prefs"; +import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags"; export const metadata: Metadata = {}; @@ -59,6 +60,9 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear if (!session) return null; const m = getMessages((session.user as { locale?: string }).locale); const featurePrefs = await getFeaturePrefs(session.user.id); + const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free"; + const featureFlags = await getFeatureFlagMatrix(); + const canImportUrl = featureFlags.recipe_import_url[viewerTier]; const { q, sort, visibility, difficulty, tag, page: pageParam, batchCook, recipeType, url, text } = await searchParams; const sharedUrl = extractSharedUrl({ url, text }); @@ -158,7 +162,8 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear initialTag={tagFilter ?? ""} initialBatchCook={batchCookFilter ?? ""} initialRecipeType={recipeTypeFilter ?? ""} - sharedUrl={sharedUrl} + sharedUrl={canImportUrl ? sharedUrl : undefined} + showImportUrl={canImportUrl} /> diff --git a/apps/web/app/(app)/settings/ai/page.tsx b/apps/web/app/(app)/settings/ai/page.tsx index 7893198..aff1e53 100644 --- a/apps/web/app/(app)/settings/ai/page.tsx +++ b/apps/web/app/(app)/settings/ai/page.tsx @@ -6,7 +6,6 @@ import { UNLIMITED, getRecipeCount, getStorageUsedMb } from "@/lib/tiers"; import { ByokManager } from "@/components/settings/byok-manager"; import { ModelPrefsForm } from "@/components/settings/model-prefs-form"; import { UsageQuotaSection } from "@/components/settings/usage-quota-section"; -import { DeveloperLockedNotice } from "@/components/settings/developer-locked-notice"; import { getMessages, formatMessage } from "@/lib/i18n/server"; export const metadata: Metadata = {}; @@ -74,19 +73,17 @@ export default async function AiSettingsPage() { -
-
-

{m.settings.byok.title}

-

- {m.settings.byok.description} -

-
- {dbUser?.isByokEnabled ? ( + {dbUser?.isByokEnabled && ( +
+
+

{m.settings.byok.title}

+

+ {m.settings.byok.description} +

+
k.provider)} /> - ) : ( - - )} -
+
+ )} {dbUser?.isByokEnabled && (
diff --git a/apps/web/app/(app)/shopping-lists/[id]/page.tsx b/apps/web/app/(app)/shopping-lists/[id]/page.tsx index e7687c9..d84d279 100644 --- a/apps/web/app/(app)/shopping-lists/[id]/page.tsx +++ b/apps/web/app/(app)/shopping-lists/[id]/page.tsx @@ -16,6 +16,7 @@ import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list import { ExportMarkdownButton } from "@/components/shared/export-markdown-button"; import { shoppingListToMarkdown } from "@/lib/markdown/shopping-list"; import { getMessages, formatMessage } from "@/lib/i18n/server"; +import { getFeatureFlagMatrix, type Tier } from "@/lib/feature-flags"; type Params = { params: Promise<{ id: string }> }; @@ -37,7 +38,10 @@ export default async function ShoppingListPage({ params }: Params) { if (!list) notFound(); const canEdit = canWriteShoppingList(access.role); - const instacartEnabled = process.env["NEXT_PUBLIC_GROCERY_PROVIDER"] === "instacart"; + const viewerTier = (session.user as { tier?: string }).tier as Tier | undefined ?? "free"; + const featureFlags = await getFeatureFlagMatrix(); + const canExportMarkdown = featureFlags.markdown_export[viewerTier]; + const instacartEnabled = process.env["NEXT_PUBLIC_GROCERY_PROVIDER"] === "instacart" && featureFlags.grocery_delivery[viewerTier]; return (
@@ -63,10 +67,12 @@ export default async function ShoppingListPage({ params }: Params) { } /> {m.common.print} - + {canExportMarkdown && ( + + )} {access.role === "owner" && ( )} diff --git a/apps/web/app/api/v1/ai/import-photo/route.ts b/apps/web/app/api/v1/ai/import-photo/route.ts index 7d4f6e8..e4b0677 100644 --- a/apps/web/app/api/v1/ai/import-photo/route.ts +++ b/apps/web/app/api/v1/ai/import-photo/route.ts @@ -7,6 +7,7 @@ import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error"; import { importFromPhoto } from "@/lib/ai/features/import-photo"; import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key"; import { checkTierLimitInTransaction, TierLimitError } from "@/lib/tiers"; +import { requireFeatureEnabledResponse } from "@/lib/feature-flags"; const Schema = z.object({ imageBase64: z.string().max(14_000_000), @@ -17,6 +18,9 @@ export async function POST(req: NextRequest) { const { session, response } = await requireSessionOrApiKey(req); if (response) return response; + const featureDenied = await requireFeatureEnabledResponse(session!.user.id, "recipe_import_photo"); + if (featureDenied) return featureDenied; + const body = await req.json() as unknown; const parsed = Schema.safeParse(body); if (!parsed.success) { diff --git a/apps/web/app/api/v1/ai/import-url/route.ts b/apps/web/app/api/v1/ai/import-url/route.ts index 4775467..0d5b7ca 100644 --- a/apps/web/app/api/v1/ai/import-url/route.ts +++ b/apps/web/app/api/v1/ai/import-url/route.ts @@ -6,6 +6,7 @@ import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error"; import { importFromUrl } from "@/lib/ai/features/import-url"; import { validateWebhookUrl } from "@/lib/validate-webhook-url"; import { withUserKey } from "@/lib/ai/resolve-user-key"; +import { requireFeatureEnabledResponse } from "@/lib/feature-flags"; const Schema = z.object({ url: z.string().url(), @@ -17,6 +18,9 @@ export async function POST(req: NextRequest) { const { session, response } = await requireSessionOrApiKey(req); if (response) return response; + const featureDenied = await requireFeatureEnabledResponse(session!.user.id, "recipe_import_url"); + if (featureDenied) return featureDenied; + const body = await req.json() as unknown; const parsed = Schema.safeParse(body); if (!parsed.success) { diff --git a/apps/web/app/api/v1/meal-plans/[weekStart]/nutrition/route.ts b/apps/web/app/api/v1/meal-plans/[weekStart]/nutrition/route.ts index 99e03f9..c6c668a 100644 --- a/apps/web/app/api/v1/meal-plans/[weekStart]/nutrition/route.ts +++ b/apps/web/app/api/v1/meal-plans/[weekStart]/nutrition/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { db, mealPlans, mealPlanEntries, userNutritionGoals, eq, and } from "@epicure/db"; import { requireSessionOrApiKey } from "@/lib/api-auth"; +import { requireFeatureEnabledResponse } from "@/lib/feature-flags"; type Params = { params: Promise<{ weekStart: string }> }; @@ -16,6 +17,9 @@ export async function GET(req: NextRequest, { params }: Params) { const { session, response } = await requireSessionOrApiKey(req); if (response) return response; + const featureDenied = await requireFeatureEnabledResponse(session!.user.id, "weekly_nutrition"); + if (featureDenied) return featureDenied; + const { weekStart } = await params; const userId = session!.user.id; diff --git a/apps/web/app/api/v1/recipes/[id]/nutrition/route.ts b/apps/web/app/api/v1/recipes/[id]/nutrition/route.ts index ebe0978..e9eed79 100644 --- a/apps/web/app/api/v1/recipes/[id]/nutrition/route.ts +++ b/apps/web/app/api/v1/recipes/[id]/nutrition/route.ts @@ -5,6 +5,7 @@ import { requireSessionOrApiKey } from "@/lib/api-auth"; import { applyRateLimit } from "@/lib/rate-limit"; import { withAiQuota } from "@/lib/ai/ai-error"; import { estimateNutrition } from "@/lib/ai/features/estimate-nutrition"; +import { requireFeatureEnabledResponse } from "@/lib/feature-flags"; type Params = { params: Promise<{ id: string }> }; @@ -33,6 +34,9 @@ export async function POST(req: NextRequest, { params }: Params) { if (response) return response; const { id } = await params; + const featureDenied = await requireFeatureEnabledResponse(session!.user.id, "nutrition_estimation"); + if (featureDenied) return featureDenied; + const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60); if (limited) return limited; diff --git a/apps/web/app/api/v1/recipes/bulk/export/route.ts b/apps/web/app/api/v1/recipes/bulk/export/route.ts index 3cf9ae1..a944c22 100644 --- a/apps/web/app/api/v1/recipes/bulk/export/route.ts +++ b/apps/web/app/api/v1/recipes/bulk/export/route.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { requireSessionOrApiKey } from "@/lib/api-auth"; import { db, recipes, eq, and, inArray } from "@epicure/db"; import { recipeToMarkdown } from "@/lib/markdown/recipe"; +import { requireFeatureEnabledResponse } from "@/lib/feature-flags"; const ExportSchema = z.object({ ids: z.array(z.string()).min(1).max(100), @@ -12,6 +13,9 @@ export async function POST(req: NextRequest) { const { session, response } = await requireSessionOrApiKey(req); if (response) return response; + const featureDenied = await requireFeatureEnabledResponse(session!.user.id, "markdown_export"); + if (featureDenied) return featureDenied; + const body = ExportSchema.safeParse(await req.json()); if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 }); diff --git a/apps/web/app/api/v1/shopping-lists/[id]/export/instacart/route.ts b/apps/web/app/api/v1/shopping-lists/[id]/export/instacart/route.ts index 278cee9..9cbdf1f 100644 --- a/apps/web/app/api/v1/shopping-lists/[id]/export/instacart/route.ts +++ b/apps/web/app/api/v1/shopping-lists/[id]/export/instacart/route.ts @@ -4,6 +4,7 @@ import { requireSessionOrApiKey } from "@/lib/api-auth"; import { getShoppingListAccess } from "@/lib/shopping-list-access"; import { buildGroceryExportPayload } from "@/lib/grocery-export"; import { createInstacartShoppingListLink } from "@/lib/grocery-providers/instacart"; +import { requireFeatureEnabledResponse } from "@/lib/feature-flags"; type Params = { params: Promise<{ id: string }> }; @@ -12,6 +13,9 @@ export async function POST(req: NextRequest, { params }: Params) { if (response) return response; const { id } = await params; + const featureDenied = await requireFeatureEnabledResponse(session!.user.id, "grocery_delivery"); + if (featureDenied) return featureDenied; + const access = await getShoppingListAccess(id, session!.user.id); if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 }); diff --git a/apps/web/components/admin/feature-flags-form.tsx b/apps/web/components/admin/feature-flags-form.tsx index 74b289d..22c7d56 100644 --- a/apps/web/components/admin/feature-flags-form.tsx +++ b/apps/web/components/admin/feature-flags-form.tsx @@ -48,7 +48,7 @@ export function FeatureFlagsForm({

Feature Toggles

- Disable a feature for a tier to keep its button visible but gated — clicking it shows an upgrade prompt instead of running. + Disable a feature for a tier to hide it for that tier's users (most features hide entirely; a few show a locked/upgrade state instead — see each feature's actual behavior in the app).

diff --git a/apps/web/components/marketing/language-switcher.tsx b/apps/web/components/marketing/language-switcher.tsx index e973ed1..7b020c6 100644 --- a/apps/web/components/marketing/language-switcher.tsx +++ b/apps/web/components/marketing/language-switcher.tsx @@ -10,7 +10,7 @@ import { } from "@/components/ui/dropdown-menu"; import { MARKETING_LOCALE_COOKIE } from "@/lib/marketing-locale-cookie"; import type { Locale } from "@/lib/i18n/provider"; -import { FlagGB, FlagFR } from "./flag-icons"; +import { FlagGB, FlagFR } from "@/components/shared/flag-icons"; const OPTIONS: { code: Locale; Flag: typeof FlagGB; label: string }[] = [ { code: "en", Flag: FlagGB, label: "English" }, diff --git a/apps/web/components/pantry/pantry-page-header.tsx b/apps/web/components/pantry/pantry-page-header.tsx index f3cb7fd..a211c90 100644 --- a/apps/web/components/pantry/pantry-page-header.tsx +++ b/apps/web/components/pantry/pantry-page-header.tsx @@ -9,7 +9,7 @@ import { pantryToMarkdown } from "@/lib/markdown/pantry"; type PantryItem = { rawName: string; quantity: string | null; unit: string | null; expiresAt: string | null }; -export function PantryPageHeader({ items }: { items: PantryItem[] }) { +export function PantryPageHeader({ items, canExportMarkdown = true }: { items: PantryItem[]; canExportMarkdown?: boolean }) { const t = useTranslations("pantry"); const tCommon = useTranslations("common"); return ( @@ -27,7 +27,7 @@ export function PantryPageHeader({ items }: { items: PantryItem[] }) { {tCommon("print")} - + {canExportMarkdown && }
); diff --git a/apps/web/components/recipe/nutrition-panel.tsx b/apps/web/components/recipe/nutrition-panel.tsx index fbcc97b..aaadb8b 100644 --- a/apps/web/components/recipe/nutrition-panel.tsx +++ b/apps/web/components/recipe/nutrition-panel.tsx @@ -20,9 +20,13 @@ interface NutritionPanelProps { recipeId: string; initialData?: NutritionData | null; initialManual?: boolean; + /** AI/USDA estimation feature toggled off for this tier — hides the + * (re-)estimate action. Previously-stored data (manual or a past + * estimate) still displays; there's just no button to refresh it. */ + estimateEnabled?: boolean; } -export function NutritionPanel({ recipeId, initialData, initialManual }: NutritionPanelProps) { +export function NutritionPanel({ recipeId, initialData, initialManual, estimateEnabled = true }: NutritionPanelProps) { const t = useTranslations("nutritionPanel"); const [nutrition, setNutrition] = useState(initialData ?? null); const [manual, setManual] = useState(!!initialManual); @@ -50,6 +54,7 @@ export function NutritionPanel({ recipeId, initialData, initialManual }: Nutriti } if (!nutrition && !loading) { + if (!estimateEnabled) return null; return (
{error &&

{error}

} @@ -65,15 +70,17 @@ export function NutritionPanel({ recipeId, initialData, initialManual }: Nutriti
{t("title")} - + {estimateEnabled && ( + + )}
{manual && !loading && (

{t("manualBadge")}

diff --git a/apps/web/components/recipe/recipes-header.tsx b/apps/web/components/recipe/recipes-header.tsx index a04fc26..de52dd3 100644 --- a/apps/web/components/recipe/recipes-header.tsx +++ b/apps/web/components/recipe/recipes-header.tsx @@ -88,6 +88,7 @@ export function RecipesHeader({ initialBatchCook = "", initialRecipeType = "", sharedUrl, + showImportUrl = true, }: { count: number; initialQuery?: string; @@ -103,6 +104,9 @@ export function RecipesHeader({ * Auto-opens the import dialog pre-filled instead of requiring the user * to paste the link again. */ sharedUrl?: string; + /** Tier feature flag (recipe_import_url) — hides the button and dialog + * entirely when off, not just disabled. */ + showImportUrl?: boolean; }) { const router = useRouter(); const pathname = usePathname(); @@ -160,10 +164,12 @@ export function RecipesHeader({ {t("generate")} - + {showImportUrl && ( + + )} {t("new")} diff --git a/apps/web/components/settings/settings-form.tsx b/apps/web/components/settings/settings-form.tsx index 1c091b4..378bcca 100644 --- a/apps/web/components/settings/settings-form.tsx +++ b/apps/web/components/settings/settings-form.tsx @@ -10,8 +10,11 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Switch } from "@/components/ui/switch"; import { useTranslations } from "next-intl"; import { useLocale, SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/provider"; +import { FlagGB, FlagFR } from "@/components/shared/flag-icons"; import { AvatarUploader } from "./avatar-uploader"; +const LOCALE_FLAGS: Record = { en: FlagGB, fr: FlagFR }; + const USERNAME_PATTERN = /^[a-z0-9_]{3,20}$/; type UserProps = { @@ -282,11 +285,17 @@ export function SettingsForm({ user }: { user: UserProps }) { - {SUPPORTED_LOCALES.map((l) => ( - - {l.label} - - ))} + {SUPPORTED_LOCALES.map((l) => { + const Flag = LOCALE_FLAGS[l.code]; + return ( + + + + {l.label} + + + ); + })}
diff --git a/apps/web/components/marketing/flag-icons.tsx b/apps/web/components/shared/flag-icons.tsx similarity index 100% rename from apps/web/components/marketing/flag-icons.tsx rename to apps/web/components/shared/flag-icons.tsx diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 0b14957..c891434 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -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", diff --git a/apps/web/lib/feature-flags.ts b/apps/web/lib/feature-flags.ts index ce5ede7..bf74333 100644 --- a/apps/web/lib/feature-flags.ts +++ b/apps/web/lib/feature-flags.ts @@ -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 = 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; 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 { + 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; + } +} diff --git a/apps/web/lib/openapi.ts b/apps/web/lib/openapi.ts index 13260ce..dc49660 100644 --- a/apps/web/lib/openapi.ts +++ b/apps/web/lib/openapi.ts @@ -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 } } } } }); diff --git a/apps/web/package.json b/apps/web/package.json index 7f0e4e7..f06a87f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.73.1", + "version": "0.74.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index 1274c0c..454976d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.73.1", + "version": "0.74.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",