diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index cbdc1a4..9eec9f1 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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); diff --git a/apps/api/src/lib/translator.ts b/apps/api/src/lib/translator.ts new file mode 100644 index 0000000..8f1174e --- /dev/null +++ b/apps/api/src/lib/translator.ts @@ -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 = { + 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 { + 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 { + 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 { + 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)"); +} diff --git a/apps/api/src/routes/languages.ts b/apps/api/src/routes/languages.ts new file mode 100644 index 0000000..20eb83b --- /dev/null +++ b/apps/api/src/routes/languages.ts @@ -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 { + 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 }); + }); +}; diff --git a/apps/api/src/routes/menu-categories.ts b/apps/api/src/routes/menu-categories.ts index 03e5a93..86b7965 100644 --- a/apps/api/src/routes/menu-categories.ts +++ b/apps/api/src/routes/menu-categories.ts @@ -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 = {}; + 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); }); diff --git a/apps/api/src/routes/menu-items.ts b/apps/api/src/routes/menu-items.ts index f23f954..76d4315 100644 --- a/apps/api/src/routes/menu-items.ts +++ b/apps/api/src/routes/menu-items.ts @@ -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 = {}; + 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); }); diff --git a/supabase/migrations/20260829000000_menu_translations.sql b/supabase/migrations/20260829000000_menu_translations.sql new file mode 100644 index 0000000..d5b3c24 --- /dev/null +++ b/supabase/migrations/20260829000000_menu_translations.sql @@ -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": {...} }