d8dc0aa465
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
153 lines
5.3 KiB
TypeScript
153 lines
5.3 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { z } from "zod";
|
|
import { and, eq } 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([]),
|
|
extraConstraints: z.string().max(500).optional(),
|
|
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
|
|
model: z.string().optional(),
|
|
});
|
|
|
|
type Params = { params: Promise<{ id: string }> };
|
|
|
|
export async function POST(req: NextRequest, { params }: Params) {
|
|
const { session, response } = await requireSessionOrApiKey(req);
|
|
if (response) return response;
|
|
|
|
const { id } = await params;
|
|
const userId = session!.user.id;
|
|
|
|
const recipe = await db.query.recipes.findFirst({
|
|
where: and(eq(recipes.id, id)),
|
|
with: {
|
|
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
|
steps: { orderBy: (t, { asc }) => asc(t.order) },
|
|
},
|
|
});
|
|
if (!recipe || (recipe.visibility === "private" && recipe.authorId !== userId)) {
|
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
}
|
|
|
|
const body = await req.json() as unknown;
|
|
const parsed = Schema.safeParse(body);
|
|
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
|
|
|
if (parsed.data.excludeIngredients.length === 0 && !parsed.data.extraConstraints?.trim()) {
|
|
return NextResponse.json({ error: "Provide at least one constraint" }, { status: 400 });
|
|
}
|
|
|
|
const [configResult, privateBio] = await Promise.all([
|
|
resolveAiConfigOrError(() => withUserKey(userId, { provider: parsed.data.provider, model: parsed.data.model })),
|
|
getUserPrivateBio(userId),
|
|
]);
|
|
if (!configResult.ok) return configResult.response;
|
|
const aiConfig = configResult.data;
|
|
|
|
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro", () =>
|
|
adaptRecipe(
|
|
{
|
|
title: recipe.title,
|
|
description: recipe.description,
|
|
baseServings: recipe.baseServings,
|
|
ingredients: recipe.ingredients,
|
|
steps: recipe.steps,
|
|
},
|
|
{
|
|
excludeIngredients: parsed.data.excludeIngredients,
|
|
extraConstraints: parsed.data.extraConstraints,
|
|
},
|
|
{ ...aiConfig, userContext: privateBio ?? undefined },
|
|
(session!.user as { locale?: string }).locale ?? "en"
|
|
), { skipQuota: aiConfig.isByok }
|
|
);
|
|
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();
|
|
|
|
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,
|
|
});
|
|
});
|
|
} catch {
|
|
return NextResponse.json({ error: "Failed to save the adapted recipe. Please try again." }, { status: 500 });
|
|
}
|
|
|
|
return NextResponse.json({ id: newId, adaptationNotes: adapted.adaptationNotes });
|
|
}
|