Files
Epicure/apps/web/app/api/v1/ai/generate-meal/route.ts
T
Arnaud f6975e98a9 fix: recipe/storage tier limits are lifetime totals, not monthly (v0.58.0)
Recipe count and storage usage shared the monthly user_usage bucket with
AI calls, so both incorrectly reset every month even though nothing was
deleted. Only AI calls should be monthly.

Recipe count and storage are now derived live from real data (recipes,
recipe/review photos, avatar) instead of a counter — deleting a photo or
recipe is itself the "decrement", no extra wiring needed. Storage size is
tracked per-row (recipePhotos.sizeMb, ratings.photoSizeMb, users.avatarSizeMb)
and threaded through presign -> upload -> save.

Also fixes avatar removal silently no-oping (client sent a field the PATCH
schema didn't recognize).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 20:32:31 +02:00

129 lines
4.8 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, recipes, recipeIngredients, recipeSteps, collections, collectionRecipes, eq, and } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
import { generateMeal, MEAL_COURSES } from "@/lib/ai/features/generate-meal";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
import { getMessages } from "@/lib/i18n/server";
const Schema = z.object({
collectionId: z.string().min(1),
theme: z.string().min(1).max(200),
courses: z.array(z.enum(MEAL_COURSES)).min(2).max(6).refine((c) => new Set(c).size === c.length, "Duplicate courses"),
servings: z.number().int().min(1).max(20).default(4),
dietaryPrefs: z.string().max(200).optional(),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const userId = session!.user.id;
const tier = session!.user.tier as "free" | "pro" | "family";
const { collectionId, theme, courses, servings, dietaryPrefs, difficulty } = parsed.data;
const collection = await db.query.collections.findFirst({
where: and(eq(collections.id, collectionId), eq(collections.userId, userId)),
});
if (!collection) return NextResponse.json({ error: "Not found" }, { status: 404 });
const limited = await applyRateLimit(`rl:ai:${userId}`, 3, 60);
if (limited) return limited;
const locale = (session!.user as { locale?: string }).locale ?? "en";
const [configResult, privateBio] = await Promise.all([
resolveAiConfigOrError(() => getDefaultProviderWithKey(userId, "mealPlan")),
getUserPrivateBio(userId),
]);
if (!configResult.ok) return configResult.response;
const config = configResult.data;
const result = await withAiQuota(userId, tier, () =>
generateMeal(
{ theme, courses, servings, dietaryPrefs, difficulty },
{ ...config, userContext: privateBio ?? undefined },
locale
), { skipQuota: config.isByok }
);
if (!result.ok) return result.response;
const meal = result.data;
// Each recipe in the meal creates a real recipe row — check the recipe
// limit covers all of them before inserting anything.
try {
await checkAndIncrementTierLimit(userId, tier, "recipe", meal.recipes.length);
} catch (err) {
if (err instanceof TierLimitError) {
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
}
throw err;
}
const created: Array<{ id: string; title: string; course: string }> = [];
await db.transaction(async (tx) => {
for (const recipe of meal.recipes) {
const recipeId = crypto.randomUUID();
await tx.insert(recipes).values({
id: recipeId,
authorId: userId,
title: recipe.title,
description: recipe.description,
baseServings: recipe.baseServings,
recipeType: recipe.recipeType,
visibility: "private",
language: locale,
aiGenerated: true,
difficulty: recipe.difficulty ?? null,
prepMins: recipe.prepMins ?? null,
cookMins: recipe.cookMins ?? null,
dietaryTags: recipe.dietaryTags ?? {},
tags: [getMessages(locale).collections.course[recipe.course]],
});
if (recipe.ingredients.length > 0) {
await tx.insert(recipeIngredients).values(
recipe.ingredients.map((ing, i) => ({
id: crypto.randomUUID(),
recipeId,
rawName: ing.rawName,
quantity: ing.quantity != null ? String(ing.quantity) : null,
unit: ing.unit ?? null,
note: ing.note ?? null,
order: i,
}))
);
}
if (recipe.steps.length > 0) {
await tx.insert(recipeSteps).values(
recipe.steps.map((step, i) => ({
id: crypto.randomUUID(),
recipeId,
instruction: step.instruction,
timerSeconds: step.timerSeconds ?? null,
order: i,
}))
);
}
await tx.insert(collectionRecipes).values({ collectionId, recipeId }).onConflictDoNothing();
created.push({ id: recipeId, title: recipe.title, course: recipe.course });
}
});
return NextResponse.json({ collectionId, recipes: created });
}