c8f4b50ef3
All literal "team" tier-value references renamed to "family" across API routes, admin UI, OpenAPI schemas, and lib/tiers.ts. The DB enum value itself is renamed in place via ALTER TYPE ... RENAME VALUE (migration 0044) rather than drizzle-kit's auto-generated drop-and-recreate-the-enum migration, which would have failed against any existing row still holding 'team' — RENAME VALUE preserves existing data with no cast/backfill needed. Also adds STRIPE_PLAN.md — a full Stripe billing integration plan (Checkout+Portal, tier→Price mapping, admin billing dashboard, and a multi-user Family-group design since Family is meant to cover several accounts under one subscription, not one payer). Planning only, no Stripe code yet. v0.47.0
196 lines
7.3 KiB
TypeScript
196 lines
7.3 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" | "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 — 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" | "family", "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 });
|
|
}
|