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 { checkTierLimitInTransaction, 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; // 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 }> = []; try { await db.transaction(async (tx) => { // Each plan entry creates a draft recipe — check the recipe limit covers // all of them before inserting anything. await checkTierLimitInTransaction(tx, userId, session!.user.tier as "free" | "pro" | "family", "recipe", plan.entries.length); 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 }); } }); } catch (err) { if (err instanceof TierLimitError) { return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 }); } throw err; } return NextResponse.json({ weekStart: parsed.data.weekStart, entries: createdEntries }); }