diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b6f3af..c6c88cb 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.80.0 — 2026-07-24 16:45 + +### Added +- Recipe version history is now a 10th per-tier feature flag (enabled by default, same as variations/pairings), with the same locked-vs-hidden treatment as the others. +- Upgrade prompts for locked features now show real detail — a tagline and 2-3 concrete bullet points per feature — instead of one generic sentence for every feature. + +### Fixed +- The AI-generate dialog's Photo tab was a second, completely unguarded entry point into recipe_import_photo — reachable and fully functional regardless of the feature being disabled for your tier, even though the dedicated Photo Import button was correctly gated. Now hidden/locked in step with every other entry point (server-side enforcement in the API route was already correct; this was a UI-only gap). + ## 0.79.0 — 2026-07-24 16:00 ### Fixed diff --git a/FEATURE_AUDIT.md b/FEATURE_AUDIT.md index 950dbb8..5d0332d 100644 --- a/FEATURE_AUDIT.md +++ b/FEATURE_AUDIT.md @@ -116,8 +116,9 @@ 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 9 capabilities by tier: recipe_variations/drink_pairing/meal_pairing (default enabled) plus recipe_import_url, recipe_import_photo, nutrition_estimation, markdown_export, weekly_nutrition, grocery_delivery (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` | -| Locked-vs-hidden rule for gated features (standardized 2026-07-24) | Exists | Consistent rule across all 9 flags via `isFeatureAvailableAnyTier()`: if a feature is enabled on **at least one** tier, it stays visible for locked-out viewers with a small "Pro" badge (in the tooltip for icon buttons, inline for text buttons) and clicking opens `UpgradeDialog` instead of the real action; if a feature is disabled on **every** tier, it hides entirely (no dead-end upsell for something nobody can unlock). Previously inconsistent — some features hid outright, one (variations) showed a lock icon overlapping its own icon. Applies to: variations, meal/drink pairing, nutrition estimation, markdown export (5 call sites: recipe/meal-plan/shopping-list/collection/pantry), weekly nutrition, import-from-URL, import-from-photo, and the Instacart grocery-delivery menu item (which additionally requires `NEXT_PUBLIC_GROCERY_PROVIDER=instacart` to be configured before it's ever considered "available"). | `apps/web/lib/feature-flags.ts` (`isFeatureAvailableAnyTier`), `apps/web/components/premium/pro-badge.tsx` | +| Feature flags (per-tier) vs feature prefs (per-user cosmetic) | Exists, two distinct systems | Flags gate 10 capabilities by tier: recipe_variations/drink_pairing/meal_pairing/version_history (default enabled) plus recipe_import_url, recipe_import_photo, nutrition_estimation, markdown_export, weekly_nutrition, grocery_delivery (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` | +| Locked-vs-hidden rule for gated features (standardized 2026-07-24) | Exists | Consistent rule across all 10 flags via `isFeatureAvailableAnyTier()`: if a feature is enabled on **at least one** tier, it stays visible for locked-out viewers with a small "Pro" badge (in the tooltip for icon buttons, inline for text buttons) and clicking opens `UpgradeDialog` instead of the real action; if a feature is disabled on **every** tier, it hides entirely (no dead-end upsell for something nobody can unlock). Previously inconsistent — some features hid outright, one (variations) showed a lock icon overlapping its own icon. Applies to: variations, meal/drink pairing, nutrition estimation, markdown export (5 call sites: recipe/meal-plan/shopping-list/collection/pantry), weekly nutrition, import-from-URL, import-from-photo, recipe version history, and the Instacart grocery-delivery menu item (which additionally requires `NEXT_PUBLIC_GROCERY_PROVIDER=instacart` to be configured before it's ever considered "available"). `UpgradeDialog` itself now shows a per-feature tagline + bullet list (`FEATURE_COPY` in the dialog component) instead of one generic sentence for every feature. | `apps/web/lib/feature-flags.ts` (`isFeatureAvailableAnyTier`), `apps/web/components/premium/{pro-badge,upgrade-dialog}.tsx` | +| AI-generate dialog's Photo tab bypassed the photo-import gate (closed 2026-07-24) | Exists | A second, unguarded front door into `recipe_import_photo` — the tab in `AiGenerateDialog` had no tier check at all, while the dedicated `PhotoImportButton` on `/recipes/new` did. Server-side `requireFeatureEnabledResponse` on the API route meant a fully-disabled feature still 403'd, but a viewer without the tier could reach the tab, fill it in, and hit a dead-end error instead of an upgrade prompt. Now the tab hides/locks in step with `PhotoImportButton`, threaded from `RecipesHeader`. | `apps/web/components/recipe/ai-generate-dialog.tsx`, `apps/web/components/recipe/recipes-header.tsx` | | **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)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx index eaf7fee..6c58d75 100644 --- a/apps/web/app/(app)/recipes/[id]/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/page.tsx @@ -117,6 +117,7 @@ export default async function RecipePage({ params }: Params) { mealPairing: !featureFlags.meal_pairing[viewerTier], nutritionEstimation: !featureFlags.nutrition_estimation[viewerTier], markdownExport: !featureFlags.markdown_export[viewerTier], + versionHistory: !featureFlags.version_history[viewerTier], }; // A feature disabled for every tier has no upgrade path, so it hides // outright; one enabled for at least one tier still shows (locked, with a @@ -127,6 +128,7 @@ export default async function RecipePage({ params }: Params) { mealPairing: isFeatureAvailableAnyTier(featureFlags, "meal_pairing"), nutritionEstimation: isFeatureAvailableAnyTier(featureFlags, "nutrition_estimation"), markdownExport: isFeatureAvailableAnyTier(featureFlags, "markdown_export"), + versionHistory: isFeatureAvailableAnyTier(featureFlags, "version_history"), }; const isOwner = recipe.authorId === session.user.id; @@ -289,23 +291,26 @@ export default async function RecipePage({ params }: Params) { )} {isOwner && ( <> - ({ - rawName: ing.rawName, - quantity: ing.quantity, - unit: ing.unit, - note: ing.note, - })), - steps: recipe.steps.map((s) => ({ - instruction: s.instruction, - timerSeconds: s.timerSeconds, - })), - }} - /> + {available.versionHistory && ( + ({ + rawName: ing.rawName, + quantity: ing.quantity, + unit: ing.unit, + note: ing.note, + })), + steps: recipe.steps.map((s) => ({ + instruction: s.instruction, + timerSeconds: s.timerSeconds, + })), + }} + locked={locked.versionHistory} + /> + )} diff --git a/apps/web/app/(app)/recipes/page.tsx b/apps/web/app/(app)/recipes/page.tsx index d8f6fa2..66e283c 100644 --- a/apps/web/app/(app)/recipes/page.tsx +++ b/apps/web/app/(app)/recipes/page.tsx @@ -64,6 +64,8 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear const featureFlags = await getFeatureFlagMatrix(); const importUrlLocked = !featureFlags.recipe_import_url[viewerTier]; const importUrlAvailable = isFeatureAvailableAnyTier(featureFlags, "recipe_import_url"); + const importPhotoLocked = !featureFlags.recipe_import_photo[viewerTier]; + const importPhotoAvailable = isFeatureAvailableAnyTier(featureFlags, "recipe_import_photo"); const { q, sort, visibility, difficulty, tag, page: pageParam, batchCook, recipeType, url, text } = await searchParams; const sharedUrl = extractSharedUrl({ url, text }); @@ -166,6 +168,8 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear sharedUrl={importUrlAvailable && !importUrlLocked ? sharedUrl : undefined} importUrlAvailable={importUrlAvailable} importUrlLocked={importUrlLocked} + importPhotoAvailable={importPhotoAvailable} + importPhotoLocked={importPhotoLocked} /> diff --git a/apps/web/app/api/v1/recipes/[id]/versions/[versionId]/route.ts b/apps/web/app/api/v1/recipes/[id]/versions/[versionId]/route.ts index 03dddd0..d448575 100644 --- a/apps/web/app/api/v1/recipes/[id]/versions/[versionId]/route.ts +++ b/apps/web/app/api/v1/recipes/[id]/versions/[versionId]/route.ts @@ -2,12 +2,17 @@ import { NextRequest, NextResponse } from "next/server"; import { db, recipes, recipeIngredients, recipeSteps, recipeSnapshots } from "@epicure/db"; import { eq, and, max, desc } from "@epicure/db"; import { requireSessionOrApiKey } from "@/lib/api-auth"; +import { requireFeatureEnabledResponse } from "@/lib/feature-flags"; type Params = { params: Promise<{ id: string; versionId: string }> }; export async function GET(req: NextRequest, { params }: Params) { const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } }); if (response) return response; + + const featureDenied = await requireFeatureEnabledResponse(session!.user.id, "version_history"); + if (featureDenied) return featureDenied; + const { id, versionId } = await params; // Verify ownership @@ -31,6 +36,10 @@ export async function GET(req: NextRequest, { params }: Params) { export async function POST(req: NextRequest, { params }: Params) { const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } }); if (response) return response; + + const featureDenied = await requireFeatureEnabledResponse(session!.user.id, "version_history"); + if (featureDenied) return featureDenied; + const { id, versionId } = await params; const body = await req.json() as { action?: string }; diff --git a/apps/web/app/api/v1/recipes/[id]/versions/route.ts b/apps/web/app/api/v1/recipes/[id]/versions/route.ts index c866700..37dba82 100644 --- a/apps/web/app/api/v1/recipes/[id]/versions/route.ts +++ b/apps/web/app/api/v1/recipes/[id]/versions/route.ts @@ -2,12 +2,17 @@ import { NextRequest, NextResponse } from "next/server"; import { db, recipes, recipeSnapshots } from "@epicure/db"; import { eq, and, desc } from "@epicure/db"; import { requireSessionOrApiKey } from "@/lib/api-auth"; +import { requireFeatureEnabledResponse } from "@/lib/feature-flags"; type Params = { params: Promise<{ id: string }> }; export async function GET(req: NextRequest, { params }: Params) { const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } }); if (response) return response; + + const featureDenied = await requireFeatureEnabledResponse(session!.user.id, "version_history"); + if (featureDenied) return featureDenied; + const { id } = await params; // Verify ownership diff --git a/apps/web/components/premium/upgrade-dialog.tsx b/apps/web/components/premium/upgrade-dialog.tsx index 5431350..f77e50f 100644 --- a/apps/web/components/premium/upgrade-dialog.tsx +++ b/apps/web/components/premium/upgrade-dialog.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { Sparkles } from "lucide-react"; +import { Sparkles, Check } from "lucide-react"; import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { @@ -13,6 +13,93 @@ import { DialogFooter, } from "@/components/ui/dialog"; +// Marketing copy per feature — kept client-side (not sourced from +// lib/feature-flags.ts's FEATURE_DEFINITIONS) since that module pulls in +// the DB client for server-only helpers and can't be bundled for the +// browser. Falls back to a generic pitch for any key not listed here. +const FEATURE_COPY: Record = { + recipe_variations: { + tagline: "Get AI-generated twists on any recipe you save — dietary swaps, flavor changes, or a whole new spin — without starting from scratch.", + bullets: [ + "Dietary swaps (vegan, gluten-free, dairy-free, and more)", + "Flavor variations generated from your existing recipe", + "Saved as a new recipe, linked back to the original", + ], + }, + drink_pairing: { + tagline: "Never wonder what to serve alongside dinner again — get drink pairings suggested for any dish.", + bullets: [ + "Wine, beer, cocktail, and non-alcoholic suggestions", + "Tailored to the specific dish, not generic pairing charts", + "Explains why each pairing works", + ], + }, + meal_pairing: { + tagline: "Turn a single recipe into a full meal — get side dish, salad, and sauce suggestions that actually complement it.", + bullets: [ + "Starters, sides, salads, breads, and sauces suggested per dish", + "One click to generate and save any suggestion as its own recipe", + "Pick multiple pairings and generate them all at once", + ], + }, + recipe_import_url: { + tagline: "Stop retyping recipes from other sites — paste a link and get a fully structured recipe in seconds.", + bullets: [ + "Works with most recipe blogs and sites", + "Extracts ingredients, steps, servings, and timing automatically", + "Edit anything before saving", + ], + }, + recipe_import_photo: { + tagline: "Digitize a cookbook page or handwritten family recipe just by photographing it.", + bullets: [ + "Recognizes printed or handwritten recipes", + "Extracts ingredients and steps automatically", + "Saved as a private, editable recipe", + ], + }, + nutrition_estimation: { + tagline: "Know what's in every dish — get calorie and macro estimates for any recipe, no manual entry required.", + bullets: [ + "Calories, protein, carbs, fat, fiber, and sodium per serving", + "Combines AI estimation with USDA reference data", + "Powers your weekly nutrition rollup on the meal plan", + ], + }, + markdown_export: { + tagline: "Take your recipes, meal plans, and shopping lists anywhere — export clean Markdown you can paste into any note app.", + bullets: [ + "Works on recipes, collections, meal plans, and pantry lists", + "Copy to clipboard or download as a .md file", + "Clean formatting, no app lock-in", + ], + }, + weekly_nutrition: { + tagline: "See how your whole week stacks up against your nutrition goals, not just one meal at a time.", + bullets: [ + "Daily average calories and macros across your meal plan week", + "Compared directly against your saved nutrition goals", + "Flags days with missing nutrition data", + ], + }, + version_history: { + tagline: "Never lose a good version of a recipe — every edit is saved, so you can compare or roll back anytime.", + bullets: [ + "Every save keeps a full snapshot of the prior version", + "Side-by-side diff view to see exactly what changed", + "Restore any past version with one click", + ], + }, + grocery_delivery: { + tagline: "Skip the manual shopping trip — send your list straight to a grocery delivery provider.", + bullets: [ + "One click from your shopping list to checkout", + "Keeps quantities and aisle grouping intact", + "No re-typing your list into another app", + ], + }, +}; + export function UpgradeDialog({ open, onOpenChange, @@ -24,18 +111,33 @@ export function UpgradeDialog({ featureKey: string; featureLabel: string; }) { + const copy = FEATURE_COPY[featureKey]; + return ( - A Pro feature + {featureLabel} - {featureLabel} is available on the Pro plan (€4.99/mo). Free accounts don't include it. + {copy?.tagline ?? `${featureLabel} is available on the Pro plan. Free accounts don't include it.`} + {copy && ( +
    + {copy.bullets.map((bullet) => ( +
  • + + {bullet} +
  • + ))} +
