-- Migration 062: BLS/food catalog, typed attributes, FDDB mapping, nutrition items, day marks -- Additive only. Official BLS rows upsert by bls_code / attr_key — never delete+reinsert. CREATE TABLE IF NOT EXISTS food_attributes ( id SERIAL PRIMARY KEY, attr_key VARCHAR(64) NOT NULL, name_de VARCHAR(255) NOT NULL, name_en VARCHAR(255), unit VARCHAR(40), category VARCHAR(80), data_type VARCHAR(20) NOT NULL DEFAULT 'num_per_100g' CHECK (data_type IN ('num_per_100g', 'boolean', 'text', 'enum')), enum_values JSONB, origin VARCHAR(20) NOT NULL DEFAULT 'official_bls' CHECK (origin IN ('official_bls', 'extension')), sort_order INT NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_food_attributes_key UNIQUE (attr_key) ); CREATE INDEX IF NOT EXISTS idx_food_attributes_origin ON food_attributes (origin); CREATE TABLE IF NOT EXISTS food_catalog ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), bls_code VARCHAR(16), external_key VARCHAR(80), name_de VARCHAR(500) NOT NULL, name_en VARCHAR(500), food_group VARCHAR(8), catalog_kind VARCHAR(20) NOT NULL DEFAULT 'official_bls' CHECK (catalog_kind IN ('official_bls', 'manual_admin', 'manual_user')), owner_profile_id UUID REFERENCES profiles(id) ON DELETE CASCADE, bls_version VARCHAR(16), source VARCHAR(40) NOT NULL DEFAULT 'bls_4.0', is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_food_catalog_bls_code UNIQUE (bls_code), CONSTRAINT chk_food_catalog_bls_code CHECK ( (catalog_kind = 'official_bls' AND bls_code IS NOT NULL) OR (catalog_kind <> 'official_bls' AND bls_code IS NULL) ) ); CREATE INDEX IF NOT EXISTS idx_food_catalog_name_de ON food_catalog (lower(name_de)); CREATE INDEX IF NOT EXISTS idx_food_catalog_kind ON food_catalog (catalog_kind); CREATE INDEX IF NOT EXISTS idx_food_catalog_owner ON food_catalog (owner_profile_id) WHERE owner_profile_id IS NOT NULL; CREATE TABLE IF NOT EXISTS food_attribute_values ( id BIGSERIAL PRIMARY KEY, food_id UUID NOT NULL REFERENCES food_catalog(id) ON DELETE CASCADE, attribute_id INT NOT NULL REFERENCES food_attributes(id) ON DELETE CASCADE, value_num DOUBLE PRECISION, value_bool BOOLEAN, value_text TEXT, is_trace BOOLEAN NOT NULL DEFAULT false, origin_code VARCHAR(80), reference_text TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_food_attribute_value UNIQUE (food_id, attribute_id), CONSTRAINT chk_food_attr_one_value CHECK ( ( (value_num IS NOT NULL)::int + (value_bool IS NOT NULL)::int + (value_text IS NOT NULL)::int ) <= 1 ) ); CREATE INDEX IF NOT EXISTS idx_fav_food ON food_attribute_values (food_id); CREATE INDEX IF NOT EXISTS idx_fav_attr ON food_attribute_values (attribute_id); CREATE TABLE IF NOT EXISTS food_name_mappings ( id SERIAL PRIMARY KEY, source_system VARCHAR(20) NOT NULL DEFAULT 'fddb', source_name_raw VARCHAR(500) NOT NULL, source_name_normalized VARCHAR(500) NOT NULL, food_id UUID NOT NULL REFERENCES food_catalog(id) ON DELETE CASCADE, profile_id UUID REFERENCES profiles(id) ON DELETE CASCADE, source VARCHAR(20) NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'bulk', 'admin')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE UNIQUE INDEX IF NOT EXISTS uq_food_map_global ON food_name_mappings (source_system, source_name_normalized) WHERE profile_id IS NULL; CREATE UNIQUE INDEX IF NOT EXISTS uq_food_map_user ON food_name_mappings (source_system, source_name_normalized, profile_id) WHERE profile_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_food_map_food ON food_name_mappings (food_id); CREATE TABLE IF NOT EXISTS nutrition_items ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, date DATE NOT NULL, logged_at TIMESTAMPTZ, source_name_raw VARCHAR(500) NOT NULL, source_name_normalized VARCHAR(500) NOT NULL, source_system VARCHAR(20) NOT NULL DEFAULT 'fddb', quantity_raw VARCHAR(80), quantity_g NUMERIC(10,3), fddb_kcal NUMERIC(8,2), fddb_protein_g NUMERIC(8,2), fddb_fat_g NUMERIC(8,2), fddb_carbs_g NUMERIC(8,2), food_id UUID REFERENCES food_catalog(id) ON DELETE SET NULL, mapping_id INT REFERENCES food_name_mappings(id) ON DELETE SET NULL, value_origin VARCHAR(20) NOT NULL DEFAULT 'fddb' CHECK (value_origin IN ('fddb', 'bls', 'manual_catalog')), source VARCHAR(20) NOT NULL DEFAULT 'csv' CHECK (source IN ('csv', 'manual')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_nutrition_items_profile_date ON nutrition_items (profile_id, date DESC); CREATE INDEX IF NOT EXISTS idx_nutrition_items_unmapped ON nutrition_items (profile_id, source_name_normalized) WHERE food_id IS NULL; CREATE TABLE IF NOT EXISTS nutrition_daily_nutrients ( id BIGSERIAL PRIMARY KEY, profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, date DATE NOT NULL, attribute_id INT NOT NULL REFERENCES food_attributes(id) ON DELETE CASCADE, value DOUBLE PRECISION NOT NULL, contributing_item_count INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_nutrition_daily_nutrient UNIQUE (profile_id, date, attribute_id) ); CREATE INDEX IF NOT EXISTS idx_ndn_profile_date ON nutrition_daily_nutrients (profile_id, date DESC); CREATE TABLE IF NOT EXISTS nutrition_day_marks ( id SERIAL PRIMARY KEY, profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, date DATE NOT NULL, mark_type VARCHAR(20) NOT NULL CHECK (mark_type IN ('fasting', 'incomplete')), note TEXT, source VARCHAR(20) NOT NULL DEFAULT 'manual', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_nutrition_day_mark UNIQUE (profile_id, date) ); ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS macro_origin VARCHAR(20); ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS mapped_item_count INT; ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS unmapped_item_count INT; ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS has_items BOOLEAN DEFAULT false; ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS last_import_at TIMESTAMPTZ; ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS macros_confirmed_at TIMESTAMPTZ; DELETE FROM nutrition_log a USING nutrition_log b WHERE a.profile_id = b.profile_id AND a.date = b.date AND (a.created < b.created OR (a.created = b.created AND a.id::text < b.id::text)); CREATE UNIQUE INDEX IF NOT EXISTS uq_nutrition_log_profile_date ON nutrition_log (profile_id, date); ALTER TABLE profiles ADD COLUMN IF NOT EXISTS nutrition_import_conflict_policy VARCHAR(30) DEFAULT 'prompt'; DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'chk_nutrition_import_policy' ) THEN ALTER TABLE profiles ADD CONSTRAINT chk_nutrition_import_policy CHECK (nutrition_import_conflict_policy IN ( 'prompt', 'overwrite_catalog', 'overwrite_fddb', 'keep_existing' )); END IF; END $$; UPDATE csv_field_mappings SET field_mappings = COALESCE(field_mappings, '{}'::jsonb) || '{"bezeichnung": "food_name", "menge": "quantity_raw"}'::jsonb, updated_at = NOW() WHERE is_system = true AND module = 'nutrition' AND ( mapping_name ILIKE '%FDDB%' OR mapping_name ILIKE '%fddb%' ); COMMENT ON TABLE food_catalog IS 'BLS 4.0 official foods + manual catalog extensions'; COMMENT ON COLUMN food_catalog.bls_code IS 'Stable official BLS identity; upsert key'; COMMENT ON TABLE food_name_mappings IS 'Learned FDDB name -> food_catalog; user overrides global'; COMMENT ON TABLE nutrition_day_marks IS 'Explicit fasting/incomplete; import must not delete'; DO $$ BEGIN RAISE NOTICE 'Migration 062: BLS food catalog, mappings, nutrition items, day marks'; END $$;