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
153 lines
5.3 KiB
TypeScript
153 lines
5.3 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { z } from "zod";
|
|
import { and, eq } from "@epicure/db";
|
|
import { db, recipes, recipeIngredients, recipeSteps, recipeVariations } from "@epicure/db";
|
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
|
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
|
import { adaptRecipe } from "@/lib/ai/features/adapt-recipe";
|
|
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
|
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
|
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
|
|
import { isRecipeUnchanged } from "@/lib/recipe-diff";
|
|
|
|
const Schema = z.object({
|
|
excludeIngredients: z.array(z.string()).default([]),
|
|
extraConstraints: z.string().max(500).optional(),
|
|
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
|
|
model: z.string().optional(),
|
|
});
|
|
|
|
type Params = { params: Promise<{ id: string }> };
|
|
|
|
export async function POST(req: NextRequest, { params }: Params) {
|
|
const { session, response } = await requireSessionOrApiKey(req);
|
|
if (response) return response;
|
|
|
|
const { id } = await params;
|
|
const userId = session!.user.id;
|
|
|
|
const recipe = await db.query.recipes.findFirst({
|
|
where: and(eq(recipes.id, id)),
|
|
with: {
|
|
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
|
steps: { orderBy: (t, { asc }) => asc(t.order) },
|
|
},
|
|
});
|
|
if (!recipe || (recipe.visibility === "private" && recipe.authorId !== userId)) {
|
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
}
|
|
|
|
const body = await req.json() as unknown;
|
|
const parsed = Schema.safeParse(body);
|
|
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
|
|
|
if (parsed.data.excludeIngredients.length === 0 && !parsed.data.extraConstraints?.trim()) {
|
|
return NextResponse.json({ error: "Provide at least one constraint" }, { status: 400 });
|
|
}
|
|
|
|
const [configResult, privateBio] = await Promise.all([
|
|
resolveAiConfigOrError(() => withUserKey(userId, { provider: parsed.data.provider, model: parsed.data.model })),
|
|
getUserPrivateBio(userId),
|
|
]);
|
|
if (!configResult.ok) return configResult.response;
|
|
const aiConfig = configResult.data;
|
|
|
|
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "family", () =>
|
|
adaptRecipe(
|
|
{
|
|
title: recipe.title,
|
|
description: recipe.description,
|
|
baseServings: recipe.baseServings,
|
|
ingredients: recipe.ingredients,
|
|
steps: recipe.steps,
|
|
},
|
|
{
|
|
excludeIngredients: parsed.data.excludeIngredients,
|
|
extraConstraints: parsed.data.extraConstraints,
|
|
},
|
|
{ ...aiConfig, userContext: privateBio ?? undefined },
|
|
(session!.user as { locale?: string }).locale ?? "en"
|
|
), { skipQuota: aiConfig.isByok }
|
|
);
|
|
if (!result.ok) return result.response;
|
|
const adapted = result.data;
|
|
|
|
// The AI sometimes "adapts" a recipe into something byte-for-byte
|
|
// identical (e.g. it decides the constraints are already satisfied) —
|
|
// don't persist a duplicate recipe/variation when nothing actually changed.
|
|
if (isRecipeUnchanged(recipe, adapted)) {
|
|
return NextResponse.json({ id: recipe.id, adaptationNotes: adapted.adaptationNotes, unchanged: true });
|
|
}
|
|
|
|
try {
|
|
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro" | "family", "recipe");
|
|
} catch (err) {
|
|
if (err instanceof TierLimitError) {
|
|
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const newId = crypto.randomUUID();
|
|
const now = new Date();
|
|
|
|
try {
|
|
await db.transaction(async (tx) => {
|
|
await tx.insert(recipes).values({
|
|
id: newId,
|
|
authorId: userId,
|
|
title: adapted.title,
|
|
description: adapted.description,
|
|
baseServings: adapted.baseServings,
|
|
visibility: "private",
|
|
difficulty: adapted.difficulty,
|
|
prepMins: adapted.prepMins,
|
|
cookMins: adapted.cookMins,
|
|
dietaryTags: adapted.dietaryTags ?? {},
|
|
aiGenerated: true,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
|
|
if (adapted.ingredients.length > 0) {
|
|
await tx.insert(recipeIngredients).values(
|
|
adapted.ingredients.map((ing, i) => ({
|
|
id: crypto.randomUUID(),
|
|
recipeId: newId,
|
|
rawName: ing.rawName,
|
|
quantity: ing.quantity !== undefined ? String(ing.quantity) : undefined,
|
|
unit: ing.unit,
|
|
note: ing.note,
|
|
order: i,
|
|
}))
|
|
);
|
|
}
|
|
|
|
if (adapted.steps.length > 0) {
|
|
await tx.insert(recipeSteps).values(
|
|
adapted.steps.map((step, i) => ({
|
|
id: crypto.randomUUID(),
|
|
recipeId: newId,
|
|
instruction: step.instruction,
|
|
timerSeconds: step.timerSeconds,
|
|
order: i,
|
|
}))
|
|
);
|
|
}
|
|
|
|
await tx.insert(recipeVariations).values({
|
|
id: crypto.randomUUID(),
|
|
parentRecipeId: recipe.id,
|
|
childRecipeId: newId,
|
|
description: adapted.adaptationNotes,
|
|
aiGenerated: true,
|
|
createdAt: now,
|
|
});
|
|
});
|
|
} catch {
|
|
return NextResponse.json({ error: "Failed to save the adapted recipe. Please try again." }, { status: 500 });
|
|
}
|
|
|
|
return NextResponse.json({ id: newId, adaptationNotes: adapted.adaptationNotes });
|
|
}
|