Files
Epicure/apps/web/app/api/v1/ai/meal-plan/generate/route.ts
T
Arnaud affa6f5c3d feat: admin-configurable default AI providers/models (v0.30.0)
Add DEFAULT_{TEXT,VISION,MEAL_PLAN}_{PROVIDER,MODEL} site settings,
editable from Settings -> Admin (new AdminDefaultModelForm, mirroring
the per-user model-prefs UI). getDefaultProviderWithKey now accepts
the use case and checks the admin default (after the user's own BYOK
key, before the old "first configured site key" heuristic) so admins
can pin a specific provider/model per feature instead of it being
whichever key happens to exist.

Wired into scale/substitute/batch-cook/meal-plan generation routes,
which previously called getDefaultProviderWithKey() without a use
case and so never had visibility into per-feature routing at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 13:54:24 +02:00

196 lines
7.2 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, incrementUsage, 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", () =>
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
)
);
if (!result.ok) return result.response;
const plan = result.data;
// Each plan entry creates a draft recipe — charge the recipe limit for all
// of them before inserting anything, refunding on breach so a rejected plan
// doesn't consume quota.
let chargedRecipes = 0;
try {
for (let i = 0; i < plan.entries.length; i++) {
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro", "recipe");
chargedRecipes++;
}
} catch (err) {
if (err instanceof TierLimitError) {
if (chargedRecipes > 0) await incrementUsage(userId, "recipe", -chargedRecipes);
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",
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 });
}