Files
pantry/docs/architecture/database.md
Pantry Lead Agent b1ef7e43be
Some checks failed
Deploy to Coolify / Code Quality (pull_request) Has been cancelled
Deploy to Coolify / Run Tests (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Development (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Production (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Test (pull_request) Has been cancelled
Pull Request Checks / Validate PR (pull_request) Has been cancelled
docs: restructure documentation into organized folders
Organized docs into logical subdirectories:

**New Structure:**
- docs/
  - README.md (index with quick links)
  - PROJECT_PLAN.md (root level - main roadmap)
  - development/
    - getting-started.md (5-min quickstart)
    - local-setup.md (detailed Docker Compose guide)
    - workflow.md (daily development)
    - git-workflow.md (branching strategy)
  - architecture/
    - overview.md (tech stack, design)
    - database.md (schema, RLS, migrations)
    - api.md (endpoints, functions)
  - deployment/
    - production.md (Docker, Coolify)
    - ci-cd.md (automated pipelines)

**Cleaned Up:**
- Moved DEV_SETUP.md → docs/development/local-setup.md
- Removed outdated SETUP.md (referenced old Coolify setup)
- Replaced with getting-started.md (current Docker Compose flow)
- Updated README.md links to new structure

All paths tested, no broken links.
2026-02-09 13:45:57 +00:00

16 KiB

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

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:

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

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:

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

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):

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

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:

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

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):

-- 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)

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

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:

-- 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:

-- 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:

-- 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:

-- 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:

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

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

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

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

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

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

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

-- Regular maintenance (run weekly)
VACUUM ANALYZE inventory_items;
VACUUM ANALYZE products;
VACUUM ANALYZE tags;

Clean old cached products

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

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

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)

-- 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)

-- migrations/002_seed_defaults.sql
INSERT INTO units (...) VALUES (...);
INSERT INTO tags (...) VALUES (...);

Version 3 (RLS Policies)

-- migrations/003_rls_policies.sql
ALTER TABLE inventory_items ENABLE ROW LEVEL SECURITY;
CREATE POLICY ...;

Next: API Reference