# Sprint 8 — Verification **Sprint:** "Deny" semantics — explicit two-button model with 90-day decay and hard-filter escalation. **User policy decision (2026-06-05):** "Hard filter. If it is denied this week twice, it should be considered denied for good." **Status:** code complete, awaiting deploy. **Date of handoff:** 2026-06-05. --- ## Policy (recap) | Action | Backend behavior | Decay | |---|---|---| | **Approve** | `item.approval_status = approved` | n/a | | **Deny this week** (1st time in 90d for this recipe) | `denied`, `denial_expires_at = now() + 90d` | after 90d, recipe is eligible again | | **Deny this week** (2nd time in 90d for this recipe — auto-escalation) | `denied`, `denial_expires_at = NULL`, **and** a `NeverSuggest` row is inserted for `(family_profile_id, recipe_id)` with `reason = "dislike"` | never (until user un-blocks) | | **Never again** (explicit) | same as the 2nd-time auto-escalation: `denied`, `denial_expires_at = NULL`, **and** a `NeverSuggest` row | never | The 2-denial escalation is **server-side and atomic** — the API checks for a prior `denied` row with `denial_expires_at > now()` for the same `(family_profile_id, recipe_id)` before deciding whether to insert a `NeverSuggest` row. No client-side double-counting. The planner's `_load_blocklists` now returns 3 sets; the soft-denied set is **hard-filtered** (per the user's decision — same as the permanent blocklist) so a denied recipe is never re-proposed until either the 90d window expires or the user un-blocks via the `NeverSuggest` API. --- ## What changed (recap) | Layer | File | Change | |---|---|---| | Migration | `backend/alembic/versions/0016_denial_decay_and_scope.py` (NEW) | Adds `meal_plan_item.denial_expires_at TIMESTAMPTZ NULL` and `meal_plan_vote.denial_scope VARCHAR(16) NULL`. Partial index on `denial_expires_at` for fast lookup. | | Model | `backend/app/models/__init__.py:221-242, 250-269` | Two new columns. | | Schema | `backend/app/schemas/__init__.py:204-219, 248-269` | `MealPlanItemResponse.denial_expires_at`, `VoteRequest.denial_scope`, `VoteResponse.denial_scope`. | | Backend | `backend/app/api/meals.py:30-138` | 3 new helpers: `_apply_denial`, `_ensure_never_suggest_recipe`, `_has_prior_active_soft_denial`. | | Backend | `backend/app/api/meals.py:510-552` | `deny_meal_item` accepts `?scope=this_week\|never_again`; returns `promoted_to_permanent`. | | Backend | `backend/app/api/meals.py:380-455` | `submit_vote` handles `vote: "never_again"`; calls `_apply_denial` for both deny scopes; returns `denial_scope` + `promoted_to_permanent`. | | Backend | `backend/app/api/meals.py:240-330` | `get_vote_page` (HTML) now renders 3 buttons and supports a one-click `?scope=...` path for email direct-action links. | | Backend | `backend/app/services/orchestrator/steps.py:283-300` | Email template: 3 direct-action links per recipe (Approve / Deny this week / Never again), each a one-click GET to the vote page. | | Planner | `backend/app/services/planner/generate.py:59-99` | `_load_blocklists` returns 3 sets; soft-denied set is hard-filtered. | | Planner | `backend/app/services/planner/generate.py:150-194` | Call site updated; `rejected_summary` adds `soft_denied_recipe` diagnostic bucket. | | Frontend | `frontend/src/api/index.ts:48-58` | `meals.denyItem(itemId, { scope })`. | | Frontend | `frontend/src/pages/Dashboard.tsx:38-50, 385-410` | `MealCard` accepts scope-aware `onDeny`; renders 3 buttons (Approve / Deny this week / Never again) for pending items. `handleDeny` is scope-aware; toast reflects `promoted_to_permanent`. | | Docs | `Review/ui-nielsen-audit.md` + `fix-ui-audit.md` + `Review/handoff-ui-audit.md` + `docs/HANDOFF.md` + `.agent/plan.md` + `.agent/context.md` | Sprint 8 status blocks. | **No new dependencies. Backend migration is required. Frontend + backend rebuild required.** --- ## Deploy commands Run on the deployment host (`100.108.224.12`): ```bash # 1. Pull cd ~/MealPlanner git pull # 2. Migration 0016 (adds denial_expires_at + denial_scope) docker compose exec backend alembic upgrade head # 3. Rebuild backend + frontend docker compose -f docker-compose.yml up -d --build backend frontend ``` **Order matters.** Pull → migrate → rebuild. The migration is forward-only and non-destructive (adds two NULL columns + one partial index). --- ## Smoke checklist (browser, on `http://100.108.208.56:8082/`) | # | Action | Expected | |---|---|---| | 1 | Open the Dashboard. Find a pending meal (e.g. Chicken Fajitas on Mon 2026-06-08). | The card now shows 3 buttons: `Approve` (green), `Deny this week` (red), `Never again` (red, dashed border). | | 2 | Click `Deny this week` on a meal that has NOT been denied before. | Toast: "Denied this week — will not reappear for 90 days". Card status badge updates to `denied`. | | 3 | In a new browser tab, navigate to `/api/meals/items/` (or use a plan regen to see the planner skip it). | The denied recipe does NOT appear in the next plan if a regen is triggered (hard filter). | | 4 | Manually set `denial_expires_at` to NULL on the first item, OR re-run the orchestrator's generate after a recipe rotation. The denied recipe's `denial_expires_at` is now NULL. | The recipe is in the `NeverSuggest` table. The planner will never propose it. | | 5 | Click `Deny this week` on a different recipe that has no prior soft denial. | Toast: "Denied this week — will not reappear for 90 days". The new recipe joins the soft set. | | 6 | Click `Deny this week` again on the SAME recipe (use the API to find a second item with the same `recipe_id`, e.g. by re-running the planner or by creating a second plan with the same recipe). | Toast: "Denied — won't suggest again (denied twice recently)". `NeverSuggest` table now has a row for this recipe. | | 7 | Click `Never again` on a fresh recipe. | Confirm dialog appears: "Never suggest '' again? This permanently blocks the recipe for your family." Click OK. Toast: "Denied — will never be suggested again". | | 8 | Check the API: `GET /api/never-suggest?family_profile_id=...` | The new row appears in the list. | | 9 | Reload the page. | The 3 buttons are gone from cards whose `approval_status` is now `denied` (Sprint 8: only `pending` items show the buttons). Approved/denied items show the badge only. | | 10 | Open `/meals/` (MealDetail). | No voting UI here — voting is via the email or the dashboard card (per the existing design). | | 11 | Open `/shopping-list` or `/pantry`. | No regression. The bulk pantry add (Sprint 6) and other features unaffected. | --- ## API smoke (curl, on the deployment host) ```bash # Find a pending item in the current plan PLAN_ID=$(curl -s "http://100.108.208.56:8082/api/meals?week_start=2026-06-08" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") ITEM_ID=$(curl -s "http://100.108.208.56:8082/api/meals/$PLAN_ID" | python3 -c "import sys,json; d=json.load(sys.stdin); print([i['id'] for i in d['items'] if i['approval_status']=='pending'][0])") echo "plan=$PLAN_ID item=$ITEM_ID" # Test 1: deny with default scope (this_week) curl -s -X POST "http://100.108.208.56:8082/api/meals/items/$ITEM_ID/deny" | python3 -m json.tool | head -20 # Expected: item.approval_status="denied", item.denial_expires_at ~ 90 days from now, # promoted_to_permanent=false (no prior denial) # Test 2: never_again on a fresh item ITEM2=$(curl -s "http://100.108.208.56:8082/api/meals/$PLAN_ID" | python3 -c "import sys,json; d=json.load(sys.stdin); print([i['id'] for i in d['items'] if i['approval_status']=='pending'][0])") curl -s -X POST "http://100.108.208.56:8082/api/meals/items/$ITEM2/deny?scope=never_again" | python3 -m json.tool | head -20 # Expected: item.approval_status="denied", item.denial_expires_at=null, # promoted_to_permanent=true, scope="never_again" # Test 3: list never-suggest entries FAMILY_ID=$(curl -s "http://100.108.208.56:8082/api/meals/$PLAN_ID" | python3 -c "import sys,json; print(json.load(sys.stdin)['family_profile_id'])") curl -s "http://100.108.208.56:8082/api/never-suggest?family_profile_id=$FAMILY_ID" | python3 -m json.tool # Expected: at least 1 row from test 2 # Test 4: vote page renders 3 buttons (HTML) TOKEN=$(curl -s "http://100.108.208.56:8082/api/meals/vote/$ITEM2" | head -50) # (token is in the page; for direct test, you'd grab it from a real email) ``` --- ## Email smoke To preview the new email without sending it through SendGrid, render the template via the planner (orchestrator step_email). On a local dev DB: ```bash docker compose exec backend python -c " from app.database import SessionLocal from app.services.orchestrator.steps import step_email from app.models import WeeklyRun db = SessionLocal() run = db.query(WeeklyRun).order_by(WeeklyRun.week_start_date.desc()).first() step_email(run, db) # Mail backend defaults to console in dev — check the docker logs " ``` The email now renders 3 direct-action buttons per recipe (Approve / Deny this week / Never again), each a GET link that consumes the token and records the vote. The legacy "Vote on this meal" link is preserved as a "Open vote page (all 3 options)" secondary link below the buttons. To exercise the 2-denial auto-promotion via the email: 1. Open the vote page for recipe X (the link is in the email). 2. Click "Deny this week" → records the soft denial; page shows "Denied this week. Will not re-suggest for 90 days unless denied again." 3. Wait for the next Friday email (or manually re-trigger the email for the same plan). Re-open the vote page for a different item with the same recipe. 4. Click "Deny this week" again → records the 2nd denial; page shows "Denied this week. You've denied this recipe recently, so it will not be suggested again (permanently blocked)." --- ## Things to look for - The webui shows 3 buttons (Approve / Deny this week / Never again) on **pending** meal cards. Approved/denied cards show only the badge. - The email shows 3 direct-action links per recipe plus a secondary "open vote page" link. - Clicking "Deny this week" twice on the same recipe (across weeks or via API) auto-promotes the recipe to a permanent block. - The `NeverSuggest` table grows by exactly 1 row per distinct recipe (idempotent). - The planner does not propose denied recipes (hard filter). `rejected_summary` includes a `soft_denied_recipe` diagnostic bucket if a soft-deny was the cause. - The 90-day decay works on read: a row with `denial_expires_at < now()` is invisible to the soft-deny filter; the recipe becomes eligible again. --- ## Rollback (if needed) The Sprint 8 change is mostly additive. To revert: ```bash # 1. Revert the code git revert 09c7525..HEAD # or whichever commit set contains Sprint 8 # 2. Downgrade the migration docker compose exec backend alembic downgrade -1 # 3. The new columns become NULL again. Existing denied rows # keep denial_expires_at = NULL (the migration default). # The recipes already in NeverSuggest stay there — the user must # manually DELETE FROM never_suggest WHERE ... if they want to # clean up. ``` The pre-Sprint 8 behavior is what the user originally reported as "the rejected meal came back." Only revert if a regression appears that wasn't there before Sprint 8. --- ## Verification log (Filled in by the operator after deploy + smoke.) - [ ] `git pull` on deployment host → success - [ ] `alembic upgrade head` → applied migration 0016 - [ ] Backend rebuild → success - [ ] Frontend rebuild → success - [ ] Browser: 3 buttons appear on pending meal cards - [ ] API: `/api/meals/items/{id}/deny` (no scope) returns 200 with `promoted_to_permanent: false` - [ ] API: `/api/meals/items/{id}/deny?scope=never_again` returns 200 with `promoted_to_permanent: true` - [ ] API: 2nd `/deny` (same recipe) auto-promotes with `promoted_to_permanent: true` - [ ] API: `/api/never-suggest?family_profile_id=...` shows the new row - [ ] Email: 3 buttons per recipe, each a one-click action - [ ] No regression in Sprints 1-7 --- **Last updated: 2026-06-05** — code complete, awaiting deploy.