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

3.9 KiB

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.pyEmailBackend 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 -q3 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.