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>
191 lines
7.1 KiB
TypeScript
191 lines
7.1 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { z } from "zod";
|
|
import { db, recipes, recipeIngredients, recipeSteps, mealPlans, mealPlanEntries, pantryItems, userNutritionGoals, 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 { generateMealPlan } from "@/lib/ai/features/generate-meal-plan";
|
|
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
|
|
|
const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
|
|
|
const Schema = z.object({
|
|
weekStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
dietaryPrefs: z.string().max(200).optional(),
|
|
servings: z.number().int().min(1).max(20).default(2),
|
|
days: z.array(z.enum(DAYS)).min(1).max(7).default([...DAYS]),
|
|
usePantry: z.boolean().default(false),
|
|
pantryMode: z.boolean().default(false),
|
|
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
|
targetNutritionGoals: z.boolean().default(false),
|
|
});
|
|
|
|
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 limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 3, 60);
|
|
if (limited) return limited;
|
|
|
|
const userId = session!.user.id;
|
|
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;
|
|
|
|
// pantryMode forces usePantry on so pantry items are always fetched when maximizing pantry use
|
|
const effectiveUsePantry = parsed.data.usePantry || parsed.data.pantryMode;
|
|
|
|
// Optionally fetch pantry items
|
|
let pantryItemNames: string[] = [];
|
|
if (effectiveUsePantry) {
|
|
const pantry = await db
|
|
.select({ rawName: pantryItems.rawName })
|
|
.from(pantryItems)
|
|
.where(eq(pantryItems.userId, userId));
|
|
pantryItemNames = pantry.map((p) => p.rawName);
|
|
}
|
|
|
|
// Optionally fetch the user's nutrition goals to nudge the AI toward them.
|
|
// Silently ignored (no-op) if the user hasn't set any goals — no need to
|
|
// fail the whole generation over a missing preference.
|
|
let nutritionGoals: { caloriesKcal?: number | null; proteinG?: number | null; carbsG?: number | null; fatG?: number | null } | undefined;
|
|
if (parsed.data.targetNutritionGoals) {
|
|
const goals = await db.query.userNutritionGoals.findFirst({
|
|
where: eq(userNutritionGoals.userId, userId),
|
|
});
|
|
if (goals && (goals.caloriesKcal || goals.proteinG || goals.carbsG || goals.fatG)) {
|
|
nutritionGoals = {
|
|
caloriesKcal: goals.caloriesKcal,
|
|
proteinG: goals.proteinG,
|
|
carbsG: goals.carbsG,
|
|
fatG: goals.fatG,
|
|
};
|
|
}
|
|
}
|
|
|
|
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "family", () =>
|
|
generateMealPlan(
|
|
{
|
|
dietaryPrefs: parsed.data.dietaryPrefs,
|
|
servings: parsed.data.servings,
|
|
pantryItems: pantryItemNames,
|
|
days: parsed.data.days,
|
|
pantryMode: parsed.data.pantryMode,
|
|
difficulty: parsed.data.difficulty,
|
|
nutritionGoals,
|
|
},
|
|
{ ...config, userContext: privateBio ?? undefined },
|
|
locale
|
|
), { skipQuota: config.isByok }
|
|
);
|
|
if (!result.ok) return result.response;
|
|
const plan = result.data;
|
|
|
|
// Each plan entry creates a draft recipe — check the recipe limit covers
|
|
// all of them before inserting anything.
|
|
try {
|
|
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro" | "family", "recipe", plan.entries.length);
|
|
} catch (err) {
|
|
if (err instanceof TierLimitError) {
|
|
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
// Ensure meal plan row exists for the week
|
|
let mealPlan = await db.query.mealPlans.findFirst({
|
|
where: and(eq(mealPlans.userId, userId), eq(mealPlans.weekStart, parsed.data.weekStart)),
|
|
});
|
|
|
|
if (!mealPlan) {
|
|
const planId = crypto.randomUUID();
|
|
await db.insert(mealPlans).values({ id: planId, userId, weekStart: parsed.data.weekStart });
|
|
mealPlan = { id: planId, userId, weekStart: parsed.data.weekStart, createdAt: new Date() };
|
|
}
|
|
|
|
const createdEntries: Array<{ id: string; day: string; mealType: string; recipeId: string; recipeTitle: string }> = [];
|
|
|
|
await db.transaction(async (tx) => {
|
|
for (const entry of plan.entries) {
|
|
// Create draft recipe
|
|
const recipeId = crypto.randomUUID();
|
|
await tx.insert(recipes).values({
|
|
id: recipeId,
|
|
authorId: userId,
|
|
title: entry.recipe.title,
|
|
description: entry.recipe.description,
|
|
baseServings: entry.servings,
|
|
visibility: "private",
|
|
language: locale,
|
|
aiGenerated: true,
|
|
difficulty: entry.recipe.difficulty ?? null,
|
|
prepMins: entry.recipe.prepMins ?? null,
|
|
cookMins: entry.recipe.cookMins ?? null,
|
|
});
|
|
|
|
if (entry.recipe.ingredients.length > 0) {
|
|
await tx.insert(recipeIngredients).values(
|
|
entry.recipe.ingredients.map((ing, i) => ({
|
|
id: crypto.randomUUID(),
|
|
recipeId,
|
|
rawName: ing.rawName,
|
|
quantity: ing.quantity != null ? String(ing.quantity) : null,
|
|
unit: ing.unit ?? null,
|
|
order: i,
|
|
}))
|
|
);
|
|
}
|
|
|
|
if (entry.recipe.steps.length > 0) {
|
|
await tx.insert(recipeSteps).values(
|
|
entry.recipe.steps.map((step, i) => ({
|
|
id: crypto.randomUUID(),
|
|
recipeId,
|
|
instruction: step.instruction,
|
|
order: i,
|
|
}))
|
|
);
|
|
}
|
|
|
|
// Remove any existing entry for this day+mealType, then insert new
|
|
const existingEntry = await tx.query.mealPlanEntries.findFirst({
|
|
where: and(
|
|
eq(mealPlanEntries.mealPlanId, mealPlan!.id),
|
|
eq(mealPlanEntries.day, entry.day),
|
|
eq(mealPlanEntries.mealType, entry.mealType)
|
|
),
|
|
});
|
|
|
|
if (existingEntry) {
|
|
await tx.delete(mealPlanEntries).where(eq(mealPlanEntries.id, existingEntry.id));
|
|
}
|
|
|
|
const entryId = crypto.randomUUID();
|
|
await tx.insert(mealPlanEntries).values({
|
|
id: entryId,
|
|
mealPlanId: mealPlan!.id,
|
|
day: entry.day,
|
|
mealType: entry.mealType,
|
|
recipeId,
|
|
servings: entry.servings,
|
|
});
|
|
|
|
createdEntries.push({ id: entryId, day: entry.day, mealType: entry.mealType, recipeId, recipeTitle: entry.recipe.title });
|
|
}
|
|
});
|
|
|
|
return NextResponse.json({ weekStart: parsed.data.weekStart, entries: createdEntries });
|
|
}
|