+ )} +

+ Included on the Pro plan (€4.99/mo). +

void; + /** Tier feature flag (recipe_import_photo) disabled for every tier — + * hides the Photo tab entirely, since there's no upgrade path. */ + photoTabAvailable?: boolean; + /** Available on some tier but not the viewer's — the tab still shows + * (with a "Pro" upsell); generating opens an upgrade prompt instead of + * calling the import API. */ + photoTabLocked?: boolean; }) { const t = useTranslations("ai.generate"); const tCommon = useTranslations("common"); @@ -90,6 +101,7 @@ export function AiGenerateDialog({ }); const [busy, setBusy] = useState(false); + const [upgradeOpen, setUpgradeOpen] = useState(false); function handleClose() { if (busy) return; @@ -150,6 +162,10 @@ export function AiGenerateDialog({ async function handlePhotoGenerate() { if (!photoBase64) return; + if (photoTabLocked) { + setUpgradeOpen(true); + return; + } setBusy(true); try { const res = await fetch("/api/v1/ai/import-photo", { @@ -206,6 +222,7 @@ export function AiGenerateDialog({ const canSubmit = tab === "describe" ? !!prompt.trim() : tab === "photo" ? !!photoBase64 : batchFields.dinners + batchFields.lunches > 0; return ( + <>
@@ -223,7 +240,7 @@ export function AiGenerateDialog({
{([ { key: "describe", label: t("tabDescribe"), icon: Type }, - { key: "photo", label: t("tabPhoto"), icon: Camera }, + ...(photoTabAvailable ? [{ key: "photo" as Tab, label: t("tabPhoto"), icon: Camera }] : []), { key: "batch", label: tBatch("title"), icon: ChefHat }, ] as { key: Tab; label: string; icon: React.ElementType }[]).map(({ key, label, icon: Icon }) => ( ))}
@@ -370,5 +388,12 @@ export function AiGenerateDialog({
+ + ); } diff --git a/apps/web/components/recipe/recipes-header.tsx b/apps/web/components/recipe/recipes-header.tsx index 2e25b29..051362d 100644 --- a/apps/web/components/recipe/recipes-header.tsx +++ b/apps/web/components/recipe/recipes-header.tsx @@ -92,6 +92,8 @@ export function RecipesHeader({ sharedUrl, importUrlAvailable = true, importUrlLocked = false, + importPhotoAvailable = true, + importPhotoLocked = false, }: { count: number; initialQuery?: string; @@ -114,6 +116,9 @@ export function RecipesHeader({ * (with a "Pro" upsell) and opens an upgrade prompt instead of the * import dialog. */ importUrlLocked?: boolean; + /** Same idea, for the AI-generate dialog's Photo tab (recipe_import_photo). */ + importPhotoAvailable?: boolean; + importPhotoLocked?: boolean; }) { const router = useRouter(); const pathname = usePathname(); @@ -340,7 +345,12 @@ export function RecipesHeader({ - + >({}); const [restoringId, setRestoringId] = useState(null); const [comparingId, setComparingId] = useState(null); + const [upgradeOpen, setUpgradeOpen] = useState(false); async function handleOpen(isOpen: boolean) { + if (isOpen && locked) { + setUpgradeOpen(true); + return; + } setOpen(isOpen); if (isOpen) { setLoading(true); @@ -137,7 +149,10 @@ export function VersionHistoryButton({ } /> - {t("historyTooltip")} + + {t("historyTooltip")} + {locked && } +
@@ -247,6 +262,12 @@ export function VersionHistoryButton({ )} + ); } diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 9717203..5cd62ff 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.79.0"; +export const APP_VERSION = "0.80.0"; export type ChangelogEntry = { version: string; @@ -11,6 +11,17 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.80.0", + date: "2026-07-24 16:45", + added: [ + "Recipe version history is now a 10th per-tier feature flag (enabled by default, same as variations/pairings), with the same locked-vs-hidden treatment as the others.", + "Upgrade prompts for locked features now show real detail — a tagline and 2-3 concrete bullet points per feature — instead of one generic sentence for every feature.", + ], + fixed: [ + "The AI-generate dialog's Photo tab was a second, completely unguarded entry point into recipe_import_photo — reachable and fully functional regardless of the feature being disabled for your tier, even though the dedicated Photo Import button was correctly gated. Now hidden/locked in step with every other entry point (server-side enforcement in the API route was already correct; this was a UI-only gap).", + ], + }, { version: "0.79.0", date: "2026-07-24 16:00", diff --git a/apps/web/lib/feature-flags.ts b/apps/web/lib/feature-flags.ts index 76e792d..b19d3a0 100644 --- a/apps/web/lib/feature-flags.ts +++ b/apps/web/lib/feature-flags.ts @@ -23,6 +23,12 @@ export const FEATURE_DEFINITIONS = [ description: "AI-suggested side dish / meal pairings for a recipe.", defaultEnabled: true, }, + { + key: "version_history", + label: "Recipe version history", + description: "Snapshots of a recipe's prior versions, with diff/compare and restore.", + defaultEnabled: true, + }, // The following ship disabled by default (2026-07-24) — turn on per tier // from this page once ready, rather than a code change. { diff --git a/apps/web/lib/openapi.ts b/apps/web/lib/openapi.ts index af6d222..7aca92a 100644 --- a/apps/web/lib/openapi.ts +++ b/apps/web/lib/openapi.ts @@ -296,9 +296,9 @@ export function generateOpenApiSpec(): object { 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 } } } } }); - registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/versions", summary: "List version history (owner only)", security, request: { params: idParam }, responses: { 200: { description: "Versions, newest first", content: { "application/json": { schema: z.array(RecipeVersionSummaryRef) } } }, 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}/versions/{versionId}", summary: "Get a specific version snapshot (owner only)", security, request: { params: z.object({ id: z.string(), versionId: z.string() }) }, responses: { 200: { description: "Snapshot", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 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}/versions/{versionId}", summary: "Restore a version (owner only)", security, request: { params: z.object({ id: z.string(), versionId: z.string() }), body: { content: { "application/json": { schema: z.object({ action: z.literal("restore") }) } }, required: true } }, responses: { 200: { description: "Restored", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid action", content: { "application/json": { schema: ApiErrorRef } } }, 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}/versions", summary: "List version history (owner only)", description: "Gated by the version_history tier feature flag.", security, request: { params: idParam }, responses: { 200: { description: "Versions, newest first", content: { "application/json": { schema: z.array(RecipeVersionSummaryRef) } } }, 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 } } } } }); + registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/versions/{versionId}", summary: "Get a specific version snapshot (owner only)", description: "Gated by the version_history tier feature flag.", security, request: { params: z.object({ id: z.string(), versionId: z.string() }) }, responses: { 200: { description: "Snapshot", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 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 } } } } }); + registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/versions/{versionId}", summary: "Restore a version (owner only)", description: "Gated by the version_history tier feature flag.", security, request: { params: z.object({ id: z.string(), versionId: z.string() }), body: { content: { "application/json": { schema: z.object({ action: z.literal("restore") }) } }, required: true } }, responses: { 200: { description: "Restored", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid action", content: { "application/json": { schema: ApiErrorRef } } }, 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 } } } } }); registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/comments/{commentId}/reactions", summary: "Get reaction counts (+ your own reactions, if authenticated)", security: [], request: { params: z.object({ id: z.string(), commentId: z.string() }) }, responses: { 200: { description: "Counts", content: { "application/json": { schema: z.object({ counts: z.record(z.string(), z.number()), userReactions: z.array(z.string()) }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); 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 } } } } }); diff --git a/apps/web/package.json b/apps/web/package.json index 1b53fbb..b4bd502 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.79.0", + "version": "0.80.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index 1267658..d857af5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.79.0", + "version": "0.80.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",