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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.38.0",
|
||||
"version": "0.39.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "epicure",
|
||||
"version": "0.38.0",
|
||||
"version": "0.39.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter web dev",
|
||||
|
||||
Reference in New Issue
Block a user