fix: address adversarial review blockers

All §1 consensus blockers and §2 high-risk gaps resolved:

Schema fixes:
- Remove RecipeIngredient join table, use JSONB for ingredients
- Add family_member table for per-voter approval tracking
- Add all ENUMs for status fields (no loose VARCHAR)
- Add CHECK constraints (household_size, rating 1-5, day_of_week)
- Add name_lower for case-insensitive ingredient matching
- Add grocery_item → ingredient FK
- Fix day_of_week to ISO-8601 (1=Monday, 7=Sunday)
- Remove calorie_target (nutrition is non-goal)

Approval flow redesign:
- Email link → confirmation page (GET), not auto-approve
- Actual vote is POST from confirmation page
- Per-voter tokens (single-use, 72h TTL)
- Record which member voted

Auth model:
- VPN-only for admin endpoints
- Session-based for family web UI

Docker hardening:
- Remove direct port exposure for backend/frontend
- nginx is sole entrypoint
- Add docker-compose.dev.yml for local dev

Skeleton fixes:
- Add missing Pantry.tsx page
- Add missing index.html (Vite entrypoint)
- Add package-lock.json
- Fix SQLAlchemy 2 text() for raw SQL
- Remove create_all from startup (use migrations)
- Configure Alembic properly

Docs updates:
- Update Lucky URL to luckysupermarkets.com
- Add WCAG 2.1 AA accessibility target
- Update family profile with correct mushroom preferences
- Add external dependencies list to SPEC

