feat: core schema redesign — dishes, structured recipes, cuisines, tags (iteration 7)

Replaces the flat JSONB-based recipe schema with a normalized relational model:

Schema (migrations consolidated to 001_initial_schema + 002_seed_data):
- New: dishes, dish_translations, dish_tags — canonical dish catalog
- New: cuisines, tags, dish_categories with _translations tables + full seed data
- New: recipe_ingredients, recipe_steps with _translations (replaces JSONB blobs)
- New: user_saved_recipes thin bookmark (drops saved_recipes + saved_recipe_translations)
- New: product_ingredients M2M table
- recipes: now a cooking variant of a dish (dish_id FK, no title/JSONB columns)
- recipe_translations: repurposed to per-language notes only
- products: mapping_id → primary_ingredient_id
- menu_items: recipe_id FK → recipes; adds dish_id
- meal_diary: adds dish_id, recipe_id → recipes, portion_g

Backend (Go):
- New packages: internal/cuisine, internal/tag, internal/dish (registry + handler + repo)
- New GET /cuisines, GET /tags (public), GET /dishes, GET /dishes/{id}, GET /recipes/{id}
- recipe, savedrecipe, menu, diary, product, ingredient packages updated for new schema

Flutter:
- New models: Cuisine, Tag; new providers: cuisineNamesProvider, tagNamesProvider
- recipe.dart: RecipeIngredient gains unit_code + effectiveUnit getter
- saved_recipe.dart: thin model, manual fromJson, computed nutrition getter
- diary_entry.dart: adds dishId, recipeId, portionG
- recipe_detail_screen.dart: localized cuisine/tag names via providers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-15 18:01:24 +02:00
parent 55d01400b0
commit 61feb91bba
52 changed files with 2479 additions and 1492 deletions

View File

@@ -1,80 +0,0 @@
-- +goose Up
-- Generate UUID v7 (time-ordered, millisecond precision).
-- Structure: 48-bit unix_ts_ms | 4-bit version (0111) | 12-bit rand_a | 2-bit variant (10) | 62-bit rand_b
CREATE OR REPLACE FUNCTION uuid_generate_v7()
RETURNS uuid
AS $$
DECLARE
unix_ts_ms bytea;
uuid_bytes bytea;
BEGIN
-- 48-bit Unix timestamp in milliseconds.
-- int8send produces 8 big-endian bytes; skip the first 2 zero bytes to get 6.
unix_ts_ms = substring(int8send(floor(extract(epoch from clock_timestamp()) * 1000)::bigint) from 3);
-- Use a random v4 UUID as the source of random bits for rand_a and rand_b.
uuid_bytes = uuid_send(gen_random_uuid());
-- Overwrite bytes 05 with the timestamp (positions 16 in 1-indexed bytea).
uuid_bytes = overlay(uuid_bytes placing unix_ts_ms from 1 for 6);
-- Set version nibble (bits 4851) to 0111 (7).
uuid_bytes = set_bit(uuid_bytes, 48, 0);
uuid_bytes = set_bit(uuid_bytes, 49, 1);
uuid_bytes = set_bit(uuid_bytes, 50, 1);
uuid_bytes = set_bit(uuid_bytes, 51, 1);
-- Variant bits (6465) stay at 10 as inherited from gen_random_uuid().
RETURN encode(uuid_bytes, 'hex')::uuid;
END
$$ LANGUAGE plpgsql VOLATILE;
CREATE TYPE user_plan AS ENUM ('free', 'paid');
CREATE TYPE user_gender AS ENUM ('male', 'female');
CREATE TYPE user_goal AS ENUM ('lose', 'maintain', 'gain');
CREATE TYPE activity_level AS ENUM ('low', 'moderate', 'high');
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
firebase_uid VARCHAR(128) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL DEFAULT '',
avatar_url TEXT,
-- Body parameters
height_cm SMALLINT,
weight_kg DECIMAL(5,2),
age SMALLINT,
gender user_gender,
activity activity_level,
-- Goal and calculated daily norm
goal user_goal,
daily_calories INTEGER,
-- Plan
plan user_plan NOT NULL DEFAULT 'free',
-- Preferences (JSONB for flexibility)
preferences JSONB NOT NULL DEFAULT '{}'::jsonb,
-- Refresh token
refresh_token TEXT,
token_expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_users_firebase_uid ON users (firebase_uid);
CREATE INDEX idx_users_email ON users (email);
-- +goose Down
DROP TABLE IF EXISTS users;
DROP TYPE IF EXISTS activity_level;
DROP TYPE IF EXISTS user_goal;
DROP TYPE IF EXISTS user_gender;
DROP TYPE IF EXISTS user_plan;
DROP FUNCTION IF EXISTS uuid_generate_v7();

View File

