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:
Arnaud
2026-07-17 16:48:38 +02:00
parent 31ff7b9ac0
commit d8dc0aa465
10 changed files with 147 additions and 48 deletions
+74 -43
View File
@@ -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 });
}