kitchen/docs/archive/MENUS-PLAN.md
jtricerolph 8d688b459d Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.

Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).

Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.

Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 12:15:39 +00:00

12 KiB

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")

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)

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

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:

{
  "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):

# 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
  • RecipeFlagMatrixfrontend/src/components/RecipeFlagMatrix.tsx visual patterns for MenuFlagMatrix
  • DishListfrontend/src/components/DishList.tsx list page patterns
  • DishEditorfrontend/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 APIGET /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