@@ -0,0 +1,452 @@
-- +goose Up
-- ---------------------------------------------------------------------------
-- Extensions
-- ---------------------------------------------------------------------------
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- ---------------------------------------------------------------------------
-- UUID v7 generator (time-ordered, millisecond precision).
-- Structure: 48-bit unix_ts_ms | 4-bit version (0111) | 12-bit rand_a | 2-bit variant (10) | 62-bit rand_b
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION uuid_generate_v7()
RETURNS uuid
AS $$
DECLARE
unix_ts_ms bytea;
uuid_bytes bytea;
BEGIN
-- 48-bit Unix timestamp in milliseconds.
-- int8send produces 8 big-endian bytes; skip the first 2 zero bytes to get 6.
unix_ts_ms = substring(int8send(floor(extract(epoch from clock_timestamp()) * 1000)::bigint) from 3);
-- Use a random v4 UUID as the source of random bits for rand_a and rand_b.
uuid_bytes = uuid_send(gen_random_uuid());
-- Overwrite bytes 0-5 with the timestamp (positions 1-6 in 1-indexed bytea).
uuid_bytes = overlay(uuid_bytes placing unix_ts_ms from 1 for 6);
-- Set version nibble (bits 48-51) to 0111 (7).
uuid_bytes = set_bit(uuid_bytes, 48, 0);
uuid_bytes = set_bit(uuid_bytes, 49, 1);
uuid_bytes = set_bit(uuid_bytes, 50, 1);
uuid_bytes = set_bit(uuid_bytes, 51, 1);
-- Variant bits (64-65) stay at 10 as inherited from gen_random_uuid().
RETURN encode(uuid_bytes, 'hex')::uuid;
END
$$ LANGUAGE plpgsql VOLATILE;
-- ---------------------------------------------------------------------------
-- Enums
-- ---------------------------------------------------------------------------
CREATE TYPE user_plan AS ENUM ('free', 'paid');
CREATE TYPE user_gender AS ENUM ('male', 'female');
CREATE TYPE user_goal AS ENUM ('lose', 'maintain', 'gain');
CREATE TYPE activity_level AS ENUM ('low', 'moderate', 'high');
CREATE TYPE recipe_source AS ENUM ('spoonacular', 'ai', 'user');
CREATE TYPE recipe_difficulty AS ENUM ('easy', 'medium', 'hard');
-- ---------------------------------------------------------------------------
-- users
-- ---------------------------------------------------------------------------
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
firebase_uid VARCHAR(128) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL DEFAULT '',
avatar_url TEXT,
height_cm SMALLINT,
weight_kg DECIMAL(5,2),
date_of_birth DATE,
gender user_gender,
activity activity_level,
goal user_goal,
daily_calories INTEGER,
plan user_plan NOT NULL DEFAULT 'free',
preferences JSONB NOT NULL DEFAULT '{}'::jsonb,
refresh_token TEXT,
token_expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_users_firebase_uid ON users (firebase_uid);
CREATE INDEX idx_users_email ON users (email);
-- ---------------------------------------------------------------------------
-- languages
-- ---------------------------------------------------------------------------
CREATE TABLE languages (
code VARCHAR(10) PRIMARY KEY,
native_name TEXT NOT NULL,
english_name TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order SMALLINT NOT NULL DEFAULT 0
);
-- ---------------------------------------------------------------------------
-- units + unit_translations
-- ---------------------------------------------------------------------------
CREATE TABLE units (
code VARCHAR(20) PRIMARY KEY,
sort_order SMALLINT NOT NULL DEFAULT 0
);
CREATE TABLE unit_translations (
unit_code VARCHAR(20) NOT NULL REFERENCES units(code) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (unit_code, lang)
);
-- ---------------------------------------------------------------------------
-- ingredient_categories + translations
-- ---------------------------------------------------------------------------
CREATE TABLE ingredient_categories (
slug VARCHAR(50) PRIMARY KEY,
sort_order SMALLINT NOT NULL DEFAULT 0
);
CREATE TABLE ingredient_category_translations (
category_slug VARCHAR(50) NOT NULL REFERENCES ingredient_categories(slug) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (category_slug, lang)
);
-- ---------------------------------------------------------------------------
-- ingredients (canonical catalog — formerly ingredient_mappings)
-- ---------------------------------------------------------------------------
CREATE TABLE ingredients (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
canonical_name VARCHAR(255) NOT NULL,
category VARCHAR(50) REFERENCES ingredient_categories(slug),
default_unit VARCHAR(20) REFERENCES units(code),
calories_per_100g DECIMAL(8,2),
protein_per_100g DECIMAL(8,2),
fat_per_100g DECIMAL(8,2),
carbs_per_100g DECIMAL(8,2),
fiber_per_100g DECIMAL(8,2),
storage_days INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_ingredient_canonical_name UNIQUE (canonical_name)
);
CREATE INDEX idx_ingredients_canonical_name ON ingredients (canonical_name);
CREATE INDEX idx_ingredients_category ON ingredients (category);
-- ---------------------------------------------------------------------------
-- ingredient_translations
-- ---------------------------------------------------------------------------
CREATE TABLE ingredient_translations (
ingredient_id UUID NOT NULL REFERENCES ingredients(id) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
name VARCHAR(255) NOT NULL,
PRIMARY KEY (ingredient_id, lang)
);
CREATE INDEX idx_ingredient_translations_ingredient_id ON ingredient_translations (ingredient_id);
-- ---------------------------------------------------------------------------
-- ingredient_aliases (relational, replaces JSONB aliases column)
-- ---------------------------------------------------------------------------
CREATE TABLE ingredient_aliases (
ingredient_id UUID NOT NULL REFERENCES ingredients(id) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
alias TEXT NOT NULL,
PRIMARY KEY (ingredient_id, lang, alias)
);
CREATE INDEX idx_ingredient_aliases_lookup ON ingredient_aliases (ingredient_id, lang);
CREATE INDEX idx_ingredient_aliases_trgm ON ingredient_aliases USING GIN (alias gin_trgm_ops);
-- ---------------------------------------------------------------------------
-- cuisines + cuisine_translations
-- ---------------------------------------------------------------------------
CREATE TABLE cuisines (
slug VARCHAR(50) PRIMARY KEY,
name TEXT NOT NULL,
sort_order SMALLINT NOT NULL DEFAULT 0
);
CREATE TABLE cuisine_translations (
cuisine_slug VARCHAR(50) NOT NULL REFERENCES cuisines(slug) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (cuisine_slug, lang)
);
-- ---------------------------------------------------------------------------
-- tags + tag_translations
-- ---------------------------------------------------------------------------
CREATE TABLE tags (
slug VARCHAR(100) PRIMARY KEY,
name TEXT NOT NULL,
sort_order SMALLINT NOT NULL DEFAULT 0
);
CREATE TABLE tag_translations (
tag_slug VARCHAR(100) NOT NULL REFERENCES tags(slug) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (tag_slug, lang)
);
-- ---------------------------------------------------------------------------
-- dish_categories + dish_category_translations
-- ---------------------------------------------------------------------------
CREATE TABLE dish_categories (
slug VARCHAR(50) PRIMARY KEY,
name TEXT NOT NULL,
sort_order SMALLINT NOT NULL DEFAULT 0
);
CREATE TABLE dish_category_translations (
category_slug VARCHAR(50) NOT NULL REFERENCES dish_categories(slug) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (category_slug, lang)
);
-- ---------------------------------------------------------------------------
-- dishes + dish_translations + dish_tags
-- ---------------------------------------------------------------------------
CREATE TABLE dishes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
cuisine_slug VARCHAR(50) REFERENCES cuisines(slug) ON DELETE SET NULL,
category_slug VARCHAR(50) REFERENCES dish_categories(slug) ON DELETE SET NULL,
name TEXT NOT NULL,
description TEXT,
image_url TEXT,
avg_rating DECIMAL(3,2) NOT NULL DEFAULT 0.0,
review_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_dishes_cuisine ON dishes (cuisine_slug);
CREATE INDEX idx_dishes_category ON dishes (category_slug);
CREATE INDEX idx_dishes_rating ON dishes (avg_rating DESC);
CREATE TABLE dish_translations (
dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
name TEXT NOT NULL,
description TEXT,
PRIMARY KEY (dish_id, lang)
);
CREATE TABLE dish_tags (
dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE CASCADE,
tag_slug VARCHAR(100) NOT NULL REFERENCES tags(slug) ON DELETE CASCADE,
PRIMARY KEY (dish_id, tag_slug)
);
-- ---------------------------------------------------------------------------
-- recipes (one cooking variant of a dish)
-- ---------------------------------------------------------------------------
CREATE TABLE recipes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE CASCADE,
source recipe_source NOT NULL DEFAULT 'ai',
difficulty recipe_difficulty,
prep_time_min INTEGER,
cook_time_min INTEGER,
servings SMALLINT,
calories_per_serving DECIMAL(8,2),
protein_per_serving DECIMAL(8,2),
fat_per_serving DECIMAL(8,2),
carbs_per_serving DECIMAL(8,2),
fiber_per_serving DECIMAL(8,2),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_recipes_dish_id ON recipes (dish_id);
CREATE INDEX idx_recipes_difficulty ON recipes (difficulty);
CREATE INDEX idx_recipes_prep_time ON recipes (prep_time_min);
CREATE INDEX idx_recipes_calories ON recipes (calories_per_serving);
CREATE INDEX idx_recipes_source ON recipes (source);
-- ---------------------------------------------------------------------------
-- recipe_translations (per-language cooking notes only)
-- ---------------------------------------------------------------------------
CREATE TABLE recipe_translations (
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
notes TEXT,
PRIMARY KEY (recipe_id, lang)
);
-- ---------------------------------------------------------------------------
-- recipe_ingredients + recipe_ingredient_translations
-- ---------------------------------------------------------------------------
CREATE TABLE recipe_ingredients (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
ingredient_id UUID REFERENCES ingredients(id) ON DELETE SET NULL,
name TEXT NOT NULL,
amount DECIMAL(10,2) NOT NULL DEFAULT 0,
unit_code VARCHAR(20),
is_optional BOOLEAN NOT NULL DEFAULT false,
sort_order SMALLINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_recipe_ingredients_recipe_id ON recipe_ingredients (recipe_id);
CREATE TABLE recipe_ingredient_translations (
ri_id UUID NOT NULL REFERENCES recipe_ingredients(id) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (ri_id, lang)
);
-- ---------------------------------------------------------------------------
-- recipe_steps + recipe_step_translations
-- ---------------------------------------------------------------------------
CREATE TABLE recipe_steps (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
step_number SMALLINT NOT NULL,
timer_seconds INTEGER,
image_url TEXT,
description TEXT NOT NULL,
UNIQUE (recipe_id, step_number)
);
CREATE INDEX idx_recipe_steps_recipe_id ON recipe_steps (recipe_id);
CREATE TABLE recipe_step_translations (
step_id UUID NOT NULL REFERENCES recipe_steps(id) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
description TEXT NOT NULL,
PRIMARY KEY (step_id, lang)
);
-- ---------------------------------------------------------------------------
-- products (user fridge / pantry items)
-- ---------------------------------------------------------------------------
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
primary_ingredient_id UUID REFERENCES ingredients(id),
name TEXT NOT NULL,
quantity DECIMAL(10,2) NOT NULL DEFAULT 1,
unit TEXT NOT NULL DEFAULT 'pcs' REFERENCES units(code),
category TEXT,
storage_days INT NOT NULL DEFAULT 7,
added_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_products_user_id ON products (user_id);
-- ---------------------------------------------------------------------------
-- product_ingredients (M2M: composite product ↔ ingredients)
-- ---------------------------------------------------------------------------
CREATE TABLE product_ingredients (
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
ingredient_id UUID NOT NULL REFERENCES ingredients(id) ON DELETE CASCADE,
amount_per_100g DECIMAL(10,2),
PRIMARY KEY (product_id, ingredient_id)
);
-- ---------------------------------------------------------------------------
-- user_saved_recipes (thin bookmark — content lives in dishes + recipes)
-- ---------------------------------------------------------------------------
CREATE TABLE user_saved_recipes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
saved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, recipe_id)
);
CREATE INDEX idx_user_saved_recipes_user_id ON user_saved_recipes (user_id, saved_at DESC);
-- ---------------------------------------------------------------------------
-- menu_plans + menu_items + shopping_lists
-- ---------------------------------------------------------------------------
CREATE TABLE menu_plans (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
week_start DATE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, week_start)
);
CREATE TABLE menu_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
menu_plan_id UUID NOT NULL REFERENCES menu_plans(id) ON DELETE CASCADE,
day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7),
meal_type TEXT NOT NULL CHECK (meal_type IN ('breakfast','lunch','dinner')),
recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL,
dish_id UUID REFERENCES dishes(id) ON DELETE SET NULL,
recipe_data JSONB,
UNIQUE (menu_plan_id, day_of_week, meal_type)
);
CREATE TABLE shopping_lists (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
menu_plan_id UUID REFERENCES menu_plans(id) ON DELETE CASCADE,
items JSONB NOT NULL DEFAULT '[]',
generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, menu_plan_id)
);
-- ---------------------------------------------------------------------------
-- meal_diary
-- ---------------------------------------------------------------------------
CREATE TABLE meal_diary (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date DATE NOT NULL,
meal_type TEXT NOT NULL,
name TEXT NOT NULL,
portions DECIMAL(5,2) NOT NULL DEFAULT 1,
calories DECIMAL(8,2),
protein_g DECIMAL(8,2),
fat_g DECIMAL(8,2),
carbs_g DECIMAL(8,2),
source TEXT NOT NULL DEFAULT 'manual',
dish_id UUID REFERENCES dishes(id) ON DELETE SET NULL,
recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL,
portion_g DECIMAL(10,2),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_meal_diary_user_date ON meal_diary (user_id, date);
-- +goose Down
DROP TABLE IF EXISTS meal_diary;
DROP TABLE IF EXISTS shopping_lists;
DROP TABLE IF EXISTS menu_items;
DROP TABLE IF EXISTS menu_plans;
DROP TABLE IF EXISTS user_saved_recipes;
DROP TABLE IF EXISTS product_ingredients;
DROP TABLE IF EXISTS products;
DROP TABLE IF EXISTS recipe_step_translations;
DROP TABLE IF EXISTS recipe_steps;
DROP TABLE IF EXISTS recipe_ingredient_translations;
DROP TABLE IF EXISTS recipe_ingredients;
DROP TABLE IF EXISTS recipe_translations;
DROP TABLE IF EXISTS recipes;
DROP TABLE IF EXISTS dish_tags;
DROP TABLE IF EXISTS dish_translations;
DROP TABLE IF EXISTS dishes;
DROP TABLE IF EXISTS dish_category_translations;
DROP TABLE IF EXISTS dish_categories;
DROP TABLE IF EXISTS tag_translations;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS cuisine_translations;
DROP TABLE IF EXISTS cuisines;
DROP TABLE IF EXISTS ingredient_aliases;
DROP TABLE IF EXISTS ingredient_translations;
DROP TABLE IF EXISTS ingredients;
DROP TABLE IF EXISTS ingredient_category_translations;
DROP TABLE IF EXISTS ingredient_categories;
DROP TABLE IF EXISTS unit_translations;
DROP TABLE IF EXISTS units;
DROP TABLE IF EXISTS languages;
DROP TABLE IF EXISTS users;
DROP TYPE IF EXISTS recipe_difficulty;
DROP TYPE IF EXISTS recipe_source;
DROP TYPE IF EXISTS activity_level;
DROP TYPE IF EXISTS user_goal;
DROP TYPE IF EXISTS user_gender;
DROP TYPE IF EXISTS user_plan;
DROP FUNCTION IF EXISTS uuid_generate_v7();
DROP EXTENSION IF EXISTS pg_trgm;

View File

@@ -1,36 +0,0 @@
-- +goose Up
CREATE TABLE ingredient_mappings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
canonical_name VARCHAR(255) NOT NULL,
canonical_name_ru VARCHAR(255),
spoonacular_id INTEGER UNIQUE,
aliases JSONB NOT NULL DEFAULT '[]'::jsonb,
category VARCHAR(50),
default_unit VARCHAR(20),
-- Nutrients per 100g
calories_per_100g DECIMAL(8, 2),
protein_per_100g DECIMAL(8, 2),
fat_per_100g DECIMAL(8, 2),
carbs_per_100g DECIMAL(8, 2),
fiber_per_100g DECIMAL(8, 2),
storage_days INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_ingredient_mappings_aliases
ON ingredient_mappings USING GIN (aliases);
CREATE INDEX idx_ingredient_mappings_canonical_name
ON ingredient_mappings (canonical_name);
CREATE INDEX idx_ingredient_mappings_category
ON ingredient_mappings (category);
-- +goose Down
DROP TABLE IF EXISTS ingredient_mappings;

View File

@@ -0,0 +1,190 @@
-- +goose Up
-- ---------------------------------------------------------------------------
-- languages
-- ---------------------------------------------------------------------------
INSERT INTO languages (code, native_name, english_name, sort_order) VALUES
('en', 'English', 'English', 1),
('ru', 'Русский', 'Russian', 2),
('es', 'Español', 'Spanish', 3),
('de', 'Deutsch', 'German', 4),
('fr', 'Français', 'French', 5),
('it', 'Italiano', 'Italian', 6),
('pt', 'Português', 'Portuguese', 7),
('zh', '中文', 'Chinese (Simplified)', 8),
('ja', '日本語', 'Japanese', 9),
('ko', '한국어', 'Korean', 10),
('ar', 'العربية', 'Arabic', 11),
('hi', 'हिन्दी', 'Hindi', 12);
-- ---------------------------------------------------------------------------
-- units + unit_translations
-- ---------------------------------------------------------------------------
INSERT INTO units (code, sort_order) VALUES
('g', 1),
('kg', 2),
('ml', 3),
('l', 4),
('pcs', 5),
('pack', 6);
INSERT INTO unit_translations (unit_code, lang, name) VALUES
('g', 'ru', 'г'),
('kg', 'ru', 'кг'),
('ml', 'ru', 'мл'),
('l', 'ru', 'л'),
('pcs', 'ru', 'шт'),
('pack', 'ru', 'уп');
-- ---------------------------------------------------------------------------
-- ingredient_categories + ingredient_category_translations
-- ---------------------------------------------------------------------------
INSERT INTO ingredient_categories (slug, sort_order) VALUES
('dairy', 1),
('meat', 2),
('produce', 3),
('bakery', 4),
('frozen', 5),
('beverages', 6),
('other', 7);
INSERT INTO ingredient_category_translations (category_slug, lang, name) VALUES
('dairy', 'ru', 'Молочные продукты'),
('meat', 'ru', 'Мясо и птица'),
('produce', 'ru', 'Овощи и фрукты'),
('bakery', 'ru', 'Выпечка и хлеб'),
('frozen', 'ru', 'Замороженные'),
('beverages', 'ru', 'Напитки'),
('other', 'ru', 'Прочее');
-- ---------------------------------------------------------------------------
-- cuisines + cuisine_translations
-- ---------------------------------------------------------------------------
INSERT INTO cuisines (slug, name, sort_order) VALUES
('italian', 'Italian', 1),
('french', 'French', 2),
('russian', 'Russian', 3),
('chinese', 'Chinese', 4),
('japanese', 'Japanese', 5),
('korean', 'Korean', 6),
('mexican', 'Mexican', 7),
('mediterranean', 'Mediterranean', 8),
('indian', 'Indian', 9),
('thai', 'Thai', 10),
('american', 'American', 11),
('georgian', 'Georgian', 12),
('spanish', 'Spanish', 13),
('german', 'German', 14),
('middle_eastern', 'Middle Eastern', 15),
('turkish', 'Turkish', 16),
('greek', 'Greek', 17),
('vietnamese', 'Vietnamese', 18),
('other', 'Other', 19);
INSERT INTO cuisine_translations (cuisine_slug, lang, name) VALUES
('italian', 'ru', 'Итальянская'),
('french', 'ru', 'Французская'),
('russian', 'ru', 'Русская'),
('chinese', 'ru', 'Китайская'),
('japanese', 'ru', 'Японская'),
('korean', 'ru', 'Корейская'),
('mexican', 'ru', 'Мексиканская'),
('mediterranean', 'ru', 'Средиземноморская'),
('indian', 'ru', 'Индийская'),
('thai', 'ru', 'Тайская'),
('american', 'ru', 'Американская'),
('georgian', 'ru', 'Грузинская'),
('spanish', 'ru', 'Испанская'),
('german', 'ru', 'Немецкая'),
('middle_eastern', 'ru', 'Ближневосточная'),
('turkish', 'ru', 'Турецкая'),
('greek', 'ru', 'Греческая'),
('vietnamese', 'ru', 'Вьетнамская'),
('other', 'ru', 'Другая');
-- ---------------------------------------------------------------------------
-- tags + tag_translations
-- ---------------------------------------------------------------------------
INSERT INTO tags (slug, name, sort_order) VALUES
('vegan', 'Vegan', 1),
('vegetarian', 'Vegetarian', 2),
('gluten_free', 'Gluten-Free', 3),
('dairy_free', 'Dairy-Free', 4),
('healthy', 'Healthy', 5),
('quick', 'Quick', 6),
('spicy', 'Spicy', 7),
('sweet', 'Sweet', 8),
('soup', 'Soup', 9),
('salad', 'Salad', 10),
('main_course', 'Main Course', 11),
('appetizer', 'Appetizer', 12),
('breakfast', 'Breakfast', 13),
('dessert', 'Dessert', 14),
('grilled', 'Grilled', 15),
('baked', 'Baked', 16),
('fried', 'Fried', 17),
('raw', 'Raw', 18),
('fermented', 'Fermented', 19);
INSERT INTO tag_translations (tag_slug, lang, name) VALUES
('vegan', 'ru', 'Веганское'),
('vegetarian', 'ru', 'Вегетарианское'),
('gluten_free', 'ru', 'Без глютена'),
('dairy_free', 'ru', 'Без молока'),
('healthy', 'ru', 'Здоровое'),
('quick', 'ru', 'Быстрое'),
('spicy', 'ru', 'Острое'),
('sweet', 'ru', 'Сладкое'),
('soup', 'ru', 'Суп'),
('salad', 'ru', 'Салат'),
('main_course', 'ru', 'Основное блюдо'),
('appetizer', 'ru', 'Закуска'),
('breakfast', 'ru', 'Завтрак'),
('dessert', 'ru', 'Десерт'),
('grilled', 'ru', 'Жареное на гриле'),
('baked', 'ru', 'Запечённое'),
('fried', 'ru', 'Жареное'),
('raw', 'ru', 'Сырое'),
('fermented', 'ru', 'Ферментированное');
-- ---------------------------------------------------------------------------
-- dish_categories + dish_category_translations
-- ---------------------------------------------------------------------------
INSERT INTO dish_categories (slug, name, sort_order) VALUES
('soup', 'Soup', 1),
('salad', 'Salad', 2),
('main_course', 'Main Course', 3),
('side_dish', 'Side Dish', 4),
('appetizer', 'Appetizer', 5),
('dessert', 'Dessert', 6),
('breakfast', 'Breakfast', 7),
('drink', 'Drink', 8),
('bread', 'Bread', 9),
('sauce', 'Sauce', 10),
('snack', 'Snack', 11);
INSERT INTO dish_category_translations (category_slug, lang, name) VALUES
('soup', 'ru', 'Суп'),
('salad', 'ru', 'Салат'),
('main_course', 'ru', 'Основное блюдо'),
('side_dish', 'ru', 'Гарнир'),
('appetizer', 'ru', 'Закуска'),
('dessert', 'ru', 'Десерт'),
('breakfast', 'ru', 'Завтрак'),
('drink', 'ru', 'Напиток'),
('bread', 'ru', 'Выпечка'),
('sauce', 'ru', 'Соус'),
('snack', 'ru', 'Снэк');
-- +goose Down
DELETE FROM dish_category_translations;
DELETE FROM dish_categories;
DELETE FROM tag_translations;
DELETE FROM tags;
DELETE FROM cuisine_translations;
DELETE FROM cuisines;
DELETE FROM ingredient_category_translations;
DELETE FROM ingredient_categories;
DELETE FROM unit_translations;
DELETE FROM units;
DELETE FROM languages;

View File

@@ -1,58 +0,0 @@
-- +goose Up
CREATE TYPE recipe_source AS ENUM ('spoonacular', 'ai', 'user');
CREATE TYPE recipe_difficulty AS ENUM ('easy', 'medium', 'hard');
CREATE TABLE recipes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
source recipe_source NOT NULL DEFAULT 'spoonacular',
spoonacular_id INTEGER UNIQUE,
title VARCHAR(500) NOT NULL,
description TEXT,
title_ru VARCHAR(500),
description_ru TEXT,
cuisine VARCHAR(100),
difficulty recipe_difficulty,
prep_time_min INTEGER,
cook_time_min INTEGER,
servings SMALLINT,
image_url TEXT,
calories_per_serving DECIMAL(8, 2),
protein_per_serving DECIMAL(8, 2),
fat_per_serving DECIMAL(8, 2),
carbs_per_serving DECIMAL(8, 2),
fiber_per_serving DECIMAL(8, 2),
ingredients JSONB NOT NULL DEFAULT '[]'::jsonb,
steps JSONB NOT NULL DEFAULT '[]'::jsonb,
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
avg_rating DECIMAL(3, 2) NOT NULL DEFAULT 0.0,
review_count INTEGER NOT NULL DEFAULT 0,
created_by UUID REFERENCES users (id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_recipes_title_fts ON recipes
USING GIN (to_tsvector('simple',
coalesce(title_ru, '') || ' ' || coalesce(title, '')));
CREATE INDEX idx_recipes_ingredients ON recipes USING GIN (ingredients);
CREATE INDEX idx_recipes_tags ON recipes USING GIN (tags);
CREATE INDEX idx_recipes_cuisine ON recipes (cuisine);
CREATE INDEX idx_recipes_difficulty ON recipes (difficulty);
CREATE INDEX idx_recipes_prep_time ON recipes (prep_time_min);
CREATE INDEX idx_recipes_calories ON recipes (calories_per_serving);
CREATE INDEX idx_recipes_source ON recipes (source);
CREATE INDEX idx_recipes_rating ON recipes (avg_rating DESC);
-- +goose Down
DROP TABLE IF EXISTS recipes;
DROP TYPE IF EXISTS recipe_difficulty;
DROP TYPE IF EXISTS recipe_source;

View File

@@ -1,25 +0,0 @@
-- +goose Up
CREATE TABLE saved_recipes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
cuisine TEXT,
difficulty TEXT,
prep_time_min INT,
cook_time_min INT,
servings INT,
image_url TEXT,
ingredients JSONB NOT NULL DEFAULT '[]',
steps JSONB NOT NULL DEFAULT '[]',
tags JSONB NOT NULL DEFAULT '[]',
nutrition JSONB,
source TEXT NOT NULL DEFAULT 'ai',
saved_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_saved_recipes_user_id ON saved_recipes(user_id);
CREATE INDEX idx_saved_recipes_saved_at ON saved_recipes(user_id, saved_at DESC);
-- +goose Down
DROP TABLE saved_recipes;

View File

@@ -1,12 +0,0 @@
-- +goose Up
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_ingredient_mappings_canonical_ru_trgm
ON ingredient_mappings USING GIN (canonical_name_ru gin_trgm_ops);
CREATE INDEX idx_ingredient_mappings_canonical_ru_fts
ON ingredient_mappings USING GIN (to_tsvector('russian', coalesce(canonical_name_ru, '')));
-- +goose Down
DROP INDEX IF EXISTS idx_ingredient_mappings_canonical_ru_trgm;
DROP INDEX IF EXISTS idx_ingredient_mappings_canonical_ru_fts;

View File

@@ -1,19 +0,0 @@
-- +goose Up
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mapping_id UUID REFERENCES ingredient_mappings(id),
name TEXT NOT NULL,
quantity DECIMAL(10, 2) NOT NULL DEFAULT 1,
unit TEXT NOT NULL DEFAULT 'pcs',
category TEXT,
storage_days INT NOT NULL DEFAULT 7,
added_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- expires_at is computed as (added_at + storage_days * INTERVAL '1 day') in queries.
-- A stored generated column cannot be used because timestamptz + interval is STABLE, not IMMUTABLE.
CREATE INDEX idx_products_user_id ON products(user_id);
-- +goose Down
DROP TABLE products;

View File

@@ -1,35 +0,0 @@
-- +goose Up
CREATE TABLE menu_plans (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
week_start DATE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(user_id, week_start)
);
CREATE TABLE menu_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
menu_plan_id UUID NOT NULL REFERENCES menu_plans(id) ON DELETE CASCADE,
day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7),
meal_type TEXT NOT NULL CHECK (meal_type IN ('breakfast','lunch','dinner')),
recipe_id UUID REFERENCES saved_recipes(id) ON DELETE SET NULL,
recipe_data JSONB,
UNIQUE(menu_plan_id, day_of_week, meal_type)
);
-- Stores the generated shopping list for a menu plan.
-- items is a JSONB array of {name, category, amount, unit, checked, in_stock}.
CREATE TABLE shopping_lists (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
menu_plan_id UUID REFERENCES menu_plans(id) ON DELETE CASCADE,
items JSONB NOT NULL DEFAULT '[]',
generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(user_id, menu_plan_id)
);
-- +goose Down
DROP TABLE shopping_lists;
DROP TABLE menu_items;
DROP TABLE menu_plans;

