Public Access
153 lines
7.6 KiB
Markdown
153 lines
7.6 KiB
Markdown
# Meal Planner — Orientation
|
||
|
||
First stop for any agent resuming work. Read this, then `docs/HANDOFF.md` for the deep dive.
|
||
|
||
---
|
||
|
||
## What this project is
|
||
|
||
Self-hosted meal planning for one family of 4. Pulls weekly grocery prices from Lucky California (San Pablo, store 757) via the Swiftly JSON API, generates a 7-day meal plan, emails per-member approval links, builds a shopping list grouped by aisle. Replaces meal-kit subscriptions (Blue Apron / Sunbasket / etc.) which marked up ingredients ~3× and produced repetitive meals.
|
||
|
||
Constraint that drives the design: 3 of 4 members do not like mushrooms; family is calorie/budget conscious; no allergies. Must work for non-technical wife + 2 kids; technical owner self-hosts.
|
||
|
||
---
|
||
|
||
## Architecture (current)
|
||
|
||
```
|
||
email ─► SendGrid (R3-C, not yet wired) ──┐
|
||
web ─► nginx :80/:443 ─► React/Vite ────┼─► FastAPI ──► PostgreSQL 15
|
||
│ │
|
||
│ └─► Swiftly JSON API
|
||
│ (prod.swiftlyapi.net)
|
||
│
|
||
└─► /api/admin/scrape (BackgroundTasks)
|
||
```
|
||
|
||
- **backend** (FastAPI 0.109, SQLAlchemy 2.0, Alembic) — internal only, `expose: 8000`
|
||
- **frontend** (React 18 + TS + Vite + Tailwind) — internal only via nginx
|
||
- **db** (Postgres 15-alpine) — internal only
|
||
- **nginx** — sole external entry, ports 80/443
|
||
|
||
---
|
||
|
||
## Phase status (2026-05-06)
|
||
|
||
| # | Phase | Status |
|
||
|---|---|---|
|
||
| 1 | Infra (Docker, FastAPI, React, nginx, Postgres) | **Complete** |
|
||
| 2 | DB & models (Alembic, Pydantic schemas, API endpoints) | **Complete** (real, verified) |
|
||
| 3 | Lucky California ingestion (Swiftly JSON API) | **Complete** — 17 categories, ~10k products live |
|
||
| 4 | Recipe engine (CRUD, search, tagging, never-suggest filter) | **Thin slice complete** — recipe + ingredient CRUD, ingredient↔grocery match layer (rapidfuzz, manual override), NeverSuggest CRUD, 30-recipe seed. Ingestion source decision deferred (see spec). |
|
||
| 5 | Meal planner orchestration (generate → email → vote → finalize) | Not started |
|
||
| 6 | SendGrid email integration (proposal/reminder/confirmation) | Stub only — `app/services/email.py::SendGridEmailBackend` raises NotImplementedError |
|
||
| 7 | Web UI core (Dashboard / Meal Detail / Pantry / Shopping List) | **Complete** (no auth UI yet) |
|
||
| 8 | Web UI feedback portal | Not started |
|
||
| 9 | **Meal-planner generation algorithm** | Not started — *the core of the project* |
|
||
| 10 | Image strategy (scraped + AI fallback) | Not started |
|
||
| 11 | Polish (variety analysis, budget tracking, APScheduler) | Not started |
|
||
|
||
Verification gate (R1+R2 + R3-0): 31/31 pytest green; alembic upgrade→downgrade→upgrade clean; frontend `npm run build` clean; live scrape persists 9,960 grocery_item rows in 36 s; email approval round-trip (approve/deny/single-use) verified end-to-end.
|
||
|
||
---
|
||
|
||
## Auth model (R1-B+D, locked in)
|
||
|
||
- Bearer token `ADMIN_TOKEN` for every `/api/admin/*` route.
|
||
- Signed-cookie session (itsdangerous, key=`SECRET_KEY`) for all NON-GET routes on profile/pantry/recipes/meals/shopping-list. GET reads stay open inside the trusted network.
|
||
- `/api/auth/login` accepts `{password}` matching `SESSION_PASSWORD`. Sets the cookie.
|
||
- `/api/meals/vote/{item_id}?token=…` keeps its per-voter token flow; not session-gated.
|
||
- **Bootstrap hatch**: when no `family_profile` row exists, login signs the literal string `"bootstrap"` instead of a UUID. First-run convenience only — replace with a real setup gate before any non-trusted exposure.
|
||
|
||
---
|
||
|
||
## Database schema highlights
|
||
|
||
Core tables: `family_profile`, `family_member`, `recipe`, `ingredient`, `meal_plan`, `meal_plan_item`, `meal_plan_vote`, `approval_token`, `home_pantry`, `feedback`, `grocery_item`, `scrape_log`, `email_log`.
|
||
|
||
Conventions: UUID PKs everywhere, `TIMESTAMPTZ`, Postgres ENUMs (with `values_callable=lambda obj: [e.value for e in obj]` on every SQLEnum — name-mode silently breaks otherwise), ISO day-of-week (1=Mon).
|
||
|
||
Key relationships: `family_profile`→`family_member`; `family_member`→`meal_plan_vote` (per-voter); `recipe.ingredients` JSONB (no recipe-ingredient join table); `grocery_item.(source, external_id)` is the upsert key for scrape ingestion.
|
||
|
||
Migrations applied: 0001 initial, 0002 seed (idempotent via `ON CONFLICT DO NOTHING`), 0003 grocery_item.description, 0004 family_profile.calorie_target, 0005 grocery_item.external_id + source + composite index.
|
||
|
||
Full schema: `docs/database-schema.md`.
|
||
|
||
---
|
||
|
||
## Environment variables (verified)
|
||
|
||
```bash
|
||
# Database
|
||
POSTGRES_PASSWORD=...
|
||
DATABASE_URL=postgresql://mealplanner:${POSTGRES_PASSWORD}@db:5432/mealplanner
|
||
|
||
# Auth
|
||
SECRET_KEY=... # signs session cookies + approval tokens
|
||
ADMIN_TOKEN=... # bearer for /api/admin/*
|
||
SESSION_PASSWORD=... # family-shared password for /api/auth/login
|
||
|
||
# Email
|
||
EMAIL_BACKEND=console # 'console' (default) or 'sendgrid'
|
||
SENDGRID_API_KEY=... # only when EMAIL_BACKEND=sendgrid (R3-C)
|
||
|
||
# Lucky / Swiftly
|
||
LUCKY_STORE_ID=757 # Lucky California — San Pablo
|
||
SWIFTLY_API_BASE=https://prod.swiftlyapi.net
|
||
SWIFTLY_CATEGORIES_URL=https://luckysupermarkets.com/categories
|
||
SWIFTLY_BEARER_TOKEN=... # Firebase anon JWT, expires hourly
|
||
|
||
# Other
|
||
LUCKY_CA_URL=https://luckysupermarkets.com
|
||
AI_IMAGE_ENABLED=false
|
||
LOG_LEVEL=INFO
|
||
```
|
||
|
||
`.env.example` carries a literal expiring bearer token — rotate before any real run. On expiry, the next scrape's `ScrapeLog.error_message` reads `SWIFTLY_BEARER_TOKEN expired — request a fresh token from the user (capture from luckysupermarkets.com network tab on a /search/api/v1 request)`. Fix: capture a fresh `Authorization: Bearer …` from devtools, update env, restart backend, re-trigger.
|
||
|
||
---
|
||
|
||
## Verification commands
|
||
|
||
```bash
|
||
# Full local stack
|
||
docker compose --env-file .env.test up -d db backend
|
||
docker compose --env-file .env.test exec backend alembic upgrade head
|
||
docker compose --env-file .env.test exec -e TEST_DATABASE_URL=postgresql://mealplanner:${POSTGRES_PASSWORD}@db:5432/mealplanner backend pytest -q tests/
|
||
|
||
# Frontend
|
||
cd frontend && npm ci && npm run build
|
||
|
||
# CI: .github/workflows/ci.yml runs both jobs on push/PR.
|
||
```
|
||
|
||
A `.env.test` template lives in the repo root (gitignored) for local stack runs. Pytest uses `TEST_DATABASE_URL`; alembic uses `DATABASE_URL`.
|
||
|
||
---
|
||
|
||
## Conventions
|
||
|
||
- Python: Black + isort. TypeScript: Prettier + ESLint.
|
||
- Conventional commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`).
|
||
- API paths: no trailing slash, no `/list`/`/planned` suffixes.
|
||
- Tests: pytest; `requires_postgres` marker auto-skips locally without `TEST_DATABASE_URL`.
|
||
- Migrations: Alembic only. Never `Base.metadata.create_all()` at runtime.
|
||
- Background work: FastAPI `BackgroundTasks` (current). APScheduler with `--workers 1` planned for Phase 11.
|
||
|
||
---
|
||
|
||
## Where to look
|
||
|
||
- `docs/HANDOFF.md` — comprehensive handoff for fresh agents (start here for non-trivial work).
|
||
- `docs/SPEC.md` — product spec.
|
||
- `docs/ARCHITECTURE.md` — system design.
|
||
- `docs/database-schema.md` — full DDL reference.
|
||
- `docs/implementation-plan.md` — original phased plan.
|
||
- `docs/RUNNING.md` — local dev workflow.
|
||
- `.agent/plan.md`, `.agent/context.md`, `.agent/phase-summaries/` — recovery decisions and per-phase summaries from the R1+R2+R3-0 work.
|
||
- `Review/reviewconcensus.md` — the adversarial review that drove the recovery.
|
||
|
||
---
|
||
|
||
Last updated: 2026-05-06 — after thin Phase 4 (ingredient + recipe CRUD, match layer, 50 ingredient + 30 recipe seed). 59/59 pytest green.
|