# Meal Planner - Database Schema ## 1. Schema Overview PostgreSQL 15+ with the following extensions: - `uuid-ossp` for UUID generation - `pg_trgm` for fuzzy text search (if needed) --- ## 2. Tables ### 2.1 `family_profile` Primary household configuration. | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | id | UUID | PK, default uuid_generate_v4() | Primary key | | name | VARCHAR(100) | NOT NULL | Household name | | household_size | INTEGER | NOT NULL, CHECK (household_size > 0) | Number of people | | adult_count | INTEGER | NOT NULL | Number of adults | | child_count | INTEGER | NOT NULL | Number of children | | dietary_notes | TEXT | | Free-text dietary notes | | budget_per_meal | NUMERIC(10,2) | DEFAULT 50.00 | Budget target per meal (in dollars) | | calorie_target | INTEGER | | Daily calorie target per adult | | created_at | TIMESTAMPTZ | DEFAULT NOW() | | | updated_at | TIMESTAMPTZ | DEFAULT NOW() | | ### 2.2 `ingredient` Master list of all ingredients. | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | id | UUID | PK | | | name | VARCHAR(200) | NOT NULL, UNIQUE | Ingredient name | | plural_name | VARCHAR(200) | | For shopping list grouping | | aisle | VARCHAR(100) | | Lucky California aisle | | typical_price | NUMERIC(10,2) | | Price per unit | | unit | VARCHAR(50) | | e.g., "lb", "oz", "bunch" | | season_months | INTEGER[] | | Array of month numbers 1-12 | | created_at | TIMESTAMPTZ | DEFAULT NOW() | | ### 2.3 `recipe` All recipes in the system. | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | id | UUID | PK | | | family_profile_id | UUID | FK → family_profile(id) | Optional, for family-specific recipes | | name | VARCHAR(300) | NOT NULL | Recipe name | | description | TEXT | | Short description | | image_url | TEXT | | URL to recipe image | | image_source | VARCHAR(50) | | 'scraped', 'ai_generated', 'manual' | | prep_time_minutes | INTEGER | | Prep time | | cook_time_minutes | INTEGER | | Cook time | | total_time_minutes | INTEGER | | Computed: prep + cook | | servings | INTEGER | NOT NULL | | | servings_scaled | INTEGER | | For scaling recipes | | cuisine_tags | VARCHAR(50)[] | | Array: 'italian', 'asian', etc. | | dietary_tags | VARCHAR(50)[] | | Array: 'vegetarian', 'gluten_free', etc. | | protein_type | VARCHAR(50) | | 'chicken', 'beef', 'vegetarian', 'seafood' | | spice_level | INTEGER | CHECK (spice_level BETWEEN 1 AND 5) | 1=mild, 5=very spicy | | ingredients | JSONB | NOT NULL | [{ingredient_id, quantity, unit, is_optional}] | | instructions | TEXT[] | NOT NULL | Array of step strings | | source_url | TEXT | | Original recipe URL if scraped | | scraped_at | TIMESTAMPTZ | | When originally scraped | | is_manually_added | BOOLEAN | DEFAULT FALSE | User-created vs scraped | | created_at | TIMESTAMPTZ | DEFAULT NOW() | | | updated_at | TIMESTAMPTZ | DEFAULT NOW() | | ### 2.4 `meal_plan` A generated weekly meal plan. | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | id | UUID | PK | | | family_profile_id | UUID | FK → family_profile(id) | | | week_start_date | DATE | NOT NULL | Monday of the week | | status | VARCHAR(20) | NOT NULL, DEFAULT 'draft' | 'draft', 'pending_approval', 'approved', 'locked' | | approval_deadline | TIMESTAMPTZ | | When approval period ends | | total_estimated_cost | NUMERIC(10,2) | | Sum of all meal costs | | notes | TEXT | | Admin notes | | created_at | TIMESTAMPTZ | DEFAULT NOW() | | | updated_at | TIMESTAMPTZ | DEFAULT NOW() | | **Unique constraint**: `(family_profile_id, week_start_date)` ### 2.5 `meal_plan_item` Individual meal within a plan. | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | id | UUID | PK | | | meal_plan_id | UUID | FK → meal_plan(id) ON DELETE CASCADE | | | recipe_id | UUID | FK → recipe(id) | | | day_of_week | INTEGER | NOT NULL, CHECK (day_of_week BETWEEN 0 AND 6) | 0=Monday, 6=Sunday | | meal_type | VARCHAR(20) | NOT NULL | 'breakfast', 'lunch', 'dinner' | | approval_status | VARCHAR(20) | DEFAULT 'pending' | 'pending', 'approved', 'denied', 'swapped' | | approval_token | UUID | UNIQUE, DEFAULT uuid_generate_v4() | Token for email approval links | | approval_token_expires | TIMESTAMPTZ | | | | denial_reason | VARCHAR(50) | | 'too_expensive', 'boring', 'disliked_ingredient', 'other' | | denial_details | TEXT | | Free-text explanation | | estimated_cost | NUMERIC(10,2) | | Per serving cost | | used_pantry_items | UUID[] | | Home pantry items used | | created_at | TIMESTAMPTZ | DEFAULT NOW() | | | updated_at | TIMESTAMPTZ | DEFAULT NOW() | | **Unique constraint**: `(meal_plan_id, day_of_week, meal_type)` ### 2.6 `home_pantry` Items the family has at home. | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | id | UUID | PK | | | family_profile_id | UUID | FK → family_profile(id) ON DELETE CASCADE | | | ingredient_id | UUID | FK → ingredient(id) | | | quantity | NUMERIC(10,2) | | How much on hand | | unit | VARCHAR(50) | | e.g., "cans", "lb" | | expires_at | DATE | | Perishable expiry date | | added_at | TIMESTAMPTZ | DEFAULT NOW() | | | created_at | TIMESTAMPTZ | DEFAULT NOW() | | **Unique constraint**: `(family_profile_id, ingredient_id)` ### 2.7 `feedback` Meal feedback and ratings. | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | id | UUID | PK | | | family_profile_id | UUID | FK → family_profile(id) | | | meal_plan_item_id | UUID | FK → meal_plan_item(id) ON DELETE CASCADE | | | rating | INTEGER | CHECK (rating BETWEEN 1 AND 5) | 1-5 stars | | never_suggest | BOOLEAN | DEFAULT FALSE | Add to "never suggest" list | | denial_reason | VARCHAR(50) | | Same as meal_plan_item | | feedback_text | TEXT | | Free-text feedback | | created_at | TIMESTAMPTZ | DEFAULT NOW() | | **Unique constraint**: `(meal_plan_item_id)` — one feedback per meal ### 2.8 `never_suggest` Global "never suggest" flags per family. | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | id | UUID | PK | | | family_profile_id | UUID | FK → family_profile(id) ON DELETE CASCADE | | | ingredient_id | UUID | FK → ingredient(id) | Optionally block specific ingredients | | recipe_id | UUID | FK → recipe(id) | Or block entire recipes | | reason | VARCHAR(50) | | 'allergy', 'dislike', 'tried_too_much', 'other' | | notes | TEXT | | | | created_at | TIMESTAMPTZ | DEFAULT NOW() | | ### 2.9 `grocery_item` Scraped items from Lucky California. | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | id | UUID | PK | | | name | VARCHAR(300) | NOT NULL | Product name | | brand | VARCHAR(200) | | Brand name | | current_price | NUMERIC(10,2) | | Current sale price | | regular_price | NUMERIC(10,2) | | Normal price | | unit | VARCHAR(50) | | Price unit | | aisle | VARCHAR(100) | | Store aisle | | image_url | TEXT | | Product image | | product_url | TEXT | | Lucky California product page | | is_on_sale | BOOLEAN | DEFAULT FALSE | Currently on sale | | sale_start_date | DATE | | | | sale_end_date | DATE | | | | in_season | BOOLEAN | DEFAULT FALSE | Currently in season | | scraped_at | TIMESTAMPTZ | DEFAULT NOW() | | | scraped_url | TEXT | | Source URL | **Index**: `CREATE INDEX idx_grocery_item_is_on_sale ON grocery_item(is_on_sale) WHERE is_on_sale = TRUE` ### 2.10 `scrape_log` Scraping operation history. | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | id | UUID | PK | | | source | VARCHAR(50) | NOT NULL | 'lucky_california', 'recipe_site' | | scrape_type | VARCHAR(50) | NOT NULL | 'weekly_ad', 'product_catalog', 'recipe' | | status | VARCHAR(20) | NOT NULL | 'started', 'success', 'failed' | | items_scraped | INTEGER | DEFAULT 0 | | | error_message | TEXT | | | | started_at | TIMESTAMPTZ | DEFAULT NOW() | | | completed_at | TIMESTAMPTZ | | | | duration_seconds | INTEGER | | | ### 2.11 `email_log` Email sending history. | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | id | UUID | PK | | | recipient_email | VARCHAR(300) | NOT NULL | | | recipient_name | VARCHAR(200) | | | | template | VARCHAR(100) | NOT NULL | 'meal_proposal', 'reminder', 'confirmation' | | meal_plan_id | UUID | FK → meal_plan(id) | Related meal plan | | meal_plan_item_id | UUID | FK → meal_plan_item(id) | Optional: specific meal | | sendgrid_message_id | VARCHAR(100) | | SendGrid message ID | | status | VARCHAR(20) | NOT NULL | 'sent', 'delivered', 'failed', 'bounced' | | error_message | TEXT | | | | created_at | TIMESTAMPTZ | DEFAULT NOW() | | | delivered_at | TIMESTAMPTZ | | | --- ## 3. Indexes ### 3.1 Primary Indexes All PKs have default B-tree indexes. ### 3.2 Foreign Key Indexes ```sql CREATE INDEX idx_recipe_family_profile ON recipe(family_profile_id); CREATE INDEX idx_meal_plan_family_profile ON meal_plan(family_profile_id); CREATE INDEX idx_meal_plan_item_meal_plan ON meal_plan_item(meal_plan_id); CREATE INDEX idx_meal_plan_item_recipe ON meal_plan_item(recipe_id); CREATE INDEX idx_home_pantry_family_profile ON home_pantry(family_profile_id); CREATE INDEX idx_feedback_family_profile ON feedback(family_profile_id); CREATE INDEX idx_feedback_meal_plan_item ON feedback(meal_plan_item_id); CREATE INDEX idx_never_suggest_family_profile ON never_suggest(family_profile_id); CREATE INDEX idx_grocery_item_sale ON grocery_item(is_on_sale) WHERE is_on_sale = TRUE; ``` ### 3.3 Full-Text Search Indexes ```sql CREATE INDEX idx_ingredient_name_fts ON ingredient USING gin(to_tsvector('english', name)); CREATE INDEX idx_recipe_name_fts ON recipe USING gin(to_tsvector('english', name)); ``` --- ## 4. Data Migration Strategy ### 4.1 Initial Schema Use SQLAlchemy or Alembic for schema management. ### 4.2 Future Migrations - Alembic for version-controlled migrations - All migrations must be reversible ### 4.3 Seed Data - Basic ingredient list pre-populated - Sample recipes for initial testing (5-10 meals) - Default family profile template --- ## 5. Data Retention | Data Type | Retention | Action After Expiry | |----------|-----------|---------------------| | Meal plans | 12 weeks | Archive to JSON, delete rows | | Feedback | Indefinite | Keep for learning | | Scraped grocery items | 2 weeks | Delete old items | | Scrape logs | 30 days | Delete old logs | | Email logs | 90 days | Delete old logs | | Never-suggest | Indefinite | Keep | --- ## 6. Row-Level Security (Future) If multi-family support is added: - RLS on all tables - Policies based on `family_profile_id` - Backend enforces tenant isolation