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
112 lines
4.0 KiB
TypeScript
112 lines
4.0 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { z } from "zod";
|
|
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
|
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
|
import { applyRateLimit } from "@/lib/rate-limit";
|
|
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
|
import { importFromPhoto } from "@/lib/ai/features/import-photo";
|
|
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
|
|
|
|
const Schema = z.object({
|
|
imageBase64: z.string().max(14_000_000),
|
|
mimeType: z.enum(["image/jpeg", "image/png", "image/webp"]),
|
|
});
|
|
|
|
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}`, 5, 60);
|
|
if (limited) return limited;
|
|
|
|
const userId = session!.user.id;
|
|
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
|
|
|
const visionConfigResult = await resolveAiConfigOrError(() => getModelConfigForUseCase(userId, "vision"));
|
|
if (!visionConfigResult.ok) return visionConfigResult.response;
|
|
const visionConfig = visionConfigResult.data;
|
|
|
|
// Fall back to vision-capable defaults if no explicit model configured
|
|
if (!visionConfig.model) {
|
|
if (visionConfig.provider === "openai") visionConfig.model = "gpt-4o";
|
|
else if (visionConfig.provider === "anthropic") visionConfig.model = "claude-sonnet-4-6";
|
|
}
|
|
|
|
const textConfigResult = await resolveAiConfigOrError(() => getModelConfigForUseCase(userId, "text"));
|
|
if (!textConfigResult.ok) return textConfigResult.response;
|
|
const textConfig = textConfigResult.data;
|
|
|
|
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "family", () =>
|
|
importFromPhoto(parsed.data.imageBase64, parsed.data.mimeType, visionConfig, textConfig, locale),
|
|
{ skipQuota: visionConfig.isByok && textConfig.isByok }
|
|
);
|
|
if (!result.ok) return result.response;
|
|
const recipe = result.data;
|
|
|
|
if (!recipe.found) {
|
|
return NextResponse.json({ error: "No recipe recognized in photo" }, { status: 422 });
|
|
}
|
|
|
|
const newRecipeId = crypto.randomUUID();
|
|
const now = new Date();
|
|
|
|
await db.transaction(async (tx) => {
|
|
await tx.insert(recipes).values({
|
|
id: newRecipeId,
|
|
authorId: userId,
|
|
title: recipe.title,
|
|
description: recipe.description,
|
|
baseServings: recipe.baseServings ?? 4,
|
|
recipeType: recipe.recipeType,
|
|
visibility: "private",
|
|
difficulty: recipe.difficulty,
|
|
prepMins: recipe.prepMins,
|
|
cookMins: recipe.cookMins,
|
|
dietaryTags: recipe.dietaryTags ?? {},
|
|
aiGenerated: true,
|
|
// The extraction prompt always writes the recipe in the caller's own
|
|
// locale (see importFromPhoto's langInstruction) regardless of what
|
|
// language the photo/label was in — so this *is* the recipe's
|
|
// language, not a guess, and lets the Translate button correctly
|
|
// stay hidden until the user's app language changes.
|
|
language: locale,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
|
|
if (recipe.ingredients.length > 0) {
|
|
await tx.insert(recipeIngredients).values(
|
|
recipe.ingredients.map((ing, i) => ({
|
|
id: crypto.randomUUID(),
|
|
recipeId: newRecipeId,
|
|
rawName: ing.rawName,
|
|
quantity: ing.quantity !== undefined ? String(ing.quantity) : undefined,
|
|
unit: ing.unit,
|
|
note: ing.note,
|
|
order: i,
|
|
}))
|
|
);
|
|
}
|
|
|
|
if (recipe.steps.length > 0) {
|
|
await tx.insert(recipeSteps).values(
|
|
recipe.steps.map((step, i) => ({
|
|
id: crypto.randomUUID(),
|
|
recipeId: newRecipeId,
|
|
instruction: step.instruction,
|
|
timerSeconds: step.timerSeconds,
|
|
order: i,
|
|
}))
|
|
);
|
|
}
|
|
});
|
|
|
|
return NextResponse.json({ id: newRecipeId });
|
|
}
|