View File

@@ -1,22 +0,0 @@
-- +goose Up
CREATE TABLE meal_diary (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date DATE NOT NULL,
meal_type TEXT NOT NULL,
name TEXT NOT NULL,
portions DECIMAL(5,2) NOT NULL DEFAULT 1,
calories DECIMAL(8,2),
protein_g DECIMAL(8,2),
fat_g DECIMAL(8,2),
carbs_g DECIMAL(8,2),
source TEXT NOT NULL DEFAULT 'manual',
recipe_id UUID REFERENCES saved_recipes(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_meal_diary_user_date ON meal_diary(user_id, date);
-- +goose Down
DROP TABLE meal_diary;

View File

@@ -1,118 +0,0 @@
-- +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

@@ -1,36 +0,0 @@
-- +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, '')));

View File

@@ -1,13 +0,0 @@
-- +goose Up
ALTER TABLE users ADD COLUMN date_of_birth DATE;
UPDATE users
SET date_of_birth = (CURRENT_DATE - (age * INTERVAL '1 year'))::DATE
WHERE age IS NOT NULL;
ALTER TABLE users DROP COLUMN age;
-- +goose Down
ALTER TABLE users ADD COLUMN age SMALLINT;
UPDATE users
SET age = EXTRACT(YEAR FROM AGE(CURRENT_DATE, date_of_birth))::SMALLINT
WHERE date_of_birth IS NOT NULL;
ALTER TABLE users DROP COLUMN date_of_birth;

