# Pantry - Database Schema **Version:** 1.0 **Last Updated:** 2026-02-08 **PostgreSQL:** 15+ --- ## 📊 Schema Overview ### Tables | Table | Purpose | Rows (Est.) | |-------|---------|-------------| | `inventory_items` | Current inventory (in your kitchen) | 100-500 | | `products` | Master data cache (from Open Food Facts) | 500-2000 | | `tags` | Organization labels (position, type, custom) | 20-50 | | `item_tags` | Many-to-many item ↔ tag | 200-1000 | | `units` | Measurement units + conversions | 30-50 | | `users` | User accounts (Supabase Auth manages) | 2-10 | --- ## 🗃️ Table Definitions ### `inventory_items` **Purpose:** Actual items in your kitchen right now ```sql 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() ); -- Indexes 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; -- Auto-update timestamp CREATE TRIGGER update_items_updated_at BEFORE UPDATE ON inventory_items FOR EACH ROW EXECUTE FUNCTION update_updated_at(); ``` **Sample Data:** ```sql INSERT INTO inventory_items (product_id, name, quantity, unit_id, expiry_date, added_by) VALUES ('abc-123', 'Whole Milk', 1.5, 'unit-liter', '2026-02-15', 'user-123'), (NULL, 'Homemade Jam', 300, 'unit-gram', '2026-06-01', 'user-123'); ``` --- ### `products` **Purpose:** Cached product data from Open Food Facts ```sql 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) ); -- Indexes CREATE UNIQUE INDEX idx_products_barcode ON products(barcode); CREATE INDEX idx_products_name ON products USING GIN (to_tsvector('english', name)); -- Full-text search CREATE INDEX idx_products_search ON products USING GIN (to_tsvector('english', name || ' ' || COALESCE(brand, ''))); ``` **Sample Data:** ```sql INSERT INTO products (barcode, name, brand, image_url, default_unit_id, cached_at) VALUES ('8000500310427', 'Nutella', 'Ferrero', 'https://...', 'unit-gram', NOW()), ('5449000000996', 'Coca-Cola', 'Coca-Cola', 'https://...', 'unit-liter', NOW()); ``` --- ### `tags` **Purpose:** Flexible labeling system ```sql 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 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) ); -- Indexes CREATE INDEX idx_tags_category ON tags(category); CREATE INDEX idx_tags_created_by ON tags(created_by); ``` **Sample Data (Seed):** ```sql -- System tags (created_by = NULL) INSERT INTO tags (name, category, icon, color, created_by) VALUES -- Position ('Fridge', 'position', '🧊', '#3b82f6', NULL), ('Freezer', 'position', '❄️', '#0ea5e9', NULL), ('Pantry', 'position', '📦', '#f59e0b', NULL), ('Spices', 'position', '🌶️', '#ef4444', NULL), -- Type ('Dairy', 'type', '🥛', '#fbbf24', NULL), ('Cheese', 'type', '🧀', '#fcd34d', NULL), ('Meat', 'type', '🥩', '#dc2626', NULL), ('Fish', 'type', '🐟', '#06b6d4', NULL), ('Vegetables', 'type', '🥬', '#10b981', NULL), ('Fruits', 'type', '🍎', '#f87171', NULL), ('Bakery', 'type', '🍞', '#d97706', NULL), ('Snacks', 'type', '🍫', '#7c3aed', NULL), -- Dietary ('Vegan', 'dietary', '🌱', '#22c55e', NULL), ('Gluten-Free', 'dietary', '🌾', '#eab308', NULL), ('Organic', 'dietary', '🍃', '#84cc16', NULL); ``` --- ### `item_tags` **Purpose:** Many-to-many relationship between items and tags ```sql 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) ); -- Indexes CREATE INDEX idx_item_tags_tag ON item_tags(tag_id); CREATE INDEX idx_item_tags_item ON item_tags(item_id); ``` **Sample Data:** ```sql -- Milk in fridge + dairy tag INSERT INTO item_tags (item_id, tag_id) VALUES ('item-milk', 'tag-fridge'), ('item-milk', 'tag-dairy'); ``` --- ### `units` **Purpose:** Measurement units with conversion support ```sql 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) ); 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 -- E.g., for grams: base_unit = kg, factor = 0.001 -- To convert: value_in_g * 0.001 = value_in_kg -- 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) ); -- Indexes CREATE INDEX idx_units_type ON units(unit_type); CREATE INDEX idx_units_base ON units(base_unit_id); ``` **Sample Data (Seed):** ```sql -- Weight (metric) INSERT INTO units (name, abbreviation, unit_type, base_unit_id, conversion_factor, is_default, created_by) VALUES ('kilogram', 'kg', 'weight', NULL, 1.0, true, NULL), -- Base unit ('gram', 'g', 'weight', (SELECT id FROM units WHERE abbreviation = 'kg'), 0.001, true, NULL), ('milligram', 'mg', 'weight', (SELECT id FROM units WHERE abbreviation = 'kg'), 0.000001, true, NULL), -- Volume (metric) ('liter', 'L', 'volume', NULL, 1.0, true, NULL), -- Base unit ('milliliter', 'mL', 'volume', (SELECT id FROM units WHERE abbreviation = 'L'), 0.001, true, NULL), -- Count ('piece', 'pcs', 'count', NULL, 1.0, true, NULL), -- No conversion ('item', 'item', 'count', NULL, 1.0, true, NULL), -- Custom (common containers) ('can', 'can', 'custom', NULL, NULL, true, NULL), -- User defines conversion ('jar', 'jar', 'custom', NULL, NULL, true, NULL), ('bottle', 'bottle', 'custom', NULL, NULL, true, NULL), ('package', 'pkg', 'custom', NULL, NULL, true, NULL); ``` --- ### `users` (Supabase Auth) **Purpose:** User accounts (managed by Supabase Auth) ```sql -- This table is managed by Supabase Auth (auth.users) -- We only reference it via foreign keys -- Additional user metadata (if needed) 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() ); ``` --- ## 🔐 Row Level Security (RLS) ### Enable RLS ```sql ALTER TABLE inventory_items ENABLE ROW LEVEL SECURITY; ALTER TABLE products ENABLE ROW LEVEL SECURITY; ALTER TABLE tags ENABLE ROW LEVEL SECURITY; ALTER TABLE item_tags ENABLE ROW LEVEL SECURITY; ALTER TABLE units ENABLE ROW LEVEL SECURITY; ``` ### Policies **inventory_items:** ```sql -- Everyone can read (shared inventory) CREATE POLICY "items_select_all" ON inventory_items FOR SELECT USING (true); -- Authenticated users can insert CREATE POLICY "items_insert_auth" ON inventory_items FOR INSERT WITH CHECK (auth.uid() IS NOT NULL); -- Authenticated users can update CREATE POLICY "items_update_auth" ON inventory_items FOR UPDATE USING (auth.uid() IS NOT NULL); -- Authenticated users can delete CREATE POLICY "items_delete_auth" ON inventory_items FOR DELETE USING (auth.uid() IS NOT NULL); ``` **products:** ```sql -- Everyone can read cached products CREATE POLICY "products_select_all" ON products FOR SELECT USING (true); -- Only service role can write (via Edge Functions) -- (No user-level INSERT/UPDATE policy) ``` **tags:** ```sql -- Everyone can read all tags CREATE POLICY "tags_select_all" ON tags FOR SELECT USING (true); -- Authenticated users can create tags CREATE POLICY "tags_insert_auth" ON tags FOR INSERT WITH CHECK (auth.uid() IS NOT NULL); -- Users can only update their own tags (or system tags if admin) CREATE POLICY "tags_update_own" ON tags FOR UPDATE USING ( created_by = auth.uid() OR created_by IS NULL ); -- Users can only delete their own tags CREATE POLICY "tags_delete_own" ON tags FOR DELETE USING (created_by = auth.uid()); ``` **item_tags:** ```sql -- Everyone can read CREATE POLICY "item_tags_select_all" ON item_tags FOR SELECT USING (true); -- Authenticated users can add tags to items CREATE POLICY "item_tags_insert_auth" ON item_tags FOR INSERT WITH CHECK (auth.uid() IS NOT NULL); -- Authenticated users can remove tags CREATE POLICY "item_tags_delete_auth" ON item_tags FOR DELETE USING (auth.uid() IS NOT NULL); ``` **units:** ```sql -- Everyone can read CREATE POLICY "units_select_all" ON units FOR SELECT USING (true); -- Authenticated users can create custom units CREATE POLICY "units_insert_auth" ON units FOR INSERT WITH CHECK ( auth.uid() IS NOT NULL AND is_default = false ); -- Users can update their own custom units CREATE POLICY "units_update_own" ON units FOR UPDATE USING ( created_by = auth.uid() AND is_default = false ); ``` --- ## 🔄 Functions & Triggers ### Update Timestamp Trigger ```sql CREATE OR REPLACE FUNCTION update_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; -- Apply to tables with updated_at 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(); ``` ### Unit Conversion Function ```sql 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; -- Usage: -- SELECT convert_unit(500, 'gram-id', 'kg-id'); -> 0.5 ``` ### Full-Text Search Function ```sql 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; -- Usage: -- SELECT * FROM search_products('chocolate'); ``` --- ## 📈 Example Queries ### List all inventory items with tags and units ```sql SELECT i.id, i.name, i.quantity, u.abbreviation AS unit, i.expiry_date, ARRAY_AGG(t.name) AS tags, p.brand, p.image_url FROM inventory_items i LEFT JOIN units u ON i.unit_id = u.id LEFT JOIN products p ON i.product_id = p.id LEFT JOIN item_tags it ON i.id = it.item_id LEFT JOIN tags t ON it.tag_id = t.id GROUP BY i.id, u.abbreviation, p.brand, p.image_url ORDER BY i.created_at DESC; ``` ### Find items in fridge expiring soon ```sql SELECT i.name, i.quantity, u.abbreviation, i.expiry_date, i.expiry_date - CURRENT_DATE AS days_left FROM inventory_items i JOIN units u ON i.unit_id = u.id JOIN item_tags it ON i.id = it.item_id JOIN tags t ON it.tag_id = t.id WHERE t.name = 'Fridge' AND i.expiry_date IS NOT NULL AND i.expiry_date <= CURRENT_DATE + INTERVAL '7 days' ORDER BY i.expiry_date ASC; ``` ### Convert all items to base units ```sql SELECT i.name, i.quantity, u.abbreviation AS original_unit, convert_unit(i.quantity, i.unit_id, bu.id) AS base_quantity, bu.abbreviation AS base_unit FROM inventory_items i JOIN units u ON i.unit_id = u.id LEFT JOIN units bu ON u.base_unit_id = bu.id OR (u.base_unit_id IS NULL AND u.id = bu.id); ``` --- ## 🔧 Maintenance ### Vacuum & Analyze ```sql -- Regular maintenance (run weekly) VACUUM ANALYZE inventory_items; VACUUM ANALYZE products; VACUUM ANALYZE tags; ``` ### Clean old cached products ```sql -- Delete products not referenced by any items and older than 30 days DELETE FROM products WHERE id NOT IN (SELECT DISTINCT product_id FROM inventory_items WHERE product_id IS NOT NULL) AND cached_at < NOW() - INTERVAL '30 days'; ``` --- ## 📊 Monitoring ### Table sizes ```sql SELECT schemaname, tablename, pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size FROM pg_tables WHERE schemaname = 'public' ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC; ``` ### Index usage ```sql SELECT schemaname, tablename, indexname, idx_scan AS scans, pg_size_pretty(pg_relation_size(indexrelid)) AS size FROM pg_stat_user_indexes ORDER BY idx_scan DESC; ``` --- ## 🔄 Migration Strategy ### Version 1 (Initial Schema) ```sql -- migrations/001_initial_schema.sql CREATE TABLE inventory_items (...); CREATE TABLE products (...); CREATE TABLE tags (...); CREATE TABLE item_tags (...); CREATE TABLE units (...); ``` ### Version 2 (Seed Data) ```sql -- migrations/002_seed_defaults.sql INSERT INTO units (...) VALUES (...); INSERT INTO tags (...) VALUES (...); ``` ### Version 3 (RLS Policies) ```sql -- migrations/003_rls_policies.sql ALTER TABLE inventory_items ENABLE ROW LEVEL SECURITY; CREATE POLICY ...; ``` --- **Next:** [API Reference](./API.md)