Files
adminandClaude Opus 4.7 8e89f793d5 feat: phase r1+r2 recovery + r3-0 swiftly api ingestion
R1 stabilization: pytest harness with transactional db fixture, smoke
+ alembic + auth + scrape + approval + swiftly tests, github actions
ci yaml. Bearer-token admin auth + signed-cookie session for family
ui mutations. Async POST /api/admin/scrape (BackgroundTasks, returns
202). Path canonicalization (no /list, /planned suffixes). DATABASE_URL
fail-fast on empty.

R2 deferred-risk spikes: live lucky california fetch (R2-A), full
email+per-voter approval click round trip with single-use enforcement
(R2-B, console email backend, sendgrid stub).

R3-0 phase 3 redesign: replaced playwright html scraper with requests
based swiftly json api client. 17 categories, ~10k products per scrape,
upsert by (source, external_id). 401 surfaces actionable token-refresh
message via ScrapeLog.error_message.

Pre-existing defects fixed: shopping_list.py syntax error blocking app
import, MealPlan.votes orphan relationship, JSONB(astext=True) invalid
kwarg, missing requests dep, calorie_target schema drift, every SQLEnum
needed values_callable, 0001 had empty downgrade(), seed had duplicate
ingredient rows.

Migrations added: 0003 grocery_item.description, 0004 family_profile.
calorie_target, 0005 grocery_item.external_id + source + composite index.

Verified: 31/31 pytest green, alembic upgrade->downgrade->upgrade clean,
frontend npm run build clean, live scrape 9,960 grocery_item rows in 36s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:08:19 -07:00

83 lines
3.9 KiB
Markdown

# R2-B — Email + per-voter approval round-trip spike
## Schema gap (top, per spec)
The spike surfaced a pre-existing **model relationship bug** that blocks
the round-trip even after a clean schema bootstrap:
`backend/app/models/__init__.py:201` declares
`MealPlan.votes = relationship("MealPlanVote", back_populates="meal_plan", ...)`
but `MealPlanVote` has no `meal_plan` FK or relationship. SQLAlchemy
fails at first mapper configure with `NoForeignKeysError`. Details +
two pre-existing migration bug in `r2b-blockers.md`. Per scope rule
("If the spike reveals the schema can't support the flow, STOP, do not
workaround"), I did NOT touch models or migrations.
## What landed
- `backend/app/services/email.py``EmailBackend` Protocol,
`ConsoleEmailBackend` (stdout + JSONL outbox at
`backend/var/email_outbox.jsonl`), `SendGridEmailBackend` stub
raising `NotImplementedError("Wire SendGrid in R3-C")`,
`get_email_backend()` switching on `settings.EMAIL_BACKEND`.
- `backend/app/services/approval.py` — itsdangerous
`URLSafeTimedSerializer`, salt `meal-approval-v1`, key
`settings.SECRET_KEY`. `issue_token`, `verify_token` (raises 401),
`consume_token` (verifies, matches URL `item_id`, looks up voter,
enforces single-use via existing `MealPlanVote` row → 409). Single-
use enforcement lives ONLY in `consume_token`.
- `backend/app/api/meals.py` — replaced two vote routes:
- `GET /api/meals/vote/{item_id}?token=...` → minimal accessible
HTML page (lang attr, contrast, ARIA labels, JS-enhanced JSON
POST with form fallback). Token only inside form `action`,
never visible body.
- `POST /api/meals/vote/{item_id}?token=...` body `{"vote": "approve"|"deny"}`.
Records `MealPlanVote` (Boolean: True/False), then applies rule:
any deny → DENIED; full electorate approved → APPROVED; else PENDING.
Returns `{"status": "recorded", "item_status": "..."}`.
- `backend/app/config.py` — added `EMAIL_BACKEND: str = "console"`.
- `backend/requirements-dev.txt` — added `freezegun>=1.4`.
- `.gitignore` — appended `backend/var/`.
- `scripts/send_test_approval.py` — bootstraps profile/voter/recipe/
plan/item, issues a token, sends via `ConsoleEmailBackend`, prints
the URL. `--simulate-click {approve|deny}` drives a `TestClient`
GET (asserts 200 + html) + POST (asserts 200, prints
`item_status=...`) + a second POST (asserts 409 single-use).
- `backend/tests/test_approval.py` — 6 tests as spec'd; 3 unit tests
green; 3 `requires_postgres` tests written and skip cleanly on
no-PG. They will pass once Blocker 1 is fixed.
## URL contract
`/api/meals/vote/{item_id}?token=<itsdangerous-signed JSON>`.
Token payload: `{"item": str(uuid), "voter": str(uuid)}`. TTL 7 days.
Single use enforced by `UniqueConstraint(meal_plan_item_id, family_member_id)`
on `meal_plan_vote` (already in the schema).
## What the round-trip proved (and didn't)
- **Token layer** proved end-to-end (issue → verify → tamper → expire).
- **Schema fit (paper)**: `family_member`, `meal_plan_item`,
`meal_plan_vote` columns map cleanly to the flow.
- **Schema fit (runtime)**: BLOCKED by `MealPlan.votes` defect.
Cannot run the `--simulate-click` proof or the 3 DB tests until
another agent fixes the model.
## Verification (what I ran)
- `pytest backend/tests/test_approval.py -q`**3 passed, 3 skipped**.
- `python -c "from app.services.email import get_email_backend; print(...)"`
`ConsoleEmailBackend`.
- `python scripts/send_test_approval.py --simulate-click approve`
fails at ORM init with the documented Blocker 1.
## Files
- created: `backend/app/services/__init__.py`,
`backend/app/services/email.py`, `backend/app/services/approval.py`,
`scripts/send_test_approval.py`,
`backend/tests/test_approval.py`,
`.agent/phase-summaries/r2b-blockers.md`,
`.agent/phase-summaries/r2b-summary.md`.
- modified: `backend/app/api/meals.py`, `backend/app/config.py`,
`backend/requirements-dev.txt`, `.gitignore`.