feat: implement backend localization infrastructure

- Add internal/locale package: Parse(Accept-Language), FromContext/WithLang helpers, 12 supported languages
- Add Language middleware that reads Accept-Language header and stores lang in context
- Register Language middleware globally in server router (after CORS)

Database migrations:
- 009: create recipe_translations, saved_recipe_translations, ingredient_translations tables; migrate existing _ru data
- 010: drop legacy _ru columns (title_ru, description_ru, canonical_name_ru); update FTS index

Models: remove all _ru fields (TitleRu, DescriptionRu, NameRu, UnitRu, CanonicalNameRu)

Repositories:
- recipe: Upsert drops _ru params; GetByID does LEFT JOIN COALESCE on recipe_translations; ListMissingTranslation(lang); UpsertTranslation
- ingredient: same pattern with ingredient_translations; Search now queries translated names/aliases
- savedrecipe: List/GetByID LEFT JOIN COALESCE on saved_recipe_translations; UpsertTranslation

Gemini:
- RecipeRequest/MenuRequest gain Lang field
- buildRecipePrompt rewritten in English with target-language content instruction; image_query always in English
- GenerateMenu propagates Lang to GenerateRecipes

Handlers:
- recommendation/menu: pass locale.FromContext(ctx) as Lang
- recognition: saveClassification stores Russian translation via UpsertTranslation instead of _ru column

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-02-27 23:17:34 +02:00
parent ea4a6301ea
commit c0cf1b38ea
18 changed files with 718 additions and 273 deletions

View File

@@ -13,6 +13,7 @@ type MenuRequest struct {
Restrictions []string Restrictions []string
CuisinePrefs []string CuisinePrefs []string
AvailableProducts []string AvailableProducts []string
Lang string // ISO 639-1 target language code, e.g. "en", "ru"
} }
// DayPlan is the AI-generated plan for a single day. // DayPlan is the AI-generated plan for a single day.
@@ -63,6 +64,7 @@ func (c *Client) GenerateMenu(ctx context.Context, req MenuRequest) ([]DayPlan,
CuisinePrefs: req.CuisinePrefs, CuisinePrefs: req.CuisinePrefs,
Count: 7, Count: 7,
AvailableProducts: req.AvailableProducts, AvailableProducts: req.AvailableProducts,
Lang: req.Lang,
}) })
results[idx] = mealResult{r, err} results[idx] = mealResult{r, err}
}(i, slot.mealType, slot.fraction) }(i, slot.mealType, slot.fraction)

View File

