Files
kooltab-backoffice/docs/api.md
T
brammie15 ca3af671cf
CI / build-and-test (push) Canceled after 0s
feat: add better tab view
2026-10-28 00:37:16 +01:00

172 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# API Documentation
Base URL: `http://localhost:3001/api/v1`
Swagger UI: `http://localhost:3001/api/docs`
## Authentication
### Dashboard Users
- Login to receive JWT access token (24h) + refresh token (30 days)
- Include access token in `Authorization: Bearer <token>` header
### Devices
- Register to receive device bearer token (90 days)
- Include device token in `Authorization: Bearer <token>` header
- Device ID must match URL parameter
- Send the app version in an `X-App-Version` header (e.g. `1.4.2+37`, the build number is ignored) on every
`/devices/...` call. Without it, or below the minimum version (`MIN_APP_VERSION`), the call fails with
`426 app_update_required` before the token is checked: the app must be updated to use the backoffice again
## Endpoints
### Health
`GET /health` - Server health check (503 when the database is unreachable)
### Auth
`POST /auth/login` - Login with email/password
`POST /auth/refresh` - Refresh access token
`POST /auth/logout` - Logout and revoke refresh token
`POST /auth/change-password` - Change the signed-in user's password `{ currentPassword, newPassword }`. Revokes all
their refresh tokens and returns new tokens, like a login. `400 wrong_password` when the current one is wrong.
### Venues
`GET /venues` - List venues
`GET /venues/:venueId` - Get venue details
`GET /venues/:venueId/devices` - The venue's paired terminals `{ devices: [...] }`
`GET /venues/:venueId/users` - List dashboard users (admin)
`POST /venues/:venueId/users` - Create a user (admin)
`DELETE /venues/:venueId/users/:userId` - Delete a user and revoke their refresh tokens (admin; not yourself)
### Devices
`POST /devices/register` - Pair a terminal; every pairing adds one
`POST /devices/:deviceId/sync` - Sync device state (device auth)
`POST /devices/:deviceId/images` - Upload a picture taken on the terminal (device auth, multipart field `file`):
`{ imageId }`. Processed like a dashboard upload (see Images); the terminal links it with a `product_updated` event
`POST /devices/:deviceId/commands/:commandId/ack` - Acknowledge command (device auth)
`GET /devices/generation-code` - Generate registration code (admin)
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) and
`orderId`: the order on the terminal the drinks belong to. Every unit keeps the `orderId` of its `line_added`, and
the dashboard and terminals show a tab per order. `line_removed` with an `orderId` (or `null`: drinks without an
order) only takes that order's drinks off; without the field (older app versions) the latest of the line
- `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`
- `product_created` / `product_updated` / `product_archived`: `productId` (the terminal makes the id of a new
product) and `product` with the fields that changed: any of `name`, `category`, `priceInCents`,
`lowStockThreshold`, `archived`, `imageId`. `product_created` needs `name` and `priceInCents`; no `tabId`.
Every field keeps the time it last changed, and an event only changes a field it changed later (by
`occurredAt`), so an offline rename and a dashboard price change both survive. An `imageId` the server doesn't
have is ignored; an update of a product the venue doesn't have is acknowledged and dropped
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 are changed on the dashboard and, through the product events above, on terminals. `products` in the
request (at most 2000) is only used on that same first sync, to take over the terminal's catalog with its stock;
after that it is ignored. The response's `catalog` lists every product of the venue:
`[{ id, name, category, priceInCents, lowStockThreshold, archived, imageId }]`. The terminal adds and updates its
products from it (replaying its unsent product events on top), hides archived products and products the catalog
doesn't list, and downloads pictures it doesn't have yet from `GET /images/:imageId`.
- 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
`DELETE /venues/:venueId/tabs/:tabId?expectedVersion=` - Delete a tab without settling it, like `tab_deleted` on a
terminal (admins and managers). `409 version_mismatch` when it changed since `expectedVersion`.
### Planned Tabs (customer list)
`GET /venues/:venueId/planned-tabs` - List planned customers
`POST /venues/:venueId/planned-tabs` - Add a customer (admin)
`POST /venues/:venueId/planned-tabs/import` - Bulk import `{ entries: [{ customerName, email? }], mode: 'merge' | 'replace' }` (admin).
Names match case-insensitively and keep their existing spelling; `replace` also removes customers missing from the import.
`PUT /venues/:venueId/planned-tabs/:id` - Rename a customer / set email (admin)
`DELETE /venues/:venueId/planned-tabs/:id` - Remove a customer (admin)
### Reminders
`GET /venues/:venueId/reminders/candidates` - Customers with an unpaid balance, with their reminder count, last reminder
and whether they are eligible (have an email and weren't reminded in the last 7 days)
`POST /venues/:venueId/reminders/send` - Send payment reminders (admin). Body `{ "tabIds": [...] }` picks the
recipients, including customers still in their cooldown; without it every eligible customer is reminded
`GET /venues/:venueId/reminders?customerName=` - Reminder history, optionally for one customer
`POST /venues/:venueId/reminders/test/:plannedTabId` - Send a test email (admin)
### Stock
`GET /venues/:venueId/stock` - The venue's products (archived ones too, with `archivedAt`) with stock, low stock
level and pending adjustments
`GET /venues/:venueId/stock/adjustments?productId=&limit=&offset=` - Stock adjustment history, newest first
`POST /venues/:venueId/stock/adjustments` - Request stock changes (admins and managers): `{ adjustments: [{ productId, type: 'add' | 'set', quantity, note? }] }`.
`add` takes a non-zero quantity (negative for breakage) and needs a tracked product; `set` takes 0 or more.
`POST /venues/:venueId/stock/adjustments/:id/cancel` - Cancel an adjustment the terminal hasn't received yet (admins and managers)
`POST /venues/:venueId/stock/products` - Add a product (admins and managers): `{ name, category?, priceInCents, stock?,
lowStockThreshold? }`. `stock` is the starting count (recorded as an applied `set` adjustment); null or left out:
not tracked. `product_name_exists` when an active product has the same name (ignoring case). Terminals may still
create products with the same name; the products page marks those as duplicates.
`PATCH /venues/:venueId/stock/products/:productId` - Change a product (admins and managers): any of `{ name, category, priceInCents,
lowStockThreshold, archived }`. `archived: true` deletes it for the terminals (kept for tabs and history),
`archived: false` brings it back. The stock itself changes through adjustments.
`POST /venues/:venueId/stock/products/:productId/image` - Set a product's picture (admins and managers; multipart
field `file`, see Images): the product with its new `imageId`
`DELETE /venues/:venueId/stock/products/:productId/image` - Remove a product's picture (admins and managers)
### Images
`GET /images/:imageId` - A product picture (public, `image/webp`, cached for a year: an id never changes content).
Uploads (JPEG, PNG, WebP, GIF or AVIF, at most 10 MB) are turned upright and stored as a 512×512 WebP without
metadata. A product on a plain background (all four edges one colour, or transparent) is trimmed and centred
with a 40 px margin on that background. Any other photo is cropped around its centre when roughly square, or
fitted whole on a transparent background when not (bottles, cans). The id is the SHA-256 of the stored file.
`invalid_image` when the upload can't be read.
### Commands
`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` - Unpair a terminal (admin)
All `/venues/:venueId/...` routes return `403 venue_forbidden` unless the user belongs to that venue.
## Error Format
```json
{
"error": {
"code": "error_code",
"message": "Human-readable message"
}
}
```
## HTTP Status Codes
- 200 Success
- 201 Created
- 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 409 Conflict
- 426 Upgrade Required (`app_update_required`: the terminal app is too old)
- 429 Too Many Requests