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:
| Column | Table |
|---|---|
logo_url | restaurants |
image_url | menu_categories |
image_url | menu_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.
| Method | Path |
|---|---|
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.logoUrlGET /storefront/menuandGET /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'srestaurantId. Mario GET / storefront does not show that logo. - Luigi item image under Mario's
restaurantId, or Mario'sitemIdin Luigi's path →404, no object, Marioimage_urlunchanged. - Multipart field
restaurantIddoes 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.