From d8dc0aa465674281ebff48b086d8b18a179ce3b1 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Fri, 17 Jul 2026 16:48:38 +0200 Subject: [PATCH] fix: variations/adapt-recipe silent errors and no-op duplicates AI adapt/variations flows had no catch on their fetch calls, so a network failure surfaced as nothing happening rather than an error toast. They also unconditionally persisted the AI's output even when it was identical to the source recipe, and the adapt route never recorded a recipeVariations row (only plain forks did) or enforced the per-tier recipe-count limit other create paths already check. v0.39.0 --- CHANGELOG.md | 5 + apps/web/app/api/v1/ai/adapt/[id]/route.ts | 117 +++++++++++------- .../components/recipe/adapt-recipe-button.tsx | 10 +- .../components/recipe/variations-dialog.tsx | 15 +++ apps/web/lib/changelog.ts | 9 +- apps/web/lib/recipe-diff.ts | 31 +++++ apps/web/messages/en.json | 2 + apps/web/messages/fr.json | 2 + apps/web/package.json | 2 +- package.json | 2 +- 10 files changed, 147 insertions(+), 48 deletions(-) create mode 100644 apps/web/lib/recipe-diff.ts 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",