@@ -14,12 +14,13 @@ type RecipeGenerator interface {
// RecipeRequest contains parameters for recipe generation. // RecipeRequest contains parameters for recipe generation.
type RecipeRequest struct { type RecipeRequest struct {
UserGoal string // "weight_loss" | "maintain" | "gain" UserGoal string // "lose" | "maintain" | "gain"
DailyCalories int DailyCalories int
Restrictions []string // e.g. ["gluten_free", "vegetarian"] Restrictions []string // e.g. ["gluten_free", "vegetarian"]
CuisinePrefs []string // e.g. ["russian", "asian"] CuisinePrefs []string // e.g. ["russian", "asian"]
Count int Count int
AvailableProducts []string // human-readable list of products in user's pantry AvailableProducts []string // human-readable list of products in user's pantry
Lang string // ISO 639-1 target language code, e.g. "en", "ru"
} }
// Recipe is a recipe returned by Gemini. // Recipe is a recipe returned by Gemini.
@@ -62,35 +63,55 @@ type NutritionInfo struct {
Approximate bool `json:"approximate"` Approximate bool `json:"approximate"`
} }
// langNames maps ISO 639-1 codes to English language names used in the prompt.
var langNames = map[string]string{
"en": "English",
"ru": "Russian",
"es": "Spanish",
"de": "German",
"fr": "French",
"it": "Italian",
"pt": "Portuguese",
"zh": "Chinese (Simplified)",
"ja": "Japanese",
"ko": "Korean",
"ar": "Arabic",
"hi": "Hindi",
}
// goalNames maps internal goal codes to English descriptions used in the prompt.
var goalNames = map[string]string{
"lose": "weight loss",
"maintain": "weight maintenance",
"gain": "muscle gain",
}
// GenerateRecipes generates recipes using the Gemini AI. // GenerateRecipes generates recipes using the Gemini AI.
// Retries up to maxRetries times only when the response is not valid JSON. // Retries up to maxRetries times only when the response is not valid JSON.
// API-level errors (rate limits, auth, etc.) are returned immediately. // API-level errors (rate limits, auth, etc.) are returned immediately.
func (c *Client) GenerateRecipes(ctx context.Context, req RecipeRequest) ([]Recipe, error) { func (c *Client) GenerateRecipes(ctx context.Context, req RecipeRequest) ([]Recipe, error) {
prompt := buildRecipePrompt(req) prompt := buildRecipePrompt(req)
// OpenAI messages format.
messages := []map[string]string{ messages := []map[string]string{
{"role": "user", "content": prompt}, {"role": "user", "content": prompt},
} }
var lastErr error var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ { for attempt := range maxRetries {
if attempt > 0 { if attempt > 0 {
messages = []map[string]string{ messages = []map[string]string{
{"role": "user", "content": prompt}, {"role": "user", "content": prompt},
{"role": "user", "content": "Предыдущий ответ не был валидным JSON. Верни ТОЛЬКО массив JSON без какого-либо текста до или после."}, {"role": "user", "content": "Previous response was not valid JSON. Return ONLY a JSON array with no text before or after."},
} }
} }
text, err := c.generateContent(ctx, messages) text, err := c.generateContent(ctx, messages)
if err != nil { if err != nil {
// API-level error (4xx/5xx): no point retrying immediately.
return nil, err return nil, err
} }
recipes, err := parseRecipesJSON(text) recipes, err := parseRecipesJSON(text)
if err != nil { if err != nil {
// Malformed JSON from the model — retry with a clarifying message.
lastErr = fmt.Errorf("attempt %d parse JSON: %w", attempt+1, err) lastErr = fmt.Errorf("attempt %d parse JSON: %w", attempt+1, err)
continue continue
} }
@@ -105,22 +126,26 @@ func (c *Client) GenerateRecipes(ctx context.Context, req RecipeRequest) ([]Reci
} }
func buildRecipePrompt(req RecipeRequest) string { func buildRecipePrompt(req RecipeRequest) string {
goalRu := map[string]string{ lang := req.Lang
"weight_loss": "похудение", if lang == "" {
"maintain": "поддержание веса", lang = "en"
"gain": "набор массы",
} }
goal := goalRu[req.UserGoal] langName, ok := langNames[lang]
if goal == "" { if !ok {
goal = "поддержание веса" langName = "English"
} }
restrictions := "нет" goal := goalNames[req.UserGoal]
if goal == "" {
goal = "weight maintenance"
}
restrictions := "none"
if len(req.Restrictions) > 0 { if len(req.Restrictions) > 0 {
restrictions = strings.Join(req.Restrictions, ", ") restrictions = strings.Join(req.Restrictions, ", ")
} }
cuisines := "любые" cuisines := "any"
if len(req.CuisinePrefs) > 0 { if len(req.CuisinePrefs) > 0 {
cuisines = strings.Join(req.CuisinePrefs, ", ") cuisines = strings.Join(req.CuisinePrefs, ", ")
} }
@@ -137,48 +162,46 @@ func buildRecipePrompt(req RecipeRequest) string {
productsSection := "" productsSection := ""
if len(req.AvailableProducts) > 0 { if len(req.AvailableProducts) > 0 {
productsSection = "\nДоступные продукты (приоритет — скоро истекают ⚠):\n" + productsSection = "\nAvailable products (⚠ = expiring soon, prioritise these):\n" +
strings.Join(req.AvailableProducts, "\n") + strings.Join(req.AvailableProducts, "\n") + "\n"
"\nПредпочтительно использовать эти продукты в рецептах.\n"
} }
return fmt.Sprintf(`Ты — диетолог-повар. Предложи %d рецептов на русском языке. return fmt.Sprintf(`You are a chef and nutritionist. Generate %d recipes in %s.
Профиль пользователя: User profile:
- Цель: %s - Goal: %s
- Дневная норма калорий: %d ккал - Daily calories: %d kcal
- Ограничения: %s - Dietary restrictions: %s
- Предпочтения: %s - Cuisine preferences: %s
%s %s
Требования к каждому рецепту: Requirements for each recipe:
- Калорийность на порцию: не более %d ккал - Max %d kcal per serving
- Время приготовления: до 60 минут - Total cooking time: max 60 minutes
- Укажи КБЖУ на порцию (приблизительно) - Include approximate macros per serving
ВАЖНО: поле image_query заполняй ТОЛЬКО на английском языке — оно используется для поиска фото. IMPORTANT:
- All text fields (title, description, ingredient names, units, step descriptions, tags) MUST be in %s.
- The "image_query" field MUST always be in English (it is used for stock-photo search).
Верни ТОЛЬКО валидный JSON-массив без markdown-обёртки: Return ONLY a valid JSON array without markdown or extra text:
[{ [{
"title": "Название", "title": "...",
"description": "2-3 предложения", "description": "2-3 sentences",
"cuisine": "russian|asian|european|mediterranean|american|other", "cuisine": "russian|asian|european|mediterranean|american|other",
"difficulty": "easy|medium|hard", "difficulty": "easy|medium|hard",
"prep_time_min": 10, "prep_time_min": 10,
"cook_time_min": 20, "cook_time_min": 20,
"servings": 2, "servings": 2,
"image_query": "chicken breast vegetables healthy (ENGLISH ONLY, used for photo search)", "image_query": "short English photo-search query",
"ingredients": [{"name": "Куриная грудка", "amount": 300, "unit": "г"}], "ingredients": [{"name": "...", "amount": 300, "unit": "..."}],
"steps": [{"number": 1, "description": "...", "timer_seconds": null}], "steps": [{"number": 1, "description": "...", "timer_seconds": null}],
"tags": ["высокий белок"], "tags": ["..."],
"nutrition_per_serving": { "nutrition_per_serving": {"calories": 420, "protein_g": 48, "fat_g": 12, "carbs_g": 18}
"calories": 420, "protein_g": 48, "fat_g": 12, "carbs_g": 18 }]`, count, langName, goal, req.DailyCalories, restrictions, cuisines, productsSection, perMealCalories, langName)
}
}]`, count, goal, req.DailyCalories, restrictions, cuisines, productsSection, perMealCalories)
} }
func parseRecipesJSON(text string) ([]Recipe, error) { func parseRecipesJSON(text string) ([]Recipe, error) {
text = strings.TrimSpace(text) text = strings.TrimSpace(text)
// Strip potential markdown code fences
if strings.HasPrefix(text, "```") { if strings.HasPrefix(text, "```") {
text = strings.TrimPrefix(text, "```json") text = strings.TrimPrefix(text, "```json")
text = strings.TrimPrefix(text, "```") text = strings.TrimPrefix(text, "```")

View File

@@ -7,10 +7,11 @@ import (
// IngredientMapping is the canonical ingredient record used to link // IngredientMapping is the canonical ingredient record used to link
// user products, recipe ingredients, and Spoonacular data. // user products, recipe ingredients, and Spoonacular data.
// CanonicalName holds the content for the language resolved at query time
// (English by default, or from ingredient_translations when available).
type IngredientMapping struct { type IngredientMapping struct {
ID string `json:"id"` ID string `json:"id"`
CanonicalName string `json:"canonical_name"` CanonicalName string `json:"canonical_name"`
CanonicalNameRu *string `json:"canonical_name_ru"`
SpoonacularID *int `json:"spoonacular_id"` SpoonacularID *int `json:"spoonacular_id"`
Aliases json.RawMessage `json:"aliases"` // []string Aliases json.RawMessage `json:"aliases"` // []string
Category *string `json:"category"` Category *string `json:"category"`

View File

@@ -6,11 +6,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/food-ai/backend/internal/locale"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
// Repository handles persistence for ingredient_mappings. // Repository handles persistence for ingredient_mappings and their translations.
type Repository struct { type Repository struct {
pool *pgxpool.Pool pool *pgxpool.Pool
} }
@@ -20,16 +21,16 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool} return &Repository{pool: pool}
} }
// Upsert inserts or updates an ingredient mapping. // Upsert inserts or updates an ingredient mapping (English canonical content).
// Conflict is resolved on spoonacular_id when set; otherwise a simple insert is done. // Conflict is resolved on spoonacular_id when set.
func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*IngredientMapping, error) { func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*IngredientMapping, error) {
query := ` query := `
INSERT INTO ingredient_mappings ( INSERT INTO ingredient_mappings (
canonical_name, canonical_name_ru, spoonacular_id, aliases, canonical_name, spoonacular_id, aliases,
category, default_unit, category, default_unit,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g, calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
storage_days storage_days
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (spoonacular_id) DO UPDATE SET ON CONFLICT (spoonacular_id) DO UPDATE SET
canonical_name = EXCLUDED.canonical_name, canonical_name = EXCLUDED.canonical_name,
aliases = EXCLUDED.aliases, aliases = EXCLUDED.aliases,
@@ -42,13 +43,13 @@ func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*Ingredi
fiber_per_100g = EXCLUDED.fiber_per_100g, fiber_per_100g = EXCLUDED.fiber_per_100g,
storage_days = EXCLUDED.storage_days, storage_days = EXCLUDED.storage_days,
updated_at = now() updated_at = now()
RETURNING id, canonical_name, canonical_name_ru, spoonacular_id, aliases, RETURNING id, canonical_name, spoonacular_id, aliases,
category, default_unit, category, default_unit,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g, calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
storage_days, created_at, updated_at` storage_days, created_at, updated_at`
row := r.pool.QueryRow(ctx, query, row := r.pool.QueryRow(ctx, query,
m.CanonicalName, m.CanonicalNameRu, m.SpoonacularID, m.Aliases, m.CanonicalName, m.SpoonacularID, m.Aliases,
m.Category, m.DefaultUnit, m.Category, m.DefaultUnit,
m.CaloriesPer100g, m.ProteinPer100g, m.FatPer100g, m.CarbsPer100g, m.FiberPer100g, m.CaloriesPer100g, m.ProteinPer100g, m.FatPer100g, m.CarbsPer100g, m.FiberPer100g,
m.StorageDays, m.StorageDays,
@@ -57,17 +58,22 @@ func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*Ingredi
} }
// GetBySpoonacularID returns an ingredient mapping by Spoonacular ID. // GetBySpoonacularID returns an ingredient mapping by Spoonacular ID.
// CanonicalName is resolved for the language stored in ctx.
// Returns nil, nil if not found. // Returns nil, nil if not found.
func (r *Repository) GetBySpoonacularID(ctx context.Context, id int) (*IngredientMapping, error) { func (r *Repository) GetBySpoonacularID(ctx context.Context, id int) (*IngredientMapping, error) {
lang := locale.FromContext(ctx)
query := ` query := `
SELECT id, canonical_name, canonical_name_ru, spoonacular_id, aliases, SELECT im.id,
category, default_unit, COALESCE(it.name, im.canonical_name) AS canonical_name,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g, im.spoonacular_id, im.aliases,
storage_days, created_at, updated_at im.category, im.default_unit,
FROM ingredient_mappings im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
WHERE spoonacular_id = $1` im.storage_days, im.created_at, im.updated_at
FROM ingredient_mappings im
LEFT JOIN ingredient_translations it ON it.ingredient_id = im.id AND it.lang = $2
WHERE im.spoonacular_id = $1`
row := r.pool.QueryRow(ctx, query, id) row := r.pool.QueryRow(ctx, query, id, lang)
m, err := scanMapping(row) m, err := scanMapping(row)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, nil return nil, nil
@@ -76,17 +82,22 @@ func (r *Repository) GetBySpoonacularID(ctx context.Context, id int) (*Ingredien
} }
// GetByID returns an ingredient mapping by UUID. // GetByID returns an ingredient mapping by UUID.
// CanonicalName is resolved for the language stored in ctx.
// Returns nil, nil if not found. // Returns nil, nil if not found.
func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping, error) { func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping, error) {
lang := locale.FromContext(ctx)
query := ` query := `
SELECT id, canonical_name, canonical_name_ru, spoonacular_id, aliases, SELECT im.id,
category, default_unit, COALESCE(it.name, im.canonical_name) AS canonical_name,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g, im.spoonacular_id, im.aliases,
storage_days, created_at, updated_at im.category, im.default_unit,
FROM ingredient_mappings im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
WHERE id = $1` im.storage_days, im.created_at, im.updated_at
FROM ingredient_mappings im
LEFT JOIN ingredient_translations it ON it.ingredient_id = im.id AND it.lang = $2
WHERE im.id = $1`
row := r.pool.QueryRow(ctx, query, id) row := r.pool.QueryRow(ctx, query, id, lang)
m, err := scanMapping(row) m, err := scanMapping(row)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, nil return nil, nil
@@ -95,6 +106,7 @@ func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping
} }
// FuzzyMatch finds the single best matching ingredient mapping for a given name. // FuzzyMatch finds the single best matching ingredient mapping for a given name.
// Searches both English and translated names for the language in ctx.
// Returns nil, nil when no match is found. // Returns nil, nil when no match is found.
func (r *Repository) FuzzyMatch(ctx context.Context, name string) (*IngredientMapping, error) { func (r *Repository) FuzzyMatch(ctx context.Context, name string) (*IngredientMapping, error) {
results, err := r.Search(ctx, name, 1) results, err := r.Search(ctx, name, 1)
@@ -108,24 +120,31 @@ func (r *Repository) FuzzyMatch(ctx context.Context, name string) (*IngredientMa
} }
// Search finds ingredient mappings matching the query string. // Search finds ingredient mappings matching the query string.
// Searches English aliases/name and the translated name for the language in ctx.
// Uses a three-level strategy: exact aliases match, ILIKE, and pg_trgm similarity. // Uses a three-level strategy: exact aliases match, ILIKE, and pg_trgm similarity.
func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*IngredientMapping, error) { func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*IngredientMapping, error) {
if limit <= 0 { if limit <= 0 {
limit = 10 limit = 10
} }
lang := locale.FromContext(ctx)
q := ` q := `
SELECT id, canonical_name, canonical_name_ru, spoonacular_id, aliases, SELECT im.id,
category, default_unit, COALESCE(it.name, im.canonical_name) AS canonical_name,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g, im.spoonacular_id, im.aliases,
storage_days, created_at, updated_at im.category, im.default_unit,
FROM ingredient_mappings im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
WHERE aliases @> to_jsonb(lower($1)::text) im.storage_days, im.created_at, im.updated_at
OR canonical_name_ru ILIKE '%' || $1 || '%' FROM ingredient_mappings im
OR similarity(canonical_name_ru, $1) > 0.3 LEFT JOIN ingredient_translations it ON it.ingredient_id = im.id AND it.lang = $3
ORDER BY similarity(canonical_name_ru, $1) DESC WHERE im.aliases @> to_jsonb(lower($1)::text)
OR it.aliases @> to_jsonb(lower($1)::text)
OR im.canonical_name ILIKE '%' || $1 || '%'
OR it.name ILIKE '%' || $1 || '%'
OR similarity(COALESCE(it.name, im.canonical_name), $1) > 0.3
ORDER BY similarity(COALESCE(it.name, im.canonical_name), $1) DESC
LIMIT $2` LIMIT $2`
rows, err := r.pool.Query(ctx, q, query, limit) rows, err := r.pool.Query(ctx, q, query, limit, lang)
if err != nil { if err != nil {
return nil, fmt.Errorf("search ingredient_mappings: %w", err) return nil, fmt.Errorf("search ingredient_mappings: %w", err)
} }
@@ -142,45 +161,41 @@ func (r *Repository) Count(ctx context.Context) (int, error) {
return n, nil return n, nil
} }
// ListUntranslated returns ingredients without a Russian name, ordered by id. // ListMissingTranslation returns ingredients that have no translation for the
func (r *Repository) ListUntranslated(ctx context.Context, limit, offset int) ([]*IngredientMapping, error) { // given language, ordered by id.
func (r *Repository) ListMissingTranslation(ctx context.Context, lang string, limit, offset int) ([]*IngredientMapping, error) {
query := ` query := `
SELECT id, canonical_name, canonical_name_ru, spoonacular_id, aliases, SELECT im.id, im.canonical_name, im.spoonacular_id, im.aliases,
category, default_unit, im.category, im.default_unit,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g, im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
storage_days, created_at, updated_at im.storage_days, im.created_at, im.updated_at
FROM ingredient_mappings FROM ingredient_mappings im
WHERE canonical_name_ru IS NULL WHERE NOT EXISTS (
ORDER BY id SELECT 1 FROM ingredient_translations it
WHERE it.ingredient_id = im.id AND it.lang = $3
)
ORDER BY im.id
LIMIT $1 OFFSET $2` LIMIT $1 OFFSET $2`
rows, err := r.pool.Query(ctx, query, limit, offset) rows, err := r.pool.Query(ctx, query, limit, offset, lang)
if err != nil { if err != nil {
return nil, fmt.Errorf("list untranslated: %w", err) return nil, fmt.Errorf("list missing translation (%s): %w", lang, err)
} }
defer rows.Close() defer rows.Close()
return collectMappings(rows) return collectMappings(rows)
} }
// UpdateTranslation saves the Russian name and adds Russian aliases. // UpsertTranslation inserts or replaces a translation for an ingredient mapping.
func (r *Repository) UpdateTranslation(ctx context.Context, id, canonicalNameRu string, aliasesRu []string) error { func (r *Repository) UpsertTranslation(ctx context.Context, id, lang, name string, aliases json.RawMessage) error {
// Merge new aliases into existing JSONB array without duplicates
query := ` query := `
UPDATE ingredient_mappings SET INSERT INTO ingredient_translations (ingredient_id, lang, name, aliases)
canonical_name_ru = $2, VALUES ($1, $2, $3, $4)
aliases = ( ON CONFLICT (ingredient_id, lang) DO UPDATE SET
SELECT jsonb_agg(DISTINCT elem) name = EXCLUDED.name,
FROM ( aliases = EXCLUDED.aliases`
SELECT jsonb_array_elements(aliases) AS elem
UNION
SELECT to_jsonb(unnest) FROM unnest($3::text[]) AS unnest
) sub
),
updated_at = now()
WHERE id = $1`
if _, err := r.pool.Exec(ctx, query, id, canonicalNameRu, aliasesRu); err != nil { if _, err := r.pool.Exec(ctx, query, id, lang, name, aliases); err != nil {
return fmt.Errorf("update translation %s: %w", id, err) return fmt.Errorf("upsert ingredient translation %s/%s: %w", id, lang, err)
} }
return nil return nil
} }
@@ -192,7 +207,7 @@ func scanMapping(row pgx.Row) (*IngredientMapping, error) {
var aliases []byte var aliases []byte
err := row.Scan( err := row.Scan(
&m.ID, &m.CanonicalName, &m.CanonicalNameRu, &m.SpoonacularID, &aliases, &m.ID, &m.CanonicalName, &m.SpoonacularID, &aliases,
&m.Category, &m.DefaultUnit, &m.Category, &m.DefaultUnit,
&m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g, &m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g,
&m.StorageDays, &m.CreatedAt, &m.UpdatedAt, &m.StorageDays, &m.CreatedAt, &m.UpdatedAt,
@@ -210,7 +225,7 @@ func collectMappings(rows pgx.Rows) ([]*IngredientMapping, error) {
var m IngredientMapping var m IngredientMapping
var aliases []byte var aliases []byte
if err := rows.Scan( if err := rows.Scan(
&m.ID, &m.CanonicalName, &m.CanonicalNameRu, &m.SpoonacularID, &aliases, &m.ID, &m.CanonicalName, &m.SpoonacularID, &aliases,
&m.Category, &m.DefaultUnit, &m.Category, &m.DefaultUnit,
&m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g, &m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g,
&m.StorageDays, &m.CreatedAt, &m.UpdatedAt, &m.StorageDays, &m.CreatedAt, &m.UpdatedAt,

View File

@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"testing" "testing"
"github.com/food-ai/backend/internal/locale"
"github.com/food-ai/backend/internal/testutil" "github.com/food-ai/backend/internal/testutil"
) )
@@ -65,7 +66,6 @@ func TestIngredientRepository_Upsert_ConflictUpdates(t *testing.T) {
t.Fatalf("first upsert: %v", err) t.Fatalf("first upsert: %v", err)
} }
// Update with same spoonacular_id
cal := 89.0 cal := 89.0
second := &IngredientMapping{ second := &IngredientMapping{
CanonicalName: "banana_updated", CanonicalName: "banana_updated",
@@ -137,7 +137,7 @@ func TestIngredientRepository_GetBySpoonacularID_NotFound(t *testing.T) {
} }
} }
func TestIngredientRepository_ListUntranslated(t *testing.T) { func TestIngredientRepository_ListMissingTranslation(t *testing.T) {
pool := testutil.SetupTestDB(t) pool := testutil.SetupTestDB(t)
repo := NewRepository(pool) repo := NewRepository(pool)
ctx := context.Background() ctx := context.Background()
@@ -145,7 +145,7 @@ func TestIngredientRepository_ListUntranslated(t *testing.T) {
cat := "produce" cat := "produce"
unit := "g" unit := "g"
// Insert 3 without translation // Insert 3 without any translation.
for i, name := range []string{"carrot", "onion", "garlic"} { for i, name := range []string{"carrot", "onion", "garlic"} {
id := 4000 + i id := 4000 + i
_, err := repo.Upsert(ctx, &IngredientMapping{ _, err := repo.Upsert(ctx, &IngredientMapping{
@@ -160,45 +160,38 @@ func TestIngredientRepository_ListUntranslated(t *testing.T) {
} }
} }
// Insert 1 with translation (shouldn't appear in untranslated list) // Insert 1 and add a Russian translation should not appear in the result.
id := 4100 id := 4100
ruName := "помидор" saved, err := repo.Upsert(ctx, &IngredientMapping{
withTranslation := &IngredientMapping{
CanonicalName: "tomato", CanonicalName: "tomato",
CanonicalNameRu: &ruName,
SpoonacularID: &id, SpoonacularID: &id,
Aliases: json.RawMessage(`[]`), Aliases: json.RawMessage(`[]`),
Category: &cat, Category: &cat,
DefaultUnit: &unit, DefaultUnit: &unit,
} })
saved, err := repo.Upsert(ctx, withTranslation)
if err != nil { if err != nil {
t.Fatalf("upsert with translation: %v", err) t.Fatalf("upsert tomato: %v", err)
} }
// The upsert doesn't set canonical_name_ru because the UPDATE clause doesn't include it if err := repo.UpsertTranslation(ctx, saved.ID, "ru", "помидор", json.RawMessage(`["помидор","томат"]`)); err != nil {
// We need to manually set it after t.Fatalf("upsert translation: %v", err)
if err := repo.UpdateTranslation(ctx, saved.ID, "помидор", []string{"помидор", "томат"}); err != nil {
t.Fatalf("update translation: %v", err)
} }
untranslated, err := repo.ListUntranslated(ctx, 10, 0) missing, err := repo.ListMissingTranslation(ctx, "ru", 10, 0)
if err != nil { if err != nil {
t.Fatalf("list untranslated: %v", err) t.Fatalf("list missing translation: %v", err)
} }
// Should return the 3 without translation (carrot, onion, garlic) for _, m := range missing {
// The translated tomato should not appear
for _, m := range untranslated {
if m.CanonicalName == "tomato" { if m.CanonicalName == "tomato" {
t.Error("translated ingredient should not appear in ListUntranslated") t.Error("translated ingredient should not appear in ListMissingTranslation")
} }
} }
if len(untranslated) < 3 { if len(missing) < 3 {
t.Errorf("expected at least 3 untranslated, got %d", len(untranslated)) t.Errorf("expected at least 3 untranslated, got %d", len(missing))
} }
} }
func TestIngredientRepository_UpdateTranslation(t *testing.T) { func TestIngredientRepository_UpsertTranslation(t *testing.T) {
pool := testutil.SetupTestDB(t) pool := testutil.SetupTestDB(t)
repo := NewRepository(pool) repo := NewRepository(pool)
ctx := context.Background() ctx := context.Background()
@@ -218,33 +211,29 @@ func TestIngredientRepository_UpdateTranslation(t *testing.T) {
t.Fatalf("upsert: %v", err) t.Fatalf("upsert: %v", err)
} }
err = repo.UpdateTranslation(ctx, saved.ID, "куриная грудка", err = repo.UpsertTranslation(ctx, saved.ID, "ru", "куриная грудка",
[]string{"куриная грудка", "куриное филе"}) json.RawMessage(`["куриная грудка","куриное филе"]`))
if err != nil { if err != nil {
t.Fatalf("update translation: %v", err) t.Fatalf("upsert translation: %v", err)
} }
got, err := repo.GetByID(ctx, saved.ID) // Retrieve with Russian context — CanonicalName should be the Russian name.
ruCtx := locale.WithLang(ctx, "ru")
got, err := repo.GetByID(ruCtx, saved.ID)
if err != nil { if err != nil {
t.Fatalf("get by id: %v", err) t.Fatalf("get by id: %v", err)
} }
if got.CanonicalNameRu == nil || *got.CanonicalNameRu != "куриная грудка" { if got.CanonicalName != "куриная грудка" {
t.Errorf("expected canonical_name_ru='куриная грудка', got %v", got.CanonicalNameRu) t.Errorf("expected CanonicalName='куриная грудка', got %q", got.CanonicalName)
} }
var aliases []string // Retrieve with English context (default) — CanonicalName should be the English name.
if err := json.Unmarshal(got.Aliases, &aliases); err != nil { enCtx := locale.WithLang(ctx, "en")
t.Fatalf("unmarshal aliases: %v", err) gotEn, err := repo.GetByID(enCtx, saved.ID)
if err != nil {
t.Fatalf("get by id (en): %v", err)
} }
if gotEn.CanonicalName != "chicken_breast" {
hasRu := false t.Errorf("expected English CanonicalName='chicken_breast', got %q", gotEn.CanonicalName)
for _, a := range aliases {
if a == "куриное филе" {
hasRu = true
break
}
}
if !hasRu {
t.Errorf("Russian alias not found in aliases: %v", aliases)
} }
} }

View File

@@ -0,0 +1,69 @@
package locale
import (
"context"
"net/http"
"strings"
)
// Default is the fallback language when no supported language is detected.
const Default = "en"
// Supported is the set of language codes the application currently handles.
// Keys are ISO 639-1 two-letter codes (lower-case).
var Supported = map[string]bool{
"en": true,
"ru": true,
"es": true,
"de": true,
"fr": true,
"it": true,
"pt": true,
"zh": true,
"ja": true,
"ko": true,
"ar": true,
"hi": true,
}
type contextKey struct{}
// Parse returns the best-matching supported language from an Accept-Language
// header value. It iterates through the comma-separated list in preference
// order and returns the first entry whose primary subtag is in Supported.
// Returns Default when the header is empty or no match is found.
func Parse(acceptLang string) string {
if acceptLang == "" {
return Default
}
for part := range strings.SplitSeq(acceptLang, ",") {
// Strip quality value (e.g. ";q=0.9").
tag := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
// Use only the primary subtag (e.g. "ru" from "ru-RU").
lang := strings.ToLower(strings.SplitN(tag, "-", 2)[0])
if Supported[lang] {
return lang
}
}
return Default
}
// WithLang returns a copy of ctx carrying the given language code.
func WithLang(ctx context.Context, lang string) context.Context {
return context.WithValue(ctx, contextKey{}, lang)
}
// FromContext returns the language stored in ctx.
// Returns Default when no language has been set.
func FromContext(ctx context.Context) string {
if lang, ok := ctx.Value(contextKey{}).(string); ok && lang != "" {
return lang
}
return Default
}
// FromRequest extracts the preferred language from the request's
// Accept-Language header.
func FromRequest(r *http.Request) string {
return Parse(r.Header.Get("Accept-Language"))
}

View File

@@ -0,0 +1,94 @@
package locale_test
import (
"context"
"net/http"
"testing"
"github.com/food-ai/backend/internal/locale"
)
func TestParse(t *testing.T) {
tests := []struct {
name string
acceptLang string
want string
}{
{
name: "empty header returns default",
acceptLang: "",
want: locale.Default,
},
{
name: "exact match",
acceptLang: "ru",
want: "ru",
},
{
name: "region subtag stripped",
acceptLang: "ru-RU",
want: "ru",
},
{
name: "full browser header picks first supported",
acceptLang: "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
want: "ru",
},
{
name: "unsupported first falls through to supported",
acceptLang: "xx-XX,ru;q=0.8",
want: "ru",
},
{
name: "completely unsupported returns default",
acceptLang: "xx-XX,yy-YY",
want: locale.Default,
},
{
name: "chinese region subtag",
acceptLang: "zh-CN,zh;q=0.9",
want: "zh",
},
{
name: "case insensitive",
acceptLang: "RU-RU",
want: "ru",
},
{
name: "whitespace around tag",
acceptLang: " ru ",
want: "ru",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := locale.Parse(tc.acceptLang)
if got != tc.want {
t.Errorf("Parse(%q) = %q, want %q", tc.acceptLang, got, tc.want)
}
})
}
}
func TestWithLangAndFromContext(t *testing.T) {
ctx := context.Background()
if got := locale.FromContext(ctx); got != locale.Default {
t.Errorf("FromContext on empty ctx = %q, want %q", got, locale.Default)
}
ctx = locale.WithLang(ctx, "ru")
if got := locale.FromContext(ctx); got != "ru" {
t.Errorf("FromContext after WithLang(ru) = %q, want %q", got, "ru")
}
}
func TestFromRequest(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Accept-Language", "es-ES,es;q=0.9")
if got := locale.FromRequest(req); got != "es" {
t.Errorf("FromRequest = %q, want %q", got, "es")
}
}

View File

@@ -11,6 +11,7 @@ import (
"time" "time"
"github.com/food-ai/backend/internal/gemini" "github.com/food-ai/backend/internal/gemini"
"github.com/food-ai/backend/internal/locale"
"github.com/food-ai/backend/internal/middleware" "github.com/food-ai/backend/internal/middleware"
"github.com/food-ai/backend/internal/savedrecipe" "github.com/food-ai/backend/internal/savedrecipe"
"github.com/food-ai/backend/internal/user" "github.com/food-ai/backend/internal/user"
@@ -128,7 +129,7 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
return return
} }
menuReq := buildMenuRequest(u) menuReq := buildMenuRequest(u, locale.FromContext(r.Context()))
// Attach pantry products. // Attach pantry products.
if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil { if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil {
@@ -460,8 +461,8 @@ type userPreferences struct {
Restrictions []string `json:"restrictions"` Restrictions []string `json:"restrictions"`
} }
func buildMenuRequest(u *user.User) gemini.MenuRequest { func buildMenuRequest(u *user.User, lang string) gemini.MenuRequest {
req := gemini.MenuRequest{DailyCalories: 2000} req := gemini.MenuRequest{DailyCalories: 2000, Lang: lang}
if u.Goal != nil { if u.Goal != nil {
req.UserGoal = *u.Goal req.UserGoal = *u.Goal
} }

View File

@@ -0,0 +1,18 @@
package middleware
import (
"net/http"
"github.com/food-ai/backend/internal/locale"
)
// Language reads the Accept-Language request header, resolves the best
// supported language via locale.Parse, and stores it in the request context.
// Downstream handlers retrieve it with locale.FromContext.
func Language(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lang := locale.FromRequest(r)
ctx := locale.WithLang(r.Context(), lang)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -6,15 +6,16 @@ import (
) )
// Recipe is a recipe record in the database. // Recipe is a recipe record in the database.
// Title, Description, Ingredients, and Steps hold the content for the language
// resolved at query time (English by default, or from recipe_translations when
// a matching row exists for the requested language).
type Recipe struct { type Recipe struct {
ID string `json:"id"` ID string `json:"id"`
Source string `json:"source"` // spoonacular | ai | user Source string `json:"source"` // spoonacular | ai | user
SpoonacularID *int `json:"spoonacular_id"` SpoonacularID *int `json:"spoonacular_id"`
Title string `json:"title"` Title string `json:"title"`
TitleRu *string `json:"title_ru"`
Description *string `json:"description"` Description *string `json:"description"`
DescriptionRu *string `json:"description_ru"`
Cuisine *string `json:"cuisine"` Cuisine *string `json:"cuisine"`
Difficulty *string `json:"difficulty"` // easy | medium | hard Difficulty *string `json:"difficulty"` // easy | medium | hard
@@ -45,10 +46,8 @@ type RecipeIngredient struct {
SpoonacularID *int `json:"spoonacular_id"` SpoonacularID *int `json:"spoonacular_id"`
MappingID *string `json:"mapping_id"` MappingID *string `json:"mapping_id"`
Name string `json:"name"` Name string `json:"name"`
NameRu *string `json:"name_ru"`
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
Unit string `json:"unit"` Unit string `json:"unit"`
UnitRu *string `json:"unit_ru"`
Optional bool `json:"optional"` Optional bool `json:"optional"`
} }
@@ -56,7 +55,6 @@ type RecipeIngredient struct {
type RecipeStep struct { type RecipeStep struct {
Number int `json:"number"` Number int `json:"number"`
Description string `json:"description"` Description string `json:"description"`
DescriptionRu *string `json:"description_ru"`
TimerSeconds *int `json:"timer_seconds"` TimerSeconds *int `json:"timer_seconds"`
ImageURL *string `json:"image_url"` ImageURL *string `json:"image_url"`
} }

View File

@@ -6,11 +6,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/food-ai/backend/internal/locale"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
// Repository handles persistence for recipes. // Repository handles persistence for recipes and their translations.
type Repository struct { type Repository struct {
pool *pgxpool.Pool pool *pgxpool.Pool
} }
@@ -20,17 +21,17 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool} return &Repository{pool: pool}
} }
// Upsert inserts or updates a recipe. // Upsert inserts or updates a recipe (English canonical content only).
// Conflict is resolved on spoonacular_id. // Conflict is resolved on spoonacular_id.
func (r *Repository) Upsert(ctx context.Context, recipe *Recipe) (*Recipe, error) { func (r *Repository) Upsert(ctx context.Context, recipe *Recipe) (*Recipe, error) {
query := ` query := `
INSERT INTO recipes ( INSERT INTO recipes (
source, spoonacular_id, source, spoonacular_id,
title, description, title_ru, description_ru, title, description,
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url, cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving, calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
ingredients, steps, tags ingredients, steps, tags
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
ON CONFLICT (spoonacular_id) DO UPDATE SET ON CONFLICT (spoonacular_id) DO UPDATE SET
title = EXCLUDED.title, title = EXCLUDED.title,
description = EXCLUDED.description, description = EXCLUDED.description,
@@ -50,7 +51,7 @@ func (r *Repository) Upsert(ctx context.Context, recipe *Recipe) (*Recipe, error
tags = EXCLUDED.tags, tags = EXCLUDED.tags,
updated_at = now() updated_at = now()
RETURNING id, source, spoonacular_id, RETURNING id, source, spoonacular_id,
title, description, title_ru, description_ru, title, description,
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url, cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving, calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
ingredients, steps, tags, ingredients, steps, tags,
@@ -58,7 +59,7 @@ func (r *Repository) Upsert(ctx context.Context, recipe *Recipe) (*Recipe, error
row := r.pool.QueryRow(ctx, query, row := r.pool.QueryRow(ctx, query,
recipe.Source, recipe.SpoonacularID, recipe.Source, recipe.SpoonacularID,
recipe.Title, recipe.Description, recipe.TitleRu, recipe.DescriptionRu, recipe.Title, recipe.Description,
recipe.Cuisine, recipe.Difficulty, recipe.PrepTimeMin, recipe.CookTimeMin, recipe.Servings, recipe.ImageURL, recipe.Cuisine, recipe.Difficulty, recipe.PrepTimeMin, recipe.CookTimeMin, recipe.Servings, recipe.ImageURL,
recipe.CaloriesPerServing, recipe.ProteinPerServing, recipe.FatPerServing, recipe.CarbsPerServing, recipe.FiberPerServing, recipe.CaloriesPerServing, recipe.ProteinPerServing, recipe.FatPerServing, recipe.CarbsPerServing, recipe.FiberPerServing,
recipe.Ingredients, recipe.Steps, recipe.Tags, recipe.Ingredients, recipe.Steps, recipe.Tags,
@@ -66,6 +67,33 @@ func (r *Repository) Upsert(ctx context.Context, recipe *Recipe) (*Recipe, error
return scanRecipe(row) return scanRecipe(row)
} }
// GetByID returns a recipe by UUID, with content resolved for the language
// stored in ctx (falls back to English when no translation exists).
// Returns nil, nil if not found.
func (r *Repository) GetByID(ctx context.Context, id string) (*Recipe, error) {
lang := locale.FromContext(ctx)
query := `
SELECT r.id, r.source, r.spoonacular_id,
COALESCE(rt.title, r.title) AS title,
COALESCE(rt.description, r.description) AS description,
r.cuisine, r.difficulty, r.prep_time_min, r.cook_time_min, r.servings, r.image_url,
r.calories_per_serving, r.protein_per_serving, r.fat_per_serving, r.carbs_per_serving, r.fiber_per_serving,
COALESCE(rt.ingredients, r.ingredients) AS ingredients,
COALESCE(rt.steps, r.steps) AS steps,
r.tags,
r.avg_rating, r.review_count, r.created_by, r.created_at, r.updated_at
FROM recipes r
LEFT JOIN recipe_translations rt ON rt.recipe_id = r.id AND rt.lang = $2
WHERE r.id = $1`
row := r.pool.QueryRow(ctx, query, id, lang)
rec, err := scanRecipe(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return rec, err
}
// Count returns the total number of recipes. // Count returns the total number of recipes.
func (r *Repository) Count(ctx context.Context) (int, error) { func (r *Repository) Count(ctx context.Context) (int, error) {
var n int var n int
@@ -75,40 +103,51 @@ func (r *Repository) Count(ctx context.Context) (int, error) {
return n, nil return n, nil
} }
// ListUntranslated returns recipes without a Russian title, ordered by review_count DESC. // ListMissingTranslation returns Spoonacular recipes that have no translation
func (r *Repository) ListUntranslated(ctx context.Context, limit, offset int) ([]*Recipe, error) { // for the given language, ordered by review_count DESC.
func (r *Repository) ListMissingTranslation(ctx context.Context, lang string, limit, offset int) ([]*Recipe, error) {
query := ` query := `
SELECT id, source, spoonacular_id, SELECT id, source, spoonacular_id,
title, description, title_ru, description_ru, title, description,
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url, cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving, calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
ingredients, steps, tags, ingredients, steps, tags,
avg_rating, review_count, created_by, created_at, updated_at avg_rating, review_count, created_by, created_at, updated_at
FROM recipes FROM recipes
WHERE title_ru IS NULL AND source = 'spoonacular' WHERE source = 'spoonacular'
AND NOT EXISTS (
SELECT 1 FROM recipe_translations rt
WHERE rt.recipe_id = recipes.id AND rt.lang = $3
)
ORDER BY review_count DESC ORDER BY review_count DESC
LIMIT $1 OFFSET $2` LIMIT $1 OFFSET $2`
rows, err := r.pool.Query(ctx, query, limit, offset) rows, err := r.pool.Query(ctx, query, limit, offset, lang)
if err != nil { if err != nil {
return nil, fmt.Errorf("list untranslated recipes: %w", err) return nil, fmt.Errorf("list missing translation (%s): %w", lang, err)
} }
defer rows.Close() defer rows.Close()
return collectRecipes(rows) return collectRecipes(rows)
} }
// UpdateTranslation saves the Russian title, description, and step translations. // UpsertTranslation inserts or replaces a recipe translation for a specific language.
func (r *Repository) UpdateTranslation(ctx context.Context, id string, titleRu, descriptionRu *string, steps json.RawMessage) error { func (r *Repository) UpsertTranslation(
ctx context.Context,
id, lang string,
title, description *string,
ingredients, steps json.RawMessage,
) error {
query := ` query := `
UPDATE recipes SET INSERT INTO recipe_translations (recipe_id, lang, title, description, ingredients, steps)
title_ru = $2, VALUES ($1, $2, $3, $4, $5, $6)
description_ru = $3, ON CONFLICT (recipe_id, lang) DO UPDATE SET
steps = $4, title = EXCLUDED.title,
updated_at = now() description = EXCLUDED.description,
WHERE id = $1` ingredients = EXCLUDED.ingredients,
steps = EXCLUDED.steps`
if _, err := r.pool.Exec(ctx, query, id, titleRu, descriptionRu, steps); err != nil { if _, err := r.pool.Exec(ctx, query, id, lang, title, description, ingredients, steps); err != nil {
return fmt.Errorf("update recipe translation %s: %w", id, err) return fmt.Errorf("upsert recipe translation %s/%s: %w", id, lang, err)
} }
return nil return nil
} }
@@ -121,7 +160,7 @@ func scanRecipe(row pgx.Row) (*Recipe, error) {
err := row.Scan( err := row.Scan(
&rec.ID, &rec.Source, &rec.SpoonacularID, &rec.ID, &rec.Source, &rec.SpoonacularID,
&rec.Title, &rec.Description, &rec.TitleRu, &rec.DescriptionRu, &rec.Title, &rec.Description,
&rec.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL, &rec.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL,
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing, &rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing,
&ingredients, &steps, &tags, &ingredients, &steps, &tags,
@@ -143,7 +182,7 @@ func collectRecipes(rows pgx.Rows) ([]*Recipe, error) {
var ingredients, steps, tags []byte var ingredients, steps, tags []byte
if err := rows.Scan( if err := rows.Scan(
&rec.ID, &rec.Source, &rec.SpoonacularID, &rec.ID, &rec.Source, &rec.SpoonacularID,
&rec.Title, &rec.Description, &rec.TitleRu, &rec.DescriptionRu, &rec.Title, &rec.Description,
&rec.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL, &rec.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL,
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing, &rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing,
&ingredients, &steps, &tags, &ingredients, &steps, &tags,
@@ -158,24 +197,3 @@ func collectRecipes(rows pgx.Rows) ([]*Recipe, error) {
} }
return result, rows.Err() return result, rows.Err()
} }
// GetByID returns a recipe by UUID.
// Returns nil, nil if not found.
func (r *Repository) GetByID(ctx context.Context, id string) (*Recipe, error) {
query := `
SELECT id, source, spoonacular_id,
title, description, title_ru, description_ru,
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
ingredients, steps, tags,
avg_rating, review_count, created_by, created_at, updated_at
FROM recipes
WHERE id = $1`
row := r.pool.QueryRow(ctx, query, id)
rec, err := scanRecipe(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return rec, err
}

View File

@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"testing" "testing"
"github.com/food-ai/backend/internal/locale"
"github.com/food-ai/backend/internal/testutil" "github.com/food-ai/backend/internal/testutil"
) )
@@ -143,7 +144,7 @@ func TestRecipeRepository_GetByID_NotFound(t *testing.T) {
} }
} }
func TestRecipeRepository_ListUntranslated_Pagination(t *testing.T) { func TestRecipeRepository_ListMissingTranslation_Pagination(t *testing.T) {
pool := testutil.SetupTestDB(t) pool := testutil.SetupTestDB(t)
repo := NewRepository(pool) repo := NewRepository(pool)
ctx := context.Background() ctx := context.Background()
@@ -165,16 +166,16 @@ func TestRecipeRepository_ListUntranslated_Pagination(t *testing.T) {
} }
} }
untranslated, err := repo.ListUntranslated(ctx, 3, 0) missing, err := repo.ListMissingTranslation(ctx, "ru", 3, 0)
if err != nil { if err != nil {
t.Fatalf("list untranslated: %v", err) t.Fatalf("list missing translation: %v", err)
} }
if len(untranslated) != 3 { if len(missing) != 3 {
t.Errorf("expected 3 results with limit=3, got %d", len(untranslated)) t.Errorf("expected 3 results with limit=3, got %d", len(missing))
} }
} }
func TestRecipeRepository_UpdateTranslation(t *testing.T) { func TestRecipeRepository_UpsertTranslation(t *testing.T) {
pool := testutil.SetupTestDB(t) pool := testutil.SetupTestDB(t)
repo := NewRepository(pool) repo := NewRepository(pool)
ctx := context.Background() ctx := context.Background()
@@ -196,40 +197,52 @@ func TestRecipeRepository_UpdateTranslation(t *testing.T) {
titleRu := "Курица Тикка Масала" titleRu := "Курица Тикка Масала"
descRu := "Классическое индийское блюдо" descRu := "Классическое индийское блюдо"
stepsRu := json.RawMessage(`[{"number":1,"description":"Heat oil","description_ru":"Разогрейте масло"}]`) stepsRu := json.RawMessage(`[{"number":1,"description":"Разогрейте масло"}]`)
if err := repo.UpdateTranslation(ctx, saved.ID, &titleRu, &descRu, stepsRu); err != nil { if err := repo.UpsertTranslation(ctx, saved.ID, "ru", &titleRu, &descRu, nil, stepsRu); err != nil {
t.Fatalf("update translation: %v", err) t.Fatalf("upsert translation: %v", err)
} }
got, err := repo.GetByID(ctx, saved.ID) // Retrieve with Russian context — title and steps should be translated.
ruCtx := locale.WithLang(ctx, "ru")
got, err := repo.GetByID(ruCtx, saved.ID)
if err != nil { if err != nil {
t.Fatalf("get by id: %v", err) t.Fatalf("get by id: %v", err)
} }
if got.TitleRu == nil || *got.TitleRu != titleRu { if got.Title != titleRu {
t.Errorf("expected title_ru=%q, got %v", titleRu, got.TitleRu) t.Errorf("expected title=%q, got %q", titleRu, got.Title)
} }
if got.DescriptionRu == nil || *got.DescriptionRu != descRu { if got.Description == nil || *got.Description != descRu {
t.Errorf("expected description_ru=%q, got %v", descRu, got.DescriptionRu) t.Errorf("expected description=%q, got %v", descRu, got.Description)
} }
var steps []RecipeStep var steps []RecipeStep
if err := json.Unmarshal(got.Steps, &steps); err != nil { if err := json.Unmarshal(got.Steps, &steps); err != nil {
t.Fatalf("unmarshal steps: %v", err) t.Fatalf("unmarshal steps: %v", err)
} }
if len(steps) == 0 || steps[0].DescriptionRu == nil || *steps[0].DescriptionRu != "Разогрейте масло" { if len(steps) == 0 || steps[0].Description != "Разогрейте масло" {
t.Errorf("expected description_ru in steps, got %v", steps) t.Errorf("expected Russian step description, got %v", steps)
}
// Retrieve with English context — should return original English content.
enCtx := locale.WithLang(ctx, "en")
gotEn, err := repo.GetByID(enCtx, saved.ID)
if err != nil {
t.Fatalf("get by id (en): %v", err)
}
if gotEn.Title != "Chicken Tikka Masala" {
t.Errorf("expected English title, got %q", gotEn.Title)
} }
} }
func TestRecipeRepository_ListUntranslated_ExcludesTranslated(t *testing.T) { func TestRecipeRepository_ListMissingTranslation_ExcludesTranslated(t *testing.T) {
pool := testutil.SetupTestDB(t) pool := testutil.SetupTestDB(t)
repo := NewRepository(pool) repo := NewRepository(pool)
ctx := context.Background() ctx := context.Background()
diff := "easy" diff := "easy"
// Insert untranslated // Insert untranslated recipes.
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
spID := 60000 + i spID := 60000 + i
_, err := repo.Upsert(ctx, &Recipe{ _, err := repo.Upsert(ctx, &Recipe{
@@ -246,7 +259,7 @@ func TestRecipeRepository_ListUntranslated_ExcludesTranslated(t *testing.T) {
} }
} }
// Insert translated // Insert one recipe and add a Russian translation.
spID := 60100 spID := 60100
translated, err := repo.Upsert(ctx, &Recipe{ translated, err := repo.Upsert(ctx, &Recipe{
Source: "spoonacular", Source: "spoonacular",
@@ -261,21 +274,21 @@ func TestRecipeRepository_ListUntranslated_ExcludesTranslated(t *testing.T) {
t.Fatalf("upsert translated: %v", err) t.Fatalf("upsert translated: %v", err)
} }
titleRu := "Переведённый рецепт" titleRu := "Переведённый рецепт"
if err := repo.UpdateTranslation(ctx, translated.ID, &titleRu, nil, translated.Steps); err != nil { if err := repo.UpsertTranslation(ctx, translated.ID, "ru", &titleRu, nil, nil, nil); err != nil {
t.Fatalf("update translation: %v", err) t.Fatalf("upsert translation: %v", err)
} }
untranslated, err := repo.ListUntranslated(ctx, 10, 0) missing, err := repo.ListMissingTranslation(ctx, "ru", 10, 0)
if err != nil { if err != nil {
t.Fatalf("list untranslated: %v", err) t.Fatalf("list missing translation: %v", err)
} }
for _, r := range untranslated { for _, r := range missing {
if r.Title == "Translated Recipe" { if r.Title == "Translated Recipe" {
t.Error("translated recipe should not appear in ListUntranslated") t.Error("translated recipe should not appear in ListMissingTranslation")
} }
} }
if len(untranslated) < 3 { if len(missing) < 3 {
t.Errorf("expected at least 3 untranslated, got %d", len(untranslated)) t.Errorf("expected at least 3 missing, got %d", len(missing))
} }
} }

View File

@@ -17,6 +17,7 @@ import (
type ingredientRepo interface { type ingredientRepo interface {
FuzzyMatch(ctx context.Context, name string) (*ingredient.IngredientMapping, error) FuzzyMatch(ctx context.Context, name string) (*ingredient.IngredientMapping, error)
Upsert(ctx context.Context, m *ingredient.IngredientMapping) (*ingredient.IngredientMapping, error) Upsert(ctx context.Context, m *ingredient.IngredientMapping) (*ingredient.IngredientMapping, error)
UpsertTranslation(ctx context.Context, id, lang, name string, aliases json.RawMessage) error
} }
// Handler handles POST /ai/* recognition endpoints. // Handler handles POST /ai/* recognition endpoints.
@@ -218,7 +219,6 @@ func (h *Handler) saveClassification(ctx context.Context, c *gemini.IngredientCl
m := &ingredient.IngredientMapping{ m := &ingredient.IngredientMapping{
CanonicalName: c.CanonicalName, CanonicalName: c.CanonicalName,
CanonicalNameRu: &c.CanonicalNameRu,
Category: strPtr(c.Category), Category: strPtr(c.Category),
DefaultUnit: strPtr(c.DefaultUnit), DefaultUnit: strPtr(c.DefaultUnit),
CaloriesPer100g: c.CaloriesPer100g, CaloriesPer100g: c.CaloriesPer100g,
@@ -234,6 +234,13 @@ func (h *Handler) saveClassification(ctx context.Context, c *gemini.IngredientCl
slog.Warn("upsert classified ingredient", "name", c.CanonicalName, "err", err) slog.Warn("upsert classified ingredient", "name", c.CanonicalName, "err", err)
return nil return nil
} }
// Persist the Russian translation when Gemini provided one.
if c.CanonicalNameRu != "" {
if err := h.ingredientRepo.UpsertTranslation(ctx, saved.ID, "ru", c.CanonicalNameRu, json.RawMessage("[]")); err != nil {
slog.Warn("upsert ingredient translation", "id", saved.ID, "err", err)
}
}
return saved return saved
} }

View File

@@ -9,6 +9,7 @@ import (
"sync" "sync"
"github.com/food-ai/backend/internal/gemini" "github.com/food-ai/backend/internal/gemini"
"github.com/food-ai/backend/internal/locale"
"github.com/food-ai/backend/internal/middleware" "github.com/food-ai/backend/internal/middleware"
"github.com/food-ai/backend/internal/user" "github.com/food-ai/backend/internal/user"
) )
@@ -74,7 +75,7 @@ func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
return return
} }
req := buildRecipeRequest(u, count) req := buildRecipeRequest(u, count, locale.FromContext(r.Context()))
// Attach available products to personalise the prompt. // Attach available products to personalise the prompt.
if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil { if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil {
@@ -108,10 +109,11 @@ func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, recipes) writeJSON(w, http.StatusOK, recipes)
} }
func buildRecipeRequest(u *user.User, count int) gemini.RecipeRequest { func buildRecipeRequest(u *user.User, count int, lang string) gemini.RecipeRequest {
req := gemini.RecipeRequest{ req := gemini.RecipeRequest{
Count: count, Count: count,
DailyCalories: 2000, // sensible default DailyCalories: 2000, // sensible default
Lang: lang,
} }
if u.Goal != nil { if u.Goal != nil {

View File

@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/food-ai/backend/internal/locale"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
@@ -13,7 +14,7 @@ import (
// ErrNotFound is returned when a saved recipe does not exist for the given user. // ErrNotFound is returned when a saved recipe does not exist for the given user.
var ErrNotFound = errors.New("saved recipe not found") var ErrNotFound = errors.New("saved recipe not found")
// Repository handles persistence for saved recipes. // Repository handles persistence for saved recipes and their translations.
type Repository struct { type Repository struct {
pool *pgxpool.Pool pool *pgxpool.Pool
} }
@@ -24,6 +25,7 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
} }
// Save persists a recipe for userID and returns the stored record. // Save persists a recipe for userID and returns the stored record.
// The canonical content (any language) is stored directly in saved_recipes.
func (r *Repository) Save(ctx context.Context, userID string, req SaveRequest) (*SavedRecipe, error) { func (r *Repository) Save(ctx context.Context, userID string, req SaveRequest) (*SavedRecipe, error) {
const query = ` const query = `
INSERT INTO saved_recipes ( INSERT INTO saved_recipes (
@@ -61,16 +63,27 @@ func (r *Repository) Save(ctx context.Context, userID string, req SaveRequest) (
} }
// List returns all saved recipes for userID ordered by saved_at DESC. // List returns all saved recipes for userID ordered by saved_at DESC.
// Text content (title, description, ingredients, steps) is resolved for the
// language stored in ctx, falling back to the canonical content when no
// translation exists.
func (r *Repository) List(ctx context.Context, userID string) ([]*SavedRecipe, error) { func (r *Repository) List(ctx context.Context, userID string) ([]*SavedRecipe, error) {
lang := locale.FromContext(ctx)
const query = ` const query = `
SELECT id, user_id, title, description, cuisine, difficulty, SELECT sr.id, sr.user_id,
prep_time_min, cook_time_min, servings, image_url, COALESCE(srt.title, sr.title) AS title,
ingredients, steps, tags, nutrition, source, saved_at COALESCE(srt.description, sr.description) AS description,
FROM saved_recipes sr.cuisine, sr.difficulty,
WHERE user_id = $1 sr.prep_time_min, sr.cook_time_min, sr.servings, sr.image_url,
ORDER BY saved_at DESC` COALESCE(srt.ingredients, sr.ingredients) AS ingredients,
COALESCE(srt.steps, sr.steps) AS steps,
sr.tags, sr.nutrition, sr.source, sr.saved_at
FROM saved_recipes sr
LEFT JOIN saved_recipe_translations srt
ON srt.saved_recipe_id = sr.id AND srt.lang = $2
WHERE sr.user_id = $1
ORDER BY sr.saved_at DESC`
rows, err := r.pool.Query(ctx, query, userID) rows, err := r.pool.Query(ctx, query, userID, lang)
if err != nil { if err != nil {
return nil, fmt.Errorf("list saved recipes: %w", err) return nil, fmt.Errorf("list saved recipes: %w", err)
} }
@@ -78,7 +91,7 @@ func (r *Repository) List(ctx context.Context, userID string) ([]*SavedRecipe, e
var result []*SavedRecipe var result []*SavedRecipe
for rows.Next() { for rows.Next() {
rec, err := scanRows(rows) rec, err := scanRow(rows)
if err != nil { if err != nil {
return nil, fmt.Errorf("scan saved recipe: %w", err) return nil, fmt.Errorf("scan saved recipe: %w", err)
} }
@@ -88,15 +101,24 @@ func (r *Repository) List(ctx context.Context, userID string) ([]*SavedRecipe, e
} }
// GetByID returns the saved recipe with id for userID, or nil if not found. // GetByID returns the saved recipe with id for userID, or nil if not found.
// Text content is resolved for the language stored in ctx.
func (r *Repository) GetByID(ctx context.Context, userID, id string) (*SavedRecipe, error) { func (r *Repository) GetByID(ctx context.Context, userID, id string) (*SavedRecipe, error) {
lang := locale.FromContext(ctx)
const query = ` const query = `
SELECT id, user_id, title, description, cuisine, difficulty, SELECT sr.id, sr.user_id,
prep_time_min, cook_time_min, servings, image_url, COALESCE(srt.title, sr.title) AS title,
ingredients, steps, tags, nutrition, source, saved_at COALESCE(srt.description, sr.description) AS description,
FROM saved_recipes sr.cuisine, sr.difficulty,
WHERE id = $1 AND user_id = $2` sr.prep_time_min, sr.cook_time_min, sr.servings, sr.image_url,
COALESCE(srt.ingredients, sr.ingredients) AS ingredients,
COALESCE(srt.steps, sr.steps) AS steps,
sr.tags, sr.nutrition, sr.source, sr.saved_at
FROM saved_recipes sr
LEFT JOIN saved_recipe_translations srt
ON srt.saved_recipe_id = sr.id AND srt.lang = $3
WHERE sr.id = $1 AND sr.user_id = $2`
rec, err := scanRow(r.pool.QueryRow(ctx, query, id, userID)) rec, err := scanRow(r.pool.QueryRow(ctx, query, id, userID, lang))
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, nil return nil, nil
} }
@@ -119,6 +141,29 @@ func (r *Repository) Delete(ctx context.Context, userID, id string) error {
return nil return nil
} }
// UpsertTranslation inserts or replaces a translation for a saved recipe.
func (r *Repository) UpsertTranslation(
ctx context.Context,
id, lang string,
title, description *string,
ingredients, steps json.RawMessage,
) error {
const query = `
INSERT INTO saved_recipe_translations (saved_recipe_id, lang, title, description, ingredients, steps)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (saved_recipe_id, lang) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
ingredients = EXCLUDED.ingredients,
steps = EXCLUDED.steps,
generated_at = now()`
if _, err := r.pool.Exec(ctx, query, id, lang, title, description, ingredients, steps); err != nil {
return fmt.Errorf("upsert saved recipe translation %s/%s: %w", id, lang, err)
}
return nil
}
// --- helpers --- // --- helpers ---
type scannable interface { type scannable interface {
@@ -146,11 +191,6 @@ func scanRow(s scannable) (*SavedRecipe, error) {
return &rec, nil return &rec, nil
} }
// scanRows wraps pgx.Rows to satisfy the scannable interface.
func scanRows(rows pgx.Rows) (*SavedRecipe, error) {
return scanRow(rows)
}
func nullableStr(s string) *string { func nullableStr(s string) *string {
if s == "" { if s == "" {
return nil return nil

View File

@@ -41,6 +41,7 @@ func NewRouter(
r.Use(middleware.Logging) r.Use(middleware.Logging)
r.Use(middleware.Recovery) r.Use(middleware.Recovery)
r.Use(middleware.CORS(allowedOrigins)) r.Use(middleware.CORS(allowedOrigins))
r.Use(middleware.Language)
// Public // Public
r.Get("/health", healthCheck(pool)) r.Get("/health", healthCheck(pool))

View File

@@ -0,0 +1,118 @@
-- +goose Up
-- ---------------------------------------------------------------------------
-- recipe_translations
-- Stores per-language overrides for the catalog recipe fields that contain
-- human-readable text (title, description, ingredients list, step descriptions).
-- The base `recipes` row always holds the English (canonical) content.
-- ---------------------------------------------------------------------------
CREATE TABLE recipe_translations (
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
title VARCHAR(500),
description TEXT,
ingredients JSONB,
steps JSONB,
PRIMARY KEY (recipe_id, lang)
);
CREATE INDEX idx_recipe_translations_recipe_id ON recipe_translations (recipe_id);
-- ---------------------------------------------------------------------------
-- saved_recipe_translations
-- Stores per-language translations for user-saved (AI-generated) recipes.
-- The base `saved_recipes` row always holds the English canonical content.
-- Translations are generated on demand by the AI layer and recorded here.
-- ---------------------------------------------------------------------------
CREATE TABLE saved_recipe_translations (
saved_recipe_id UUID NOT NULL REFERENCES saved_recipes(id) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
title VARCHAR(500),
description TEXT,
ingredients JSONB,
steps JSONB,
generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (saved_recipe_id, lang)
);
CREATE INDEX idx_saved_recipe_translations_recipe_id ON saved_recipe_translations (saved_recipe_id);
-- ---------------------------------------------------------------------------
-- ingredient_translations
-- Stores per-language names (and optional aliases) for ingredient mappings.
-- The base `ingredient_mappings` row holds the English canonical name.
-- ---------------------------------------------------------------------------
CREATE TABLE ingredient_translations (
ingredient_id UUID NOT NULL REFERENCES ingredient_mappings(id) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
name VARCHAR(255) NOT NULL,
aliases JSONB NOT NULL DEFAULT '[]'::jsonb,
PRIMARY KEY (ingredient_id, lang)
);
CREATE INDEX idx_ingredient_translations_ingredient_id ON ingredient_translations (ingredient_id);
-- ---------------------------------------------------------------------------
-- Migrate existing Russian data from _ru columns into the translation tables.
-- ---------------------------------------------------------------------------
-- Recipe translations: title_ru / description_ru at the row level, plus the
-- embedded name_ru / unit_ru fields inside the ingredients JSONB array, and
-- description_ru inside the steps JSONB array.
INSERT INTO recipe_translations (recipe_id, lang, title, description, ingredients, steps)
SELECT
id,
'ru',
title_ru,
description_ru,
-- Rebuild ingredients array with Russian name/unit substituted in.
CASE
WHEN jsonb_array_length(ingredients) > 0 THEN (
SELECT COALESCE(
jsonb_agg(
jsonb_build_object(
'spoonacular_id', elem->>'spoonacular_id',
'mapping_id', elem->>'mapping_id',
'name', COALESCE(NULLIF(elem->>'name_ru', ''), elem->>'name'),
'amount', (elem->>'amount')::numeric,
'unit', COALESCE(NULLIF(elem->>'unit_ru', ''), elem->>'unit'),
'optional', (elem->>'optional')::boolean
)
),
'[]'::jsonb
)
FROM jsonb_array_elements(ingredients) AS elem
)
ELSE NULL
END,
-- Rebuild steps array with Russian description substituted in.
CASE
WHEN jsonb_array_length(steps) > 0 THEN (
SELECT COALESCE(
jsonb_agg(
jsonb_build_object(
'number', (elem->>'number')::int,
'description', COALESCE(NULLIF(elem->>'description_ru', ''), elem->>'description'),
'timer_seconds', elem->'timer_seconds',
'image_url', elem->>'image_url'
)
),
'[]'::jsonb
)
FROM jsonb_array_elements(steps) AS elem
)
ELSE NULL
END
FROM recipes
WHERE title_ru IS NOT NULL;
-- Ingredient translations: canonical_name_ru.
INSERT INTO ingredient_translations (ingredient_id, lang, name)
SELECT id, 'ru', canonical_name_ru
FROM ingredient_mappings
WHERE canonical_name_ru IS NOT NULL;
-- +goose Down
DROP TABLE IF EXISTS ingredient_translations;
DROP TABLE IF EXISTS saved_recipe_translations;
DROP TABLE IF EXISTS recipe_translations;

View File

@@ -0,0 +1,36 @@
-- +goose Up
-- Drop the full-text search index that references the soon-to-be-removed
-- title_ru column.
DROP INDEX IF EXISTS idx_recipes_title_fts;
-- Remove legacy _ru columns from recipes now that the data lives in
-- recipe_translations (migration 009).
ALTER TABLE recipes
DROP COLUMN IF EXISTS title_ru,
DROP COLUMN IF EXISTS description_ru;
-- Remove the legacy Russian name column from ingredient_mappings.
ALTER TABLE ingredient_mappings
DROP COLUMN IF EXISTS canonical_name_ru;
-- Recreate the FTS index on the English title only.
-- Cross-language search is now handled at the application level by querying
-- the appropriate translation row.
CREATE INDEX idx_recipes_title_fts ON recipes
USING GIN (to_tsvector('simple', coalesce(title, '')));
-- +goose Down
DROP INDEX IF EXISTS idx_recipes_title_fts;
ALTER TABLE recipes
ADD COLUMN title_ru VARCHAR(500),
ADD COLUMN description_ru TEXT;
ALTER TABLE ingredient_mappings
ADD COLUMN canonical_name_ru VARCHAR(255);
-- Restore the bilingual FTS index.
CREATE INDEX idx_recipes_title_fts ON recipes
USING GIN (to_tsvector('simple',
coalesce(title_ru, '') || ' ' || coalesce(title, '')));