Verification:
- docker compose config: PASS
- docker compose build backend: PASS
- docker compose build frontend: PASS
- backend import: PASS
- alembic context: PASS
This commit is contained in:
2026-05-04 20:11:05 -07:00
parent 624b51654c
commit a0b16f7418
22 changed files with 5754 additions and 272 deletions
+87 -51
View File
@@ -120,13 +120,21 @@ Recipe sites → Scraper → Parse → Store as recipe.image_source
**Approval Flow**:
```
Generate plan → Send proposal email
Wait for responses (48h window)
If deny → Swap meal with alternative
If approve/no response → Confirm meal
After all confirmations → Generate shopping list
Generate plan → Send proposal email (per-member tokens)
Member clicks email link → lands on confirmation page
Member submits vote (POST, not GET)
Token marked USED, vote recorded
If majority approve → meal confirmed
→ If any deny → meal swapped with alternative
→ After deadline → Generate shopping list
```
**Email Security**:
- Email links are GET to confirmation page (not direct approval)
- Actual vote is a POST from the confirmation page
- Tokens are single-use, expire after 72 hours
- Per-member tokens (not shared)
**Email Template Data**:
- Meal name and day
- Meal image (URL)
@@ -178,70 +186,98 @@ Generate plan → Send proposal email
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ family_ │ │ recipe │ │ meal_plan
│ family_ │ │ family_member │ │ recipe
│ profile │ │ │ │ │
├─────────────────┤ ├─────────────────┤ ├─────────────────┤
│ id │ │ id │ │ id │
│ name │◄────│ family_profile │ │ week_start_date
│ household_size │ │ name │◄─┐ │ status
dietary_notes │ │ descriptioncreated_at
preferences │ │ image_url│ └────────────────┘
created_at │ │ prep_time │ │
└─────────────────┘ │ cook_time
│ │ servings │ │
cuisine_tags[] │ │
│ dietary_tags[] │ │
│ protein_type
│ created_at │ │
└─────────────────┘ │
│ │
┌─────────────────┐ │ ┌─────────────────────┐
recipe meal_plan_item
_ingredient │◄──┘ ├─────────────────────┤
├─────────────────┤ │ id
│ recipe_id │ │ meal_plan_id
│ ingredient_id │ │ recipe_id ────┘
└──────────────►│ quantity│ day_of_week
unit │ │ approval_status │
is_optional │ approval_token
───────────────── │ denial_reason │
└─────────────────────┘
│ id │◄────│ family_profile │ │ id │
│ name │ │ id │ │ name
│ household_size │ │ name │ │ description
adult_count │ │ email image_url
child_count │ │ role │ ingredients │
dietary_notes │ │ likes_mushrooms │ │ (JSONB)
│ budget_per_meal │ │ created_at│ instructions[]
│ created_at │ └────────┬────────┘ │ cuisine_tags[]
└────────┬────────┘ dietary_tags[]
│ protein_type
│ prep/cook_time
┌─────────────────┐ │ servings
│ meal_plan_vote │ │ created_at
├─────────────────┤ └────────┬────────┘
│ id
│ meal_plan_item │ │
│ │ family_member │ │
│ │ vote (bool) │ │
│ voted_at │
└─────────────────┘
┌─────────────────┐ ┌─────────────────────┐
meal_plan_item ingredient
───────────────── ├─────────────────────┤
│ │ id │ │ id │
│ │ meal_plan_id │ │ name │
│ │ recipe_id │ │ name_lower (unique) │
└─────────────►│ day_of_week │ │ aisle │
│ meal_type │ │ typical_price │
│ approval_status│ │ unit │
│ denial_reason │ │ season_months[] │
│ estimated_cost │ └─────────────────────┘
└────────┬────────┘ ▲
│ │
▼ │
┌─────────────────┐ ┌───────┴─────────────┐
│ meal_plan │ │ grocery_item │
├─────────────────┤ ├───────────────────┤
│ id │ │ id │
│ week_start_date │ │ ingredient_id (FK) │
│ status │ │ name │
│ total_cost │ │ current_price │
│ approval_deadline│ │ regular_price │
└─────────────────┘ │ is_on_sale │
│ sale_end_date │
│ scraped_at │
└───────────────────┘
┌─────────────────┐ ┌─────────────────┐
grocery │ │ home_
│ item │ │ pantry │
├─────────────────┤ ├─────────────────┤
id │ │ id
name │ │ family_profile
aisle │ │ ingredient_id
current_price │ │ quantity
is_on_sale │ │ added_at
sale_end_date │ │ expires_at
│ season_months[] │ └─────────────────┘
│ scraped_at │
┌─────────────────┐
home_pantry
├─────────────────┤
│ id │
family_profile
ingredient_id
quantity
unit
expires_at │
added_at
└─────────────────┘
┌─────────────────┐ ┌─────────────────┐
│ feedback │ │ ingredient
│ feedback │ │ approval_token
├─────────────────┤ ├─────────────────┤
│ id │ │ id │
meal_plan_item │ │ name
rating │ │ aisle
never_suggest │ │ typical_price
denial_reason │ │ season_months[]
feedback_text │ │ created_at │
family_member │ │ meal_plan_item
meal_plan_item │ │ family_member
rating │ │ token
never_suggest │ │ status
denial_reason │ │ expires_at │
│ feedback_text │ │ used_at │
│ created_at │ └─────────────────┘
└─────────────────┘
```
### 3.2 Key Relationships
- `family_profile` 1:N `family_member`
- `family_profile` 1:N `meal_plan`
- `family_profile` 1:N `home_pantry`
- `recipe` N:N `ingredient` (via `recipe_ingredient`)
- `family_member` 1:N `meal_plan_vote` (per-member voting)
- `recipe` 1:N `meal_plan_item`
- `meal_plan` 1:N `meal_plan_item`
- `meal_plan` 1:N `meal_plan_vote`
- `meal_plan_item` 1:N `meal_plan_vote`
- `meal_plan_item` 1:N `approval_token`
- `meal_plan_item` 1:1 `feedback`
- `grocery_item``ingredient` (FK)
- `ingredient` 1:N `home_pantry`
- `ingredient` 1:N `grocery_item`
---
+90 -91
View File
@@ -18,28 +18,20 @@ The family has been using meal kit services (Blue Apron → EveryPlate → Hungr
### Current Status
**Phase**: Phase 1 complete. Phase 2 (Database & Models) next.
**Phase**: Post-adversarial-review fixes applied. Ready for verification.
Infrastructure skeleton is committed:
- docker-compose.yml with 4 services (backend, frontend, db, nginx)
- FastAPI backend with placeholder API routes
- React frontend with Vite + Tailwind + placeholder pages
- nginx reverse proxy config
- SQLAlchemy models created (not yet connected to real endpoints)
---
## Key Files
| File | Purpose |
|------|---------|
| `docs/SPEC.md` | Full project specification (goals, constraints, user stories) |
| `docs/ARCHITECTURE.md` | System architecture, component descriptions, data flow |
| `docs/database-schema.md` | PostgreSQL schema with all tables, indexes, relationships |
| `docs/implementation-plan.md` | 12-phase implementation plan with verification commands |
| `docs/RUNNING.md` | Deployment guide, troubleshooting, environment setup |
| `README.md` | Project overview and quick start |
| `meal-planner-plan.md` | Original planning file (may be superseded) |
**Adversarial Review Completed**: 2026-05-04
- All consensus blockers (§1.1 - §1.8) addressed
- All high-risk gaps (§2.1 - §2.8) addressed
- Key fixes applied:
- Recipe-ingredient: JSONB only (removed join table)
- Family member: Added `family_member` table for per-voter tracking
- Approval flow: Redesigned with confirmation page + POST + per-voter tokens + TTL
- Auth: VPN-only for admin endpoints, session-based for family web UI
- Schema: ENUMs, CHECKs, CITEXT for ingredients, ISO day_of_week (1=Mon)
- Lucky URL: Fixed to `https://luckysupermarkets.com`
- Docker: Hardened (no direct port exposure to backend/frontend)
- Alembic: Configured with migration policy
---
@@ -49,19 +41,20 @@ Infrastructure skeleton is committed:
User (email) ──► SendGrid ───────────────────────────────┐
User (web) ───► React UI ──► nginx ──► FastAPI ──────────┼──► PostgreSQL
│ │
└──► Lucky California scraper ──┘
└──► Lucky CA scraper ──┘
(luckysupermarkets.com)
```
### Services (Docker Compose)
- **backend**: FastAPI Python app (port 8000)
- **frontend**: React + Tailwind (port 3000, served via nginx)
- **db**: PostgreSQL 15 (port 5432)
- **backend**: FastAPI Python app (port 8000, internal only)
- **frontend**: React + Tailwind (port 3000, internal only via nginx)
- **db**: PostgreSQL 15 (internal only)
- **nginx**: Reverse proxy with SSL (ports 80/443)
### Tech Stack
- Backend: Python 3.11, FastAPI, SQLAlchemy, Alembic
- Frontend: React 18, TypeScript, Tailwind CSS, React Query
- Database: PostgreSQL 15
- Backend: Python 3.11, FastAPI, SQLAlchemy 2.0, Alembic
- Frontend: React 18, TypeScript, Tailwind CSS, React Query, Vite
- Database: PostgreSQL 15 with ENUMs and CITEXT
- Scraping: Playwright, BeautifulSoup
- Email: SendGrid
- Hosting: Docker Compose, nginx
@@ -70,39 +63,56 @@ User (web) ───► React UI ──► nginx ──► FastAPI ────
## Family Profile
- **Household**: 2 adults, 2 children
- **Dietary**: One adult + one child like mushrooms; other adult + one child do NOT
- **Goals**: Calorie, budget, and health conscious; tasty but not expensive
- **No allergies**
### Household
- 2 adults, 2 children
- 3 of 4 members do NOT like mushrooms
- No allergies
- Goals: Calorie, budget, and health conscious; tasty but not expensive
### Approval Workflow
### Family Members
| Member | Role | Mushroom Preference |
|--------|------|-------------------|
| Adult 1 | Adult | Does NOT like mushrooms |
| Adult 2 | Adult | Likes mushrooms |
| Child 1 | Child | Does NOT like mushrooms |
| Child 2 | Child | OK with mushrooms |
### Approval Workflow (REDESIGNED)
1. System generates 7-day meal plan (Sunday)
2. Email sent to both adults with meals, images, Approve/Deny links
3. One denial → meal swapped with alternative
4. No denials (or silence) → meal auto-approved
5. After all approvals → shopping list generated
2. Email sent to all adults with meals, images, approval page links
3. Email link → confirmation page (GET, not auto-approve)
4. Adult clicks Approve/Deny → POST with reason
5. Per-member token (single-use, 72h TTL)
6. Majority approve → meal confirmed; any deny → swap
7. After approval → shopping list generated
---
## Database Schema Highlights
### Core Tables
- `family_profile` - Household configuration
- `recipe` - All recipes with ingredients (JSONB), instructions, image URLs
- `ingredient` - Master ingredient list with aisle, price, season
- `meal_plan` - Weekly plan (7 days)
- `meal_plan_item` - Individual meal with approval_token, status
- `family_profile` - Household configuration with household_size CHECK
- `family_member` - Individual family members with email, role, mushroom preference
- `recipe` - Recipes with JSONB ingredients (not join table)
- `ingredient` - Master list with name_lower (CITEXT for case-insensitive matching)
- `meal_plan` - Weekly plan with ISO day_of_week (1=Mon, 7=Sun)
- `meal_plan_item` - Individual meal with approval status
- `meal_plan_vote` - Per-member votes (one vote per member per meal)
- `approval_token` - Single-use tokens with TTL and status tracking
- `home_pantry` - Family's on-hand ingredients
- `feedback` - Ratings, denial reasons, never-suggest flags
- `grocery_item` - Scraped Lucky California items with sale prices
- `grocery_item` - Scraped Lucky California items with FK to ingredient
- `scrape_log` / `email_log` - Operation history
### Key Relationships
- `family_profile` 1:N `family_member`
- `family_profile` 1:N `meal_plan`
- `family_profile` 1:N `home_pantry`
- `recipe` N:N `ingredient` (via `recipe_ingredient` junction table)
- `family_member` 1:N `meal_plan_vote` (per-voter tracking)
- `recipe` 1:N `meal_plan_item`
- `meal_plan` 1:N `meal_plan_item`
- `meal_plan_item` 1:1 `feedback`
- `meal_plan_item` 1:N `meal_plan_vote`
- `meal_plan_item` 1:N `approval_token`
- `grocery_item``ingredient` (FK)
---
@@ -111,7 +121,7 @@ User (web) ───► React UI ──► nginx ──► FastAPI ────
| Phase | Description | Status |
|-------|-------------|--------|
| 1 | Infrastructure (Docker, PostgreSQL, FastAPI, React, nginx) | **Complete** |
| 2 | Database & Models (SQLAlchemy models, Alembic migrations) | Not Started |
| 2 | Database & Models (SQLAlchemy models, Alembic migrations) | **Post-review fixes applied** |
| 3 | API Endpoints (CRUD, meal plans, shopping list, feedback) | Not Started |
| 4 | Lucky California Scraper (weekly ad, Playwright) | Not Started |
| 5 | Recipe Engine (CRUD, tagging, search) | Not Started |
@@ -125,7 +135,7 @@ User (web) ───► React UI ──► nginx ──► FastAPI ────
---
## Environment Variables Required
## Environment Variables
```bash
# Database
@@ -140,39 +150,51 @@ FAMILY_EMAIL_1=user@example.com
FAMILY_EMAIL_2=spouse@example.com
# Scraping
LUCKY_CA_URL=https://www.luckyncal.com
LUCKY_CA_URL=https://luckysupermarkets.com
# AI Images (optional)
AI_IMAGE_ENABLED=false
# Auth
SECRET_KEY=change-me-in-production
```
---
## Next Steps
### IMMEDIATE: Phase 2 - Database & Models
### IMMEDIATE: Verify skeleton with verification matrix
1. Set up Alembic for migrations
2. Create actual database tables from SQLAlchemy models
3. Add seed data (basic ingredients, sample recipes)
4. Create Pydantic schemas for API validation
5. Implement real API endpoints (not just placeholders)
```bash
docker compose config
docker compose build backend
docker compose build frontend
docker compose up -d db
docker compose run --rm backend python -c "from app.main import app; print(app.title)"
docker compose run --rm backend alembic upgrade head
docker compose run --rm frontend npm run build
```
### After Phase 2 Complete
If any of these fail, fix before proceeding.
- Verify migrations: `docker-compose exec backend alembic upgrade head`
- Test database connectivity: `curl localhost:8000/health/db`
- Test API endpoints with real data
### After Verification
### Current Git State
1. Phase 2: Implement real API endpoints (not placeholders)
2. Phase 3: Connect database models to endpoints
3. Phase 4: Spike Lucky California scrape (before committing to full schema)
---
## Current Git State
```bash
git log --oneline
624b516 docs: update ORIENTATION.md for Phase 1 complete
1328ec3 feat: add Phase 1 infrastructure skeleton
0c5b0aa docs: add complete project documentation
```
Phase 1 skeleton complete and committed. Phase 2 (Database) is next.
**Pending commit**: All adversarial review fixes (models, schema, docker-compose, docs updates)
---
@@ -190,12 +212,14 @@ Phase 1 skeleton complete and committed. Phase 2 (Database) is next.
### API Design
- RESTful endpoints with proper HTTP methods and status codes
- Pydantic schemas for request/response validation
- JWT-free for MVP (simple token-based auth for email approval links)
- Email approval links use GET → confirmation page → POST
- Per-voter tokens, not shared household tokens
### Database
- Always use UUIDs for primary keys
- Timestamps with timezone (TIMESTAMPTZ)
- Soft deletes preferred over hard deletes where applicable
- Use ENUMs for status fields (not loose VARCHAR)
- Use CITEXT or lowercase-on-write for name matching
### Testing
- Write unit tests for services (pytest)
@@ -204,37 +228,12 @@ Phase 1 skeleton complete and committed. Phase 2 (Database) is next.
---
## Common Tasks
### Run full stack
```bash
docker-compose up -d
```
### Create database migration
```bash
docker-compose exec backend alembic revision --autogenerate -m "description"
```
### Trigger scrape manually
```bash
curl -X POST http://localhost:8000/api/admin/scrape \
-H "Content-Type: application/json" \
-d '{"source": "lucky_california", "type": "weekly_ad"}'
```
### View all logs
```bash
docker-compose logs -f
```
---
## Known Issues / Open Questions
1. **Lucky California scraping**: May need adjustment if website structure changes. Has fallback to manual input.
1. **Lucky California scraping**: Feasibility not yet spiked. URL is `luckysupermarkets.com`.
2. **AI image generation**: Config flag to enable/disable. Disabled by default.
3. **WhatsApp integration**: Planned for future via Twilio. Out of scope for MVP.
4. **Planned scheduler**: APScheduler with `--workers 1` to avoid duplicate fires.
---
@@ -244,4 +243,4 @@ docker-compose logs -f
- **Wife**: Non-technical, will use web UI and email
- **Children**: 2, eating habits vary (one OK with mushrooms)
Last updated: 2026-05-04 (Phase 1 complete, Phase 2 next)
Last updated: 2026-05-04 (post adversarial review fixes)
+2 -2
View File
@@ -36,7 +36,7 @@ FAMILY_EMAIL_1=you@example.com
FAMILY_EMAIL_2=spouse@example.com
# Lucky California (for scraping)
LUCKY_CA_URL=https://www.luckyncal.com
LUCKY_CA_URL=https://luckysupermarkets.com
# AI Images (optional)
AI_IMAGE_ENABLED=false
@@ -217,7 +217,7 @@ docker-compose exec backend python -c "from app.database import SessionLocal; pr
```bash
# Check Lucky California is accessible
curl -I https://www.luckyncal.com
curl -I https://luckysupermarkets.com
# Verify Playwright browser installed
docker-compose exec backend python -c "from playwright.sync_api import sync_playwright; print('OK')"
+75 -21
View File
@@ -31,7 +31,7 @@ Current meal kit services (Blue Apron → EveryPlate → HungryRoot → Sunbaske
### Primary Goals
1. **Weekly Meal Planning**: Automatically generate a 7-day meal plan each week
2. **Grocery Integration**: Scrape Lucky California weekly ads and product catalog for sales/in-season items
3. **Family Approval Workflow**: Send email to both adults with meal proposal, image, and details; one denial swaps the meal
3. **Family Approval Workflow**: Send email to adults with meal proposal, image, and details; one denial swaps the meal
4. **Shopping List Generation**: Create weekly shopping list grouped by Lucky California aisles, highlighting sales
5. **Pantry Integration**: Allow users to specify items they have at home to incorporate into meal suggestions
6. **Web UI**: Modern interface for non-technical family members to interact with meals, feedback, and recipes
@@ -81,15 +81,21 @@ Current meal kit services (Blue Apron → EveryPlate → HungryRoot → Sunbaske
- Calorie, budget, and health conscious eating
- Food should be tasty but not overly expensive
### Family Members
| Member | Role | Mushroom Preference |
|--------|------|-------------------|
| Adult 1 | Adult | Does NOT like mushrooms |
| Adult 2 | Adult | Likes mushrooms |
| Child 1 | Child | Does NOT like mushrooms |
| Child 2 | Child | OK with mushrooms |
### Dietary Constraints
- One adult likes mushrooms
- One child is OK with mushrooms
- Two adults and one child do NOT like mushrooms
- 3 of 4 family members do NOT like mushrooms
- No allergies
### Preference Signals
- "Never suggest this ingredient" flags
- Per-meal ratings (1-5 stars)
- "Never suggest this ingredient" flags (per family, not per member)
- Per-meal ratings (1-5 stars, per member)
- Denial reasons (too expensive, looks boring, contains disliked ingredient, etc.)
- Home pantry items to incorporate
@@ -105,31 +111,53 @@ Current meal kit services (Blue Apron → EveryPlate → HungryRoot → Sunbaske
- Variety requirements (avoid sauce/ingredient repetition)
- Home pantry items to use
2. Email sent to both adults containing:
2. Email sent to all adult family members containing:
- All 7 meals listed with images
- Each meal has: Approve / Deny buttons (via email links or web UI)
- Denial requires a reason selection or free-text
- Each meal has: "View & Vote" link to web approval page
- Email does NOT auto-approve on link click
3. Approval handling:
- If both approve OR no response → meal confirmed
- If either denies → meal swapped with alternative suggestion
- Denied meals logged for learning
3. Approval page flow (Web UI):
- Adult clicks email link → lands on confirmation page
- Page shows meal details, image, ingredients, estimated cost
- Adult clicks "Approve" or "Deny"
- Denial requires selecting a reason
- Vote is recorded per-member (not per-household)
4. After approval deadline:
4. Approval handling:
- If majority of adults approve → meal confirmed
- If any adult denies → meal swapped with alternative suggestion
- Denial reason is recorded for learning
- Explicit deadline: 48 hours from email send
- After deadline: meals with insufficient responses auto-expire and are excluded
5. After approval period:
- Final meal plan locked
- Shopping list generated
- Recipes made available in web UI
### Approval Token Security
- Each email contains a unique, single-use token per family member
- Tokens expire after 72 hours
- Tokens can only be used once (marked USED after voting)
- Email links lead to a confirmation page; actual vote is a POST
---
## 8. Technical Constraints
### Self-Hosting Requirements
- Must run on local infrastructure (homelab, NUC, Synology, etc.)
- Remote access via reverse proxy (Caddy or nginx)
- No external cloud services except SendGrid for email
- Remote access via reverse proxy with VPN or TLS
- Primary access: local network only (VPN required for remote)
### Authentication & Authorization
- **Admin endpoints** (`/api/admin/*`): VPN-only access
- **Family web UI**: Session-based authentication (simple username/password)
- **Email approval links**: Token-based, single-use, time-limited
- No JWT; no OAuth
### Lucky California Integration
- **URL**: https://luckysupermarkets.com (verified)
- Primary: Scrape weekly ad and product catalog
- Store scraped data locally
- Respect robots.txt and rate limiting
@@ -139,15 +167,41 @@ Current meal kit services (Blue Apron → EveryPlate → HungryRoot → Sunbaske
- SendGrid for transactional email
- HTML email templates with meal images
- Plain text fallback for email clients that block images
- Accessible: includes alt text for images, works with screen readers
### External Dependencies
| Service | Purpose | Required |
|---------|---------|----------|
| SendGrid | Transactional email | Yes |
| luckysupermarkets.com | Grocery scraping | Yes |
| Recipe websites | Recipe images | Yes |
| AI Image API (optional) | Fallback image generation | No |
---
## 9. Data Retention
## 9. Accessibility (WCAG 2.1 AA)
### Web UI
- All interactive elements keyboard accessible
- Color contrast ratio ≥ 4.5:1 for normal text
- Form inputs have visible labels
- Error messages are descriptive and associated with inputs
- Skip navigation links provided
### Email
- HTML emails include meaningful alt text for all images
- Plain text version provided as fallback
- Links are descriptive (not "click here")
- Font sizes are readable (minimum 14px equivalent)
---
## 10. Data Retention
### Stored Data
- All recipes (scraped and manually added)
- Meal plans (weekly history)
- Approval/denial history with reasons
- Per-member votes and denial history
- Feedback (ratings, flags, pantry items)
- Scraped grocery data (weekly refresh)
@@ -158,17 +212,17 @@ Current meal kit services (Blue Apron → EveryPlate → HungryRoot → Sunbaske
---
## 10. Success Metrics
## 11. Success Metrics
1. **Adoption**: Family consistently uses the system weekly
2. **Meal variety**: No more than 2 meals/week share the same sauce or primary protein
3. **Cost efficiency**: Average cost per serving within 150% of equivalent grocery-store meal
4. **Approval rate**: >80% of proposed meals approved without changes
4. **Explicit approval rate**: >80% of proposed meals receive explicit approval (not silence)
5. **Learning**: After 4 weeks, system should not propose previously denied meals
---
## 11. Future Considerations
## 12. Future Considerations
- Twilio WhatsApp integration for wife who prefers messaging
- Direct Lucky California online ordering
+158 -61
View File
@@ -4,13 +4,29 @@
PostgreSQL 15+ with the following extensions:
- `uuid-ossp` for UUID generation
- `pg_trgm` for fuzzy text search (if needed)
- `CITEXT` for case-insensitive text (ingredient names)
---
## 2. Tables
## 2. Enums
### 2.1 `family_profile`
```sql
CREATE TYPE family_member_role_enum AS ENUM ('adult', 'child');
CREATE TYPE meal_type_enum AS ENUM ('breakfast', 'lunch', 'dinner');
CREATE TYPE meal_plan_status_enum AS ENUM ('draft', 'pending_approval', 'approved', 'locked');
CREATE TYPE meal_plan_item_status_enum AS ENUM ('pending', 'approved', 'denied', 'swapped');
CREATE TYPE approval_token_status_enum AS ENUM ('active', 'used', 'expired');
CREATE TYPE denial_reason_enum AS ENUM ('too_expensive', 'boring', 'disliked_ingredient', 'cultural', 'other');
CREATE TYPE never_suggest_reason_enum AS ENUM ('allergy', 'dislike', 'tried_too_much', 'other');
CREATE TYPE scrape_status_enum AS ENUM ('started', 'success', 'failed');
CREATE TYPE email_status_enum AS ENUM ('sent', 'delivered', 'failed', 'bounced');
```
---
## 3. Tables
### 3.1 `family_profile`
Primary household configuration.
@@ -23,18 +39,39 @@ Primary household configuration.
| 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 |
| calorie_target | INTEGER | | Daily calorie target per adult (aspirational) |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | |
| updated_at | TIMESTAMPTZ | DEFAULT NOW() | |
### 2.2 `ingredient`
**Constraints**:
- `CHECK (adult_count + child_count = household_size)`
Master list of all ingredients.
### 3.2 `family_member`
Individual family members for per-person voting and preferences.
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PK | |
| name | VARCHAR(200) | NOT NULL, UNIQUE | Ingredient name |
| family_profile_id | UUID | FK → family_profile(id) ON DELETE CASCADE | |
| name | VARCHAR(100) | NOT NULL | Member name |
| email | VARCHAR(300) | UNIQUE per family | Email for notifications |
| role | family_member_role_enum | NOT NULL | 'adult' or 'child' |
| likes_mushrooms | BOOLEAN | DEFAULT FALSE | Dietary preference |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | |
| updated_at | TIMESTAMPTZ | DEFAULT NOW() | |
**Unique constraint**: `(family_profile_id, email)`
### 3.3 `ingredient`
Master list of all ingredients. `name_lower` is used for case-insensitive matching.
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PK | |
| name | VARCHAR(200) | NOT NULL | Display name (e.g., "Carrots") |
| name_lower | VARCHAR(200) | NOT NULL, UNIQUE | Lowercase for matching (e.g., "carrots") |
| plural_name | VARCHAR(200) | | For shopping list grouping |
| aisle | VARCHAR(100) | | Lucky California aisle |
| typical_price | NUMERIC(10,2) | | Price per unit |
@@ -42,9 +79,9 @@ Master list of all ingredients.
| season_months | INTEGER[] | | Array of month numbers 1-12 |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | |
### 2.3 `recipe`
### 3.4 `recipe`
All recipes in the system.
All recipes in the system. Ingredients stored as JSONB for flexibility.
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
@@ -56,14 +93,13 @@ All recipes in the system.
| 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 |
| servings | INTEGER | NOT NULL | Default servings |
| servings_scaled | INTEGER | | Current scaled servings |
| 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}] |
| ingredients | JSONB | NOT NULL | [{ingredient_id, name, 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 |
@@ -71,7 +107,9 @@ All recipes in the system.
| created_at | TIMESTAMPTZ | DEFAULT NOW() | |
| updated_at | TIMESTAMPTZ | DEFAULT NOW() | |
### 2.4 `meal_plan`
**Note**: `total_time_minutes` is computed as `prep_time_minutes + cook_time_minutes` (not stored).
### 3.5 `meal_plan`
A generated weekly meal plan.
@@ -79,8 +117,8 @@ A generated weekly meal plan.
|--------|------|-------------|-------------|
| 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' |
| week_start_date | DATE | NOT NULL | Monday of the week (ISO: 1=Mon, 7=Sun) |
| status | meal_plan_status_enum | NOT NULL, DEFAULT 'draft' | |
| approval_deadline | TIMESTAMPTZ | | When approval period ends |
| total_estimated_cost | NUMERIC(10,2) | | Sum of all meal costs |
| notes | TEXT | | Admin notes |
@@ -89,21 +127,19 @@ A generated weekly meal plan.
**Unique constraint**: `(family_profile_id, week_start_date)`
### 2.5 `meal_plan_item`
### 3.6 `meal_plan_item`
Individual meal within a plan.
Individual meal within a plan. `day_of_week` uses ISO-8601 convention (1=Monday through 7=Sunday).
| 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' |
| day_of_week | INTEGER | NOT NULL, CHECK (day_of_week BETWEEN 1 AND 7) | 1=Mon, 7=Sun (ISO-8601) |
| meal_type | meal_type_enum | NOT NULL | 'breakfast', 'lunch', 'dinner' |
| approval_status | meal_plan_item_status_enum | DEFAULT 'pending' | |
| denial_reason | denial_reason_enum | | If denied |
| denial_details | TEXT | | Free-text explanation |
| estimated_cost | NUMERIC(10,2) | | Per serving cost |
| used_pantry_items | UUID[] | | Home pantry items used |
@@ -112,7 +148,38 @@ Individual meal within a plan.
**Unique constraint**: `(meal_plan_id, day_of_week, meal_type)`
### 2.6 `home_pantry`
### 3.7 `meal_plan_vote`
Per-member votes on meal plan items. This is the key to the redesigned approval flow.
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PK | |
| meal_plan_item_id | UUID | FK → meal_plan_item(id) ON DELETE CASCADE | |
| family_member_id | UUID | FK → family_member(id) ON DELETE CASCADE | |
| vote | BOOLEAN | NOT NULL | TRUE=approve, FALSE=deny |
| voted_at | TIMESTAMPTZ | DEFAULT NOW() | |
**Unique constraint**: `(meal_plan_item_id, family_member_id)` — one vote per member per meal
### 3.8 `approval_token`
Single-use tokens for email approval links.
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PK | |
| meal_plan_item_id | UUID | FK → meal_plan_item(id) ON DELETE CASCADE | |
| family_member_id | UUID | FK → family_member(id) ON DELETE CASCADE | |
| token | VARCHAR(64) | NOT NULL, UNIQUE | Secure random token |
| status | approval_token_status_enum | DEFAULT 'active' | |
| expires_at | TIMESTAMPTZ | NOT NULL | Token expiration (72h from send) |
| used_at | TIMESTAMPTZ | | When token was used |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | |
**Unique constraint**: `(meal_plan_item_id, family_member_id)`
### 3.9 `home_pantry`
Items the family has at home.
@@ -120,7 +187,7 @@ Items the family has at home.
|--------|------|-------------|-------------|
| id | UUID | PK | |
| family_profile_id | UUID | FK → family_profile(id) ON DELETE CASCADE | |
| ingredient_id | UUID | FK → ingredient(id) | |
| ingredient_id | UUID | FK → ingredient(id) ON DELETE SET NULL | |
| quantity | NUMERIC(10,2) | | How much on hand |
| unit | VARCHAR(50) | | e.g., "cans", "lb" |
| expires_at | DATE | | Perishable expiry date |
@@ -129,24 +196,25 @@ Items the family has at home.
**Unique constraint**: `(family_profile_id, ingredient_id)`
### 2.7 `feedback`
### 3.10 `feedback`
Meal feedback and ratings.
Meal feedback and ratings. One feedback row per meal, can be associated with a specific member.
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PK | |
| family_profile_id | UUID | FK → family_profile(id) | |
| family_member_id | UUID | FK → family_member(id) ON DELETE SET NULL | |
| 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 |
| denial_reason | denial_reason_enum | | 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
**Note**: Multiple feedback rows can exist for the same meal (one per family member).
### 2.8 `never_suggest`
### 3.11 `never_suggest`
Global "never suggest" flags per family.
@@ -154,19 +222,20 @@ Global "never suggest" flags per family.
|--------|------|-------------|-------------|
| 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' |
| ingredient_id | UUID | FK → ingredient(id) ON DELETE CASCADE | Optionally block specific ingredients |
| recipe_id | UUID | FK → recipe(id) ON DELETE CASCADE | Or block entire recipes |
| reason | never_suggest_reason_enum | | |
| notes | TEXT | | |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | |
### 2.9 `grocery_item`
### 3.12 `grocery_item`
Scraped items from Lucky California.
Scraped items from Lucky California. Links to `ingredient` table.
| Column | Type | Constraints | Description |
|--------|------|-------------|-------------|
| id | UUID | PK | |
| ingredient_id | UUID | FK → ingredient(id) ON DELETE SET NULL | Links to master ingredient list |
| name | VARCHAR(300) | NOT NULL | Product name |
| brand | VARCHAR(200) | | Brand name |
| current_price | NUMERIC(10,2) | | Current sale price |
@@ -182,9 +251,7 @@ Scraped items from Lucky California.
| 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`
### 3.13 `scrape_log`
Scraping operation history.
@@ -193,14 +260,14 @@ Scraping operation history.
| 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' |
| status | scrape_status_enum | NOT NULL | |
| items_scraped | INTEGER | DEFAULT 0 | |
| error_message | TEXT | | |
| started_at | TIMESTAMPTZ | DEFAULT NOW() | |
| completed_at | TIMESTAMPTZ | | |
| duration_seconds | INTEGER | | |
### 2.11 `email_log`
### 3.14 `email_log`
Email sending history.
@@ -213,71 +280,101 @@ Email sending history.
| 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' |
| status | email_status_enum | NOT NULL | |
| error_message | TEXT | | |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | |
| delivered_at | TIMESTAMPTZ | | |
---
## 3. Indexes
## 4. Indexes
### 3.1 Primary Indexes
### 4.1 Primary Indexes
All PKs have default B-tree indexes.
### 3.2 Foreign Key Indexes
### 4.2 Foreign Key Indexes
```sql
CREATE INDEX idx_family_member_profile ON family_member(family_profile_id);
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_meal_plan_vote_item ON meal_plan_vote(meal_plan_item_id);
CREATE INDEX idx_meal_plan_vote_member ON meal_plan_vote(family_member_id);
CREATE INDEX idx_approval_token_item ON approval_token(meal_plan_item_id);
CREATE INDEX idx_approval_token_member ON approval_token(family_member_id);
CREATE INDEX idx_home_pantry_family_profile ON home_pantry(family_profile_id);
CREATE INDEX idx_home_pantry_ingredient ON home_pantry(ingredient_id);
CREATE INDEX idx_feedback_family_profile ON feedback(family_profile_id);
CREATE INDEX idx_feedback_member ON feedback(family_member_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;
CREATE INDEX idx_grocery_item_ingredient ON grocery_item(ingredient_id);
CREATE INDEX idx_grocery_item_is_on_sale ON grocery_item(is_on_sale) WHERE is_on_sale = TRUE;
```
### 3.3 Full-Text Search Indexes
### 4.3 Full-Text Search Indexes
```sql
CREATE INDEX idx_ingredient_name_fts ON ingredient USING gin(to_tsvector('english', name));
CREATE INDEX idx_ingredient_name_lower_fts ON ingredient USING gin(to_tsvector('english', name_lower));
CREATE INDEX idx_recipe_name_fts ON recipe USING gin(to_tsvector('english', name));
```
---
## 4. Data Migration Strategy
## 5. Day of Week Convention
### 4.1 Initial Schema
Use SQLAlchemy or Alembic for schema management.
**ISO-8601 standard** (1=Monday through 7=Sunday):
- 1 = Monday
- 2 = Tuesday
- 3 = Wednesday
- 4 = Thursday
- 5 = Friday
- 6 = Saturday
- 7 = Sunday
### 4.2 Future Migrations
- Alembic for version-controlled migrations
- All migrations must be reversible
This applies to `meal_plan_item.day_of_week`.
### 4.3 Seed Data
- Basic ingredient list pre-populated
- Sample recipes for initial testing (5-10 meals)
- Default family profile template
**Note**: PostgreSQL's `EXTRACT(DOW FROM date)` returns 0=Sunday, 6=Saturday. Convert accordingly.
---
## 5. Data Retention
## 6. Data Migration Strategy
### 6.1 Initial Schema
Use Alembic for version-controlled migrations.
### 6.2 Migration Policy
- All schema changes must go through Alembic migrations
- Migrations must be reversible where possible
- No `Base.metadata.create_all()` in application startup code
### 6.3 Seed Data
- Enum types created first
- Basic ingredients pre-populated (50-100 common items)
- Sample recipes for initial testing (5-10 meals)
- Default family profile with 2 adult members
---
## 7. 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 |
| Used approval tokens | 7 days | Delete after vote processed |
| Expired approval tokens | 30 days | Delete |
---
## 6. Row-Level Security (Future)
## 8. Row-Level Security (Future)
If multi-family support is added:
- RLS on all tables
- Policies based on `family_profile_id`
- Backend enforces tenant isolation
- Current implementation: single-family, no RLS needed