# Plan: Menus Feature — Publish Dishes to Curated Menus

## Context

Dishes exist in the system with full allergen flag tracking, but there's no way to curate them into actual menus (e.g., "Evening Menu", "Lunch Menu"). The Menus feature lets chefs publish dishes to named menus with sections (Starters, Mains, Desserts, etc.), requiring allergen confirmation before publishing. Menus are served via the existing external API for website widgets. When a dish or its sub-recipes change after publishing, the menu item is flagged as stale, requiring republishing.

---

## Data Model

### New Tables

**`menus`** — Named menus (e.g., "Dinner Menu", "Sunday Lunch")
```sql
CREATE TABLE menus (
    id SERIAL PRIMARY KEY,
    kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
    name VARCHAR(255) NOT NULL,
    is_active BOOLEAN DEFAULT true,
    sort_order INTEGER DEFAULT 0,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),
    UNIQUE(kitchen_id, name)
);
```

**`menu_item_sections`** — Sections within a specific menu (per-menu, not shared)
```sql
CREATE TABLE menu_item_sections (
    id SERIAL PRIMARY KEY,
    menu_id INTEGER NOT NULL REFERENCES menus(id) ON DELETE CASCADE,
    name VARCHAR(100) NOT NULL,
    sort_order INTEGER DEFAULT 0,
    UNIQUE(menu_id, name)
);
```

**`menu_items`** — Published dishes on a menu
```sql
CREATE TABLE menu_items (
    id SERIAL PRIMARY KEY,
    menu_id INTEGER NOT NULL REFERENCES menus(id) ON DELETE CASCADE,
    section_id INTEGER NOT NULL REFERENCES menu_item_sections(id) ON DELETE CASCADE,
    recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
    display_name VARCHAR(255) NOT NULL,
    description TEXT,
    price NUMERIC(10, 2),
    sort_order INTEGER DEFAULT 0,
    confirmed_flags_json TEXT,           -- JSON snapshot of flags at publish time
    confirmed_by_name VARCHAR(100),      -- who confirmed allergens
    published_at TIMESTAMP DEFAULT NOW(),
    UNIQUE(menu_id, recipe_id)           -- dish appears once per menu
);
```

### Staleness (computed, not stored)

At query time, compare `menu_item.published_at` against:
- `recipe.updated_at` — direct dish edit
- Sub-recipe `updated_at` — component recipe edit (reuse `_collect_recipe_ingredient_ids` pattern from `food_flags.py:582` to walk the sub-recipe tree)

No `is_stale` column needed — computed on each load.

---

## Backend

### Models (`backend/models/menu.py` — NEW)

- `Menu` — id, kitchen_id, name, is_active, sort_order, created_at, updated_at
- `MenuItemSection` — id, menu_id, name, sort_order
- `MenuItem` — id, menu_id, section_id, recipe_id, display_name, description, price, sort_order, confirmed_flags_json, confirmed_by_name, published_at
- Relationships: Menu → sections, Menu → items, MenuItem → recipe

### Migration (`backend/migrations/add_menus.py` — NEW)

Follow existing pattern (`add_ingredient_flag_dismissals.py`): `CREATE TABLE IF NOT EXISTS`, indexes, idempotent.

