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:
@@ -0,0 +1,74 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user