-- Migration: Initial database schema -- Created: 2026-02-09 -- Issue: #13 -- Description: Creates core tables for Pantry app (inventory, products, tags, units) -- ====================== -- ENUM TYPES -- ====================== CREATE TYPE tag_category AS ENUM ( 'position', -- Location: fridge, freezer, pantry 'type', -- Food type: dairy, meat, vegan 'dietary', -- Diet: gluten-free, vegan, organic 'custom' -- User-defined ); CREATE TYPE unit_type AS ENUM ( 'weight', -- kg, g, lb, oz 'volume', -- L, mL, cup, tbsp 'count', -- pcs, items (no conversion) 'custom' -- can, jar, bottle (user-defined) ); -- ====================== -- BASE TABLES (no foreign keys) -- ====================== -- Units: measurement units with conversion support CREATE TABLE units ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Core data name TEXT NOT NULL, -- "kilogram", "liter", "piece" abbreviation TEXT NOT NULL, -- "kg", "L", "pcs" unit_type unit_type NOT NULL, -- Conversion system base_unit_id UUID REFERENCES units(id), -- NULL = this is a base unit conversion_factor DECIMAL(20,10), -- Factor to convert to base unit -- Metadata is_default BOOLEAN DEFAULT false, -- Shipped with app created_by UUID REFERENCES auth.users(id), -- NULL = system unit created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT unique_unit_abbr UNIQUE (abbreviation, created_by), CONSTRAINT check_conversion_logic CHECK ( (base_unit_id IS NULL AND conversion_factor IS NULL) OR (base_unit_id IS NOT NULL AND conversion_factor IS NOT NULL) ) ); -- Tags: flexible labeling system CREATE TABLE tags ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Core data name TEXT NOT NULL, category tag_category NOT NULL DEFAULT 'custom', -- Visual icon TEXT, -- Emoji or icon name: "🧊", "cheese" color TEXT, -- Hex color: "#3b82f6" -- Ownership created_by UUID REFERENCES auth.users(id), -- NULL = system tag created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT unique_tag_name UNIQUE (name, created_by) ); -- ====================== -- MAIN TABLES -- ====================== -- Products: cached product data from Open Food Facts CREATE TABLE products ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Open Food Facts data barcode TEXT UNIQUE NOT NULL, name TEXT NOT NULL, brand TEXT, image_url TEXT, image_small_url TEXT, -- Thumbnail -- Categories from Open Food Facts categories TEXT[], -- Array: ['dairy', 'milk'] -- Nutrition (optional, for future features) nutrition JSONB, -- Full nutrition data -- Defaults default_unit_id UUID REFERENCES units(id), default_quantity DECIMAL(10,2), -- E.g., 1L bottle -- Metadata source TEXT DEFAULT 'openfoodfacts', cached_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_fetched TIMESTAMPTZ, -- Quality score (from Open Food Facts) completeness_score INTEGER CHECK (completeness_score BETWEEN 0 AND 100) ); -- Inventory Items: actual items in your kitchen right now CREATE TABLE inventory_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Product reference (nullable for custom items) product_id UUID REFERENCES products(id) ON DELETE SET NULL, -- Core data name TEXT NOT NULL, -- Product name or custom name quantity DECIMAL(10,2) NOT NULL CHECK (quantity >= 0), unit_id UUID NOT NULL REFERENCES units(id), -- Optional metadata expiry_date DATE, location TEXT, -- Free text: "top shelf", "door", etc. notes TEXT, -- Audit trail added_by UUID NOT NULL REFERENCES auth.users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Item Tags: many-to-many relationship between items and tags CREATE TABLE item_tags ( item_id UUID NOT NULL REFERENCES inventory_items(id) ON DELETE CASCADE, tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (item_id, tag_id) ); -- User Profiles: additional user metadata CREATE TABLE user_profiles ( id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, display_name TEXT, avatar_url TEXT, -- Preferences default_unit_system TEXT DEFAULT 'metric', -- 'metric' or 'imperial' theme TEXT DEFAULT 'auto', -- 'light', 'dark', 'auto' created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- ====================== -- INDEXES -- ====================== -- Units CREATE INDEX idx_units_type ON units(unit_type); CREATE INDEX idx_units_base ON units(base_unit_id); -- Tags CREATE INDEX idx_tags_category ON tags(category); CREATE INDEX idx_tags_created_by ON tags(created_by); -- Products CREATE UNIQUE INDEX idx_products_barcode ON products(barcode); CREATE INDEX idx_products_name ON products USING GIN (to_tsvector('english', name)); CREATE INDEX idx_products_search ON products USING GIN (to_tsvector('english', name || ' ' || COALESCE(brand, ''))); -- Inventory Items CREATE INDEX idx_items_product ON inventory_items(product_id); CREATE INDEX idx_items_added_by ON inventory_items(added_by); CREATE INDEX idx_items_expiry ON inventory_items(expiry_date) WHERE expiry_date IS NOT NULL; -- Item Tags CREATE INDEX idx_item_tags_tag ON item_tags(tag_id); CREATE INDEX idx_item_tags_item ON item_tags(item_id); -- ====================== -- FUNCTIONS -- ====================== -- Auto-update timestamp trigger function CREATE OR REPLACE FUNCTION update_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; -- Unit conversion function CREATE OR REPLACE FUNCTION convert_unit( quantity DECIMAL, from_unit_id UUID, to_unit_id UUID ) RETURNS DECIMAL AS $$ DECLARE from_factor DECIMAL; to_factor DECIMAL; from_type unit_type; to_type unit_type; base_quantity DECIMAL; BEGIN -- Get unit types and conversion factors SELECT unit_type, COALESCE(conversion_factor, 1.0) INTO from_type, from_factor FROM units WHERE id = from_unit_id; SELECT unit_type, COALESCE(conversion_factor, 1.0) INTO to_type, to_factor FROM units WHERE id = to_unit_id; -- Check if units are compatible IF from_type != to_type THEN RAISE EXCEPTION 'Cannot convert between % and %', from_type, to_type; END IF; -- Convert to base unit, then to target unit base_quantity := quantity * from_factor; RETURN base_quantity / to_factor; END; $$ LANGUAGE plpgsql IMMUTABLE; -- Full-text search function for products CREATE OR REPLACE FUNCTION search_products(search_query TEXT) RETURNS TABLE ( id UUID, barcode TEXT, name TEXT, brand TEXT, rank REAL ) AS $$ BEGIN RETURN QUERY SELECT p.id, p.barcode, p.name, p.brand, ts_rank(to_tsvector('english', p.name || ' ' || COALESCE(p.brand, '')), plainto_tsquery('english', search_query)) AS rank FROM products p WHERE to_tsvector('english', p.name || ' ' || COALESCE(p.brand, '')) @@ plainto_tsquery('english', search_query) ORDER BY rank DESC LIMIT 20; END; $$ LANGUAGE plpgsql; -- ====================== -- TRIGGERS -- ====================== -- Auto-update timestamp triggers CREATE TRIGGER update_items_updated_at BEFORE UPDATE ON inventory_items FOR EACH ROW EXECUTE FUNCTION update_updated_at(); CREATE TRIGGER update_profiles_updated_at BEFORE UPDATE ON user_profiles FOR EACH ROW EXECUTE FUNCTION update_updated_at(); -- ====================== -- COMMENTS -- ====================== COMMENT ON TABLE inventory_items IS 'Current inventory items in the kitchen'; COMMENT ON TABLE products IS 'Cached product data from Open Food Facts'; COMMENT ON TABLE tags IS 'Flexible labeling system for organization'; COMMENT ON TABLE item_tags IS 'Many-to-many relationship between items and tags'; COMMENT ON TABLE units IS 'Measurement units with conversion support'; COMMENT ON TABLE user_profiles IS 'Additional user metadata and preferences'; COMMENT ON FUNCTION update_updated_at() IS 'Automatically updates the updated_at timestamp'; COMMENT ON FUNCTION convert_unit(DECIMAL, UUID, UUID) IS 'Converts quantity between compatible units'; COMMENT ON FUNCTION search_products(TEXT) IS 'Full-text search for products by name and brand';