feat: make server own tabs
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-20 16:18:36 +02:00
parent 1e4438ec5a
commit 0ca9f4d16a
31 changed files with 2101 additions and 892 deletions
+39 -36
View File
@@ -28,47 +28,48 @@ Swagger UI: `http://localhost:3001/api/docs`
### Venues
`GET /venues` - List venues
`GET /venues/:venueId` - Get venue details
`GET /venues/:venueId/devices` - List devices in venue
`GET /venues/:venueId/devices` - The venue's paired terminals `{ devices: [...] }`
### Devices
`POST /devices/register` - Register a new device
`POST /devices/register` - Pair a terminal; every pairing adds one
`POST /devices/:deviceId/sync` - Sync device state (device auth)
`POST /devices/:deviceId/commands/:commandId/ack` - Acknowledge command (device auth)
`GET /devices/generation-code` - Generate registration code (admin)
Sync semantics:
- `openTabs` must be the device's **complete** list of open tabs. Tabs it no longer reports
(e.g. deleted on the terminal) are removed from the dashboard. Settling a tab on the terminal
keeps the tab open under the same name with a zero balance, so the name stays listed.
- Every sync returns **all** pending commands for the device; `cursor` is still returned but no longer
filters. Devices dedupe by command id and re-send the ack for commands they already processed.
- The first ack for a command wins; later acks are accepted as no-ops.
- `plannedTabNames` only contains planned customers the device has not opened a tab for yet. Once a sync
reports an open tab with that name (ignoring case and extra spaces), the customer is marked delivered
and no longer sent. The customer (and their email, used for reminders) is kept.
- `events` (optional) reports what happened on tabs since the last sync, for sales statistics. Each event
has a terminal-generated UUID `id`, a `type`, `tabId`, `customerName` and `occurredAt` (ISO-8601, UTC):
- `line_added` / `line_removed`: `productName`, `unitPriceInCents`, `quantity` (always positive)
- `settled`: `amountInCents`, `paymentMethod` (`cash`, `payconiq`, `payment_pending` or `other`),
optional `commandId` (the remote close that caused it) and `items` (the settled lines)
Sync semantics. The server holds the venue's open tabs and stock; a venue can have several terminals. Each terminal
works offline, queues what it does as **events**, and on every sync replaces its tabs and stock with the server's and
replays its unsent events on top. The full rules are in the app repository: `docs/server-tabs-source-of-truth.md`
and `docs/server-stock-source-of-truth.md`.
- Every write to a venue's tabs, stock and settlements (syncs, dashboard settles, stock adjustments) runs in one
transaction that locks the venue row, so simultaneous terminals apply their changes one after the other.
- `events` (at most 1000): terminal-generated UUID `id`, `type`, `occurredAt` (ISO-8601, UTC; capped at the server
time when more than 5 minutes ahead) and per type:
- `tab_opened` (`tabId`, `customerName`, `openedAt`), `tab_renamed` (new `customerName`), `tab_deleted`
- `line_added` / `line_removed`: `lineId`, `productId`, `productName`, `unitPriceInCents`, `quantity` (≥ 1)
- `settled`: `amountInCents`, `paymentMethod` (`cash`, `payconiq`, `payment_pending` or `other`), optional
`commandId` and `items` (`[{ productId?, productName, quantity, unitPriceInCents }]`)
- `stock_set`: `productId`, `quantity` (a count ≥ 0, or `null` to stop tracking); no `tabId`
The response lists `acknowledgedEventIds` (stored, including events already stored by an earlier sync)
and `rejectedEvents` (`[{ id, reason }]`, invalid and never stored). The terminal removes both from its
outbox. An invalid event never fails the sync. At most 1000 events per sync.
- `products` (optional) is the terminal's **complete** product catalog:
`[{ id, name, category?, priceInCents, stock }]`, where `stock` is an integer (may be negative) or `null` when
the terminal doesn't track stock for that product. Products left out are removed from the dashboard. When the
field is omitted (older app versions) the stored products are left alone. An invalid product is skipped and
logged, never failing the sync. At most 2000 products.
- `stockAdjustments` in the response lists **every** pending stock change requested from the dashboard
for the venue, oldest first: `[{ id, productId, type, quantity, note, requestedBy, createdAt }]`.
`type: "add"` changes the stock by `quantity` (negative takes stock out); `type: "set"` makes the stock
`quantity` (a count, ≥ 0, and starts tracking an untracked product). They are re-sent on every sync until the
terminal reports an outcome, so the terminal must apply each id only once.
- `stockAdjustmentResults` (optional) reports outcomes: `[{ id, status: "applied" | "rejected", stockAfter?, reason? }]`
(`stockAfter` is required for `applied`). The response lists `acknowledgedStockAdjustmentResultIds` and
`rejectedStockAdjustmentResults` (`[{ id, reason }]`); the terminal drops both from its outbox. The terminal's
outcome is recorded even if the adjustment was cancelled on the dashboard in the meantime.
Each id is applied once (tab state and stock in the same transaction). `acknowledgedEventIds` lists the applied
ones, including repeats; `rejectedEvents` (`[{ id, reason }]`) were invalid and never applied. The terminal drops
both from its outbox. An invalid event never fails the sync.
- The response's `tabs` is the venue's **complete** open tab state after this request's events:
`[{ id, customerName, openedAt, version, lines: [{ id, productId, productName, unitPriceInCents, quantity,
units: [{ id: "<event id>:<index>", addedAt, removedAt }] }] }]`.
- `stock` is every product of the venue: `[{ productId, stock, countedAt }]` (`stock` null when not tracked, may be
negative). Line events change it unless they happened before `countedAt`; the latest count wins.
- `settlements` lists the venue's settlements after `settlementsCursor` (or of the last 30 days without one), oldest
first, at most 500; the response's `settlementsCursor` is sent back next time.
- `openTabs` is only used once per venue, on the first sync after the server started holding the tabs: the terminal's
own tabs (and catalog stock) are taken over and the events of that request are stored without applying them again.
- `products` (optional, at most 2000) upserts names, categories and prices. Its `stock` is ignored (counts arrive as
`stock_set`), a new product starts untracked, and products are never removed because one terminal doesn't report
them. An invalid product is skipped and logged.
- Planned customers are opened as tabs by the server, once; `plannedTabNames` is always empty.
- Dashboard stock adjustments are applied by the server right away; `stockAdjustments` is always empty.
`stockAdjustmentResults` from older app versions are still recorded and acknowledged.
- Every sync returns **all** pending commands for the device (only `close_tab` commands created before the server
settled tabs itself); `cursor` is still returned but no longer filters. The first ack for a command wins.
### Tabs
`GET /venues/:venueId/tabs` - List open tabs
@@ -98,9 +99,11 @@ Names match case-insensitively and keep their existing spelling; `replace` also
`PUT /venues/:venueId/stock/products/:productId` - Set `{ lowStockThreshold: number | null }` (admin, dashboard only)
### Commands
`POST /venues/:venueId/commands` - Create a command
`POST /venues/:venueId/commands` - Settle a tab: `{ tabId, type: "close_tab", expectedVersion, paymentMethod }`. The
server settles everything on the tab at once (also with every terminal offline) and records the command as
`completed`. `version_mismatch` when the tab changed since it was shown, `tab_empty` when there is nothing on it.
`GET /venues/:venueId/commands` - List command history
`POST /venues/:venueId/commands/devices/:deviceId/revoke` - Revoke device (admin)
`POST /venues/:venueId/commands/devices/:deviceId/revoke` - Unpair a terminal (admin)
All `/venues/:venueId/...` routes return `403 venue_forbidden` unless the user belongs to that venue.