View File

@@ -1,89 +0,0 @@
-- +goose Up
-- 1. ingredient_categories: slug-keyed table (the 7 known slugs)
CREATE TABLE ingredient_categories (
slug VARCHAR(50) PRIMARY KEY,
sort_order SMALLINT NOT NULL DEFAULT 0
);
INSERT INTO ingredient_categories (slug, sort_order) VALUES
('dairy', 1), ('meat', 2), ('produce', 3),
('bakery', 4), ('frozen', 5), ('beverages', 6), ('other', 7);
-- 2. ingredient_category_translations
CREATE TABLE ingredient_category_translations (
category_slug VARCHAR(50) NOT NULL REFERENCES ingredient_categories(slug) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (category_slug, lang)
);
INSERT INTO ingredient_category_translations (category_slug, lang, name) VALUES
('dairy', 'ru', 'Молочные продукты'),
('meat', 'ru', 'Мясо и птица'),
('produce', 'ru', 'Овощи и фрукты'),
('bakery', 'ru', 'Выпечка и хлеб'),
('frozen', 'ru', 'Замороженные'),
('beverages', 'ru', 'Напитки'),
('other', 'ru', 'Прочее');
-- 3. Nullify any unknown category values before adding FK
UPDATE ingredient_mappings
SET category = NULL
WHERE category IS NOT NULL
AND category NOT IN (SELECT slug FROM ingredient_categories);
ALTER TABLE ingredient_mappings
ADD CONSTRAINT fk_ingredient_category
FOREIGN KEY (category) REFERENCES ingredient_categories(slug);
-- 4. ingredient_aliases table
CREATE TABLE ingredient_aliases (
ingredient_id UUID NOT NULL REFERENCES ingredient_mappings(id) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
alias TEXT NOT NULL,
PRIMARY KEY (ingredient_id, lang, alias)
);
CREATE INDEX idx_ingredient_aliases_lookup ON ingredient_aliases (ingredient_id, lang);
CREATE INDEX idx_ingredient_aliases_trgm ON ingredient_aliases USING GIN (alias gin_trgm_ops);
-- 5. Migrate English aliases from ingredient_mappings.aliases
INSERT INTO ingredient_aliases (ingredient_id, lang, alias)
SELECT im.id, 'en', a.val
FROM ingredient_mappings im,
jsonb_array_elements_text(im.aliases) a(val)
ON CONFLICT DO NOTHING;
-- 6. Migrate per-language aliases from ingredient_translations.aliases
INSERT INTO ingredient_aliases (ingredient_id, lang, alias)
SELECT it.ingredient_id, it.lang, a.val
FROM ingredient_translations it,
jsonb_array_elements_text(it.aliases) a(val)
ON CONFLICT DO NOTHING;
-- 7. Drop aliases JSONB columns
DROP INDEX IF EXISTS idx_ingredient_mappings_aliases;
ALTER TABLE ingredient_mappings DROP COLUMN aliases;
ALTER TABLE ingredient_translations DROP COLUMN aliases;
-- 8. Drop spoonacular_id
ALTER TABLE ingredient_mappings DROP COLUMN spoonacular_id;
-- 9. Unique constraint on canonical_name (replaces spoonacular_id as conflict key)
ALTER TABLE ingredient_mappings
ADD CONSTRAINT uq_ingredient_canonical_name UNIQUE (canonical_name);
-- +goose Down
ALTER TABLE ingredient_mappings DROP CONSTRAINT IF EXISTS uq_ingredient_canonical_name;
ALTER TABLE ingredient_mappings DROP CONSTRAINT IF EXISTS fk_ingredient_category;
ALTER TABLE ingredient_mappings ADD COLUMN spoonacular_id INTEGER;
ALTER TABLE ingredient_translations ADD COLUMN aliases JSONB NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE ingredient_mappings ADD COLUMN aliases JSONB NOT NULL DEFAULT '[]'::jsonb;
-- Restore aliases JSONB from ingredient_aliases (best-effort)
UPDATE ingredient_mappings im
SET aliases = COALESCE(
(SELECT json_agg(ia.alias) FROM ingredient_aliases ia
WHERE ia.ingredient_id = im.id AND ia.lang = 'en'),
'[]'::json);
DROP TABLE IF EXISTS ingredient_aliases;
DROP TABLE IF EXISTS ingredient_category_translations;
DROP TABLE IF EXISTS ingredient_categories;
CREATE INDEX idx_ingredient_mappings_aliases ON ingredient_mappings USING GIN (aliases);

