ByteBite Docs
Architecture

Object storage uploads

Dashboard uploads for restaurant logos and menu images via S3-compatible object storage.

Object storage uploads

The seventh product slice is Menu → Object Storage. An operator uploads a restaurant logo, category image, or item image from the dashboard. The file is stored in S3-compatible object storage (local: MinIO, ADR-018). PostgreSQL keeps only the existing URL columns.

Storefront and dashboard continue to render logoUrl / imageUrl. There is no CDN and no staging/production B2 media in this slice.

Tables

No new tables. Persistence stays on:

ColumnTable
logo_urlrestaurants
image_urlmenu_categories
image_urlmenu_items

Images remain optional. An empty or missing upload file does not clear the URL. Text-URL edits on category/item PATCH still work.

Seeds do not create uploads or orders.

Domain rules

Allowed upload targets: logo, categoryImage, itemImage.

Every object key starts with restaurants/:restaurantId/:

restaurants/:restaurantId/logo/:objectId.:ext
restaurants/:restaurantId/categories/:categoryId/:objectId.:ext
restaurants/:restaurantId/items/:itemId/:objectId.:ext

:objectId is a UUID. Keys must not escape the restaurant prefix (.. is rejected). Luigi cannot write an object under Mario's prefix, and the reverse.

Accepted types: image/jpeg, image/png, image/webp, image/gif. Maximum size is 5 MiB.

API

Upload is multipart (file field). Restaurant context is the path (ADR-021). Uploads require a staff session. Body restaurantId does not change the tenant. Foreign categoryId / itemId → 404 and no object write. GET /media/* stays public.

MethodPath
POST/dashboard/restaurants/:restaurantId/logo
POST/dashboard/restaurants/:restaurantId/categories/:categoryId/image
POST/dashboard/restaurants/:restaurantId/items/:itemId/image
GET/media/*

After a successful upload, existing GET payloads include the new URL:

  • GET /storefront/restaurant → branding.logoUrl
  • GET /storefront/menu and GET /dashboard/restaurants/:restaurantId/menu → imageUrl

The API storage client reads S3_ENDPOINT, S3_REGION, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, and S3_FORCE_PATH_STYLE. Missing credentials produce a clear error. There is no filesystem fallback.

Why GET /media/*

Compose MinIO listens on the backend network only (http://minio:9000). Port 9000 is not published to the browser, and this slice does not add a CDN or public bucket URLs. The persisted value is therefore a same-origin path /api/media/restaurants/:restaurantId/.... Traefik strips /api; Fastify serves GET /media/:key by streaming the object. That is a code-forced serving path, not a new storage product.

Guessing another restaurant's object requires its UUID object id. There is no listing endpoint.

Dashboard and storefront

  • Dashboard / — restaurant switcher + logo upload. Luigi's UI does not show or change Mario's logo.
  • Dashboard /menu — category and item image upload next to the existing text URL fields.
  • Storefront — no upload UI. Existing <img src={logoUrl|imageUrl}> rendering is enough.

Isolation

  • Luigi logo upload sets Luigi logo_url. The object key contains Luigi's restaurantId. Mario GET / storefront does not show that logo.
  • Luigi item image under Mario's restaurantId, or Mario's itemId in Luigi's path → 404, no object, Mario image_url unchanged.
  • Multipart field restaurantId does not switch tenant.

Not in this slice

BitePay, Kitchen, Checkout changes, Delivery, opening hours, Better Auth, variants/extras/allergens, SMS/e-mail, CDN, staging/production B2 media, sound/PWA, production deploy.

See Infrastructure storage for MinIO/B2 environment status and ADR-018.

On this page