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:
118
backend/migrations/009_create_translation_tables.sql
Normal file
118
backend/migrations/009_create_translation_tables.sql
Normal 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;
|
||||
36
backend/migrations/010_drop_ru_columns.sql
Normal file
36
backend/migrations/010_drop_ru_columns.sql
Normal 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, '')));
|
||||
Reference in New Issue
Block a user