View File

@@ -1,24 +0,0 @@
-- +goose Up
CREATE TABLE languages (
code VARCHAR(10) PRIMARY KEY,
native_name TEXT NOT NULL,
english_name TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order SMALLINT NOT NULL DEFAULT 0
);
INSERT INTO languages (code, native_name, english_name, sort_order) VALUES
('en', 'English', 'English', 1),
('ru', 'Русский', 'Russian', 2),
('es', 'Español', 'Spanish', 3),
('de', 'Deutsch', 'German', 4),
('fr', 'Français', 'French', 5),
('it', 'Italiano', 'Italian', 6),
('pt', 'Português', 'Portuguese', 7),
('zh', '中文', 'Chinese (Simplified)', 8),
('ja', '日本語', 'Japanese', 9),
('ko', '한국어', 'Korean', 10),
('ar', 'العربية', 'Arabic', 11),
('hi', 'हिन्दी', 'Hindi', 12);
-- +goose Down
DROP TABLE IF EXISTS languages;

View File

@@ -1,58 +0,0 @@
-- +goose Up
CREATE TABLE units (
code VARCHAR(20) PRIMARY KEY,
sort_order SMALLINT NOT NULL DEFAULT 0
);
INSERT INTO units (code, sort_order) VALUES
('g', 1),
('kg', 2),
('ml', 3),
('l', 4),
('pcs', 5),
('pack', 6);
CREATE TABLE unit_translations (
unit_code VARCHAR(20) NOT NULL REFERENCES units(code) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (unit_code, lang)
);
INSERT INTO unit_translations (unit_code, lang, name) VALUES
('g', 'ru', 'г'),
('kg', 'ru', 'кг'),
('ml', 'ru', 'мл'),
('l', 'ru', 'л'),
('pcs', 'ru', 'шт'),
('pack', 'ru', 'уп');
-- Normalize products.unit from Russian display strings to English codes
UPDATE products SET unit = 'g' WHERE unit = 'г';
UPDATE products SET unit = 'kg' WHERE unit = 'кг';
UPDATE products SET unit = 'ml' WHERE unit = 'мл';
UPDATE products SET unit = 'l' WHERE unit = 'л';
UPDATE products SET unit = 'pcs' WHERE unit = 'шт';
UPDATE products SET unit = 'pack' WHERE unit = 'уп';
-- Normalize any remaining unknown values
UPDATE products SET unit = 'pcs' WHERE unit NOT IN (SELECT code FROM units);
-- Nullify unknown default_unit values in ingredient_mappings before adding FK
UPDATE ingredient_mappings
SET default_unit = NULL
WHERE default_unit IS NOT NULL
AND default_unit NOT IN (SELECT code FROM units);
-- Foreign key constraints
ALTER TABLE products
ADD CONSTRAINT fk_product_unit
FOREIGN KEY (unit) REFERENCES units(code);
ALTER TABLE ingredient_mappings
ADD CONSTRAINT fk_default_unit
FOREIGN KEY (default_unit) REFERENCES units(code);
-- +goose Down
ALTER TABLE ingredient_mappings DROP CONSTRAINT IF EXISTS fk_default_unit;
ALTER TABLE products DROP CONSTRAINT IF EXISTS fk_product_unit;
DROP TABLE IF EXISTS unit_translations;
DROP TABLE IF EXISTS units;