feat(api): AI-powered multi-language menu translation

Adds enabled_languages on restaurants and translations JSONB on menu
categories/items. New /restaurants/:id/languages endpoints bulk-translate
the full menu via Gemini/GPT-4o-mini (reusing the ai-scanner dual-provider
pattern) when a language is enabled. New categories/items are auto-
translated in the background into any already-enabled languages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AyrisAI
2026-08-29 01:46:19 +03:00
co-authored by Claude Sonnet 5
parent 739de7de5c
commit 00195ae7d7
6 changed files with 378 additions and 0 deletions
+2
View File
@@ -4,6 +4,7 @@ import { env } from "./env.js";
import { aiImportsRoutes } from "./routes/ai-imports.js";
import { analyticsRoutes } from "./routes/analytics.js";
import { domainsRoutes } from "./routes/domains.js";
import { languagesRoutes } from "./routes/languages.js";
import { menuCategoriesRoutes } from "./routes/menu-categories.js";
import { menuItemsRoutes } from "./routes/menu-items.js";
import { menusRoutes } from "./routes/menus.js";
@@ -46,6 +47,7 @@ await app.register(qrRoutes);
await app.register(qrTemplatesRoutes);
await app.register(aiImportsRoutes);
await app.register(domainsRoutes);
await app.register(languagesRoutes);
await app.register(analyticsRoutes);
await app.register(subscriptionRoutes);
+148
View File
@@ -0,0 +1,148 @@
import { env } from "../env.js";
export interface TranslatableItem {
id: string;
name: string;
description: string | null;
}
export interface TranslatableCategory {
id: string;
name: string;
items: TranslatableItem[];
}
export interface TranslatedItem {
id: string;
name: string;
description: string | null;
}
export interface TranslatedCategory {
id: string;
name: string;
items: TranslatedItem[];
}
const LANGUAGE_NAMES: Record<string, string> = {
en: "English",
de: "German",
ar: "Arabic",
ru: "Russian",
fr: "French",
};
function buildPrompt(targetLanguageName: string): string {
return `Sen profesyonel bir restoran menüsü çevirmenisin. Sana Türkçe bir restoran menüsü (kategori adları, ürün adları ve açıklamaları) JSON olarak verilecek.
Bunları ${targetLanguageName} diline çevir. Yemek isimlerini o mutfağın doğal terimleriyle çevir (örn. "Adana Kebap" -> "Adana Kebab", tamamen literal çeviri yapma, restoran menüsü diline uygun doğal bir üslup kullan).
Her kategori ve ürünün "id" alanını AYNEN koru, sadece "name" ve "description" alanlarını çevir. description null ise null bırak.
SADECE aşağıdaki JSON formatında geçerli bir JSON yanıtı ver, başka hiçbir metin ekleme:
{
"categories": [
{ "id": "...", "name": "çevrilmiş kategori adı", "items": [ { "id": "...", "name": "çevrilmiş ürün adı", "description": "çevrilmiş açıklama veya null" } ] }
]
}`;
}
function cleanAndParse(text: string): { categories: TranslatedCategory[] } {
const cleaned = text
.replace(/^```json\s*/i, "")
.replace(/^```\s*/i, "")
.replace(/\s*```$/i, "")
.trim();
const parsed = JSON.parse(cleaned) as { categories: TranslatedCategory[] };
if (!parsed.categories || !Array.isArray(parsed.categories)) {
throw new Error("Invalid structure from translator: categories array missing");
}
return parsed;
}
async function translateWithGemini(
categories: TranslatableCategory[],
targetLanguageName: string,
apiKey: string,
): Promise<TranslatedCategory[]> {
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [
{
parts: [
{ text: buildPrompt(targetLanguageName) },
{ text: JSON.stringify({ categories }) },
],
},
],
generationConfig: { response_mime_type: "application/json", temperature: 0.2 },
}),
});
if (!response.ok) {
throw new Error(`Gemini translate error (${response.status}): ${await response.text()}`);
}
const data = (await response.json()) as {
candidates?: { content?: { parts?: { text?: string }[] } }[];
};
const rawText = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!rawText) throw new Error("No text response from Gemini translate");
return cleanAndParse(rawText).categories;
}
async function translateWithOpenAI(
categories: TranslatableCategory[],
targetLanguageName: string,
apiKey: string,
): Promise<TranslatedCategory[]> {
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model: "gpt-4o-mini",
response_format: { type: "json_object" },
messages: [
{ role: "system", content: buildPrompt(targetLanguageName) },
{ role: "user", content: JSON.stringify({ categories }) },
],
temperature: 0.2,
}),
});
if (!response.ok) {
throw new Error(`OpenAI translate error (${response.status}): ${await response.text()}`);
}
const data = (await response.json()) as { choices?: { message?: { content?: string } }[] };
const content = data.choices?.[0]?.message?.content;
if (!content) throw new Error("No message content from OpenAI translate");
return cleanAndParse(content).categories;
}
export function resolveLanguageName(code: string): string {
return LANGUAGE_NAMES[code] ?? code;
}
export async function translateMenu(
categories: TranslatableCategory[],
languageCode: string,
): Promise<TranslatedCategory[]> {
if (categories.length === 0) return [];
const targetLanguageName = resolveLanguageName(languageCode);
if (env.GEMINI_API_KEY) {
try {
return await translateWithGemini(categories, targetLanguageName, env.GEMINI_API_KEY);
} catch (err) {
console.warn("Gemini translate failed, falling back:", err);
}
}
if (env.OPENAI_API_KEY) {
return translateWithOpenAI(categories, targetLanguageName, env.OPENAI_API_KEY);
}
throw new Error("No translation provider configured (GEMINI_API_KEY / OPENAI_API_KEY missing)");
}
+147
View File
@@ -0,0 +1,147 @@
import type { FastifyPluginAsync } from "fastify";
import { z } from "zod";
import { requireAuth, requireRestaurantMember } from "../lib/auth.js";
import { supabase } from "../lib/supabase.js";
import { translateMenu, type TranslatableCategory } from "../lib/translator.js";
const SUPPORTED_LANGUAGES = ["en", "de", "ar", "ru", "fr"] as const;
const addLanguageSchema = z.object({
code: z.enum(SUPPORTED_LANGUAGES),
});
async function getRestaurantMenuCategories(restaurantId: string): Promise<TranslatableCategory[]> {
if (!supabase) return [];
const { data: locations } = await supabase.from("locations").select("id").eq("restaurant_id", restaurantId);
const locationIds = (locations ?? []).map((l) => l.id);
if (locationIds.length === 0) return [];
const { data: menus } = await supabase.from("menus").select("id").in("location_id", locationIds);
const menuIds = (menus ?? []).map((m) => m.id);
if (menuIds.length === 0) return [];
const { data: categories } = await supabase
.from("menu_categories")
.select("id, name, menu_items(id, name, description)")
.in("menu_id", menuIds);
return (categories ?? []).map((cat) => ({
id: cat.id,
name: cat.name,
items: (cat.menu_items ?? []).map((item: { id: string; name: string; description: string | null }) => ({
id: item.id,
name: item.name,
description: item.description,
})),
}));
}
export const languagesRoutes: FastifyPluginAsync = async (app) => {
// Add a language: translates all current menu content into it, then
// enables it. New items created afterwards are translated on save
// (see menu-categories.ts / menu-items.ts).
app.post("/restaurants/:id/languages", async (req, reply) => {
const userId = await requireAuth(req, reply);
if (!userId || !supabase) return;
const { id: restaurantId } = req.params as { id: string };
const isMember = await requireRestaurantMember(userId, restaurantId);
if (!isMember) return reply.code(403).send({ message: "forbidden" });
const parsed = addLanguageSchema.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ message: "invalid language code", issues: parsed.error.issues });
}
const { code } = parsed.data;
const { data: restaurant } = await supabase
.from("restaurants")
.select("enabled_languages")
.eq("id", restaurantId)
.single();
const current: string[] = restaurant?.enabled_languages ?? ["tr"];
if (current.includes(code)) {
return reply.code(409).send({ message: "Bu dil zaten aktif." });
}
const categories = await getRestaurantMenuCategories(restaurantId);
try {
const translated = await translateMenu(categories, code);
for (const cat of translated) {
const original = categories.find((c) => c.id === cat.id);
const { data: existingCatRow } = await supabase
.from("menu_categories")
.select("translations")
.eq("id", cat.id)
.single();
await supabase
.from("menu_categories")
.update({
translations: { ...(existingCatRow?.translations ?? {}), [code]: { name: cat.name } },
})
.eq("id", cat.id);
for (const item of cat.items) {
const { data: existingItemRow } = await supabase
.from("menu_items")
.select("translations")
.eq("id", item.id)
.single();
await supabase
.from("menu_items")
.update({
translations: {
...(existingItemRow?.translations ?? {}),
[code]: { name: item.name, description: item.description },
},
})
.eq("id", item.id);
}
void original;
}
} catch (err) {
req.log.error(err);
return reply.code(500).send({ message: "Menü çevirisi başarısız oldu", error: (err as Error).message });
}
const { data: updated } = await supabase
.from("restaurants")
.update({ enabled_languages: [...current, code] })
.eq("id", restaurantId)
.select("enabled_languages")
.single();
return reply.send({ enabledLanguages: updated?.enabled_languages ?? [...current, code] });
});
app.delete("/restaurants/:id/languages/:code", async (req, reply) => {
const userId = await requireAuth(req, reply);
if (!userId || !supabase) return;
const { id: restaurantId, code } = req.params as { id: string; code: string };
const isMember = await requireRestaurantMember(userId, restaurantId);
if (!isMember) return reply.code(403).send({ message: "forbidden" });
const { data: restaurant } = await supabase
.from("restaurants")
.select("enabled_languages")
.eq("id", restaurantId)
.single();
const current: string[] = restaurant?.enabled_languages ?? ["tr"];
const next = current.filter((c) => c !== code);
const { data: updated } = await supabase
.from("restaurants")
.update({ enabled_languages: next })
.eq("id", restaurantId)
.select("enabled_languages")
.single();
return reply.send({ enabledLanguages: updated?.enabled_languages ?? next });
});
};
+30
View File
@@ -3,6 +3,34 @@ import { z } from "zod";
import { requireAuth } from "../lib/auth.js";
import { getCategoryIfMember, getMenuIfMember } from "../lib/access.js";
import { supabase } from "../lib/supabase.js";
import { translateMenu } from "../lib/translator.js";
// New categories/items are translated into whatever languages the
// restaurant already has enabled beyond Turkish (bulk-translated once when
// a language is first added — see routes/languages.ts).
async function translateNewCategoryInBackground(restaurantId: string, categoryId: string, name: string) {
if (!supabase) return;
const { data: restaurant } = await supabase
.from("restaurants")
.select("enabled_languages")
.eq("id", restaurantId)
.single();
const extraLanguages = (restaurant?.enabled_languages ?? []).filter((l: string) => l !== "tr");
if (extraLanguages.length === 0) return;
const translations: Record<string, { name: string }> = {};
for (const lang of extraLanguages) {
try {
const [translated] = await translateMenu([{ id: categoryId, name, items: [] }], lang);
if (translated) translations[lang] = { name: translated.name };
} catch (err) {
console.warn(`Category translation to ${lang} failed:`, err);
}
}
if (Object.keys(translations).length > 0) {
await supabase.from("menu_categories").update({ translations }).eq("id", categoryId);
}
}
const createCategorySchema = z.object({
name: z.string().min(1).max(120),
@@ -47,6 +75,8 @@ export const menuCategoriesRoutes: FastifyPluginAsync = async (app) => {
return reply.code(500).send({ message: "failed to create category" });
}
void translateNewCategoryInBackground(access.restaurantId, data.id, data.name);
return reply.code(201).send(data);
});
+36
View File
@@ -3,6 +3,40 @@ import { z } from "zod";
import { requireAuth } from "../lib/auth.js";
import { getCategoryIfMember, getItemIfMember } from "../lib/access.js";
import { supabase } from "../lib/supabase.js";
import { translateMenu } from "../lib/translator.js";
async function translateNewItemInBackground(
restaurantId: string,
itemId: string,
name: string,
description: string | null,
) {
if (!supabase) return;
const { data: restaurant } = await supabase
.from("restaurants")
.select("enabled_languages")
.eq("id", restaurantId)
.single();
const extraLanguages = (restaurant?.enabled_languages ?? []).filter((l: string) => l !== "tr");
if (extraLanguages.length === 0) return;
const translations: Record<string, { name: string; description: string | null }> = {};
for (const lang of extraLanguages) {
try {
const [translatedCat] = await translateMenu(
[{ id: "single", name: "-", items: [{ id: itemId, name, description }] }],
lang,
);
const translatedItem = translatedCat?.items[0];
if (translatedItem) translations[lang] = { name: translatedItem.name, description: translatedItem.description };
} catch (err) {
console.warn(`Item translation to ${lang} failed:`, err);
}
}
if (Object.keys(translations).length > 0) {
await supabase.from("menu_items").update({ translations }).eq("id", itemId);
}
}
const createItemSchema = z.object({
name: z.string().min(1).max(160),
@@ -53,6 +87,8 @@ export const menuItemsRoutes: FastifyPluginAsync = async (app) => {
return reply.code(500).send({ message: "failed to create item" });
}
void translateNewItemInBackground(access.restaurantId, data.id, data.name, data.description);
return reply.code(201).send(data);
});
@@ -0,0 +1,15 @@
-- Multi-language menu support (PRD §13). Translations are stored inline as
-- JSONB keyed by language code rather than a separate table — menu content
-- is small and always read/written as a whole category+items tree, so a
-- join-free read matches how the public page already fetches it.
alter table restaurants
add column if not exists enabled_languages text[] not null default array['tr'];
alter table menu_categories
add column if not exists translations jsonb not null default '{}'::jsonb;
-- shape: { "en": { "name": "...", "description": "..." }, "de": {...} }
alter table menu_items
add column if not exists translations jsonb not null default '{}'::jsonb;
-- shape: { "en": { "name": "...", "description": "..." }, "de": {...} }