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>
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_idspattern fromfood_flags.py:582to 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_atMenuItemSection— id, menu_id, name, sort_orderMenuItem— 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):
- Verify recipe_id is a dish (
recipe_type == 'dish'), not archived - Run
compute_recipe_flags()to get current flags - Check for unassessed ingredients (from flags endpoint pattern)
- If unassessed exist → 400 error with details
- Accept:
{ recipe_id, section_id, display_name, description, price, confirmed_by_name } - Snapshot current flags as
confirmed_flags_json - 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:
- From DishEditor — "Publish to Menu" button in the dish header area
- From MenuEditor — "Add Dish" button
Both use the same PublishToMenuModal component:
Step 1 — Allergen Check:
- Fetch
compute_recipe_flagsfor 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_priceif 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/recipesaccess 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:582for consolidated flag computation_collect_recipe_ingredient_ids()—backend/api/food_flags.pyfor walking sub-recipe tree (staleness check)get_kitchen_from_api_key()—backend/api/external.py:25for external API authRecipeFlagMatrix—frontend/src/components/RecipeFlagMatrix.tsxvisual patterns for MenuFlagMatrixDishList—frontend/src/components/DishList.tsxlist page patternsDishEditor—frontend/src/components/DishEditor.tsxeditor page patterns- Migration pattern —
backend/migrations/add_ingredient_flag_dismissals.py
Implementation Order
- Migration + models (backend/migrations + backend/models)
- Backend menus API — CRUD + publish endpoints
- Frontend MenuList + MenuEditor pages + routing
- Frontend PublishToMenuModal + DishEditor "Publish" button
- External API menu endpoints
- MenuFlagMatrix print view
- Docker rebuild + end-to-end test
Verification
- Docker rebuild — migration creates 3 tables
- Create menu — name, add sections, reorder sections
- Publish dish — from DishEditor, confirm allergens, pick menu+section, set display info → appears on menu
- Staleness — edit a published dish (or its sub-recipe) → menu shows "Needs Republishing" indicator
- Republish — click republish, re-confirm allergens → staleness clears, new flag snapshot saved
- External API —
GET /api/external/menuswith X-API-Key returns active menus with dishes + flags - Print matrix — from MenuEditor, click Print → flag matrix shows consolidated allergens by section, renders cleanly in print
- Unassessed block — try to publish dish with unassessed ingredients → blocked with error message
- Remove/edit — edit display_name/price on published item, remove item from menu