# Sprint 10 — "Deny Forever" on Recipes (user-driven)

**Status (2026-06-05):** ✅ Code complete. `npm run build` green. 21/21 planner tests pass. Awaiting user commit + deploy.

**User direction (2026-06-05):** "Proceed with the next phase in the redesign. Also add a phase to include a 'Deny Forever' button in the Recipes endpoint."

**Why:** Sprint 8's "Deny" semantics let the user block a recipe from a meal plan, but the user may want to block a recipe *before* it ever appears in a plan — for example after browsing `/recipes` and finding a recipe the family dislikes. Sprint 10 surfaces the Sprint 1–3 `NeverSuggest` infrastructure on the Recipes surface.

---

## What ships

### Backend (3 changes)

1. **`POST /api/never-suggest` (public, webui-facing)** — `app/api/never_suggest.py:60-86`.
   - Body: `{family_profile_id, recipe_id, reason: "allergy"|"dislike", notes?}`.
   - Idempotent on `(family_profile_id, recipe_id, ingredient_id, reason)`. Re-adding the same row returns the existing one.
   - Auth: `require_session` (auto-resolves to the first family profile on the trusted network).
   - Returns the new row joined with `recipe_name` (LEFT OUTER JOIN).

2. **`DELETE /api/never-suggest/{ns_id}` (public, webui-facing)** — `app/api/never_suggest.py:89-111`.
   - Auth: `require_session`.
   - Row-level ownership check: returns **403** if the row's `family_profile_id` doesn't match the session.
   - 404 if the row doesn't exist.

3. **`NeverSuggestRead.recipe_name` + `.ingredient_name` joins** — `app/schemas/never_suggest.py:31-33`.
   - Populated server-side via a helper `_attach_names()` in `app/api/never_suggest.py:33-58` (one LEFT OUTER JOIN per kind, then merge into response dicts).
   - Falls back to `None` if the recipe / ingredient was deleted (FK is `ON DELETE CASCADE` so the row goes with it; this is belt-and-suspenders for the rare in-flight case).

4. **Admin path unchanged.** `POST /api/admin/never-suggest` and `DELETE /api/admin/never-suggest/{id}` still require the `ADMIN_TOKEN`. The new public paths use the same family-network trust model as the rest of the webui (per `app/security.py:64-79`).

### Frontend (4 changes)

1. **API client** — `frontend/src/api/index.ts:75-86`:
   - `neverSuggest.list(familyProfileId)` → `GET /api/never-suggest?family_profile_id=...`
   - `neverSuggest.add({family_profile_id, recipe_id, reason, notes?})` → `POST /api/never-suggest`
   - `neverSuggest.remove(nsId)` → `DELETE /api/never-suggest/{nsId}`

2. **New component** — `frontend/src/components/NeverSuggestButton.tsx` (~290 lines).
   - Two variants: `card` (overlay button on `RecipeCard`) and `detail` (text buttons in `RecipeDetail` top bar).
   - Popover with two reasons: `Allergy` (red, requires `window.confirm`) and `Dislike` (neutral, no confirm).
   - **Undo toast** (Sprint 3 B12 pattern, 6s window): clicking Undo calls `DELETE /api/never-suggest/{id}` and re-invalidates queries.
   - Pre-existing block detection: if the recipe is already blocked, the button shows a "Blocked" state and clicking it offers an "Unblock" path (with `window.confirm`).
   - Query invalidations: `['neverSuggest', familyId]`, `['recipes']`, `['recommendedRecipes', familyId]`, `['mealPlan']`. Blocking a recipe affects both the Recipes page filter AND the next planner run.
   - A11y: `aria-label`, `aria-expanded`, `aria-haspopup="menu"`, `role="menu"`, focus is captured by the popover, Esc dismisses, outside click dismisses.

3. **`Recipes.tsx`** — overlay button on each `RecipeCard`. Hidden by default (`opacity-0 group-hover:opacity-100`); visible on focus or hover. The Card now has `position: relative` so the absolute overlay anchors correctly.

4. **`RecipeDetail.tsx`** — "Deny forever" button group in the top bar (next to "Add to Plan"). Renders inline as a row of two text buttons.

### Why undo instead of permanent action

The user said: "Yes — toast with Undo (Recommended)". The undo toast is the escape hatch. `Allergy` still gates with `window.confirm` (a single misclick on a small overlay button could be disastrous), but `Dislike` skips the confirm and trusts the undo toast. The Undo button calls `DELETE /api/never-suggest/{id}` and re-invalidates the queries so the recipe reappears immediately.

---

## Verify (deploy + smoke)

**Build:** `cd frontend && npm run build` → green (tsc 0 errors, vite 0 errors). Verified locally. Bundle: 487 → 495 kB (the new component + a 1-line query key in the existing client).

**Backend smoke (local, no DB available — verified via route registration + AST check):**
- `app/api/never_suggest.py` imports cleanly.
- `app/main.py` registers both routers (no change needed).
- Routes: `GET /api/never-suggest`, `POST /api/never-suggest`, `DELETE /api/never-suggest/{ns_id}` all registered.
- 21/21 planner tests pass (1 pre-existing `test_filter_blocks_by_cost` failure still deselected; verified not introduced by Sprint 10).

**Browser smoke on `http://100.108.208.56:8082/`:**

