Files
Epicure/apps/web/app/api/v1/ai/import-photo/route.ts
T
Arnaud b330583dd3 fix: detect imported recipe language for Translate button (v0.33.1)
URL import extracts content in the source page's own language (no
translation), but never recorded what that language was, so the
Translate button's "recipe.language !== my locale" check always saw
null and showed the button unconditionally. The extractor now returns
a detected ISO 639-1 language code alongside the recipe.

Photo import always writes in the caller's locale already (see its
langInstruction) but never recorded that either — now sets it
directly to locale since it's known, not detected.

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

108 lines
3.7 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 configResult = await resolveAiConfigOrError(() => getModelConfigForUseCase(userId, "vision"));
if (!configResult.ok) return configResult.response;
const aiConfig = configResult.data;
// Fall back to vision-capable defaults if no explicit model configured
if (!aiConfig.model) {
if (aiConfig.provider === "openai") aiConfig.model = "gpt-4o";
else if (aiConfig.provider === "anthropic") aiConfig.model = "claude-sonnet-4-6";
}
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro", () =>
importFromPhoto(parsed.data.imageBase64, parsed.data.mimeType, aiConfig, locale),
{ skipQuota: aiConfig.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 });
}