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