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:
@@ -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 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;
|
||||
|
||||
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();
|
||||
452
backend/migrations/001_initial_schema.sql
Normal file
452
backend/migrations/001_initial_schema.sql
Normal 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;
|
||||
@@ -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;
|
||||
190
backend/migrations/002_seed_data.sql
Normal file
190
backend/migrations/002_seed_data.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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, '')));
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user