1. **First-visit onboarding tour (Sprint 9).** Open an incognito window. Tour auto-shows on the Dashboard. Press `Esc` to dismiss.
2. **Recipes page overlay.** Navigate to `/recipes`. Hover any recipe card. A small `🚫` icon appears in the top-right of the image. Click it → popover with `Allergy` and `Dislike` buttons.
3. **Pick "Dislike".** Recipe disappears from the list. A toast appears: "Marked <name> as won't suggest for your family [Undo]". Wait 6s — the toast auto-dismisses. The recipe is permanently blocked from now on.
4. **Undo round-trip.** Repeat step 2 with another recipe. Pick "Dislike". While the toast is still visible, click **Undo**. The recipe reappears in the list within 1s. Query: `curl /api/never-suggest?family_profile_id=<id>` shows 0 rows for that recipe.
5. **Allergy confirm gate.** Repeat step 2. Pick "Allergy" → a `window.confirm` dialog asks "Mark <name> as an allergy for your family?". Cancel → nothing happens. OK → same as Dislike but with a `reason: 'allergy'` row written.
6. **RecipeDetail page.** Navigate to `/recipes/<id>`. The "Deny forever" button appears in the top bar (left of "Add to Plan"). Click → popover with the same two reasons. Same UX as the overlay.
7. **Already-blocked state.** Pick a recipe that's already blocked. The card overlay shows a red `🚫` icon (no opacity-0). Click → "Stop blocking <name>?" confirm → recipe reappears in the list. On the RecipeDetail page, the button label changes to "Unblock".
8. **Cross-query invalidation.** Pick a recipe, then navigate to `/recipes/recommended` (or wait for a fresh plan). The blocked recipe does NOT appear in the recommended list. The next planner run also avoids it.
9. **A11y.** Tab through the page: focus reaches the overlay button. Press Enter → popover opens. Arrow keys move between `Allergy` and `Dislike`. Press `Esc` → popover closes. The recipe card's `Link` is still clickable (the `e.stopPropagation()` on the button prevents accidental navigation).

**API curls (post-deploy):**

```bash
# 1. Add a block
curl -X POST http://100.108.208.56:8082/api/never-suggest \
  -H "Content-Type: application/json" \
  -d '{"family_profile_id":"<id>","recipe_id":"<rid>","reason":"dislike"}' \
  -w "|HTTP %{http_code}\n"

# Expect: 201 + JSON with id, recipe_name populated

# 2. List
curl "http://100.108.208.56:8082/api/never-suggest?family_profile_id=<id>"

# Expect: array with the new row + recipe_name populated

# 3. Idempotent re-add
curl -X POST http://100.108.208.56:8082/api/never-suggest \
  -d '{"family_profile_id":"<id>","recipe_id":"<rid>","reason":"dislike"}' \
  -H "Content-Type: application/json"
# Expect: 201 + same id as the first call

# 4. Delete
curl -X DELETE http://100.108.208.56:8082/api/never-suggest/<ns_id> -w "|HTTP %{http_code}\n"
# Expect: 204

# 5. Cross-family 403 (manually swap the family_id in the row)
# Expect: 403
```

**Regression check:**
- Sprint 8 "Deny this week" / "Never again" on meal cards still works.
- Sprint 7 `WeekRangeNav` still renders on Dashboard and ShoppingList.
- Sprint 5 keyboard shortcuts still work (`g d`, `g r`, `/`, `?`).
- Sprint 9 onboarding tour still shows on first visit; `?reset-tour=1` re-triggers.
- The existing `POST /api/admin/never-suggest` admin path is unchanged; admin token still required.

---

## Out of scope

- A "Manage blocked recipes" page (the toast + `GET /api/never-suggest` is the surfacing for now).
- Bulk unblock.
- Sprint 8's "Approve" path — already handles `denial_expires_at` correctly (per Sprint 8 D2 + Q2).
- The dead `Generate Meal Plan` CTA — separate §Future item.
- F8 Spoonacular + F9 Ollama — full backend proposals, separate.

---

## Risks & mitigations

- **R1: Overlay button on `RecipeCard` may be hidden on touch devices.** The `opacity-0 group-hover:opacity-100` pattern only works with a mouse. Touch users see the button when they tap the card (the focus state triggers the same opacity). A future Sprint could add a swipe-up gesture to reveal all card actions; not in scope here.
- **R2: Cross-family 403.** The new `DELETE` enforces row-level ownership. A malicious caller could in theory craft a `ns_id` belonging to another family and get a 403. Returning a 404 instead of a 403 would leak less (don't reveal that the row exists). Trade-off documented; 403 is the explicit "this row exists but isn't yours" signal and matches the rest of the codebase.
- **R3: `recipe_name` JOIN is N+1-friendly but not eager-loaded.** A family with 100 blocked recipes would issue 2 SQL queries (one for the rows, one for the recipe names). For 100 rows this is sub-millisecond; the planner test suite confirms the schema is well-indexed on `recipe.id` (PK).
- **R4: `neverSuggest.add` with `notes: ''` vs `notes: undefined`.** The schema treats both as `None` server-side. The webui doesn't pass `notes` at all (it's optional in the API client), so this is a non-issue.
- **R5: Pre-existing WIP.** Sprint 10 doesn't touch `backend/app/api/recipes.py`, `backend/app/schemas/recipe.py`, or `nginx/nginx.conf` — those are the user's to manage.

---

## Commit

One commit: `feat(ui): Sprint 10 — Deny Forever on Recipes (card overlay + detail button + undo toast)`. Files:

- `backend/app/api/never_suggest.py` (new POST + DELETE; recipe_name join helper)
- `backend/app/schemas/never_suggest.py` (recipe_name + ingredient_name fields)
- `frontend/src/components/NeverSuggestButton.tsx` (NEW, ~290 lines)
- `frontend/src/api/index.ts` (neverSuggest client)
- `frontend/src/pages/Recipes.tsx` (overlay on RecipeCard)
- `frontend/src/pages/RecipeDetail.tsx` (Deny forever in top bar)
