ByteBite Docs
Architecture

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

ColumnNotes
idUUID
restaurant_idFK → restaurants.id (ON DELETE restrict)
nameRequired
description / image_urlOptional
is_activeCatalog visibility (boolean, not a tenant status enum)
sort_orderInteger; ordered with created_at / id as tie-breakers
created_at / updated_atTimestamps
ColumnNotes
idUUID
restaurant_idRequired defense-in-depth copy of the tenant key
category_idFK → menu_categories.id (ON DELETE cascade)
nameRequired
description / image_urlOptional
price_centsInteger >= 0. Display currency is EUR. No currency column.
is_activeCatalog visibility
sort_orderInteger

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 restaurantId is ignored
  • Empty catalog → 200 with categories: []

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.

MethodPath
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.

On this page