New waiter_pwa/src/config/server.js is the single place that knows where the backend is. Served by the venue's server (https domain or http://<LAN IP>) nothing changes: same-origin URLs and the original storage keys, so existing installs keep their token and unsynced offline queue. With an active venue (native app, or dev builds with VITE_SERVER_URL) URLs become absolute and all venue data is namespaced by siteId: token/savedUsername keys, the Dexie DB (pos_snapshot__<siteId>, which also covers the WS cursor), favorites and table-view prefs. Switching venue reloads the app. - api client baseURL, WebSocket and SSE URLs routed through the layer - product images / waiter avatars rendered via assetUrl() - service-worker update prompt skipped in native builds - InstallAppBanner: shown only in plain-HTTP browser mode and only when VITE_APP_DOWNLOAD_URL is set at build time (dismiss for 7 days) - VITE_SERVER_URL override is DEV-only (a URL-controlled server in prod would let a crafted link capture PINs) - pack README: rule CS-8 on never assuming same-origin Verified with Playwright/Edge against a local backend: prod build same-origin, prod build via LAN IP over plain HTTP (insecure context, no SW, banner shown), and dev build pointed at the backend by URL - all three log in, reach /tables and receive the WebSocket 'ready' frame; storage keys and IndexedDB names are as expected. Lint: no new problems (103 before/after). Build passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6.5 KiB
client-services — On-site Pack
Everything that runs inside the restaurant, on the local server box.
This folder is its own git repo, nested inside the xenia-pos parent repo.
Read the global rules first: ../../docs/README.md. This file adds only the rules specific to this pack. The feature catalog, the cloud contract, known issues and site records all live in the parent's
docs/.
Services
| Service | Stack | Dev port | Prod exposure | Image |
|---|---|---|---|---|
local_backend |
FastAPI + SQLAlchemy + SQLite (/app/data/pos.db) |
8000 | only through proxy / inner nginx | pos-backend |
waiter_pwa |
React + Vite + vite-plugin-pwa, axios, zustand, react-query, Dexie (IndexedDB) | 5173 | https://<host> (443) |
pos-waiter |
manager_dashboard |
React + Vite | 5174 | https://<host>:4443 |
pos-manager |
proxy |
nginx:alpine, TLS termination | — | 80 → 443 redirect, 443, 4443 | stock |
Request path in production
phone ──https──▶ proxy:443 ──http──▶ waiter_pwa nginx:80 ──┬─ / → static SPA
├─ /api/ → backend:8000
├─ /api/ws/ → backend:8000 (WebSocket upgrade)
└─ /static/ → backend:8000/static/
Every hop must forward WebSocket upgrades: proxy_http_version 1.1, plus Upgrade and Connection headers, plus a long proxy_read_timeout.
The manager calls the API same-origin, with relative paths. The waiter app resolves every backend URL through waiter_pwa/src/config/server.js: same-origin when served by the venue's server, absolute when running in the native app (see CS-8). Don't hardcode hosts.
Key files
| File | Why it matters |
|---|---|
local_backend/main.py |
App setup, router registration, CORS, _run_migrations() |
local_backend/services/cloud_sync.py |
Every call to the cloud (see the parent's docs/reference/cloud-contract.md) |
local_backend/roles.py, routers/deps.py |
Roles, permission checks, auth dependencies |
local_backend/routers/ws.py |
Real-time event stream (seq + cursor replay) |
waiter_pwa/src/config/server.js |
Where the backend is: active venue, apiBase(), wsUrl(), assetUrl(), storageKey(), dbName(), deliveryMode() |
waiter_pwa/src/api/client.js |
axios instance: auth header, 401 → logout, network error → offline |
waiter_pwa/src/context/SSEContext.jsx |
Real-time lifecycle, event → store/cache updates, visibility refresh |
waiter_pwa/src/db/posdb, src/services/offlineOrders |
Offline cache and queued orders |
nginx-proxy/nginx.conf |
Proxy config used in dev / by hand |
install.sh |
Site installer. Writes its own copy of the proxy config. |
docker-compose.yml / docker-compose.dev.yml |
Prod (pull images) / dev override (build + expose ports) |
Pack-specific rules
CS-1. Schema changes go through _run_migrations().
A new table: create_all handles it. A new column on an existing table: append an ALTER TABLE <t> ADD COLUMN ... to the migrations list in local_backend/main.py, with a SQLite-safe default for NOT NULL.
Add the column to the SQLAlchemy model and the Pydantic schema in the same commit. Never drop or rename columns.
CS-2. New real-time events need both ends.
If the backend emits a new event type, handle it in waiter_pwa/src/context/SSEContext.jsx (and in the manager, if relevant), or state explicitly that it's ignored.
Events carry IDs; clients re-fetch the full object (/api/orders/{id}) instead of trusting partial payloads.
CS-3. The waiter app must survive bad networks. Waiters walk in and out of WiFi range and phones sleep. Any new waiter flow must:
- work from the IndexedDB cache when offline, or clearly block with an offline state
- never lose an order that was entered (queue it)
- tolerate duplicate or replayed events
CS-4. Money and prices are snapshotted.
Prices, costs and discounts are copied onto order items and logs when the action happens (snapshot pattern, PriceEventLog). Reports read the snapshots, never the live catalogue.
CS-5. The proxy config lives in two places.
Edit nginx-proxy/nginx.conf and the heredoc in install.sh together (global Rule 9).
CS-6. Permissions are checked on the backend.
Hiding a button in the UI is not access control. Every new endpoint declares its role or perm_* requirement through routers/deps.py.
CS-8. The waiter app never assumes it's served by the backend.
The same waiter build runs same-origin (https domain, or plain http://<LAN IP>) and inside the native app talking to a remote venue server. So in waiter_pwa:
- REST goes through
api/client.js(itsbaseURLisapiBase()). Any other fetch,EventSourceorWebSocketmust build its URL withapiBase()orwsUrl(). - Backend-served files (
/static/...images, avatars) are rendered throughassetUrl(). - Persistent venue-specific data (token, IDs of products/tables/zones) uses
storageKey()for localStorage keys and zustandpersistnames. The IndexedDB name comes fromdbName(). Device preferences (theme, payment safety…) stay global. - Features that need a secure context (service worker, camera, notifications, clipboard) must degrade gracefully when
deliveryMode()is'plain-web'.
CS-7. Greek-market realities. Staff UIs are used by Greek staff and must render Greek correctly, including on thermal printers (codepage). Money uses €.
Common commands
# Dev: backend with reload
cd local_backend; uvicorn main:app --reload --port 8000
# Dev: frontends (Vite proxies /api and /api/ws to :8000)
cd waiter_pwa; npm run dev # :5173
cd manager_dashboard; npm run dev # :5174
cd waiter_pwa; npm run lint
# Build release images (VERSION comes from .env). See the parent's DEPLOYMENT_GUIDE.md.
docker compose -f docker-compose.yml -f docker-compose.dev.yml build
docker push registry.bonamin.gr/pos-backend:<ver> # + pos-waiter, pos-manager
Testing
There is no automated test suite yet. Before committing:
- the backend starts cleanly against an existing
pos.db(this catches missing migrations) npm run buildpasses for any frontend you touched- the flow was exercised by hand (the parent's
PLANS AND STRATEGIES/TESTING_CHECKLIST.md) - printing was checked against real hardware or
esc-pos-emulator
Say in the commit or hand-off what was and wasn't verified.