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
95 lines
3.8 KiB
TypeScript
95 lines
3.8 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { z } from "zod";
|
|
import { generateText } from "ai";
|
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
|
import { applyRateLimit } from "@/lib/rate-limit";
|
|
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
|
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
|
|
import { resolveModel } from "@/lib/ai/factory";
|
|
import { db, recipes, chatMessages, eq, and } from "@epicure/db";
|
|
import { getUserPrivateBio, buildUserBioContext } from "@/lib/ai/user-bio";
|
|
import { hasQuantity } from "@/lib/fractions";
|
|
|
|
const Schema = z.object({
|
|
recipeId: z.string().uuid(),
|
|
question: z.string().min(1).max(500),
|
|
});
|
|
|
|
const LANG: Record<string, string> = { en: "English", fr: "French" };
|
|
|
|
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" }, { status: 400 });
|
|
}
|
|
|
|
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 30, 60);
|
|
if (limited) return limited;
|
|
|
|
const recipe = await db.query.recipes.findFirst({
|
|
where: and(eq(recipes.id, parsed.data.recipeId), eq(recipes.authorId, session!.user.id)),
|
|
with: {
|
|
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
|
steps: { orderBy: (t, { asc }) => asc(t.order) },
|
|
},
|
|
});
|
|
|
|
if (!recipe) {
|
|
return NextResponse.json({ error: "Recipe not found" }, { status: 404 });
|
|
}
|
|
|
|
const ingredientList = recipe.ingredients
|
|
.map((ing) => [hasQuantity(ing.quantity) ? ing.quantity : null, ing.unit, ing.rawName, ing.note ? `(${ing.note})` : ""].filter(Boolean).join(" "))
|
|
.join("\n");
|
|
|
|
const stepList = recipe.steps
|
|
.map((s, i) => `${i + 1}. ${s.instruction}`)
|
|
.join("\n");
|
|
|
|
const recipeContext = `
|
|
Recipe: ${recipe.title}
|
|
${recipe.description ? `Description: ${recipe.description}` : ""}
|
|
Difficulty: ${recipe.difficulty ?? "unknown"}
|
|
Prep: ${recipe.prepMins ?? 0}min | Cook: ${recipe.cookMins ?? 0}min | Servings: ${recipe.baseServings}
|
|
|
|
INGREDIENTS:
|
|
${ingredientList || "None listed"}
|
|
|
|
STEPS:
|
|
${stepList || "None listed"}
|
|
`.trim();
|
|
|
|
const [configResult, privateBio] = await Promise.all([
|
|
resolveAiConfigOrError(() => getModelConfigForUseCase(session!.user.id, "text")),
|
|
getUserPrivateBio(session!.user.id),
|
|
]);
|
|
if (!configResult.ok) return configResult.response;
|
|
const aiConfig = configResult.data;
|
|
const model = resolveModel(aiConfig);
|
|
const bioContext = buildUserBioContext(privateBio);
|
|
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
|
const lang = LANG[locale] ?? "English";
|
|
|
|
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () =>
|
|
generateText({
|
|
model,
|
|
system: `You are Epicure, a helpful culinary assistant. Answer questions about the following recipe concisely and accurately. If asked who you are or what model/AI you're built on, say you're Epicure — never name the underlying model or provider. If a question is not related to the recipe or cooking, politely redirect. Keep answers under 200 words. Respond in ${lang}.
|
|
|
|
${recipeContext}${bioContext}`,
|
|
prompt: parsed.data.question,
|
|
}), { skipQuota: aiConfig.isByok }
|
|
);
|
|
if (!result.ok) return result.response;
|
|
|
|
void db.insert(chatMessages).values([
|
|
{ id: crypto.randomUUID(), userId: session!.user.id, recipeId: recipe.id, role: "user", content: parsed.data.question },
|
|
{ id: crypto.randomUUID(), userId: session!.user.id, recipeId: recipe.id, role: "assistant", content: result.data.text },
|
|
]).catch((err) => console.error("[recipe-chat] failed to persist chat history", err));
|
|
|
|
return NextResponse.json({ answer: result.data.text });
|
|
}
|