Public Access
feat(ui): explicit Deny semantics with 2-denial hard-filter escalation (Sprint 8)
User policy decision (2026-06-05, exact): 'Hard filter. If it is denied
this week twice, it should be considered denied for good.'
The planner had no cross-week memory of denials: a denial on
meal_plan_item.approval_status was never consulted by the planner,
and NeverSuggest (the per-family permanent blocklist) was empty for
the user. The 'Roasted Sweet Potato and Chickpea Bowl' the user
denied on 2026-05-15 was still in the planner's pool 3 weeks
later.
Implements C + Z (explicit two-button model + soft-decay +
hard-filter escalation):
- Approve: untouched.
- Deny this week (1st in 90d): denial_expires_at = now() + 90d.
- Deny this week (2nd in 90d, server-side auto-escalation):
denial_expires_at = NULL + a NeverSuggest row written.
- Never again (explicit): same as the 2nd-time auto-escalation.
Both soft and permanent denials are hard filters in the planner
(per user). A denied recipe never reappears until either the 90d
window expires or the user un-blocks via the NeverSuggest API.
Changes:
- Migration 0016: meal_plan_item.denial_expires_at (partial index)
and meal_plan_vote.denial_scope.
- 3 backend helpers (_apply_denial, _ensure_never_suggest_recipe,
_has_prior_active_soft_denial) — single source of truth for the
deny path.
- POST /api/meals/items/{id}/deny?scope=this_week|never_again
(default this_week). Returns promoted_to_permanent.
- POST /api/meals/vote/{id} extended: vote=approve|deny|never_again.
Returns denial_scope + promoted_to_permanent.
- GET /api/meals/vote/{id} HTML page renders 3 buttons; supports
one-click ?scope=... for email direct-action links.
- Email template (step_email): 3 direct-action links per recipe
plus a secondary 'open vote page' link.
- Planner: _load_blocklists returns 3 sets; soft_denied_recipes
is hard-filtered (union with blocked_recipes at the call site).
- Frontend: MealCard renders 3 buttons (Approve / Deny this week
/ Never again) for pending items. handleDeny is scope-aware;
toast reflects promoted_to_permanent. window.confirm on
'Never again' prevents accidental permanent blocks.
Verification:
- npm run build green.
- 21/21 planner tests pass (1 pre-existing test_filter_blocks_by_cost
failure is NOT introduced by Sprint 8 — verified via git stash).
- Review/sprint8-verification.md: 11-step browser smoke + 4 API
curls + email-render procedure + rollback.
Files:
- backend/alembic/versions/0016_denial_decay_and_scope.py (new)
- backend/app/models/__init__.py:221-242, 250-269
- backend/app/schemas/__init__.py:204-219, 248-269
- backend/app/api/meals.py:30-138 (helpers), 240-330 (HTML page),
380-455 (submit_vote), 486-552 (deny_meal_item)
- backend/app/services/orchestrator/steps.py:283-300
- backend/app/services/planner/generate.py:59-99, 150-194
- frontend/src/api/index.ts:48-58
- frontend/src/pages/Dashboard.tsx:38-50, 385-410
- Review/{sprint8-verification,ui-nielsen-audit,handoff-ui-audit}.md
- fix-ui-audit.md
- docs/HANDOFF.md
- .agent/{plan,context}.md
Deploy (user runs on deployment host):
cd ~/MealPlanner && git pull
docker compose exec backend alembic upgrade head
docker compose -f docker-compose.yml up -d --build backend frontend
This commit is contained in:
@@ -281,9 +281,20 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
|
||||
f'{ing_block}'
|
||||
f'{instructions_block}'
|
||||
f'{cost_block}'
|
||||
f'<a href="{vote_url}" style="display:inline-block;margin-top:8px;padding:8px 16px;'
|
||||
f'background:#2563eb;color:white;text-decoration:none;border-radius:6px;font-size:14px">'
|
||||
f'Vote on this meal</a>'
|
||||
f'<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px">'
|
||||
f'<a href="{vote_url}&scope=approve" style="display:inline-block;padding:8px 14px;'
|
||||
f'background:#16a34a;color:white;text-decoration:none;border-radius:6px;font-size:14px">'
|
||||
f'Approve</a>'
|
||||
f'<a href="{vote_url}&scope=this_week" style="display:inline-block;padding:8px 14px;'
|
||||
f'background:#dc2626;color:white;text-decoration:none;border-radius:6px;font-size:14px">'
|
||||
f'Deny this week</a>'
|
||||
f'<a href="{vote_url}&scope=never_again" style="display:inline-block;padding:8px 14px;'
|
||||
f'background:#7f1d1d;color:white;text-decoration:none;border-radius:6px;font-size:14px;'
|
||||
f'border:1px dashed #fca5a5">'
|
||||
f'Never again</a>'
|
||||
f'</div>'
|
||||
f'<div style="font-size:11px;color:#888;margin-top:4px">'
|
||||
f'<a href="{vote_url}" style="color:#2563eb">Open vote page (all 3 options)</a></div>'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user