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
+1 -1
View File
@@ -11,7 +11,7 @@ FAMILY_EMAIL_2=spouse@example.com
RECIPES_EMAIL=you@example.com
# Lucky California
LUCKY_CA_URL=https://www.luckyncal.com
LUCKY_CA_URL=https://luckysupermarkets.com
# AI Image Generation (optional)
AI_IMAGE_ENABLED=false
+3
View File
@@ -143,3 +143,6 @@ Thumbs.db
*.sql.bak
backups/
nginx/ssl/*.pem
# Node
node_modules/
+214
View File
@@ -0,0 +1,214 @@
# Adversarial Documentation Review — Claude
Scope: `docs/SPEC.md`, `docs/ARCHITECTURE.md`, `docs/database-schema.md`, `docs/implementation-plan.md`, `docs/ORIENTATION.md`, `docs/RUNNING.md`, `README.md`, `meal-planner-plan.md`, `.env.example`, `docker-compose.yml`. Reviewed independently of the GPT-5.5 review.
Tone: adversarial. Findings ranked Blocking / Major / Minor. Cite file:line where useful.
---
## Verdict
The docs read as a coherent first draft, but the spec, architecture, and schema do not agree with each other on three load-bearing surfaces: **how recipes link to ingredients, how approvals identify a voter, and what the authentication model is** (there isn't one). The mushroom/family-profile section is mathematically self-contradictory. Several "self-hosted" claims are undermined by hard external dependencies (SendGrid, AI image APIs, scraping third-party sites). Phase 1 was committed before these contradictions were resolved, so the migration written next will encode whichever side of the contradiction the implementer happens to read first.
Recommendation: **do not start Phase 2 (DB & Models) until SPEC §6, ARCHITECTURE §2.2/§3, and `database-schema.md` §2.3 are reconciled** — the migration is the wrong place to make these decisions.
---
## Blocking
### B1. Family profile is internally inconsistent — the math doesn't close
`docs/SPEC.md:79-88` states household = 2 adults + 2 children. Then:
- "One adult likes mushrooms" → 1 adult
- "One child is OK with mushrooms" → 1 child
- "Two adults and one child do NOT like mushrooms" → 2 adults + 1 child
Adult count: 1 + 2 = **3 adults claimed in a 2-adult household**. Either the spec is wrong about the household composition, the preference list is wrong, or this is meant to illustrate a structure (per-person preferences) that the schema does not actually model — see B2.
This is the single concrete worked example in the entire spec. If it doesn't typecheck, neither does anything downstream that consumes it (planner constraints, denial reasons, "never suggest" semantics).
### B2. There is no `family_member` (or `user`) table — but the spec is per-person
SPEC §6 talks about individual preferences ("one adult likes…"). SPEC §7 sends the proposal to "both adults" and treats their votes asymmetrically ("if either denies → swap"). Yet `database-schema.md` has only `family_profile` (a household row) and `feedback` keyed by `meal_plan_item_id` with `UNIQUE(meal_plan_item_id)` (`database-schema.md:147`).
Consequences:
- Cannot record *which* family member likes/dislikes mushrooms.
- Cannot record *which* adult denied a meal — both share one `approval_token` per `meal_plan_item` (`database-schema.md:104`). Whoever clicks first wins; the other can silently overwrite, and the system can't tell them apart.
- The `UNIQUE` on feedback means a household gets exactly one rating per meal — a regression from per-member learning that the spec implies.
- "Send to both adult email addresses" (`implementation-plan.md:234`) has no schema support — addresses live only in env vars (`FAMILY_EMAIL_1`, `FAMILY_EMAIL_2`), which means you cannot extend to in-laws/guests/kid-with-phone without a redeploy.
Fix the model before writing the migration. Either add `family_member(id, family_profile_id, name, role, email)` and key tokens/feedback off it, or explicitly downgrade the spec to "one shared mailbox, one shared opinion."
### B3. Recipe↔ingredient relationship is documented two contradictory ways
- `ARCHITECTURE.md:201, 241` — N:N via `recipe_ingredient` join table, drawn in the ASCII diagram.
- `database-schema.md:66``recipe.ingredients JSONB NOT NULL`, no join table defined.
These are not equivalent. JSONB precludes the FK indexes shown later (`idx_…` in `database-schema.md` §3.2) for ingredient-driven planner queries ("find recipes that use this on-sale item"), which is the **core meal-planner query**. The planner inputs at `ARCHITECTURE.md:94-108` cannot be served efficiently by a JSONB column without a GIN index that no one has specified, and even then ingredient-level operations (substitution, pantry subtraction, sale lookup) become awkward.
Pick one. If JSONB is genuinely the choice, the schema doc must justify it and add the GIN index; if the join table is the choice, the JSONB column must come out of the recipe row. Until then, `Recipe` SQLAlchemy model is being asked to be both shapes simultaneously.
### B4. There is no authentication anywhere in the system
Search across all docs for `auth`, `login`, `password`, `session`, `user` returns: nothing actionable. Yet:
- The web UI has a "Pantry Manager", "Approval Center", and "Admin/Settings" page (`ARCHITECTURE.md:159-165`).
- "Remote access via reverse proxy" is a stated requirement (`SPEC.md:128-129`, `ARCHITECTURE.md:337`).
- `docker-compose.yml` exposes port 80/443 publicly.
- `POST /api/admin/scrape` and `GET /api/admin/logs` (`ARCHITECTURE.md:267-268`) are unauthenticated.
"Family of 4 inside the house" is not a security boundary once `RUNNING.md` describes Let's Encrypt + a public domain. Anyone on the internet who guesses the URL can trigger scrapes, dump logs (potentially containing emails and SendGrid IDs from `email_log`), edit the pantry, or change the family profile. The `SECRET_KEY=change-me-to-a-random-secret-key` placeholder in `.env.example` is referenced by no doc — what does it sign? If JWTs, where? If session cookies, against what user table?
The "VPN option" and "IP whitelist capability" mentioned at `ARCHITECTURE.md:339-340` are not a substitute for auth on a system that explicitly accepts approval/denial state mutations from email links.
### B5. Approval-by-email is fragile and probably exploitable
`ARCHITECTURE.md:271-273`:
```
GET /api/approve/{token} → Mark meal approved
GET /api/deny/{token} → Show denial reason form
```
Two distinct problems:
1. **GET mutates state.** Many corporate/AV email scanners (Microsoft Defender SafeLinks, Proofpoint URL Defense, GMail link prefetch) and some clients pre-fetch every link in an email to scan it. Every meal will be silently auto-approved before the human sees the message. This is a known, common, well-documented failure mode for "click the link to approve" patterns. Fix: the email link must lead to a confirmation page that POSTs.
2. **Token model gives no per-voter accountability.** See B2. Combined with B4 (no login on the confirmation page), once a token leaks (forwarded email, screenshot in a chat, archived in a mailbox) anyone can vote. `approval_token_expires` is nullable (`database-schema.md:105`), so absent application logic the token is permanent.
Also: `denial_reason VARCHAR(50)` with the values listed in `database-schema.md:106` should be a CHECK or enum; otherwise denial-reason analytics in the learning step will be polluted by typos.
### B6. "Self-hosted, no external cloud services *except SendGrid*" is undermined
`SPEC.md:130-131` makes a clean claim, then immediately exempts SendGrid, then `ARCHITECTURE.md` adds:
- "AI Image Service (DALL-E, Anthropic, etc.)" (`ARCHITECTURE.md:351-356`)
- "Centralized logging optional (Papertrail, Datadog)" (`ARCHITECTURE.md:397`)
- "Caddy or Nginx with Let's Encrypt" (an external CA) (`ARCHITECTURE.md:338`)
- Recipe site scraping (third-party, see B7)
- Hotlinked image URLs from third-party recipe sites (`ARCHITECTURE.md:347-350`)
If the user genuinely cares about self-hosting (the README leads with it), at minimum SPEC §8 needs to enumerate every external dependency, not just SendGrid. If they don't, drop the "self-hosted, no external services" framing — it's marketing copy at this point.
---
## Major
### M1. Scraping legality / ToS unaddressed
`SPEC.md:132-136` and `ARCHITECTURE.md §2.1` describe scraping Lucky California's weekly ad, product catalog, *and* "public recipe sites". "Respect robots.txt and rate limiting" is the entire policy. There is no:
- Review of Lucky California's ToS (most US grocery retailers prohibit scraping).
- Plan for Cloudflare/bot-protection bypass (Playwright is mentioned, suggesting the author already knows static fetch will be blocked — `RUNNING.md:225` even has a manual Playwright retry incantation).
- Recipe-site copyright analysis (image hotlinking + caching scraped images is copying protected content — `ARCHITECTURE.md:347` calls it out as "stored as URLs, not downloaded" but `ARCHITECTURE.md:354` says generated images are "cached in database", and `recipe.image_source = 'scraped'` (`database-schema.md:56`) implies persistence).
A homelab project for one family is unlikely to attract a takedown, but a doc that says "this is the architecture" should at least name the risk.
Also worth verifying: `LUCKY_CA_URL=https://www.luckyncal.com` (`.env.example:14`, `RUNNING.md:39`). The actual chain's site is `luckysupermarkets.com`. `luckyncal.com` *might* be the franchise's California-specific site, but no doc cites the source. If the URL is wrong, every Phase 4 task is built on sand.
### M2. Phase ordering buries the hard problems behind weeks of plumbing
`implementation-plan.md` puts the actually risky work — scraping (Phase 4), planner algorithm (Phase 6), email/approval (Phase 7) — *after* full backend skeleton, full schema, full API surface, and most of the React UI. Each of those phases is the one most likely to fail or force schema changes; deferring them maximizes rework cost.
A more honest plan would build a minimal "can I scrape one ad page" and "can I email + receive an approval click" spike before committing to a 12-table schema and 6 React pages. Otherwise you'll do Phase 2 twice.
### M3. APScheduler + multiple workers will misfire
`implementation-plan.md:154` picks APScheduler. `docker-compose.yml` and the Dockerfile docs don't mention worker count, but `uvicorn --workers N` is the default FastAPI deployment. APScheduler in-process will fire the weekly scrape *N times* if N>1. No doc mentions a leader-election scheme, a separate `scheduler` service, or `--workers 1`. Either pin to one worker (and document it) or move scheduling to a dedicated container / cron.
Same hazard: per-worker state for retry counters, image caches, etc.
### M4. Schema has avoidable defects
- `meal_plan_item.day_of_week` 0=Monday (`database-schema.md:101`). Postgres `EXTRACT(DOW)` is 0=Sunday; ISO-8601 is 1=Monday. Pick ISO and document it; "0=Monday" is the worst of three options because it silently disagrees with both common conventions.
- `recipe.total_time_minutes` is documented as "Computed: prep + cook" (`database-schema.md:59`) but stored as a plain INTEGER. Make it a Postgres `GENERATED ALWAYS AS … STORED` column or remove it. As written, it will drift the first time someone backfills.
- `family_profile.household_size` with separate `adult_count`/`child_count` and no CHECK that they sum (`database-schema.md:21-23`). Trivial CHECK; add it.
- `feedback.meal_plan_item_id UNIQUE` (`database-schema.md:147`) — see B2.
- `home_pantry.unit` and `ingredient.unit` (`database-schema.md:41, 125`) are duplicate truth. The pantry-vs-recipe unit-mismatch problem (cans vs oz vs cups) is real but not addressed — `implementation-plan.md:184` waves at "handle unit conversions" with no design.
- `grocery_item` has no FK to `ingredient` (`database-schema.md §2.9`). The "map products to existing ingredients or create new ones" step (`implementation-plan.md:147`) is the entire bridge between scraping and planning, and the schema gives it no place to live. A `grocery_item.ingredient_id` (nullable until matched) is the obvious missing column.
- `ingredient.name UNIQUE` (`database-schema.md:37`) is case-sensitive. "Carrots" ≠ "carrots" ≠ "Carrot". Either lower-case-on-write or use `CITEXT`.
- `denial_reason`, `image_source`, `meal_plan.status`, `feedback.denial_reason`, `email_log.status`, `scrape_log.status` — all are `VARCHAR(N)` with values enumerated in prose. Use Postgres ENUMs or CHECK constraints; otherwise "approved " (with a trailing space) is a valid status.
### M5. Migration story is muddled
`database-schema.md:251-257`: "Use SQLAlchemy or Alembic for schema management." These aren't alternatives — Alembic *is* SQLAlchemy's migration tool. Pick the wording. Also: `implementation-plan.md` Phase 1.1 commits to Alembic + initial migration *before* Phase 2 defines the SQLAlchemy models. Either Phase 1 ships an empty migration and Phase 2 generates the real one, or the order is wrong. As written, the doc says do them simultaneously.
If the Phase 1 commit (`1328ec3`) already created models or a migration, that drift is already in the repo and the docs need to catch up.
### M6. "If both approve OR no response → meal confirmed" is silence-as-consent
`SPEC.md:114`. Combined with B5, this means the default state of an unread email is **approved**. For a system whose pitch is "stop forcing me to log into apps", that's coherent — but the spec should *say* that explicitly, and the success metric "Approval rate >80% of proposed meals approved without changes" (`SPEC.md:166`) is meaningless if silence counts as approval. You'll hit 100% the first week the email lands in spam.
### M7. "Calorie target" appears in the schema but is a non-goal
`database-schema.md:26` defines `calorie_target INTEGER`. `SPEC.md:55` lists "Nutrition tracking beyond high-level calorie awareness" as a non-goal. Either the column has no consumer (dead schema), or §4 is wrong. Same tension between `database-schema.md:65` (`spice_level`) and the spec's silence on heat preferences.
### M8. Image strategy contradicts itself
`SPEC.md:54`: "AI image generation as primary source (scraped images first)" is listed as a *non-goal*, suggesting AI-as-primary is rejected. `ARCHITECTURE.md §6.2` and `implementation-plan.md §11.2` then build AI image generation as a fallback. "Non-goal: X as primary" and "build X as fallback" are not in conflict, but the wording in SPEC §4 reads as if AI generation is out entirely. Tighten the language so an implementer doesn't shave the AI-fallback task on a literal reading.
Also: the email image strategy says "Embed images via CDN URL or inline base64" (`ARCHITECTURE.md:359`). Inline base64 inflates emails by ~33% and is filtered by GMail above 102KB; "or" hides a real decision.
---
## Minor
### m1. Doc hygiene
- `implementation-plan.md:431` references `docs/implemenation-plan.md` (typo: missing `t`). Self-referential broken link.
- `README.md` "Quick Start" section is empty per the indexer (just a header) — the file references `docs/RUNNING.md` for setup but offers no `docker compose up` one-liner up front.
- `meal-planner-plan.md` is described as "Original planning file (may be superseded)" in `ORIENTATION.md`. Either delete it or freeze it with a banner; leaving "may be superseded" in a Key Files table guarantees someone reads it as canonical.
- ORIENTATION says "Phase 1 complete" but `implementation-plan.md` checkboxes are all unticked. The plan is the canonical task list per the user's CLAUDE.md memory rules; update it.
### m2. Operational gaps not in any doc
- No backup/restore procedure (postgres volume, recipe images if ever downloaded, scraped catalog).
- No CI plan. `pytest` is in `requirements.txt`-equivalent but no test phase, no test layout, no CI yaml. Phase verification commands are all `curl localhost:…` — fine for a smoke test, useless as regression coverage.
- No accessibility commitment. The user's global CLAUDE.md mandates WCAG 2.1 AA for UI work; the React UI plan never mentions it.
- No log redaction. `email_log` stores recipient emails; `scrape_log` may capture HTML; both are retained 30-90 days (`database-schema.md §5`). PII handling not addressed.
- No rate-limiting design beyond "Rate limiting on approval endpoints" (`ARCHITECTURE.md:336`). What limit? Per IP? Per token? Per family?
### m3. Docker-compose exposure
`docker-compose.yml` (per the indexer) publishes ports for backend (8000), frontend (3000), *and* nginx (80/443). On a remote-accessible host, 8000 and 3000 should be `expose:` (intra-compose only), not `ports:` (host-published). Otherwise nginx routing is bypassable by hitting :8000 directly, and the rate limits on /api/* don't apply. Worth a one-line fix in the next iteration but doc-impacting because `RUNNING.md` and `ARCHITECTURE.md` both describe nginx as "the" entrypoint.
### m4. Success metrics aren't measurable
`SPEC.md §10`:
- "Family consistently uses the system weekly" — undefined ("consistent" = ?).
- ">80% approved without changes" — broken by silence-as-consent (M6).
- "After 4 weeks, system should not propose previously denied meals" — that's a correctness invariant, not a metric. Should hold week 1.
- "Within 150% of equivalent grocery-store meal" — equivalent how? No baseline source.
Either drop the metrics section or make them queryable from the schema.
### m5. Future features are mixed into the implementation plan
`implementation-plan.md §12.3` (WhatsApp) and `§12.4` (recipe scraping) are explicitly out-of-scope per `SPEC.md §4`/`§11`. Putting them in the implementation plan creates pressure to do them. Move to a separate "future" doc or remove the checkboxes.
### m6. `SECRET_KEY` ghost variable
`.env.example` defines `SECRET_KEY=change-me-to-a-random-secret-key`. No doc says what consumes it. If it's for FastAPI session/JWT signing, the auth model (B4) needs to exist first. If it's vestigial, drop it — placeholder secrets in `.env.example` get copied into real `.env` files unchanged more often than anyone admits.
---
## Cross-doc contradictions, summarized
| Topic | Doc A says… | Doc B says… |
|---|---|---|
| Recipe ingredients | join table `recipe_ingredient` (ARCHITECTURE.md:201, 241) | `recipe.ingredients JSONB` column (database-schema.md:66) |
| Calorie tracking | non-goal (SPEC.md:55) | `calorie_target` column (database-schema.md:26) |
| Self-hosting | "no external services except SendGrid" (SPEC.md:130) | DALL-E, Let's Encrypt, recipe scraping, hotlinked images, optional Datadog (ARCHITECTURE.md §6, §8.2) |
| Reverse proxy | nginx (ARCHITECTURE.md §5.2) | "Caddy or Nginx" (ARCHITECTURE.md:338, implementation-plan.md:64) |
| AI image generation | non-goal as primary (SPEC.md:54) | full Phase 11.2 fallback (implementation-plan.md §11.2) |
| Meal types | "dinner only for MVP" (implementation-plan.md:198) | breakfast/lunch/dinner enum (database-schema.md:102) |
| Migration tool | "SQLAlchemy or Alembic" (database-schema.md:251) | Alembic, Phase 1.1 (implementation-plan.md) |
| Phase 1 status | complete (ORIENTATION.md) | all unchecked (implementation-plan.md) |
| Voter identity | sent to "both adults", denial swaps (SPEC.md §7) | one shared `approval_token` per meal_plan_item, no member table (database-schema.md §2.5) |
---
## Suggested order of operations before any more code
1. **Resolve B1 + B2.** Decide whether the system models the household as one entity or as members. This is a 1-line decision that propagates everywhere.
2. **Resolve B3.** Pick join-table or JSONB. Then update both docs.
3. **Resolve B4.** Decide auth model: VPN-only (drop the public-internet framing), basic-auth at nginx, or app-level sessions. Pick one and put it in SPEC §8.
4. **Resolve B5.** Switch email approval to a confirmation-page POST; document token TTL and per-voter scoping (which depends on B2).
5. **Tighten SPEC §6 and §10** so the worked example typechecks and the metrics are queryable.
6. *Then* regenerate the migration and proceed to Phase 2.
Phase 2 written today against the current docs will produce a schema that contradicts itself in three places and will be revised within two phases. The cheapest review is the one done before the migration ships.
+153
View File
@@ -0,0 +1,153 @@
# Adversarial Documentation Review
## Verdict
Do not move directly into implementation from these docs without resolving the blockers below. The documentation is directionally useful, but it currently mixes aspirational design, stale implementation status, contradictory API/schema definitions, and missing security requirements. If implementation starts as-is, likely outcomes are rework in Phase 2, broken startup/API assumptions, and unauthenticated admin or approval workflows that become hard to retrofit.
## Blocking Findings
### 1. Phase/status docs contradict current implementation state
`docs/ORIENTATION.md:21-29` says Phase 1 is complete and SQLAlchemy models are created, while `docs/implementation-plan.md:9-19` still lists Phase 1 setup as unchecked. `docs/implementation-plan.md:70-97` says Phase 2 models/migrations are not done, but `backend/app/models/__init__.py` already contains model classes. This creates an immediate execution hazard: implementers cannot tell whether Phase 2 is greenfield, repair work, or migration alignment.
Required before implementation:
- Rewrite phase status into one canonical source of truth.
- Separate “implemented skeleton exists” from “verified and production-ready”.
- Add explicit acceptance criteria for Phase 2 based on current code, not original plan checkboxes.
### 2. Database schema is internally inconsistent and conflicts with current models
`docs/database-schema.md:45-73` defines `recipe.ingredients` as JSONB, but `docs/ARCHITECTURE.md:238-244` and `docs/ORIENTATION.md:100-105` describe a `recipe_ingredient` junction relationship. Current code includes both `Recipe.ingredients` JSONB and a `RecipeIngredient` table, while `RecipeIngredient.recipe = relationship("Recipe", back_populates="recipe_ingredients")` has no matching `Recipe.recipe_ingredients` relationship in the model. This is not just documentation drift; it points at an implementation design conflict that will break ORM mapping or duplicate ingredient truth.
Required before implementation:
- Choose either normalized `recipe_ingredient` as canonical or JSONB as canonical.
- Document why the chosen model supports unit conversion, shopping-list aggregation, pantry subtraction, and ingredient matching.
- Update schema docs, architecture diagrams, and model expectations to match one design.
### 3. API endpoint definitions conflict across docs
`docs/implementation-plan.md:110-115` specifies `POST /api/meals/{id}/approve`, `GET /api/deny/{token}`, and `POST /api/deny/{token}`. `docs/ARCHITECTURE.md:252-273` specifies `POST /api/meals/{id}/deny`, `GET /api/approve/{token}`, and token deny routes. `docs/RUNNING.md:140-151` references admin routes that are not protected in the docs. These differences matter because frontend routing, email templates, and backend routers will depend on exact paths.
Required before implementation:
- Publish one OpenAPI-aligned endpoint table.
- Distinguish authenticated web actions from unauthenticated email-token actions.
- Define request/response schemas, status codes, redirects, token expiry errors, and idempotency for approve/deny flows.
### 4. Security model is missing for externally reachable workflows
The docs state remote access via nginx/Caddy (`docs/SPEC.md:127-130`) and tokenized email approval links (`docs/ARCHITECTURE.md:270-273`), but `docs/ORIENTATION.md:190-194` says “JWT-free for MVP” without defining replacement controls. Admin endpoints like `POST /api/admin/scrape` and log views are documented (`docs/implementation-plan.md:124-127`, `docs/RUNNING.md:140-151`) but no authentication, authorization, CSRF protection, rate limits, or network restrictions are specified.
Required before implementation:
- Define MVP auth explicitly: session, basic auth behind reverse proxy, signed email tokens, or VPN-only deployment.
- Require strong random token generation, hashing-at-rest or equivalent protection, expiry, single-use/rotation behavior, and audit logging for email approvals.
- Protect admin endpoints before exposing remote access.
- Define CORS, trusted hosts, proxy headers, secure cookies if sessions are used, and secret management for `SECRET_KEY`.
### 5. Migration strategy conflicts with runtime table creation
Docs repeatedly require Alembic migrations (`docs/RUNNING.md:104-115`, `docs/implementation-plan.md:91-97`), but current backend startup calls `Base.metadata.create_all(bind=engine)` in `backend/app/main.py:17`. Starting implementation from the docs without addressing this will create unmanaged tables, then make Alembic autogeneration noisy or unsafe.
Required before implementation:
- Document whether Phase 2 must remove runtime `create_all`.
- Define initial migration ownership and whether existing dev DBs can be dropped.
- Add a verification command that proves migrations, not app startup, create schema.
### 6. Current code appears incompatible with documented model layout
`docs/implementation-plan.md:72-85` suggests one model per table and `backend/app/models/__init__.py` with imports. Current `backend/app/main.py:4` imports `from app.models import family_profile, ingredient, recipe, meal_plan, home_pantry, feedback, grocery_item, scrape_log, email_log`, but actual `backend/app/models/__init__.py` defines all classes in one file and no such submodules exist. The docs do not warn implementers that the current skeleton may not start.
Required before implementation:
- Verify and document current startup status.
- Decide whether model files will be split or imports fixed.
- Make docs describe the actual current state, including known broken skeleton areas.
## High-Risk Gaps
### 7. Scraping feasibility is under-specified
The docs say scrape Lucky California weekly ads and product catalog while respecting robots.txt (`docs/SPEC.md:132-136`), but there is no feasibility spike, selector strategy, anti-bot handling policy, cache policy, legal/compliance note, or fallback data-entry schema. `docs/implementation-plan.md:163` only verifies “10+ sale items”, which does not prove stable mapping to ingredients, price units, sale windows, or aisles.
Recommendation:
- Add a Phase 4 spike before building dependent meal-planning logic.
- Define scraped product normalization rules: package size, price-per-unit, sale terms, loyalty-card requirements, store location, unavailable/out-of-stock states.
- Define manual fallback UX and data model before scraper failure handling is implemented.
### 8. Meal-planning algorithm lacks deterministic requirements
`docs/ARCHITECTURE.md:91-109` and `docs/implementation-plan.md:189-215` describe constraints at a high level, but do not define scoring weights, hard vs soft constraints, conflict resolution, reproducibility, or failure behavior when no valid 7-day plan exists. “No mushrooms for 3/4 members” is listed, but not modeled as per-person preferences or household-level exclusion.
Recommendation:
- Define hard constraints, soft preferences, scoring weights, tie-breakers, and fallback behavior.
- Add example input/output fixtures for at least one valid plan and one no-solution case.
- Decide whether disliked ingredients exclude whole-family meals or permit optional substitutions.
### 9. Shopping-list math is not implementable from current docs
Shopping-list generation requires unit conversion, ingredient normalization, recipe scaling, pantry subtraction, and store item matching. Docs mention these features (`docs/implementation-plan.md:180-184`, `docs/implementation-plan.md:305-327`) but do not define canonical units, conversion tables, rounding rules, partial pantry quantities, or substitutions.
Recommendation:
- Add unit normalization requirements before implementing recipes and pantry.
- Define output rules for ambiguous items like “1 bunch cilantro”, “to taste salt”, and recipe-specific prepared ingredients.
- Add tests using real recipe-like data, not only CRUD checks.
### 10. Deployment docs promise production features that compose file does not provide
`docs/RUNNING.md:47-57` documents SSL certs, while `docker-compose.yml` mounts only `./nginx/nginx.conf` and no SSL directory. `docs/RUNNING.md:75-80` references `docker-compose.prod.yml`, which does not exist. `docs/ARCHITECTURE.md:329-336` says nginx handles SSL and rate limiting, but current `nginx/nginx.conf` only proxies `/api/` and serves SPA fallback over port 80.
Recommendation:
- Mark production deployment as not implemented or add missing deployment docs later.
- Remove commands referencing nonexistent compose files until they exist.
- Define dev vs prod networking, TLS termination, and rate-limit boundaries explicitly.
### 11. Environment-variable docs are inconsistent and include unsafe examples
`docs/ORIENTATION.md:128-147`, `docs/RUNNING.md:27-45`, `docs/implementation-plan.md:516-533`, and `.env.example` use overlapping but not identical environment variables. Examples include API-key-like prefixes (`SG...`, `sk-...`) and `SECRET_KEY=change-me-to-a-random-secret-key`, but docs do not say which vars are required by backend startup, frontend build, or optional integrations.
Recommendation:
- Add a canonical env var table with required/optional/default/scope.
- Avoid realistic secret prefixes in docs where possible.
- Add startup validation expectations and safe local-dev defaults.
## Medium-Risk Issues
### 12. Documentation contains stale and misspelled file paths
`docs/implementation-plan.md:417-480` places `RUNNING.md` at project root, but actual docs list it under `docs/RUNNING.md`. The same tree misspells `implemenation-plan.md` at `docs/implementation-plan.md:431`. This will cause agent/tool confusion during implementation.
### 13. Tech-stack docs are already version stale
Docs repeatedly say React 18 (`README.md:73`, `docs/ORIENTATION.md:63`, `docs/ARCHITECTURE.md:168`), while current frontend dependencies use React `^18.2.0`. That is not wrong today, but the docs lack a dependency-update policy and lockfile expectations. More important: `docs/implementation-plan.md:43` suggests Create React App or Vite, but current project uses Vite, so CRA should be removed to avoid divergent setup.
### 14. Operational docs assume endpoints that are not proven to exist
`docs/RUNNING.md:280-288` documents `/health/db`; current implementation has this route, but its SQLAlchemy call uses a raw string execute in `backend/app/main.py:28`, which may fail under SQLAlchemy 2 without `text("SELECT 1")`. Docs should avoid declaring health checks verified until command output is captured.
### 15. Data retention lacks implementation mechanics
`docs/database-schema.md:265-274` defines retention windows, but no job owner, schedule, archival storage, or privacy behavior is described. “Archive to JSON, delete rows” for meal plans can break feedback history if FK behavior is not designed.
### 16. Accessibility is barely addressed despite family-facing UI
The docs require a non-technical family web UI and email workflow, but do not specify WCAG targets, keyboard behavior, color contrast, readable print views, or email-client fallbacks beyond plain text. Approval/denial links in email need accessible labels and safe confirmation UX.
### 17. Observability requirements are aspirational
`docs/ARCHITECTURE.md:386-404` lists log categories and future monitoring, but no structured log schema, correlation IDs, error taxonomy, or alert thresholds. Scraper/email failures are core product risks, so this should be in MVP docs, not future polish.
## Suggested Gate Before Implementation
Resolve these docs before writing Phase 2 code:
- One canonical project status page that reflects actual current code and broken areas.
- One canonical schema with either JSONB recipe ingredients or normalized `recipe_ingredient`, not both as competing truths.
- One canonical API contract, preferably generated or mirrored from OpenAPI.
- MVP security section covering admin routes, approval tokens, remote access, secrets, CORS, rate limiting, and audit logs.
- Migration policy that removes or explicitly forbids runtime `create_all` outside throwaway local dev.
- Deployment doc split into current local-dev support vs future production support.
- Verification matrix with exact commands and expected output per phase.
## Implementation Readiness Rating
Current documentation readiness: 5/10.
The docs are good enough to understand product intent, but not good enough to drive implementation safely. Main risk is not missing detail; main risk is contradictory detail that will cause agents or engineers to implement incompatible schema/API/security assumptions.
+261
View File
@@ -0,0 +1,261 @@
# Repository Analysis
## Executive Summary
The repository is an early skeleton, not an implementation-ready Phase 1 baseline. Product intent is well documented, but current code cannot be treated as a verified foundation: backend imports are inconsistent with file layout, frontend imports missing source files, migration infrastructure is absent despite Alembic being documented, and all API endpoints are placeholders.
Readiness rating: 4/10.
Recommended next move: stabilize the skeleton before implementing feature phases. Do not start meal planning, scraping, email, or shopping-list logic until app startup, build, migrations, and API contract are corrected.
## Current Repo Shape
Implemented or present:
- `docker-compose.yml` with `backend`, `frontend`, `db`, and `nginx` services.
- FastAPI app skeleton under `backend/app`.
- SQLAlchemy model classes colocated in `backend/app/models/__init__.py`.
- Placeholder API routers for profile, recipes, meals, shopping list, pantry, and admin.
- Vite/React/Tailwind frontend skeleton.
- Nginx configs under both `nginx/nginx.conf` and `frontend/nginx.conf`.
- Extensive docs under `docs/` plus `README.md`.
Missing or not present:
- `frontend/index.html`.
- `frontend/package-lock.json`, despite Dockerfile using `npm ci`.
- `frontend/src/pages/Pantry.tsx`, despite being imported by `App.tsx`.
- `backend/alembic.ini` and `backend/alembic/`.
- Backend `schemas/`, `services/`, and `scraper/` directories.
- Tests.
- Authn/authz layer.
- OpenAPI contract beyond FastAPI's generated placeholder routes.
## Blocking Findings
### 1. Backend likely fails during import because model module layout is inconsistent
`backend/app/main.py:4` imports `family_profile`, `ingredient`, `recipe`, `meal_plan`, `home_pantry`, `feedback`, `grocery_item`, `scrape_log`, and `email_log` from `app.models`. Those submodules do not exist. All model classes are currently defined in `backend/app/models/__init__.py`.
Impact: backend startup is likely broken before FastAPI can serve `/health`.
Fix direction:
- Either split models into the imported module files, or remove those submodule imports and import the package/classes consistently.
- Add a backend startup smoke test before continuing any feature work.
### 2. SQLAlchemy relationships contain at least one broken back-populates reference
`backend/app/models/__init__.py:247` sets `RecipeIngredient.recipe = relationship("Recipe", back_populates="recipe_ingredients")`, but `Recipe` does not define `recipe_ingredients`. This is likely to fail mapper configuration once SQLAlchemy evaluates relationships.
Impact: even if import paths are fixed, ORM use can fail at runtime.
Fix direction:
- Add `Recipe.recipe_ingredients` or remove/replace the relationship.
- Decide whether recipe ingredients are normalized via `recipe_ingredient`, stored in `recipe.ingredients` JSONB, or intentionally duplicated with clear ownership.
### 3. Frontend build is blocked by a missing imported page
`frontend/src/App.tsx:5` imports `./pages/Pantry`, and `frontend/src/App.tsx:32` routes `/pantry` to it. No `frontend/src/pages/Pantry.tsx` file exists.
Impact: TypeScript/Vite build should fail immediately.
Fix direction:
- Add a minimal `Pantry.tsx` placeholder, or remove the route/import until the page exists.
### 4. Frontend Docker build is blocked by missing npm lockfile
`frontend/Dockerfile:5-6` copies `package*.json` then runs `npm ci`. There is no `frontend/package-lock.json`.
Impact: `npm ci` fails without a lockfile, so the frontend container cannot build reproducibly.
Fix direction:
- Generate and commit `package-lock.json`, or change Dockerfile to use `npm install` for the skeleton phase. Prefer lockfile.
### 5. Vite app is missing required HTML entrypoint
No `frontend/index.html` exists. Vite expects an HTML entrypoint at project root unless customized.
Impact: local dev/build is blocked even after fixing `Pantry.tsx`.
Fix direction:
- Add standard Vite `index.html` pointing to `/src/main.tsx`.
### 6. Alembic is documented and installed but not configured
`backend/requirements.txt:4` includes Alembic and docs repeatedly call `alembic upgrade head`, but no `backend/alembic.ini` or `backend/alembic/` exists. Meanwhile `backend/app/main.py:17` calls `Base.metadata.create_all(bind=engine)` at import/startup.
Impact: schema ownership is confused. Runtime table creation can mask missing migrations and produce unmanaged dev DB state.
Fix direction:
- Create Alembic config and first migration.
- Remove `create_all` from normal app startup once migrations exist.
- Add a documented migration verification path.
## Important Findings
### 7. API routes are placeholders with no schemas or persistence
All API functions currently return static `{"message": ...}` payloads. Examples: `backend/app/api/profile.py:8-15`, `backend/app/api/recipes.py:8-20`, `backend/app/api/meals.py:8-20`, and `backend/app/api/admin.py:8-20`.
Impact: the route shape exists, but no request models, response models, validation, DB reads/writes, or errors exist. Treat these as route stubs, not usable endpoints.
Fix direction:
- Add Pydantic schemas before endpoint implementation.
- Implement one resource end-to-end first, probably profile or ingredients, to establish patterns.
### 8. Admin endpoints are unauthenticated
`backend/app/api/admin.py` exposes scrape trigger, logs, and test email routes without auth. Docs anticipate remote access via reverse proxy.
Impact: once implemented, these routes could trigger external scraping, send email, and expose operational logs.
Fix direction:
- Define auth before implementing admin behavior.
- At minimum, gate admin routes behind a dependency and environment-configured secret/session/proxy auth.
### 9. Email approval security is not implemented and not modeled yet
Models include `approval_token` and `approval_token_expires` in `MealPlanItem`, but no token route, validation, hashing, single-use behavior, or audit trail exists. Current routes approve/deny by meal ID, not token.
Impact: approval workflow cannot safely be exposed via email links.
Fix direction:
- Decide token route contract before frontend/email work.
- Use high-entropy signed or stored tokens with expiry and replay handling.
- Keep meal-ID approval routes authenticated if they remain.
### 10. `SECRET_KEY` has an unsafe default
`backend/app/config.py:13` sets `SECRET_KEY: str = "dev-secret-key"`. `.env.example` says to change it, but code does not enforce change outside dev.
Impact: future signed tokens/sessions could be deployed with a known secret.
Fix direction:
- Make `SECRET_KEY` required for non-development environments.
- Document environment mode and startup validation.
### 11. SQLAlchemy 2 health query may fail
`backend/app/main.py:28` calls `db.execute("SELECT 1")`. With SQLAlchemy 2, textual SQL should use `text("SELECT 1")`.
Impact: `/health/db` can report failure even when DB is reachable.
Fix direction:
- Use `from sqlalchemy import text` and `db.execute(text("SELECT 1"))`.
### 12. Docker Compose exposes backend and frontend directly plus nginx
`docker-compose.yml` maps backend `8000:8000`, frontend `3000:80`, and nginx `80:80`/`443:443`. This is convenient for dev but contradicts a production reverse-proxy boundary if deployed unchanged.
Impact: backend bypasses nginx controls such as future rate limiting/auth headers unless ports are removed or bound to localhost.
Fix direction:
- Split dev and prod compose files.
- In prod, expose only reverse proxy and keep backend/frontend on internal network.
### 13. Nginx SSL and rate limiting are not implemented
`nginx/nginx.conf` and `frontend/nginx.conf` only provide SPA fallback and `/api/` proxying over HTTP. No TLS cert mounts, rate limits, security headers, request size limits, or proxy timeout policy exist.
Impact: deployment docs overstate production readiness.
Fix direction:
- Keep current nginx config labeled as local/dev only.
- Add production config later with TLS, headers, and route-specific limits.
## Maintainability Findings
### 14. Model file is too large for early iteration and hides domain decisions
`backend/app/models/__init__.py` contains all models and several unresolved design choices. This makes migrations, ownership, and imports harder to reason about.
Recommendation:
- Split per aggregate/table after schema ownership is decided, or keep colocated temporarily but remove false submodule imports.
- Add tests that import and configure all mappers.
### 15. Schema constraints are only partially represented
Some documented constraints are in code, such as `day_of_week BETWEEN 0 AND 6` and feedback rating range. Others are missing or underspecified, such as family counts, valid status enums, meal type values, and never-suggest rules.
Recommendation:
- Prefer Python enums plus DB checks for status/meal type/reason fields.
- Add validation at Pydantic layer before DB writes.
### 16. API prefix behavior may produce trailing-slash-only endpoints
Routers are included with prefixes like `/api/profile`, and router paths use `/`. This exposes `/api/profile/` rather than `/api/profile` unless FastAPI redirects. Docs mostly specify no trailing slash.
Recommendation:
- Decide canonical trailing-slash style and update routes/docs together.
### 17. No repository-level quality gates exist
There are no tests, no backend lint/format config, no frontend lockfile, and no CI config. Docs mention pytest, Black, isort, ESLint, and Prettier, but config and scripts are incomplete.
Recommendation:
- Add minimal smoke tests first: backend import/app creation, mapper configuration, frontend build.
- Add formatting/lint configs only when used by scripts/CI.
## Documentation Alignment
The separate documentation review in `Review/docs-gpt5.5.md` covers doc-specific contradictions. Repo analysis confirms several documentation risks against current files:
- Phase status is overstated: skeleton is present but not verified.
- Alembic workflow is documented but absent.
- API route contract differs between docs and code.
- Production deployment docs exceed current compose/nginx capability.
- Model/schema docs do not resolve JSONB ingredients vs normalized ingredients.
## Recommended Remediation Order
### Phase 0: Make skeleton start and build
- Fix backend model imports.
- Fix SQLAlchemy `recipe_ingredients` relationship mismatch.
- Fix `/health/db` SQLAlchemy 2 textual SQL.
- Add `frontend/index.html`.
- Add or remove `Pantry.tsx` route.
- Add frontend lockfile or adjust Dockerfile.
- Run backend import smoke test and frontend build.
### Phase 1: Establish schema/migration baseline
- Decide ingredient representation.
- Configure Alembic.
- Generate initial migration.
- Remove runtime `create_all` from normal startup.
- Add seed-data strategy.
### Phase 2: Establish API and security baseline
- Define canonical OpenAPI contract.
- Add Pydantic schemas.
- Add auth dependency for admin and authenticated web routes.
- Define approval-token routes and behavior.
- Add CORS/trusted-host/proxy assumptions.
### Phase 3: Implement first vertical slice
- Implement profile or ingredient CRUD end-to-end.
- Add DB-backed tests.
- Wire frontend API client for one real endpoint.
- Use this slice as pattern for recipes, pantry, and meal plans.
## Verification Matrix To Add
Minimum commands expected before declaring foundation complete:
```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 backend pytest
docker compose run --rm frontend npm run build
```
These commands may not pass today; they are the gate that should define “Phase 1 complete”.
## Final Assessment
The repo is valuable as a product specification plus rough skeleton. It should not be considered a working app foundation yet. Biggest immediate risk is building new features on top of a skeleton that likely does not start or build. Stabilize imports, frontend entrypoints, Docker builds, and migrations first; then implement one small DB-backed vertical slice before expanding into scraper/email/planner complexity.
+205
View File
@@ -0,0 +1,205 @@
# Review Consensus — Feedback for Originating Agent
This document consolidates findings from three independent adversarial reviews of the MealPlanner project documentation and current repository state. Use this as the canonical input for the next planning iteration. Every item here was either agreed by multiple reviewers or supported by direct repo evidence.
Reviewers:
- **Claude** — adversarial documentation review (`Review/docs-claude.md`)
- **GPT-5.5 (docs)** — adversarial documentation review (`Review/docs-gpt5.5.md`)
- **GPT-5.5 (repo)** — repository / code review (`Review/repo-gpt5.5.md`)
---
## Headline
The product intent is clear and the documentation is a coherent first draft, but **three load-bearing design decisions are unresolved** and **the "Phase 1 complete" skeleton likely does not start or build**. Proceeding to Phase 2 (DB & Models) without resolving the items in §1 will produce a schema and migration that contradict the spec in at least three places, and will be reworked within two phases.
Combined readiness: **~4/10**.
---
## 1. Consensus Blockers — must resolve before any further code
These were flagged by all three reviewers (or by both doc reviewers and confirmed in code).
### 1.1 Recipe ↔ Ingredient relationship is modeled two contradictory ways
- `database-schema.md:66``recipe.ingredients JSONB NOT NULL`.
- `ARCHITECTURE.md:201, 241` — N:N via `recipe_ingredient` join table.
- **Code already has both**, with a broken `RecipeIngredient.recipe = relationship("Recipe", back_populates="recipe_ingredients")` where `Recipe` has no matching attribute (`backend/app/models/__init__.py:247`). Mapper config will fail at runtime.
- **Action:** pick one. JSONB precludes the FK indexes needed for the core "find recipes that use this on-sale item" query; the join table makes ingredient substitution / pantry subtraction tractable. Delete the loser from both docs *and* code.
### 1.2 No authentication exists anywhere in the system
- Spec/architecture describe public-internet access via reverse proxy + Let's Encrypt.
- Admin endpoints (`POST /api/admin/scrape`, `GET /api/admin/logs`), pantry mutation, family profile edit, and approval/deny endpoints are all unauthenticated.
- `SECRET_KEY=change-me-to-a-random-secret-key` in `.env.example` is referenced by no doc.
- **Action:** decide MVP auth model — VPN-only (drop the public-internet framing), basic-auth at proxy, or app-level sessions. Put the choice in SPEC §8 before writing any more endpoints. This decision also scopes what `SECRET_KEY` signs.
### 1.3 Email approval flow is unsafe and probably exploitable
- `GET /api/approve/{token}` mutates state. Mail scanners (SafeLinks, Proofpoint, GMail prefetch) will silently auto-approve every meal before the human sees the message. **This is a known, common failure mode.**
- One token per `meal_plan_item` shared across both adults — no per-voter accountability; whoever clicks first wins.
- `approval_token_expires` is nullable → tokens are permanent absent app logic.
- **Action:** redesign — email link leads to a confirmation page that POSTs; per-voter token; explicit TTL; single-use; record which member voted. Depends on §1.4.
### 1.4 No `family_member` table despite per-person spec
- SPEC §6/§7 describes individual preferences and asymmetric voting ("if either denies → swap").
- Schema has only `family_profile` (one row per household) and `feedback` with `UNIQUE(meal_plan_item_id)` — one rating per household per meal.
- The single worked example in SPEC §6 is **mathematically inconsistent**: 2-adult household but text references three distinct adults' mushroom preferences.
- **Action:** decide whether the system models the household as one entity or as members. Either add `family_member(id, family_profile_id, name, role, email)` and key tokens/feedback off it, or downgrade the spec to "one shared mailbox, one shared opinion." This is a 1-line decision that propagates everywhere.
### 1.5 Phase status is contradictory and the skeleton does not work
- `ORIENTATION.md` says Phase 1 complete; `implementation-plan.md` checkboxes are all unticked.
- `backend/app/main.py:4` imports `family_profile, ingredient, recipe, …` as submodules from `app.models`, but all classes live in `models/__init__.py`. **Import will fail.**
- `frontend/src/App.tsx:5` imports `./pages/Pantry`; the file does not exist. **TS/Vite build will fail.**
- `frontend/Dockerfile` runs `npm ci`; no `package-lock.json`. **Frontend container will not build.**
- No `frontend/index.html` (Vite entrypoint). **Vite will not start.**
- No `backend/alembic.ini` or `backend/alembic/` despite docs requiring `alembic upgrade head`.
- `backend/app/main.py:17` calls `Base.metadata.create_all(...)` at startup, undermining migration ownership.
- `backend/app/main.py:28` uses `db.execute("SELECT 1")` — must be `text("SELECT 1")` under SQLAlchemy 2.
- **Action:** before declaring Phase 1 complete, run a verification matrix that proves the skeleton imports, builds, and starts.
### 1.6 Schema has avoidable correctness defects
- All status/reason fields are loose `VARCHAR(N)` — use Postgres ENUMs or CHECK constraints (otherwise `"approved "` with a trailing space is valid).
- `ingredient.name UNIQUE` is case-sensitive — "Carrots" ≠ "carrots". Use `CITEXT` or lowercase-on-write.
- `grocery_item` has no FK to `ingredient`. The bridge between scraping and planning has no place to live.
- `family_profile.adult_count + child_count` not checked against `household_size`.
- `recipe.total_time_minutes` documented as computed but stored as plain INTEGER — will drift. Use `GENERATED ALWAYS AS … STORED`.
- `meal_plan_item.day_of_week` 0=Monday disagrees with both Postgres `EXTRACT(DOW)` (0=Sun) and ISO-8601 (1=Mon).
- `feedback.UNIQUE(meal_plan_item_id)` blocks per-member ratings (see §1.4).
- `calorie_target` exists in schema; nutrition is a stated non-goal. Either drop it or update the spec.
### 1.7 Docker compose exposes backend (8000) and frontend (3000) directly
- `nginx` is described as "the" entrypoint, but ports 8000/3000 are host-published, so any rate limit / auth header / TLS at nginx is bypassable.
- **Action:** intra-network only for backend/frontend in prod compose; nginx is sole entrypoint. Consider splitting dev vs prod compose files.
### 1.8 Production deployment claims exceed implementation
- `RUNNING.md` references `docker-compose.prod.yml`, SSL cert mounts, rate limiting — none exist.
- `nginx/nginx.conf` only proxies `/api/` and serves SPA fallback over HTTP.
- **Action:** label current nginx config as local/dev only; remove or clearly mark unimplemented production commands.
---
## 2. High-Risk Gaps — agreed by 2 of 3 reviewers
### 2.1 Migration tooling is muddled
"SQLAlchemy or Alembic" is wrong wording (Alembic *is* SQLAlchemy's tool). Phase 1 commits to Alembic + initial migration before Phase 2 defines the models. Code uses `create_all` at startup. **Action:** define a single migration policy; remove `create_all` from normal startup; configure Alembic in Phase 1 with an empty initial migration and let Phase 2 generate the real one.
### 2.2 API endpoint contract conflicts across docs
`implementation-plan.md` and `ARCHITECTURE.md` disagree on approve/deny route shapes (`POST /api/meals/{id}/approve` vs `GET /api/approve/{token}`, etc.). **Action:** publish one OpenAPI-aligned endpoint table; distinguish authenticated web actions from token-based email actions; specify request/response schemas, status codes, redirects, expiry errors, and idempotency.
### 2.3 Scraping is under-specified and risky
- No feasibility spike, selector strategy, anti-bot policy (Playwright is mentioned, suggesting static fetch is already known to fail).
- No Lucky California / recipe-site ToS or copyright analysis.
- `LUCKY_CA_URL=https://www.luckyncal.com` may be wrong — the chain's actual site is `luckysupermarkets.com`. **Verify before Phase 4.**
- `grocery_item` to `ingredient` mapping (the entire scrape→plan bridge) has no schema support.
### 2.4 Phase ordering hides risk behind plumbing
The risky work — scraping, planner algorithm, email/approval — is queued *after* full backend, full schema, and most of the React UI. Each is most likely to force schema changes; deferring them maximizes rework. **Action:** spike scrape-one-page and email-and-receive-approval-click *before* committing to the 12-table schema.
### 2.5 Shopping-list math is not implementable from current docs
Unit conversion, recipe scaling, pantry subtraction, "1 bunch cilantro", "to taste salt" — all hand-waved. **Action:** define canonical units, conversion tables, rounding rules, and ambiguous-quantity handling before pantry/recipe models freeze.
### 2.6 Environment variables inconsistent across `.env.example`, ORIENTATION, RUNNING, plan
**Action:** one canonical env-var table with name / required / optional / default / scope (backend startup vs frontend build vs optional integration). Add startup validation.
### 2.7 Accessibility absent despite family-facing UI + email
User's global CLAUDE.md mandates WCAG 2.1 AA for UI. **Action:** state the WCAG target in SPEC; cover web UI plus email (accessible HTML + plain-text alt).
### 2.8 Health check will fail under SQLAlchemy 2
`db.execute("SELECT 1")` → use `text("SELECT 1")`.
---
## 3. Single-Reviewer Findings worth keeping
### From Claude
- **Silence-as-consent.** "If both approve OR no response → confirmed" combined with the >80% approval-rate metric means the metric is gamed by spam-foldering. State the default explicitly; replace the metric with one that's queryable.
- **APScheduler + multi-worker uvicorn** will fire weekly scrape N times if N>1. Pin `--workers 1` and document, or move scheduling to a dedicated container.
- **Self-hosting framing is undermined** by SendGrid, AI image API, Let's Encrypt, scraped images, optional Datadog. Either enumerate every external dependency in SPEC §8, or drop the "self-hosted, no external services" framing.
- **Image strategy contradicts itself.** SPEC lists "AI image generation as primary source" as a non-goal; ARCHITECTURE/plan build it as fallback. Tighten language. Also: "Embed via CDN URL or inline base64" hides a real decision (base64 inflates ~33%, GMail filters >102KB).
- **`spice_level` column with no spec mention** — dead schema or missing spec.
- **`meal-planner-plan.md`** described as "may be superseded" in ORIENTATION. Either delete or freeze with a banner.
- **Self-referential broken link:** `implementation-plan.md:431` references `docs/implemenation-plan.md` (typo).
- **`SECRET_KEY` placeholder** gets copied into real `.env` files unchanged more often than anyone admits — make startup require it in non-dev.
### From GPT-5.5 (docs)
- **Data retention windows** lack job owner / schedule / FK-cascade design. "Archive to JSON, delete rows" for meal plans can break feedback FKs.
- **Observability is aspirational.** No log schema, correlation IDs, error taxonomy, alert thresholds. Scraper/email failures are core product risks — should be MVP, not future polish.
- **Tech stack drift:** "CRA or Vite" wording in plan; project uses Vite. Remove the CRA option to avoid divergent setup.
### From GPT-5.5 (repo)
- **Trailing-slash inconsistency.** Routers prefixed `/api/profile` with paths `/`, exposing `/api/profile/` rather than `/api/profile`. Decide canonical style and update routes + docs together.
- **All API routes are static `{"message": ...}` placeholders** — no schemas, no validation, no DB I/O. Implement one resource end-to-end (probably profile or ingredients) to establish the pattern before fanning out.
- **No quality gates exist:** no tests, no lint/format config wired to scripts, no CI yaml. Docs mention pytest/Black/isort/ESLint/Prettier — config and scripts are incomplete.
---
## 4. Where Reviewers Disagreed
Almost no direct contradictions. Two soft tensions:
1. **Severity of phase-status drift.** Claude rated this Minor (doc hygiene). GPT-5.5 rated it Blocking. Repo evidence (skeleton present but broken imports, missing files, no Alembic) supports **Blocking**. Treat as blocker.
2. **Recipe-ingredient framing.** Claude framed as "pick one" abstractly. GPT-5.5 repo found the code already has both with a broken relationship — so the decision is not abstract; code must change either way.
---
## 5. Combined Remediation Order
Resolve in this order. Items 16 must land before Phase 2 schema work; 79 before any feature code.
1. **Decide household-vs-members model.** (§1.4) Unblocks per-voter approval, fixes the family-math inconsistency.
2. **Pick recipe-ingredient representation.** (§1.1) Delete the loser from docs *and* code.
3. **Define MVP auth.** (§1.2) Pick one model; put it in SPEC §8; scope `SECRET_KEY`.
4. **Redesign approval flow.** (§1.3) GET → confirmation POST; per-voter token; TTL; single-use.
5. **Reconcile phase status.** (§1.5) One canonical status doc reflecting actual code.
6. **Stabilize the skeleton.** (§1.5) Fix model imports, broken `back_populates`, add `index.html` / `Pantry.tsx` / lockfile, fix `text("SELECT 1")`, configure Alembic, remove `create_all` from startup. Run the verification matrix below.
7. **Schema cleanup pass.** (§1.6) Enums/CHECKs, `CITEXT`, `grocery_item.ingredient_id`, household-size CHECK, `GENERATED` total_time, day_of_week convention, drop or document `calorie_target` / `spice_level`.
8. **Compose hardening.** (§1.7) Intra-network only for backend/frontend; nginx sole entrypoint.
9. **Decide scheduler topology** before APScheduler is wired (§3 Claude). Separate container or pinned `--workers 1`.
---
## 6. Verification Matrix (currently failing — make this pass to claim Phase 1 complete)
```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 backend pytest
docker compose run --rm frontend npm run build
```
If any of these fail, Phase 1 is not complete regardless of what ORIENTATION.md says.
---
## 7. Additional Investigation Still Needed
No reviewer resolved these — they require concrete checks before proceeding:
- [ ] **Verify the Lucky URL** (`curl -I https://www.luckyncal.com` and confirm it serves the weekly-ad surface).
- [ ] **Run the verification matrix** (§6) and capture which commands actually fail.
- [ ] **Audit Lucky California ToS and recipe-site copyright** before the scraper exists.
- [ ] **Confirm the chosen mail provider and its deliverability profile** to GMail/iCloud — silence-as-consent is dangerous if delivery rate <100%.
- [ ] **Decide whether "self-hosted" framing is load-bearing.** If yes, enumerate every external dependency in SPEC §8 with offline-fallback story. If no, drop the framing.
- [ ] **Threat-model the email approval token end-to-end:** leak via forwarded mail, archived inbox, screenshot, mail-scanner prefetch.
- [ ] **Define planner hard-vs-soft constraints, scoring weights, tie-breakers, and no-solution fallback.** Add example fixtures (one valid plan, one no-solution case).
- [ ] **Define unit conversion / "1 bunch cilantro" / "to taste" rules** before pantry and shopping-list models freeze.
- [ ] **Decide WCAG 2.1 AA scope** (web UI + accessible email HTML with plain-text alt).
- [ ] **Define backup/restore procedure** for the postgres volume and any cached images.
- [ ] **Define CI plan** — minimal smoke tests (backend import, mapper config, frontend build) before adding lint/format configs.
---
## 8. Suggested Response Format from Originating Agent
For each numbered item in §1 and §2, please respond with one of:
- **Accept + plan:** acknowledge, summarize the chosen resolution, and identify which doc/file will change.
- **Reject + rationale:** explain why the finding does not apply, citing primary-source evidence (file:line or external doc).
- **Defer + condition:** explain what blocks immediate resolution and what condition triggers it.
For §3 (single-reviewer findings) and §7 (open investigations), a checkbox-level acknowledgement is sufficient unless the agent disagrees.
The goal of this loop is to converge on one canonical version of SPEC, ARCHITECTURE, database-schema, and implementation-plan before regenerating the Phase 2 migration.
+118
View File
@@ -0,0 +1,118 @@
# Review Synthesis — Three Reviewers Compared
Sources: `docs-claude.md` (Claude, doc review), `docs-gpt5.5.md` (GPT-5.5, doc review), `repo-gpt5.5.md` (GPT-5.5, repo/code review).
---
## 1. Strong Consensus (all reviewers, blocking)
| # | Issue | Claude | GPT docs | GPT repo |
|---|---|---|---|---|
| C1 | Recipe↔ingredient modeled two ways (JSONB column AND `recipe_ingredient` join) — must pick one | B3 | #2 | #2 (also broken back_populates in code) |
| C2 | No authentication anywhere; admin + approval endpoints publicly mutable | B4 | #4 | #8 |
| C3 | Email approval token model is unsafe (no per-voter scoping, no TTL/single-use, GET mutates state) | B5 | #4 | #9 |
| C4 | `SECRET_KEY` is a ghost/unsafe default with no documented consumer | m6 | #11 | #10 |
| C5 | Schema enums missing — status/reason fields are loose VARCHARs | M4 | (implied #15) | #15 |
| C6 | Docker compose publishes backend (8000) and frontend (3000) directly — bypasses nginx | m3 | — | #12 |
| C7 | Production deployment claims (TLS, rate limiting, prod compose) are not implemented | — | #10 | #13 |
| C8 | Phase status is contradictory — ORIENTATION says Phase 1 complete, plan boxes unticked, code is partial skeleton | m1 | #1 | (confirms repo is skeleton) |
**Implication:** these eight items are the minimum gate before any further code.
---
## 2. Partial Agreement (2 of 3)
| # | Issue | Found by | Missed by |
|---|---|---|---|
| P1 | Migration story muddled — `create_all` at startup vs Alembic-as-source-of-truth; Alembic not configured in repo | GPT docs #5, GPT repo #6 | Claude (only flagged "SQLAlchemy or Alembic" wording — M5) |
| P2 | API endpoint contract conflicts (`POST /api/meals/{id}/approve` vs `GET /api/approve/{token}`, deny variants) | GPT docs #3, GPT repo #7 | Claude did not enumerate route mismatches |
| P3 | Scraping under-specified — feasibility, selectors, anti-bot, normalization | Claude M1, GPT docs #7 | GPT repo (out of scope) |
| P4 | Phase ordering hides risk behind plumbing; need spike for scrape + email before 12-table schema | Claude M2, GPT docs #7 | GPT repo |
| P5 | Shopping-list math (units, conversion, pantry subtraction, "1 bunch cilantro") not implementable from current docs | Claude M4 (partial), GPT docs #9 | GPT repo |
| P6 | Accessibility absent despite family UI + email | Claude m2, GPT docs #16 | GPT repo |
| P7 | Env var docs inconsistent across `.env.example`, ORIENTATION, RUNNING, plan | Claude m6 (SECRET_KEY only), GPT docs #11 | GPT repo |
| P8 | Tech stack drift / "CRA or Vite" wording when project uses Vite | (none) | GPT docs #13, GPT repo (lockfile/index.html) |
| P9 | Health endpoint uses raw `db.execute("SELECT 1")` — needs `text()` under SQLAlchemy 2 | — | GPT docs #14, GPT repo #11 |
---
## 3. Unique Findings (only one reviewer)
### Claude only
- **B1 — Family-profile math doesn't close.** Spec says 2 adults + 2 children, then references "two adults and one child do NOT like mushrooms" + "one adult likes mushrooms" = 3 adults. Single most concrete worked example in the spec, and it doesn't typecheck.
- **B2 — No `family_member` table** despite per-person preferences and asymmetric voting in SPEC §6/§7. Both adults share one approval token; whoever clicks first wins.
- **M3 — APScheduler + multi-worker uvicorn will fire weekly scrape N times.** No leader election, no `--workers 1` pin, no separate scheduler container.
- **M6 — Silence-as-consent.** "If both approve OR no response → confirmed" plus the >80% approval-rate metric means the metric is gamed by spam-foldering.
- **M7 — `calorie_target` column with non-goal status; `spice_level` with no spec mention.** Dead schema or wrong spec.
- **M1 — Lucky URL probably wrong.** `LUCKY_CA_URL=https://www.luckyncal.com`; chain's actual site is `luckysupermarkets.com`. Needs verification.
- **B5.1 — Email GET prefetch.** SafeLinks/Proofpoint/GMail prefetch every link → silent auto-approval before human reads. This is a known failure mode; fix is confirmation page that POSTs.
- **M4 day_of_week 0=Monday** disagrees with both Postgres `EXTRACT(DOW)` (0=Sun) and ISO-8601 (1=Mon).
- **M4 `total_time_minutes`** documented as computed but stored as plain INTEGER — will drift.
- **M4 `ingredient.name UNIQUE`** is case-sensitive → "Carrots" ≠ "carrots".
- **M4 `grocery_item` has no FK to `ingredient`** — the bridge between scraping and planning has no place to live.
- **M8 inline base64 vs CDN** for email images — base64 inflates ~33% and GMail filters >102KB.
### GPT-5.5 docs only
- **#3 (precision)** — exact route-by-route mismatch table between implementation-plan and ARCHITECTURE for approve/deny endpoints.
- **#15** — data-retention windows have no job owner / schedule / FK-cascade design (archive-to-JSON breaks feedback FKs).
- **#17** — observability is aspirational; no log schema, correlation IDs, error taxonomy.
### GPT-5.5 repo only (Claude could not have caught these — doc-only review)
- **#1 — Backend import broken.** `main.py:4` imports `family_profile, ingredient, recipe, …` as submodules; all classes actually live in `models/__init__.py`. **App likely does not start.**
- **#2 — Broken `back_populates`.** `RecipeIngredient.recipe = relationship(..., back_populates="recipe_ingredients")` but `Recipe` has no `recipe_ingredients` attr → mapper config will fail.
- **#3 — Missing `frontend/src/pages/Pantry.tsx`** but imported by `App.tsx:5`.
- **#4`npm ci` in Dockerfile but no `package-lock.json`.**
- **#5 — Missing `frontend/index.html`** (Vite entrypoint).
- **#6 — No `alembic.ini` / `alembic/`** despite docs and requirements.
- **#11`db.execute("SELECT 1")` will fail under SQLAlchemy 2.**
- **#16 — Trailing-slash inconsistency** between router prefixes and docs.
---
## 4. Where Reviewers Conflict
Almost no direct contradictions — the reviewers are largely orthogonal. Two soft tensions:
1. **Severity of phase-status drift.** Claude treats it as Minor (m1, doc hygiene). GPT-5.5 docs treats it as Blocking (#1). GPT-5.5 repo evidence (skeleton present but broken) supports the GPT-5.5 framing — this should be **Blocking**.
2. **Recipe↔ingredient framing.** Claude frames as "pick one"; GPT-5.5 repo shows code already has *both* with a broken relationship. The decision is no longer abstract — choosing JSONB requires deleting `RecipeIngredient` table; choosing the join requires removing the JSONB column. Either way, code already has to change.
---
## 5. Combined Blocker List (deduplicated, ranked)
Resolve in order. Items 16 must land before Phase 2 schema work; 79 before any API/feature code.
1. **Reconcile household-vs-members model.** Decide `family_member` table or single shared mailbox. Fixes B1+B2 and unblocks per-voter approval scoping.
2. **Pick recipe-ingredient representation.** Delete the loser from both docs *and* code. Fixes C1 / GPT repo #2.
3. **Define MVP auth.** VPN-only, basic-auth at proxy, or app sessions — pick one and put it in SPEC §8. Fixes C2 + scopes the SECRET_KEY question (C4).
4. **Redesign approval flow.** GET → confirmation page that POSTs; per-voter token; explicit TTL; single-use; record which member voted. Fixes C3 + Claude B5.1.
5. **Reconcile phase status.** One canonical status doc reflecting actual code. Fixes C8.
6. **Stabilize the skeleton (GPT repo Phase 0).** Fix model imports, broken back_populates, missing `index.html` / `Pantry.tsx` / lockfile, `text("SELECT 1")`, configure Alembic, remove `create_all` from startup. Without this, "Phase 1 complete" is false.
7. **Schema cleanup pass:** enums/CHECKs for status fields; `CITEXT` or lowercase for ingredient names; `grocery_item.ingredient_id` FK; CHECK that adult+child = household_size; `GENERATED` column for `total_time_minutes`; pick day_of_week convention; resolve `calorie_target` / `spice_level` vs spec.
8. **Compose hardening:** intra-network only for backend/frontend; nginx is sole entrypoint.
9. **Decide scheduler topology** (separate container, or `--workers 1` pinned and documented) before APScheduler is wired.
---
## 6. Additional Investigation Needed
These were not resolved by any reviewer and require concrete checks:
- [ ] **Verify the Lucky URL.** `curl -I https://www.luckyncal.com` and confirm it serves the expected weekly-ad surface; if wrong, every Phase 4 task is built on sand.
- [ ] **Run the GPT-5.5 verification matrix** (`repo-gpt5.5.md` §"Verification Matrix") to confirm which build/start commands actually fail today. Reviewers asserted the skeleton is broken but did not run it.
- [ ] **Audit Lucky California ToS and recipe-site copyright** before the scraper exists, not after.
- [ ] **Confirm SendGrid (or alternative) is the chosen mail provider** and what the deliverability profile to GMail/iCloud is — silence-as-consent (Claude M6) is dangerous if delivery rate <100%.
- [ ] **Decide whether the project's "self-hosted" framing is load-bearing.** If yes, every external dependency (SendGrid, AI image API, Let's Encrypt, scraped images) must be enumerated in SPEC §8 with an offline-fallback story. If no, drop the framing.
- [ ] **Threat-model the email approval token** end-to-end: leak via forwarded mail, archived inbox, screenshot, mail-scanner prefetch. Claude flagged it; no reviewer wrote a model.
- [ ] **Define the planner's hard-vs-soft constraints and a no-solution fallback** (GPT docs #8). Neither doc nor code has scoring weights or tie-breakers.
- [ ] **Define unit conversion / "1 bunch cilantro" / "to taste" handling** before pantry and shopping-list models freeze.
- [ ] **Decide WCAG target** (2.1 AA per user CLAUDE.md) and which surfaces it covers (web UI yes; email — accessible HTML + plain-text alt).
---
## 7. Net Assessment
The two adversarial doc reviews are largely complementary, not redundant: GPT-5.5 caught contract-level mismatches (routes, env vars, migration tooling) and Claude caught semantic/correctness issues (the family math, the prefetch problem, scheduler concurrency, silence-as-consent). The GPT-5.5 repo review is the only source of evidence about what actually runs — and it indicates the skeleton labeled "Phase 1 complete" likely does not start.
**Combined readiness: ~4/10.** The product intent is clear, but at least three load-bearing design decisions (member model, ingredient representation, auth model) and one infrastructure gap (Alembic + skeleton import bugs) must be resolved before Phase 2.
+42
View File
@@ -0,0 +1,42 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql://mealplanner:password@localhost:5432/mealplanner
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+55
View File
@@ -0,0 +1,55 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app.models import Base
from app.config import settings
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL.replace("postgresql://", "postgresql+psycopg2://"))
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+3 -5
View File
@@ -1,7 +1,7 @@
from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session
from app.database import get_db, engine, Base
from app.models import family_profile, ingredient, recipe, meal_plan, home_pantry, feedback, grocery_item, scrape_log, email_log
from sqlalchemy import text
from app.database import get_db
from app.config import settings
import logging
@@ -14,8 +14,6 @@ app = FastAPI(
version="0.1.0",
)
Base.metadata.create_all(bind=engine)
@app.get("/health")
def health_check(db: Session = Depends(get_db)):
@@ -25,7 +23,7 @@ def health_check(db: Session = Depends(get_db)):
@app.get("/health/db")
def health_check_db(db: Session = Depends(get_db)):
try:
db.execute("SELECT 1")
db.execute(text("SELECT 1"))
return {"status": "ok", "database": "connected"}
except Exception as e:
return {"status": "error", "database": "disconnected", "error": str(e)}
+164 -34
View File
@@ -1,12 +1,82 @@
from sqlalchemy import (
Column, String, Integer, Numeric, Text, Boolean, Date, DateTime,
ForeignKey, CheckConstraint, UniqueConstraint, ARRAY
ForeignKey, CheckConstraint, UniqueConstraint, Enum as SQLEnum
)
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.dialects.postgresql import UUID, JSONB, ARRAY
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
import uuid
import enum
class DayOfWeek(enum.Enum):
MONDAY = 1
TUESDAY = 2
WEDNESDAY = 3
THURSDAY = 4
FRIDAY = 5
SATURDAY = 6
SUNDAY = 7
class MealType(enum.Enum):
BREAKFAST = "breakfast"
LUNCH = "lunch"
DINNER = "dinner"
class MealPlanStatus(enum.Enum):
DRAFT = "draft"
PENDING_APPROVAL = "pending_approval"
APPROVED = "approved"
LOCKED = "locked"
class MealPlanItemStatus(enum.Enum):
PENDING = "pending"
APPROVED = "approved"
DENIED = "denied"
SWAPPED = "swapped"
class ApprovalTokenStatus(enum.Enum):
ACTIVE = "active"
USED = "used"
EXPIRED = "expired"
class FamilyMemberRole(enum.Enum):
ADULT = "adult"
CHILD = "child"
class DenialReason(enum.Enum):
TOO_EXPENSIVE = "too_expensive"
BORING = "boring"
DISLIKED_INGREDIENT = "disliked_ingredient"
CULTURAL = "cultural"
OTHER = "other"
class NeverSuggestReason(enum.Enum):
ALLERGY = "allergy"
DISLIKE = "dislike"
TRIED_TOO_MUCH = "tried_too_much"
OTHER = "other"
class ScrapeStatus(enum.Enum):
STARTED = "started"
SUCCESS = "success"
FAILED = "failed"
class EmailStatus(enum.Enum):
SENT = "sent"
DELIVERED = "delivered"
FAILED = "failed"
BOUNCED = "bounced"
class FamilyProfile(Base):
@@ -23,18 +93,47 @@ class FamilyProfile(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
__table_args__ = (
CheckConstraint("adult_count + child_count = household_size"),
CheckConstraint("household_size > 0"),
CheckConstraint("adult_count > 0"),
)
members = relationship("FamilyMember", back_populates="family_profile", cascade="all, delete-orphan")
recipes = relationship("Recipe", back_populates="family_profile")
meal_plans = relationship("MealPlan", back_populates="family_profile")
pantry_items = relationship("HomePantry", back_populates="family_profile")
pantry_items = relationship("HomePantry", back_populates="family_profile", cascade="all, delete-orphan")
feedbacks = relationship("Feedback", back_populates="family_profile")
never_suggests = relationship("NeverSuggest", back_populates="family_profile")
never_suggests = relationship("NeverSuggest", back_populates="family_profile", cascade="all, delete-orphan")
class FamilyMember(Base):
__tablename__ = "family_member"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id", ondelete="CASCADE"))
name = Column(String(100), nullable=False)
email = Column(String(300))
role = Column(SQLEnum(FamilyMemberRole, name="family_member_role_enum", create_type=False), nullable=False)
likes_mushrooms = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint("family_profile_id", "email"),
)
family_profile = relationship("FamilyProfile", back_populates="members")
votes = relationship("MealPlanVote", back_populates="family_member", cascade="all, delete-orphan")
feedbacks = relationship("Feedback", back_populates="family_member")
class Ingredient(Base):
__tablename__ = "ingredient"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(200), nullable=False, unique=True)
name = Column(String(200), nullable=False)
name_lower = Column(String(200), nullable=False, unique=True)
plural_name = Column(String(200))
aisle = Column(String(100))
typical_price = Column(Numeric(10, 2))
@@ -42,9 +141,9 @@ class Ingredient(Base):
season_months = Column(ARRAY(Integer))
created_at = Column(DateTime(timezone=True), server_default=func.now())
recipes = relationship("RecipeIngredient", back_populates="ingredient")
pantry_items = relationship("HomePantry", back_populates="ingredient")
never_suggests = relationship("NeverSuggest", back_populates="ingredient")
grocery_item_links = relationship("GroceryItem", back_populates="ingredient")
class Recipe(Base):
@@ -75,6 +174,10 @@ class Recipe(Base):
family_profile = relationship("FamilyProfile", back_populates="recipes")
meal_plan_items = relationship("MealPlanItem", back_populates="recipe")
@property
def total_time_minutes(self):
return (self.prep_time_minutes or 0) + (self.cook_time_minutes or 0)
class MealPlan(Base):
__tablename__ = "meal_plan"
@@ -82,7 +185,7 @@ class MealPlan(Base):
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id"))
week_start_date = Column(Date, nullable=False)
status = Column(String(20), nullable=False, default="draft")
status = Column(SQLEnum(MealPlanStatus, name="meal_plan_status_enum", create_type=False), nullable=False, default=MealPlanStatus.DRAFT)
approval_deadline = Column(DateTime(timezone=True))
total_estimated_cost = Column(Numeric(10, 2))
notes = Column(Text)
@@ -95,6 +198,7 @@ class MealPlan(Base):
family_profile = relationship("FamilyProfile", back_populates="meal_plans")
items = relationship("MealPlanItem", back_populates="meal_plan", cascade="all, delete-orphan")
votes = relationship("MealPlanVote", back_populates="meal_plan", cascade="all, delete-orphan")
class MealPlanItem(Base):
@@ -104,11 +208,9 @@ class MealPlanItem(Base):
meal_plan_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan.id", ondelete="CASCADE"))
recipe_id = Column(UUID(as_uuid=True), ForeignKey("recipe.id"))
day_of_week = Column(Integer, nullable=False)
meal_type = Column(String(20), nullable=False)
approval_status = Column(String(20), default="pending")
approval_token = Column(UUID(as_uuid=True), unique=True, default=uuid.uuid4)
approval_token_expires = Column(DateTime(timezone=True))
denial_reason = Column(String(50))
meal_type = Column(SQLEnum(MealType, name="meal_type_enum", create_type=False), nullable=False)
approval_status = Column(SQLEnum(MealPlanItemStatus, name="meal_plan_item_status_enum", create_type=False), default=MealPlanItemStatus.PENDING)
denial_reason = Column(SQLEnum(DenialReason, name="denial_reason_enum", create_type=False))
denial_details = Column(Text)
estimated_cost = Column(Numeric(10, 2))
used_pantry_items = Column(ARRAY(UUID(as_uuid=True)))
@@ -117,12 +219,50 @@ class MealPlanItem(Base):
__table_args__ = (
UniqueConstraint("meal_plan_id", "day_of_week", "meal_type"),
CheckConstraint("day_of_week BETWEEN 0 AND 6"),
CheckConstraint("day_of_week BETWEEN 1 AND 7"),
)
meal_plan = relationship("MealPlan", back_populates="items")
recipe = relationship("Recipe", back_populates="meal_plan_items")
feedback = relationship("Feedback", back_populates="meal_plan_item", uselist=False)
votes = relationship("MealPlanVote", back_populates="meal_plan_item", cascade="all, delete-orphan")
class MealPlanVote(Base):
__tablename__ = "meal_plan_vote"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
meal_plan_item_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan_item.id", ondelete="CASCADE"))
family_member_id = Column(UUID(as_uuid=True), ForeignKey("family_member.id", ondelete="CASCADE"))
vote = Column(Boolean, nullable=False)
voted_at = Column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
UniqueConstraint("meal_plan_item_id", "family_member_id"),
)
meal_plan_item = relationship("MealPlanItem", back_populates="votes")
family_member = relationship("FamilyMember", back_populates="votes")
class ApprovalToken(Base):
__tablename__ = "approval_token"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
meal_plan_item_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan_item.id", ondelete="CASCADE"))
family_member_id = Column(UUID(as_uuid=True), ForeignKey("family_member.id", ondelete="CASCADE"))
token = Column(String(64), nullable=False, unique=True)
status = Column(SQLEnum(ApprovalTokenStatus, name="approval_token_status_enum", create_type=False), default=ApprovalTokenStatus.ACTIVE)
expires_at = Column(DateTime(timezone=True), nullable=False)
used_at = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
UniqueConstraint("meal_plan_item_id", "family_member_id"),
)
meal_plan_item = relationship("MealPlanItem")
family_member = relationship("FamilyMember")
class HomePantry(Base):
@@ -150,19 +290,20 @@ class Feedback(Base):
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id"))
family_member_id = Column(UUID(as_uuid=True), ForeignKey("family_member.id", ondelete="SET NULL"))
meal_plan_item_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan_item.id", ondelete="CASCADE"))
rating = Column(Integer)
never_suggest = Column(Boolean, default=False)
denial_reason = Column(String(50))
denial_reason = Column(SQLEnum(DenialReason, name="denial_reason_enum", create_type=False))
feedback_text = Column(Text)
created_at = Column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
UniqueConstraint("meal_plan_item_id"),
CheckConstraint("rating BETWEEN 1 AND 5"),
)
family_profile = relationship("FamilyProfile", back_populates="feedbacks")
family_member = relationship("FamilyMember", back_populates="feedbacks")
meal_plan_item = relationship("MealPlanItem", back_populates="feedback")
@@ -171,9 +312,9 @@ class NeverSuggest(Base):
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id", ondelete="CASCADE"))
ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredient.id"))
recipe_id = Column(UUID(as_uuid=True), ForeignKey("recipe.id"))
reason = Column(String(50))
ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredient.id", ondelete="CASCADE"))
recipe_id = Column(UUID(as_uuid=True), ForeignKey("recipe.id", ondelete="CASCADE"))
reason = Column(SQLEnum(NeverSuggestReason, name="never_suggest_reason_enum", create_type=False))
notes = Column(Text)
created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -185,6 +326,7 @@ class GroceryItem(Base):
__tablename__ = "grocery_item"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredient.id", ondelete="SET NULL"))
name = Column(String(300), nullable=False)
brand = Column(String(200))
current_price = Column(Numeric(10, 2))
@@ -200,6 +342,8 @@ class GroceryItem(Base):
scraped_at = Column(DateTime(timezone=True), server_default=func.now())
scraped_url = Column(Text)
ingredient = relationship("Ingredient", back_populates="grocery_item_links")
class ScrapeLog(Base):
__tablename__ = "scrape_log"
@@ -207,7 +351,7 @@ class ScrapeLog(Base):
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
source = Column(String(50), nullable=False)
scrape_type = Column(String(50), nullable=False)
status = Column(String(20), nullable=False)
status = Column(SQLEnum(ScrapeStatus, name="scrape_status_enum", create_type=False), nullable=False)
items_scraped = Column(Integer, default=0)
error_message = Column(Text)
started_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -225,24 +369,10 @@ class EmailLog(Base):
meal_plan_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan.id"))
meal_plan_item_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan_item.id"))
sendgrid_message_id = Column(String(100))
status = Column(String(20), nullable=False)
status = Column(SQLEnum(EmailStatus, name="email_status_enum", create_type=False), nullable=False)
error_message = Column(Text)
created_at = Column(DateTime(timezone=True), server_default=func.now())
delivered_at = Column(DateTime(timezone=True))
meal_plan = relationship("MealPlan")
meal_plan_item = relationship("MealPlanItem")
class RecipeIngredient(Base):
__tablename__ = "recipe_ingredient"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
recipe_id = Column(UUID(as_uuid=True), ForeignKey("recipe.id", ondelete="CASCADE"))
ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredient.id"))
quantity = Column(Numeric(10, 2))
unit = Column(String(50))
is_optional = Column(Boolean, default=False)
recipe = relationship("Recipe", back_populates="recipe_ingredients")
ingredient = relationship("Ingredient", back_populates="recipes")
+12
View File
@@ -0,0 +1,12 @@
version: "3.8"
services:
backend:
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://mealplanner:${POSTGRES_PASSWORD:-devpassword}@localhost:5432/mealplanner
frontend:
ports:
- "3000:80"
+8 -6
View File
@@ -5,14 +5,15 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8000:8000"
expose:
- "8000"
environment:
- DATABASE_URL=postgresql://mealplanner:${POSTGRES_PASSWORD}@db:5432/mealplanner
- SENDGRID_API_KEY=${SENDGRID_API_KEY}
- LUCKY_CA_URL=${LUCKY_CA_URL}
- AI_IMAGE_ENABLED=${AI_IMAGE_ENABLED}
- LUCKY_CA_URL=${LUCKY_CA_URL:-https://luckysupermarkets.com}
- AI_IMAGE_ENABLED=${AI_IMAGE_ENABLED:-false}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- SECRET_KEY=${SECRET_KEY}
depends_on:
db:
condition: service_healthy
@@ -27,8 +28,8 @@ services:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3000:80"
expose:
- "80"
depends_on:
- backend
restart: unless-stopped
@@ -55,6 +56,7 @@ services:
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
depends_on:
- frontend
- backend
+86 -50
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 │
└─────────────────────┘
┌─────────────────┐ ┌─────────────────┐
grocery │ │ home_
│ item │ │ pantry
├─────────────────┤ ├─────────────────┤
│ 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 │
│ name │ family_profile
│ aisle ingredient_id
│ current_price quantity
│ is_on_sale │ │ added_at
│ sale_end_date │ expires_at
│ season_months[] │ └─────────────────┘
week_start_date │ │ ingredient_id (FK)
│ status │ │ name
│ total_cost │ │ current_price
│ approval_deadline│ │ regular_price
└─────────────────┘ │ is_on_sale
│ sale_end_date │
│ 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
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MealPlanner</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4059
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
export default function Pantry() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Home Pantry</h1>
<p className="text-gray-500">Manage your pantry items...</p>
</div>
)
}