Files
Meal-Planner/backend/alembic/versions/0016_denial_decay_and_scope.py
T
MealPlanner efd1fc695f 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
2026-06-05 10:24:35 -07:00

75 lines
2.7 KiB
Python

"""Add denial_expires_at and denial_scope for Sprint 8 deny-semantics.
Revision ID: 0016
Revises: 0015
Create Date: 2026-06-05
Sprint 8 (user policy decision: "Hard filter. If it is denied this week twice,
it should be considered denied for good."):
- meal_plan_item.denial_expires_at TIMESTAMPTZ NULL
- NULL on existing rows and on "Never again" denials (no decay; permanent).
- now() + 90 days on "Deny this week" denials.
- The planner's _load_soft_denied_recipes() filters
`denial_expires_at > now()` to find still-active soft denials.
- When a "Deny this week" finds a prior active soft denial for the
same (family, recipe), the API promotes the denial to permanent:
denial_expires_at -> NULL + a NeverSuggest row is inserted.
- meal_plan_vote.denial_scope VARCHAR(16) NULL
- NULL on approve votes.
- "this_week" or "never_again" on deny votes. Captures the user's
intent at vote time (per-voter audit trail).
No data migration needed for existing rows:
- Existing 1 denied item (2026-05-15 day-2 Roasted Sweet Potato and
Chickpea Bowl) keeps denial_expires_at = NULL, which means it is NOT
in the "soft denied" pool (filter requires > now()). Effectively
forgotten after this migration. If the user wants it remembered,
they can re-trigger the soft-deny cycle.
- Existing meal_plan_vote rows keep denial_scope = NULL, which is
interpreted as "approve" (vote column is the source of truth).
Deploy via Docker (the db runs inside a container; no host psql required):
docker compose exec backend alembic upgrade head
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0016"
down_revision: Union[str, None] = "0015"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"meal_plan_item",
sa.Column("denial_expires_at", sa.DateTime(timezone=True), nullable=True),
)
# Partial index: only rows with a non-NULL denial_expires_at are
# queried by the planner. Reduces index size and lookup cost.
op.create_index(
"ix_meal_plan_item_denial_expires_at",
"meal_plan_item",
["denial_expires_at"],
postgresql_where=sa.text("denial_expires_at IS NOT NULL"),
)
op.add_column(
"meal_plan_vote",
sa.Column("denial_scope", sa.String(length=16), nullable=True),
)
def downgrade() -> None:
op.drop_column("meal_plan_vote", "denial_scope")
op.drop_index(
"ix_meal_plan_item_denial_expires_at",
table_name="meal_plan_item",
)
op.drop_column("meal_plan_item", "denial_expires_at")