feat: rename ingredients→products, products→user_products; add barcode/OFF import

- Rename catalog: ingredient/* → product/* (canonical_name, barcode, nutrition per 100g)
- Rename pantry: product/* → userproduct/* (user-owned items with expiry)
- Squash migrations into single 001_initial_schema.sql (clean-db baseline)
- product_categories: add English canonical name column; fix COALESCE in queries
- Remove product_translations: product names are stored in their original language
- Add default_unit_name to product API responses via unit_translations JOIN
- Add cmd/importoff: bulk import from OpenFoodFacts JSONL dump (COPY + ON CONFLICT)
- Diary: support product_id entries alongside dish_id (CHECK num_nonnulls = 1)
- Home: getLoggedCalories joins both recipes and catalog products
- Flutter: rename models/providers/services to match backend rename
- Flutter: add barcode scan flow for diary (mobile_scanner, product_portion_sheet)
- Flutter: localise 6 new keys across 12 languages (barcode scan, portion weight)
- Routes: GET /products/search, GET /products/barcode/{barcode}, /user-products

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-21 12:45:48 +02:00
parent 6861e5e754
commit 205edbdade
72 changed files with 2588 additions and 1444 deletions

View File

@@ -44,11 +44,11 @@ $$ 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 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');
-- ---------------------------------------------------------------------------
@@ -104,28 +104,30 @@ CREATE TABLE unit_translations (
);
-- ---------------------------------------------------------------------------
-- ingredient_categories + translations
-- product_categories + product_category_translations
-- ---------------------------------------------------------------------------
CREATE TABLE ingredient_categories (
CREATE TABLE product_categories (
slug VARCHAR(50) PRIMARY KEY,
name TEXT NOT NULL,
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)
CREATE TABLE product_category_translations (
product_category_slug VARCHAR(50) NOT NULL REFERENCES product_categories(slug) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (product_category_slug, lang)
);
-- ---------------------------------------------------------------------------
-- ingredients (canonical catalog)
-- products (canonical catalog)
-- ---------------------------------------------------------------------------
CREATE TABLE ingredients (
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
canonical_name VARCHAR(255) NOT NULL,
category VARCHAR(50) REFERENCES ingredient_categories(slug),
category VARCHAR(50) REFERENCES product_categories(slug),
default_unit VARCHAR(20) REFERENCES units(code),
barcode TEXT UNIQUE,
calories_per_100g DECIMAL(8,2),
protein_per_100g DECIMAL(8,2),
fat_per_100g DECIMAL(8,2),
@@ -134,33 +136,23 @@ CREATE TABLE ingredients (
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)
CONSTRAINT uq_product_canonical_name UNIQUE (canonical_name)
);
CREATE INDEX idx_ingredients_canonical_name ON ingredients (canonical_name);
CREATE INDEX idx_ingredients_category ON ingredients (category);
CREATE INDEX idx_products_canonical_name ON products (canonical_name);
CREATE INDEX idx_products_category ON products (category);
CREATE INDEX idx_products_barcode ON products (barcode) WHERE barcode IS NOT NULL;
-- ---------------------------------------------------------------------------
-- ingredient_translations
-- product_aliases
-- ---------------------------------------------------------------------------
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 TABLE product_aliases (
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
alias TEXT NOT NULL,
PRIMARY KEY (product_id, lang, alias)
);
CREATE INDEX idx_ingredient_translations_ingredient_id ON ingredient_translations (ingredient_id);
-- ---------------------------------------------------------------------------
-- ingredient_aliases
-- ---------------------------------------------------------------------------
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);
CREATE INDEX idx_product_aliases_lookup ON product_aliases (product_id, lang);
CREATE INDEX idx_product_aliases_trgm ON product_aliases USING GIN (alias gin_trgm_ops);
-- ---------------------------------------------------------------------------
-- cuisines + cuisine_translations
@@ -279,22 +271,22 @@ CREATE TABLE recipe_translations (
);
-- ---------------------------------------------------------------------------
-- recipe_ingredients + recipe_ingredient_translations
-- recipe_products + recipe_product_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 TABLE recipe_products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
product_id UUID REFERENCES products(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 INDEX idx_recipe_products_recipe_id ON recipe_products (recipe_id);
CREATE TABLE recipe_ingredient_translations (
ri_id UUID NOT NULL REFERENCES recipe_ingredients(id) ON DELETE CASCADE,
CREATE TABLE recipe_product_translations (
ri_id UUID NOT NULL REFERENCES recipe_products(id) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (ri_id, lang)
@@ -322,29 +314,29 @@ CREATE TABLE recipe_step_translations (
);
-- ---------------------------------------------------------------------------
-- products (user fridge / pantry items)
-- user_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 TABLE user_products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
primary_product_id UUID REFERENCES products(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);
CREATE INDEX idx_user_products_user_id ON user_products (user_id);
-- ---------------------------------------------------------------------------
-- product_ingredients (M2M: composite product ↔ ingredients)
-- user_product_components (M2M: composite user product ↔ catalog products)
-- ---------------------------------------------------------------------------
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,
CREATE TABLE user_product_components (
user_product_id UUID NOT NULL REFERENCES user_products(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
amount_per_100g DECIMAL(10,2),
PRIMARY KEY (product_id, ingredient_id)
PRIMARY KEY (user_product_id, product_id)
);
-- ---------------------------------------------------------------------------
@@ -394,21 +386,21 @@ CREATE TABLE shopping_lists (
-- dish_recognition_jobs
-- ---------------------------------------------------------------------------
CREATE TABLE dish_recognition_jobs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_plan TEXT NOT NULL,
image_base64 TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'image/jpeg',
lang TEXT NOT NULL DEFAULT 'en',
target_date DATE,
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_plan TEXT NOT NULL,
image_base64 TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'image/jpeg',
lang TEXT NOT NULL DEFAULT 'en',
target_date DATE,
target_meal_type TEXT,
status TEXT NOT NULL DEFAULT 'pending',
status TEXT NOT NULL DEFAULT 'pending',
-- pending | processing | done | failed
result JSONB,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
result JSONB,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
);
CREATE INDEX idx_dish_recognition_jobs_user
ON dish_recognition_jobs (user_id, created_at DESC);
@@ -420,16 +412,18 @@ CREATE INDEX idx_dish_recognition_jobs_pending
-- ---------------------------------------------------------------------------
CREATE TABLE meal_diary (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date DATE NOT NULL,
meal_type TEXT NOT NULL,
portions DECIMAL(5,2) NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT 'manual',
dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE RESTRICT,
recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL,
dish_id UUID REFERENCES dishes(id) ON DELETE RESTRICT,
product_id UUID REFERENCES products(id) ON DELETE RESTRICT,
recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL,
portion_g DECIMAL(10,2),
job_id UUID REFERENCES dish_recognition_jobs(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
job_id UUID REFERENCES dish_recognition_jobs(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_meal_entry_source CHECK (num_nonnulls(dish_id, product_id) = 1)
);
CREATE INDEX idx_meal_diary_user_date ON meal_diary (user_id, date);
CREATE INDEX idx_meal_diary_job_id ON meal_diary (job_id) WHERE job_id IS NOT NULL;
@@ -471,18 +465,18 @@ INSERT INTO unit_translations (unit_code, lang, name) VALUES
('pack', 'ru', 'уп');
-- ---------------------------------------------------------------------------
-- Seed data: ingredient_categories + ingredient_category_translations
-- Seed data: product_categories + product_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 product_categories (slug, name, sort_order) VALUES
('dairy', 'Dairy', 1),
('meat', 'Meat', 2),
('produce', 'Produce', 3),
('bakery', 'Bakery', 4),
('frozen', 'Frozen', 5),
('beverages', 'Beverages', 6),
('other', 'Other', 7);
INSERT INTO ingredient_category_translations (category_slug, lang, name) VALUES
INSERT INTO product_category_translations (product_category_slug, lang, name) VALUES
('dairy', 'ru', 'Молочные продукты'),
('meat', 'ru', 'Мясо и птица'),
('produce', 'ru', 'Овощи и фрукты'),
@@ -617,12 +611,12 @@ 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 user_product_components;
DROP TABLE IF EXISTS user_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_product_translations;
DROP TABLE IF EXISTS recipe_products;
DROP TABLE IF EXISTS recipe_translations;
DROP TABLE IF EXISTS recipes;
DROP TABLE IF EXISTS dish_tags;
@@ -634,11 +628,10 @@ 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 product_aliases;
DROP TABLE IF EXISTS products;
DROP TABLE IF EXISTS product_category_translations;
DROP TABLE IF EXISTS product_categories;
DROP TABLE IF EXISTS unit_translations;
DROP TABLE IF EXISTS units;
DROP TABLE IF EXISTS languages;