Files
food-ai/backend/migrations/009_create_translation_tables.sql
dbastrikin c0cf1b38ea 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>
2026-02-27 23:17:34 +02:00

119 lines
4.9 KiB
SQL

-- +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;