f6975e98a9
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>
129 lines
4.8 KiB
TypeScript
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 });
|
|
}
|