Menu catalog
Menu categories and items for the storefront and dashboard.
Menu catalog
The second product slice is Menu → Storefront. A restaurant has one implicit menu. Categories and items carry restaurant_id. There is no menus table.
Public guests see only active categories that still have at least one active item. The dashboard operator view includes inactive rows.
Tables
menu_categories
| Column | Notes |
|---|---|
id | UUID |
restaurant_id | FK → restaurants.id (ON DELETE restrict) |
name | Required |
description / image_url | Optional |
is_active | Catalog visibility (boolean, not a tenant status enum) |
sort_order | Integer; ordered with created_at / id as tie-breakers |
created_at / updated_at | Timestamps |
menu_items
| Column | Notes |
|---|---|
id | UUID |
restaurant_id | Required defense-in-depth copy of the tenant key |
category_id | FK → menu_categories.id (ON DELETE cascade) |
name | Required |
description / image_url | Optional |
price_cents | Integer >= 0. Display currency is EUR. No currency column. |
is_active | Catalog visibility |
sort_order | Integer |
Application code keeps menu_items.restaurant_id aligned with the parent category. Moving an item into another restaurant's category is rejected.
Images are optional URL strings, the same pattern as restaurants.logo_url. Dashboard operators can upload a file into object storage; the existing URL column is then set. Text-URL edits still work. See Object storage uploads.
Public API
GET /storefront/menu uses the same hostname tenant gate as GET /storefront/restaurant.
- Unknown or inactive restaurant →
404(Restaurant not found) - Database failure during hostname lookup →
503 - Query/body
restaurantIdis ignored - Empty catalog →
200withcategories: []
Example (Luigi):
{
"restaurantId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1",
"categories": [
{
"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3",
"name": "Pizza",
"description": "Wood-fired pies from Luigi's oven",
"imageUrl": null,
"sortOrder": 0,
"items": [
{
"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa5",
"name": "Margherita",
"description": "Tomato, mozzarella, basil",
"priceCents": 850,
"imageUrl": null,
"sortOrder": 0
}
]
}
]
}Inactive items such as Luigi's Secret Calzone are omitted.
Dashboard API
Dashboard hosts are not restaurant tenants. Operator routes live under /dashboard and skip hostname lookup.
Restaurant context is the path parameter (ADR-021). Mutations always include WHERE restaurant_id = :pathId. Cross-tenant ids return 404. Unauthenticated calls return 401.
| Method | Path |
|---|---|
GET | /dashboard/restaurants |
GET | /dashboard/restaurants/:restaurantId/menu |
POST | /dashboard/restaurants/:restaurantId/categories |
PATCH | /dashboard/restaurants/:restaurantId/categories/:categoryId |
DELETE | /dashboard/restaurants/:restaurantId/categories/:categoryId |
PUT | /dashboard/restaurants/:restaurantId/categories/order |
POST | /dashboard/restaurants/:restaurantId/items |
PATCH | /dashboard/restaurants/:restaurantId/items/:itemId |
DELETE | /dashboard/restaurants/:restaurantId/items/:itemId |
PUT | /dashboard/restaurants/:restaurantId/items/order |
POST | /dashboard/restaurants/:restaurantId/logo |
POST | /dashboard/restaurants/:restaurantId/categories/:categoryId/image |
POST | /dashboard/restaurants/:restaurantId/items/:itemId/image |
The dashboard UI is /menu (http://dashboard.bytebite.test/menu). It uses a restaurant switcher; it does not resolve a tenant from the dashboard hostname.
Staging HTTPS (including storefront and dashboard) is gated by Traefik BasicAuth.
Seeds
Luigi (Pizza, Getränke) and Mario (Pasta, Dessert) use disjoint item names so isolation is obvious in tests and the UI. Each restaurant has one inactive item.
Not in this slice
Extras, allergens, Better Auth, CDN, and staging/production B2 media. Sizes are documented in Menu sizes. Uploads are documented in Object storage uploads.