165 lines
10 KiB
Markdown
165 lines
10 KiB
Markdown
# 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
|
||
|
||
## 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)
|
||
- `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
|
||
- 429 Too Many Requests |