Public Access
- SPEC.md: project specification and goals - ARCHITECTURE.md: system design and component descriptions - database-schema.md: PostgreSQL schema with all tables - implementation-plan.md: 12-phase implementation guide - RUNNING.md: deployment and troubleshooting guide - ORIENTATION.md: context compaction recovery guide - README.md: project overview and quick start Family profile: 2 adults, 2 children. Mushroom avoidance for 3/4. Approval workflow: email proposals, one denial swaps meal. Tech stack: FastAPI, PostgreSQL, React, Playwright, SendGrid.
284 lines
11 KiB
Markdown
284 lines
11 KiB
Markdown
# 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
|