# 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`.