### API (`backend/api/menus.py` — NEW)

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/menus` | GET | List menus with section/item counts |
| `/api/menus` | POST | Create menu (name) |
| `/api/menus/{id}` | GET | Full menu: sections + items + staleness |
| `/api/menus/{id}` | PUT | Update menu name/is_active |
| `/api/menus/{id}` | DELETE | Delete menu |
| `/api/menus/{id}/sections` | POST | Add section |
| `/api/menus/{id}/sections/{sid}` | PUT | Rename section |
| `/api/menus/{id}/sections/{sid}` | DELETE | Delete section (cascade items) |
| `/api/menus/{id}/sections/reorder` | PUT | Reorder sections `[{id, sort_order}]` |
| `/api/menus/{id}/items` | POST | Publish dish to menu (allergen confirm) |
| `/api/menus/{id}/items/{iid}` | PUT | Edit display_name/description/price/section |
| `/api/menus/{id}/items/{iid}` | DELETE | Remove dish from menu |
| `/api/menus/{id}/items/{iid}/republish` | POST | Republish (re-confirm flags, update snapshot) |
| `/api/menus/{id}/items/reorder` | PUT | Reorder items within section |
| `/api/menus/{id}/flags` | GET | Consolidated flag matrix for all items (for print) |

**GET `/api/menus/{id}` response shape:**
```json
{
  "id": 1, "name": "Evening Menu", "is_active": true,
  "sections": [
    {
      "id": 10, "name": "Starters", "sort_order": 0,
      "items": [
        {
          "id": 100, "recipe_id": 5, "display_name": "Beetroot Tartare",
          "description": "...", "price": "12.50", "sort_order": 0,
          "confirmed_flags": [...], "confirmed_by_name": "Chef James",
          "published_at": "...",
          "is_stale": true,
          "stale_reason": "Dish edited after publishing"
        }
      ]
    }
  ]
}
```

**Staleness computation** (in the GET detail endpoint):
```python
# For each menu_item:
# 1. Check dish.updated_at > item.published_at
# 2. Walk sub-recipe tree, check any updated_at > item.published_at
# Reuse _collect_sub_recipe_ids() helper (similar to food_flags.py pattern)
```

**Publish endpoint validation** (`POST /items`):
1. Verify recipe_id is a dish (`recipe_type == 'dish'`), not archived
2. Run `compute_recipe_flags()` to get current flags
3. Check for unassessed ingredients (from flags endpoint pattern)
4. If unassessed exist → 400 error with details
5. Accept: `{ recipe_id, section_id, display_name, description, price, confirmed_by_name }`
6. Snapshot current flags as `confirmed_flags_json`
7. Create `MenuItem`

### External API (`backend/api/external.py` — MODIFY)

Add two endpoints using existing `get_kitchen_from_api_key` dependency:

| Endpoint | Purpose |
|----------|---------|
| `GET /api/external/menus` | List active menus with sections + items + flags |
| `GET /api/external/menus/{id}` | Single menu detail |

Response includes: menu name, sections with items (display_name, description, price, confirmed_flags). No cost data, no internal IDs beyond menu item IDs.

### Register in main.py

- Import `api.menus`
- Register router: `app.include_router(menus.router, prefix="/api/menus", tags=["menus"])`
- Import and run migration

---

## Frontend

### MenuList Page (`frontend/src/components/MenuList.tsx` — NEW)

Similar to DishList/RecipeList pattern:
- List of menus with name, section count, item count, active/inactive badge
- Create modal: menu name input
- Click menu → navigate to `/menus/{id}`
- Toggle active/inactive
- Delete with confirmation
- Sortable (drag handle or up/down buttons)

### MenuEditor Page (`frontend/src/components/MenuEditor.tsx` — NEW)

Full menu management page:
- **Header**: Menu name (editable), Active/Inactive toggle, Print Flag Matrix button
- **Sections**: Collapsible, sortable list
  - Each section shows name (editable), sort handle
  - Add Section button
  - Delete section (with confirmation if items exist)
- **Items within each section**: Sortable list
  - Each item shows: display_name, description preview, price, confirmed flag badges, staleness indicator
  - Edit button → inline or modal edit of display_name, description, price, section assignment
  - Remove button
  - **Stale indicator**: amber/yellow badge "Needs Republishing" with tooltip showing reason
  - **Republish button**: Re-runs allergen confirmation, snapshots new flags
- **Add Dish button** (per section or floating): Opens dish picker → then publish flow

### Publish Flow

Two entry points:
1. **From DishEditor** — "Publish to Menu" button in the dish header area
2. **From MenuEditor** — "Add Dish" button

Both use the same **PublishToMenuModal** component:

**Step 1 — Allergen Check:**
- Fetch `compute_recipe_flags` for the dish
- Show consolidated flags grouped by category
- If unassessed ingredients exist → show red warning, block publishing
- If open suggestions exist → show amber warning (advisory, not blocking)

**Step 2 — Confirm & Publish:**
- Display the consolidated flags for chef review
- "Confirmed by" name input (required, remembers last used)
- Menu picker dropdown (only active menus)
- Section picker dropdown (sections within selected menu)
- Display name (pre-filled from dish name)
- Description textarea
- Price input (pre-filled from dish `gross_sell_price` if set)
- **Publish** button

### MenuFlagMatrix (`frontend/src/components/MenuFlagMatrix.tsx` — NEW)

Print-optimized flag matrix:
- Rows: dishes grouped by section (section headers as group rows)
- Columns: all allergen flags
- Cells: flag status from `confirmed_flags_json`
- Uses the same visual style as RecipeFlagMatrix but read-only
- Print button triggers `window.print()` with print-optimized CSS
- Accessed from MenuEditor header

### Navigation Update (`frontend/src/App.tsx` — MODIFY)

- Add `import MenuList from './components/MenuList'`
- Add `import MenuEditor from './components/MenuEditor'`
- Add routes: `/menus` → MenuList, `/menus/:id` → MenuEditor (under `/recipes` access check)
- Add "Menus" link in Recipes dropdown (between "Dishes" and "Allergens")

### DishEditor Update (`frontend/src/components/DishEditor.tsx` — MODIFY)

- Add "Publish to Menu" button in the header area (next to Archive/Delete buttons)
- Button disabled with tooltip if dish has unassessed ingredients
- Opens PublishToMenuModal

---

## Files to Create/Modify

| File | Change |
|------|--------|
| `backend/migrations/add_menus.py` | **NEW** — migration for 3 tables |
| `backend/models/menu.py` | **NEW** — Menu, MenuItemSection, MenuItem models |
| `backend/api/menus.py` | **NEW** — Full CRUD + publish/republish API |
| `backend/api/external.py` | Add `GET /menus`, `GET /menus/{id}` public endpoints |
| `backend/main.py` | Register menus API + migration |
| `frontend/src/components/MenuList.tsx` | **NEW** — Menu list page |
| `frontend/src/components/MenuEditor.tsx` | **NEW** — Menu detail/editor page |
| `frontend/src/components/PublishToMenuModal.tsx` | **NEW** — Allergen confirm + publish flow |
| `frontend/src/components/MenuFlagMatrix.tsx` | **NEW** — Print-optimized flag matrix |
| `frontend/src/components/DishEditor.tsx` | Add "Publish to Menu" button |
| `frontend/src/App.tsx` | Routes + nav link |

## Key Patterns to Reuse

- `compute_recipe_flags()` — `backend/api/food_flags.py:582` for consolidated flag computation
- `_collect_recipe_ingredient_ids()` — `backend/api/food_flags.py` for walking sub-recipe tree (staleness check)
- `get_kitchen_from_api_key()` — `backend/api/external.py:25` for external API auth
- `RecipeFlagMatrix` — `frontend/src/components/RecipeFlagMatrix.tsx` visual patterns for MenuFlagMatrix
- `DishList` — `frontend/src/components/DishList.tsx` list page patterns
- `DishEditor` — `frontend/src/components/DishEditor.tsx` editor page patterns
- Migration pattern — `backend/migrations/add_ingredient_flag_dismissals.py`

## Implementation Order

1. Migration + models (backend/migrations + backend/models)
2. Backend menus API — CRUD + publish endpoints
3. Frontend MenuList + MenuEditor pages + routing
4. Frontend PublishToMenuModal + DishEditor "Publish" button
5. External API menu endpoints
6. MenuFlagMatrix print view
7. Docker rebuild + end-to-end test

## Verification

1. **Docker rebuild** — migration creates 3 tables
2. **Create menu** — name, add sections, reorder sections
3. **Publish dish** — from DishEditor, confirm allergens, pick menu+section, set display info → appears on menu
4. **Staleness** — edit a published dish (or its sub-recipe) → menu shows "Needs Republishing" indicator
5. **Republish** — click republish, re-confirm allergens → staleness clears, new flag snapshot saved
6. **External API** — `GET /api/external/menus` with X-API-Key returns active menus with dishes + flags
7. **Print matrix** — from MenuEditor, click Print → flag matrix shows consolidated allergens by section, renders cleanly in print
8. **Unassessed block** — try to publish dish with unassessed ingredients → blocked with error message
9. **Remove/edit** — edit display_name/price on published item, remove item from menu
