diff --git a/CHANGELOG.md b/CHANGELOG.md index 278807e..1ec7f85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ 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.39.0 — 2026-07-17 13:30 + +### Fixed +- Adapting/varying a recipe with AI could fail silently (no error shown) on a network hiccup, and could save a byte-for-byte duplicate recipe when the AI's output didn't actually differ from the original — both flows now surface real errors and skip saving when nothing changed. AI-adapted recipes also now show up in recipe history (Forked from…) like forks always have. + ## 0.38.0 — 2026-07-17 13:00 ### Added diff --git a/apps/web/app/api/v1/ai/adapt/[id]/route.ts b/apps/web/app/api/v1/ai/adapt/[id]/route.ts index 382c784..a1acf96 100644 --- a/apps/web/app/api/v1/ai/adapt/[id]/route.ts +++ b/apps/web/app/api/v1/ai/adapt/[id]/route.ts @@ -1,12 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { and, eq } from "@epicure/db"; -import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db"; +import { db, recipes, recipeIngredients, recipeSteps, recipeVariations } from "@epicure/db"; import { requireSessionOrApiKey } from "@/lib/api-auth"; import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error"; import { adaptRecipe } from "@/lib/ai/features/adapt-recipe"; import { withUserKey } from "@/lib/ai/resolve-user-key"; import { getUserPrivateBio } from "@/lib/ai/user-bio"; +import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers"; +import { isRecipeUnchanged } from "@/lib/recipe-diff"; const Schema = z.object({ excludeIngredients: z.array(z.string()).default([]), @@ -70,52 +72,81 @@ export async function POST(req: NextRequest, { params }: Params) { if (!result.ok) return result.response; const adapted = result.data; + // The AI sometimes "adapts" a recipe into something byte-for-byte + // identical (e.g. it decides the constraints are already satisfied) — + // don't persist a duplicate recipe/variation when nothing actually changed. + if (isRecipeUnchanged(recipe, adapted)) { + return NextResponse.json({ id: recipe.id, adaptationNotes: adapted.adaptationNotes, unchanged: true }); + } + + try { + await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro", "recipe"); + } catch (err) { + if (err instanceof TierLimitError) { + return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 }); + } + throw err; + } + const newId = crypto.randomUUID(); const now = new Date(); - await db.transaction(async (tx) => { - await tx.insert(recipes).values({ - id: newId, - authorId: userId, - title: adapted.title, - description: adapted.description, - baseServings: adapted.baseServings, - visibility: "private", - difficulty: adapted.difficulty, - prepMins: adapted.prepMins, - cookMins: adapted.cookMins, - dietaryTags: adapted.dietaryTags ?? {}, - aiGenerated: true, - createdAt: now, - updatedAt: now, + try { + await db.transaction(async (tx) => { + await tx.insert(recipes).values({ + id: newId, + authorId: userId, + title: adapted.title, + description: adapted.description, + baseServings: adapted.baseServings, + visibility: "private", + difficulty: adapted.difficulty, + prepMins: adapted.prepMins, + cookMins: adapted.cookMins, + dietaryTags: adapted.dietaryTags ?? {}, + aiGenerated: true, + createdAt: now, + updatedAt: now, + }); + + if (adapted.ingredients.length > 0) { + await tx.insert(recipeIngredients).values( + adapted.ingredients.map((ing, i) => ({ + id: crypto.randomUUID(), + recipeId: newId, + rawName: ing.rawName, + quantity: ing.quantity !== undefined ? String(ing.quantity) : undefined, + unit: ing.unit, + note: ing.note, + order: i, + })) + ); + } + + if (adapted.steps.length > 0) { + await tx.insert(recipeSteps).values( + adapted.steps.map((step, i) => ({ + id: crypto.randomUUID(), + recipeId: newId, + instruction: step.instruction, + timerSeconds: step.timerSeconds, + order: i, + })) + ); + } + + await tx.insert(recipeVariations).values({ + id: crypto.randomUUID(), + parentRecipeId: recipe.id, + childRecipeId: newId, + description: adapted.adaptationNotes, + aiGenerated: true, + createdAt: now, + }); }); - - if (adapted.ingredients.length > 0) { - await tx.insert(recipeIngredients).values( - adapted.ingredients.map((ing, i) => ({ - id: crypto.randomUUID(), - recipeId: newId, - rawName: ing.rawName, - quantity: ing.quantity !== undefined ? String(ing.quantity) : undefined, - unit: ing.unit, - note: ing.note, - order: i, - })) - ); - } - - if (adapted.steps.length > 0) { - await tx.insert(recipeSteps).values( - adapted.steps.map((step, i) => ({ - id: crypto.randomUUID(), - recipeId: newId, - instruction: step.instruction, - timerSeconds: step.timerSeconds, - order: i, - })) - ); - } - }); + } catch { + return NextResponse.json({ error: "Failed to save the adapted recipe. Please try again." }, { status: 500 }); + } return NextResponse.json({ id: newId, adaptationNotes: adapted.adaptationNotes }); } diff --git a/apps/web/components/recipe/adapt-recipe-button.tsx b/apps/web/components/recipe/adapt-recipe-button.tsx index a045e87..016323f 100644 --- a/apps/web/components/recipe/adapt-recipe-button.tsx +++ b/apps/web/components/recipe/adapt-recipe-button.tsx @@ -75,12 +75,18 @@ export function AdaptRecipeButton({ return; } - const { id, adaptationNotes: notes } = await res.json() as { id: string; adaptationNotes: string }; + const { id, adaptationNotes: notes, unchanged } = await res.json() as { id: string; adaptationNotes: string; unchanged?: boolean }; setAdaptationNotes(notes); - toast.success(t("adapted")); setOpen(false); reset(); + if (unchanged) { + toast.info(t("adaptUnchanged")); + return; + } + toast.success(t("adapted")); router.push(`/recipes/${id}/edit`); + } catch { + toast.error(t("adaptFailed")); } finally { setAdapting(false); } diff --git a/apps/web/components/recipe/variations-dialog.tsx b/apps/web/components/recipe/variations-dialog.tsx index 1ed742c..a41eefa 100644 --- a/apps/web/components/recipe/variations-dialog.tsx +++ b/apps/web/components/recipe/variations-dialog.tsx @@ -18,6 +18,7 @@ import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; +import { isRecipeUnchanged } from "@/lib/recipe-diff"; type Variation = { title: string; @@ -86,6 +87,8 @@ export function VariationsDialog({ } const data = await res.json() as { variations: Variation[] }; setVariations(data.variations); + } catch { + toast.error(tv("error")); } finally { setGenerating(false); } @@ -112,6 +115,16 @@ export function VariationsDialog({ return step; }); + // The AI's changedIngredients/changedSteps are matched against the + // original by substring — if none of them actually matched anything + // (free-text vs. free-text mismatch), this "variation" is really just + // a byte-for-byte copy. Saving it would silently create a duplicate + // recipe with nothing different from the original. + if (isRecipeUnchanged({ baseServings, ingredients, steps }, { baseServings, ingredients: updatedIngredients, steps: updatedSteps })) { + toast.error(tv("noChanges")); + return; + } + const saveRes = await fetch("/api/v1/recipes", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -149,6 +162,8 @@ export function VariationsDialog({ toast.success(tv("success")); onOpenChange(false); router.push(`/recipes/${saved.id}/edit`); + } catch { + toast.error(tv("saveError")); } finally { setApplying(null); } diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 8f3fd78..fd1e104 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.38.0"; +export const APP_VERSION = "0.39.0"; export type ChangelogEntry = { version: string; @@ -11,6 +11,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.39.0", + date: "2026-07-17 13:30", + fixed: [ + "Adapting/varying a recipe with AI could fail silently (no error shown) on a network hiccup, and could save a byte-for-byte duplicate recipe when the AI's output didn't actually differ from the original — both flows now surface real errors and skip saving when nothing changed. AI-adapted recipes also now show up in recipe history (Forked from…) like forks always have.", + ], + }, { version: "0.38.0", date: "2026-07-17 13:00", diff --git a/apps/web/lib/recipe-diff.ts b/apps/web/lib/recipe-diff.ts new file mode 100644 index 0000000..a38ce9f --- /dev/null +++ b/apps/web/lib/recipe-diff.ts @@ -0,0 +1,31 @@ +type DiffIngredient = { rawName: string; quantity?: string | number | null; unit?: string | null }; +type DiffStep = { instruction: string }; + +const norm = (s: string) => s.trim().toLowerCase(); + +/** True if an AI-adapted/varied recipe is functionally identical to its + * source — same servings, same ingredients (name/quantity/unit, in order), + * same steps. Used to skip persisting a "variation" that changed nothing, + * which otherwise clutters recipe history with no-op duplicates. */ +export function isRecipeUnchanged( + original: { baseServings: number; ingredients: DiffIngredient[]; steps: DiffStep[] }, + candidate: { baseServings: number; ingredients: DiffIngredient[]; steps: DiffStep[] } +): boolean { + if (original.baseServings !== candidate.baseServings) return false; + if (original.ingredients.length !== candidate.ingredients.length) return false; + if (original.steps.length !== candidate.steps.length) return false; + + for (let i = 0; i < original.ingredients.length; i++) { + const o = original.ingredients[i]!; + const c = candidate.ingredients[i]!; + if (norm(o.rawName) !== norm(c.rawName)) return false; + if (String(o.quantity ?? "") !== String(c.quantity ?? "")) return false; + if (norm(o.unit ?? "") !== norm(c.unit ?? "")) return false; + } + + for (let i = 0; i < original.steps.length; i++) { + if (norm(original.steps[i]!.instruction) !== norm(candidate.steps[i]!.instruction)) return false; + } + + return true; +} diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index d4c5eea..ac75f81 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -97,6 +97,7 @@ "imported": "Recipe imported! Review before publishing.", "adapted": "Adapted recipe saved as draft", "adaptFailed": "Failed to adapt recipe", + "adaptUnchanged": "The adapted recipe came out identical to the original — nothing was saved.", "adaptConstraintRequired": "Select at least one ingredient to exclude or add a constraint", "adaptTooltip": "Adapt", "adaptTitle": "Adapt this recipe", @@ -292,6 +293,7 @@ "success": "Variation created — review and edit before publishing.", "error": "Failed to generate variations", "saveError": "Failed to save variation", + "noChanges": "This variation didn't actually change anything — nothing was saved.", "wait": "This may take 20–30 seconds", "directions": "Directions", "optional": "optional", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index af21cb1..bd23ebd 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -97,6 +97,7 @@ "imported": "Recette importée ! Vérifiez avant de publier.", "adapted": "Recette adaptée enregistrée comme brouillon", "adaptFailed": "Échec de l'adaptation de la recette", + "adaptUnchanged": "La recette adaptée est identique à l'originale — rien n'a été enregistré.", "adaptConstraintRequired": "Sélectionnez au moins un ingrédient à exclure ou ajoutez une contrainte", "adaptTooltip": "Adapter", "adaptTitle": "Adapter cette recette", @@ -292,6 +293,7 @@ "success": "Variation créée — vérifiez et modifiez avant de publier.", "error": "Échec de la génération des variations", "saveError": "Échec de l'enregistrement de la variation", + "noChanges": "Cette variation n'a rien changé — rien n'a été enregistré.", "wait": "Cela peut prendre 20 à 30 secondes", "directions": "Instructions", "optional": "facultatif", diff --git a/apps/web/package.json b/apps/web/package.json index 59c3974..8fc7853 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.38.0", + "version": "0.39.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index bd3a6b8..c024e4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.38.0", + "version": "0.39.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",