- 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>
119 lines
4.9 KiB
SQL
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;
|