Appearance
Proxy Layer Architecture Overview
The Upvendo backend proxy (upvendo-backend-proxy) is a Cloudflare Worker that sits between all client applications and the Laravel backend API. It provides request routing, KV-based response caching, D1-based stock management, and the Item Database API.
Repository: upvendo-backend-proxyRuntime: Cloudflare Workers (V8 isolates) Entry point: src/index.js
High-Level Architecture
Client Apps (Backoffice, Kiosk, Online Ordering)
|
v
Cloudflare Workers Proxy
| | | |
v v v v
/api/* /d1-api/* /item-db-api/* /health
| | |
v v v
Laravel D1 Stock D1 Item
Backend Database DatabasesAll traffic from frontends goes through the proxy. The proxy decides how to handle each request based on the URL path prefix.
Request Routing
The main fetch() handler in src/index.js routes requests by path:
| Path Prefix | Handler | Destination |
|---|---|---|
OPTIONS (any path) | handleCors() | CORS preflight response |
/item-database-api/* | handleItemDatabaseAPI() | D1 item databases (country-based) |
/d1-api/* | handleStockAPI() | D1 stock database |
/api/* | proxyToLaravel() | Laravel backend with KV caching |
/health or /health/* | handleHealthCheck() | Health check responses |
| Everything else | 404 | Not Found |
Proxying to Laravel Backend
For /api/* requests, the proxy forwards the request to the Laravel backend and optionally caches the response.
Flow
- Check cache -- For GET requests to cacheable endpoints, check KV cache first.
- Forward request -- Build a new request to the Laravel backend URL, forwarding all headers (except
host,cf-ray). - Add client IP -- Sets
X-Upvendo-Client-IPfromcf-connecting-ipfor accurate IP detection in Laravel. - Handle redirects -- Redirect responses (3xx) are forwarded with CORS headers.
- Handle file downloads -- Binary responses (ZIP, Excel, images) are streamed directly without loading into memory.
- Cache response -- Successful GET responses to cacheable endpoints are stored in KV with TTL.
- Return response -- Add CORS headers and
X-Cache: HIT|MISSindicator.
Cache Configuration
Cache key format: api:{userHash}:{apiKeyHash}:{pathname}:{search}
The cache key incorporates the Authorization header and API key to ensure per-user cache isolation.
Cacheable endpoints:
| Endpoint Pattern | Cache TTL |
|---|---|
/api/settings/* | 1 hour |
/api/constants/* | 1 hour |
/api/menus/* | 10 minutes |
/api/items/* | 10 minutes |
/api/languages/* | 5 minutes |
/api/categories/* | 5 minutes |
Only GET requests are cached. POST, PUT, DELETE requests always go through to the backend.
D1 Stock Management System
The stock management system uses Cloudflare D1 (SQLite at the edge) for low-latency stock tracking. This is critical for kiosk and online ordering to check item availability without hitting the Laravel backend.
Stock Service (src/stock-service.js)
The StockService class provides all stock operations. It uses merchant-specific tables with a naming convention of stocks_merchant_{slug} and stock_reservations_merchant_{slug}.
Table Structure
For each merchant, three tables exist:
stocks_merchant_{slug}
| Column | Type | Description |
|---|---|---|
item_id | TEXT | Item identifier |
location_id | TEXT | Location identifier |
stock | INTEGER | Current stock quantity |
expired_at | INTEGER | Unix timestamp when stock expires |
updated_at | INTEGER | Last update timestamp |
stock_reservations_merchant_{slug}
| Column | Type | Description |
|---|---|---|
id | TEXT | Reservation identifier |
item_id | TEXT | Item identifier |
reserved_quantity | INTEGER | Reserved quantity |
session_id | TEXT | Cart/order session ID |
expires_at | INTEGER | Unix timestamp when reservation expires |
created_at | INTEGER | Creation timestamp |
constants_merchant_{slug}
| Column | Type | Description |
|---|---|---|
key | TEXT | Constant key |
value | TEXT | JSON-encoded value |
updated_at | INTEGER | Last update timestamp |
Stock Operations
| Method | Description |
|---|---|
getStocks(slug, locationId) | Get all stocks for a merchant, with reserved quantities calculated |
updateStock(slug, itemId, locationId, stock, expiredAt) | Create or replace stock for an item |
deleteStock(slug, itemId) | Remove stock record for an item |
bulkUpdateStocks(slug, updates, alsoTruncate) | Batch stock update, optionally clearing existing data |
getAvailableStock(slug, itemId) | Get available stock (total minus active reservations) |
Reservation System
The reservation system prevents overselling by temporarily holding stock during the ordering process.
| Method | Description |
|---|---|
createReservation(slug, itemId, qty, sessionId, expirationSec, reservationId) | Reserve stock for a cart session |
releaseReservationsBySession(slug, sessionId) | Release all reservations for a session |
cleanupExpiredReservations(slug) | Delete expired reservations |
Reservation flow:
- Customer adds item to cart ->
createReservation()with 10-minute default expiration - Available stock = total stock - sum of active (non-expired) reservations
- If reservation exists for the session, it is updated rather than duplicated
- When customer places order -> reservations are released via
releaseReservationsBySession() - If customer abandons cart -> reservations expire automatically
Insufficient stock handling: If available_stock < requested_quantity, the reservation returns { success: false, error: 'Insufficient stock' }.
No stock record: If an item has no stock record in D1, it returns available_stock: 99999 (unlimited).
Constants Operations
| Method | Description |
|---|---|
getConstants(slug, key?) | Get all or specific constants |
setConstant(slug, key, value) | Set a single constant |
bulkSetConstants(slug, constants) | Batch set constants |
Analytics
The stock service records analytics for each D1 API call:
sql
INSERT INTO analytics (merchant_slug, endpoint, response_time, timestamp)Analytics recording is done asynchronously via ctx.waitUntil() to avoid blocking the response.
Health Checks
| Endpoint | Response |
|---|---|
/health | Overall health + analytics count |
/health/{merchantSlug} | Merchant-specific stock and reservation counts |
Item Database API
The Item Database API provides a centralized product database for item information, nutritional data, allergens, and ingredients. It uses a country-based architecture with separate D1 databases per country.
Handler: src/item-database-handler.js
Database Architecture
D1 Databases:
ITEMS_DB_GLOBAL -- Global reference data (allergens, ingredients, dietary info)
ITEMS_DB_BE -- Belgian items
ITEMS_DB_FR -- French items
ITEMS_DB_NL -- Dutch items
ITEMS_DB_DE -- German items
ITEMS_DB_ES -- Spanish items
ITEMS_DB_PT -- Portuguese items
ITEMS_DB_US -- American itemsEach country database contains items scoped by business type. The scope format is {business_type}-{country_code} (e.g., frituur-be, restaurant-fr).
Endpoints
| Method | Path | Auth Required | Description |
|---|---|---|---|
| GET | /item-database-api/items?scope={scope} | Yes | List items with pagination and search |
| GET | /item-database-api/items/{id}?scope={scope} | Yes | Get single item by ID |
| POST | /item-database-api/items | Yes | Create item (supports multipart/form-data) |
| PUT | /item-database-api/items/{id}?scope={scope} | Yes | Update item |
| DELETE | /item-database-api/items/{id}?scope={scope} | Yes | Delete item |
| GET | /item-database-api/suggestions?scope={scope}&search={term} | Yes | Get single most relevant item for autofill |
| GET | /item-database-api/scopes | Yes | List all scopes with pagination |
| POST | /item-database-api/scopes | Yes | Create new scope |
| PUT | /item-database-api/scopes/{id} | Yes | Update scope name |
| DELETE | /item-database-api/scopes/{id} | Yes | Delete scope |
| GET | /item-database-api/ingredients | Yes | List ingredients with pagination |
| POST | /item-database-api/ingredients | Yes | Create ingredient |
| PUT | /item-database-api/ingredients/{id} | Yes | Update ingredient |
| DELETE | /item-database-api/ingredients/{id} | Yes | Delete ingredient |
| GET | /item-database-api/allergens | Yes | List allergens |
| GET | /item-database-api/dietary-preferences | Yes | List dietary preferences |
| GET | /item-database-api/dietary-supplements | Yes | List dietary supplements |
| GET | /item-database-api/ingredient-categories | Yes | List ingredient categories |
| GET | /item-database-api/item-categories | Yes | List item categories |
| POST | /item-database-api/migrations/{scope} | Yes | Create tables for a scope |
| GET | /item-database-api/constants | Yes | Get all business types, countries, languages |
| GET | /item-database-api/reference-data/all | No (public) | Get all reference data from KV cache |
Authentication
Most endpoints require a valid bearer token with has_global_access permission. The reference-data endpoint is public.
Scope Validation
Scope values are validated against:
- Business types: Predefined list of valid types (frituur, restaurant, etc.)
- Country codes: BE, FR, NL, DE, ES, PT, US
Invalid scopes return a 400 error with the list of valid values.
Uninitialized Scope Handling
If a scope's tables have not been created yet (SQL "no such table" error), the API returns:
- Empty results for list endpoints (not a 500 error)
- A helpful error message with a hint to use the
/migrationsendpoint
Environment Configuration
Configured in wrangler.toml with per-environment settings.
Environments
| Environment | Worker Name | Backend URL |
|---|---|---|
production | upvendo-api-proxy | https://backend.upvendo.com |
staging | upvendo-api-proxy-staging | https://staging.backend.upvendo.com |
testing | upvendo-api-proxy-testing | https://testing.backend.upvendo.com |
dev | upvendo-api-proxy-dev | http://127.0.0.1:8000 |
Bindings Per Environment
Each environment has:
KV_CACHE-- KV namespace for response caching and reference dataSTOCK_DB-- D1 database for merchant stock managementITEMS_DB_GLOBAL-- D1 database for global item reference dataITEMS_DB_{CC}-- Country-specific D1 databases (US, BE, FR, NL, DE, PT, ES)
Environment Variables
| Variable | Description |
|---|---|
ENVIRONMENT | Current environment name |
LARAVEL_BACKEND_URL | Backend API base URL |
ALLOWED_ORIGINS | Comma-separated list of allowed CORS origins |
D1_API_SECRET | Secret for backend-to-proxy D1 API calls |
BACKEND_IPS | Allowed backend server IPs |
CLOUDFLARE_ACCOUNT_ID | Cloudflare account identifier |
CLOUDFLARE_ACCOUNT_HASH | Cloudflare account hash for image URLs |
Allowed Origins (Production)
https://kiosk.upvendo.com
https://backoffice.upvendo.com
https://zestidoo.com
https://zestidoo.be
https://zestidoo.co.uk
https://zestidoo.de
https://zestidoo.fr
https://zestidoo.nl
https://localhostCORS Handling
CORS is handled centrally via src/cors-helpers.js. Every response includes:
Access-Control-Allow-Origin-- Set to the matching allowed origin (not*)Access-Control-Allow-Credentials: true
For file downloads, Content-Disposition is added to Access-Control-Expose-Headers.
Key Patterns for Debugging
Stock discrepancies: Check if the D1 stock data is out of sync with the Laravel backend inventory. The D1 data is populated by the backend and may lag behind.
Cache staleness: KV cache TTLs range from 5 minutes to 1 hour. If a merchant reports stale data, check the
X-Cacheresponse header and the relevant endpoint TTL.Scope initialization: New merchants or business types need their scope tables created via the
/migrationsendpoint before the item database works.Reservation leaks: If reservations are not released (e.g., payment flow crashes), expired reservations are cleaned up by
cleanupExpiredReservations(). The default reservation expiration is 10 minutes.Environment mismatch: Ensure the frontend is pointing to the correct proxy environment. Staging frontends should hit
upvendo-api-proxy-staging, not production.