-172
@@ -1,172 +0,0 @@
|
||||
# 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
|
||||
@@ -1,49 +1,50 @@
|
||||
# Architecture Decisions
|
||||
# Architecture decisions
|
||||
|
||||
## 1. Registration Code Model
|
||||
## 1. The server holds the tabs; terminals send events
|
||||
|
||||
**Decision:** Added a `RegistrationCode` model to the database schema.
|
||||
Terminals record what happened as events and replace their state with the server's on every sync. Two terminals and
|
||||
the dashboard can change one tab without overwriting each other, and an offline terminal catches up by sending its
|
||||
outbox. The terminal's events are kept (`TabEvent`) so each is applied once.
|
||||
|
||||
**Rationale:** The specification requires a registration-code mechanism. Rather than overloading an existing model or implementing a complex external system, a simple `RegistrationCode` table with venue scoping, expiration, and single-use semantics provides a clean implementation.
|
||||
## 2. One drink is one row
|
||||
|
||||
## 2. Payment Method Must Be Explicitly Selected
|
||||
`TabUnit` is a drink, on an open tab or in a settlement. Settling moves rows instead of copying JSON, the database
|
||||
checks that a drink is in exactly one place, and sales reports are SQL. A venue's open tabs are loaded whole under a
|
||||
row lock on the venue (tens of tabs, not thousands), changed in memory with pure rules, and written back as a diff.
|
||||
|
||||
**Decision:** The UI never defaults to `cash`. The user must explicitly select a payment method.
|
||||
## 3. Per-field last write wins for products
|
||||
|
||||
**Rationale:** The specification explicitly forbids silent defaults. This prevents accidental charges and ensures compliance with the business requirement.
|
||||
A product edited on the dashboard and on an offline terminal keeps both edits: every field remembers when it was
|
||||
last changed, and a change only wins over an older one of the same field.
|
||||
|
||||
## 3. Cursor Implementation
|
||||
## 4. One contract
|
||||
|
||||
**Decision:** Opaque cursor encoded as base64url containing timestamp + UUID tiebreaker.
|
||||
The terminal protocol and the dashboard API are zod schemas and types in `@kooltap/shared`. The API validates with
|
||||
them, the dashboard and the API are typed by them, and the terminal protocol is exported as JSON Schema for the
|
||||
Flutter app.
|
||||
|
||||
**Rationale:** Provides a simple, privacy-preserving cursor that allows the server to determine which commands the device has already seen without exposing database internals.
|
||||
## 5. Sessions in httpOnly cookies
|
||||
|
||||
## 4. Refresh Token Storage
|
||||
The API sets the access token (15 minutes) and the rotating refresh token (30 days) as httpOnly, SameSite=Lax
|
||||
cookies, so no script on the dashboard can read them. The dashboard and the API must share a site
|
||||
(`kooltap.com` and `api.kooltap.com`). Every request looks the user up, so a deleted, demoted or signed-out user loses
|
||||
access at once.
|
||||
|
||||
**Decision:** Refresh tokens are stored hashed (SHA-256) in a `RefreshToken` table, supporting revocation and rotation.
|
||||
## 6. Invalidation-only live updates
|
||||
|
||||
**Rationale:** Provides security (tokens can be revoked) and aligns with OAuth2 best practices for refresh token management.
|
||||
The API pushes topic names (`tabs`, `stock`, …) over Socket.IO; the dashboard refetches that data over the API, and
|
||||
polls only while the socket is down. A socket can't see anything its owner couldn't fetch.
|
||||
|
||||
## 5. PostgreSQL Everywhere
|
||||
## 7. Pictures on disk, by content hash
|
||||
|
||||
**Decision:** PostgreSQL is used in development, CI and production.
|
||||
A picture is stored once, at a path that never changes, so it is cached forever. The database only holds its size;
|
||||
the files live on a volume next to it.
|
||||
|
||||
**Rationale:** One database means one set of migrations; the schema uses JSON columns and serializable transactions that SQLite doesn't support the same way.
|
||||
## 8. Explicit payment method
|
||||
|
||||
## 6. Invalidation-Only Live Updates
|
||||
The dashboard never defaults to a payment method: settling a tab means choosing one.
|
||||
|
||||
**Decision:** The API pushes topic names (`tabs`, `commands`, ...) over Socket.IO; the dashboard refetches that data over the normal REST API. It falls back to polling while the socket is down.
|
||||
## 9. In-memory rate limiting
|
||||
|
||||
**Rationale:** Events never carry data, so a socket can't see anything its owner couldn't fetch, and the REST API stays the single source of truth.
|
||||
|
||||
## 7. In-Memory Rate Limiting
|
||||
|
||||
**Decision:** Use NestJS ThrottlerModule with in-memory storage.
|
||||
|
||||
**Rationale:** Suitable for single-instance deployments. The abstraction allows swapping to Redis without controller changes.
|
||||
|
||||
## 8. Venue Isolation via JWT Claims
|
||||
|
||||
**Decision:** Venue ID is embedded in the JWT token. All service methods verify the requesting user/device's venue context rather than trusting URL parameters alone.
|
||||
|
||||
**Rationale:** Prevents cross-venue access even if a user manually modifies URL parameters.
|
||||
Fine for one API instance; the throttler can move to Redis without changing controllers. Terminal syncs are not
|
||||
rate limited: the till must never be locked out by other traffic from the venue's IP.
|
||||
|
||||
+41
-34
@@ -1,44 +1,51 @@
|
||||
# KoolTap Architecture
|
||||
# Architecture
|
||||
|
||||
## Overview
|
||||
KoolTap is a modular monolith: one NestJS API, one Next.js dashboard, one PostgreSQL database, and a shared package
|
||||
that holds the contract between them and the bar terminals.
|
||||
|
||||
KoolTap is a modular monolith designed for remote tab control in venues.
|
||||
## The server holds the venue
|
||||
|
||||
## Components
|
||||
The venue's open tabs, stock and catalog live on the server. A terminal records what happens on it as **events**
|
||||
(`tab_opened`, `line_added`, `settled`, `stock_set`, `product_updated`, …) in an outbox, and every sync:
|
||||
|
||||
### Backend (NestJS)
|
||||
- REST API with versioned endpoints (`/api/v1`)
|
||||
- JWT authentication for dashboard users
|
||||
- Bearer token authentication for devices
|
||||
- PostgreSQL database via Prisma ORM
|
||||
- Scheduled command expiry job
|
||||
- In-memory rate limiting
|
||||
1. sends the outbox; the server stores each event once (by its id) and applies it to the venue,
|
||||
2. gets back the venue's complete state: the open tabs, the stock, the catalog (only when it changed) and the
|
||||
settlements since its cursor,
|
||||
3. replaces its own state with that and replays the events it still has to send on top.
|
||||
|
||||
### Frontend (Next.js)
|
||||
- App Router with client-side rendering
|
||||
- TanStack Query for API data fetching
|
||||
- shadcn/ui component library
|
||||
- Tailwind CSS styling
|
||||
- Tokens kept in cookies (see security.md)
|
||||
- Socket.IO connection for live updates, polling as fallback
|
||||
Terminals apply the same rules the server does, so a terminal that is offline keeps working and lands in the same
|
||||
state once it syncs. The dashboard changes the same state directly (settling or deleting a tab, stock movements,
|
||||
products); terminals see that on their next sync. See [terminal-protocol.md](terminal-protocol.md).
|
||||
|
||||
### Shared Package
|
||||
- TypeScript types and constants
|
||||
- Shared across API and dashboard
|
||||
## API (`apps/api`)
|
||||
|
||||
## Data Flow
|
||||
| Module | What it does |
|
||||
|---|---|
|
||||
| `ledger/` | The rules, pure and unit tested: `tab-state` (tabs, lines, drinks, settling), `stock-rules`, `product-fields` (per-field last-write-wins), and `venue-ledger`, which loads a venue under a row lock, applies changes and writes back only what changed |
|
||||
| `terminal/` | Pairing, sync and picture uploads for terminals; device token auth; the minimum app version |
|
||||
| `auth/` | Dashboard sign-in with httpOnly cookies, refresh token rotation, `GET /auth/me` |
|
||||
| `tabs/`, `customers/`, `stock/`, `reminders/`, `venues/` | The dashboard API, one module per page |
|
||||
| `admin/` | The superadmin page: venues, terminals, users, resets, consistency checks, purges |
|
||||
| `realtime/` | Socket.IO: tells dashboards which data changed (topics only, never data) |
|
||||
| `email/` | Templates in `templates/emails` and Resend |
|
||||
| `images/` | Product pictures, processed with sharp and stored on disk by content hash |
|
||||
|
||||
1. Device syncs open tabs → Server stores snapshots + ownership
|
||||
2. Dashboard creates close command → Server resolves device owner
|
||||
3. Device syncs → Receives pending commands
|
||||
4. Device processes → Acknowledges command
|
||||
5. Dashboard polls → Sees updated status
|
||||
Every request body and query is validated with a zod schema from `@kooltap/shared`; responses are typed with the
|
||||
shared interfaces, so the dashboard and the API can't drift apart.
|
||||
|
||||
## Security Model
|
||||
## Data
|
||||
|
||||
- Every user/device scoped to one venue
|
||||
- Venue isolation enforced at service level
|
||||
- Device tokens hashed with SHA-256
|
||||
- Raw token returned only once during registration
|
||||
- JWT tokens with refresh token rotation
|
||||
- Rate limiting on sensitive endpoints
|
||||
- **TabUnit** is one drink ever rung up. It belongs to an open **Tab** until a **Settlement** takes it; a drink taken
|
||||
off again keeps its `removedAt` and moves with the settlement. Lines are the units sharing a `lineId`. Sales reports
|
||||
are plain SQL over this table.
|
||||
- **Product** holds the catalog and the current stock; **StockMovement** is the dashboard's deliveries, breakage and
|
||||
counts.
|
||||
- **TabEvent** is what terminals reported, as they sent it: the record of which events were applied.
|
||||
- **Customer** is a regular: the next sync opens a tab for a customer without one, and their email is used for
|
||||
reminders. Tabs match customers by name.
|
||||
- Everything belongs to a **Venue** and is deleted with it.
|
||||
|
||||
## Dashboard (`apps/dashboard`)
|
||||
|
||||
Next.js App Router, rendered on the client. TanStack Query for data, refetched when the realtime socket says a topic
|
||||
changed (polling only while the socket is down). Translations in `src/locales/{en,nl}.json`.
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
## KoolTap Flutter App — Send the App Version, Ask to Update When It's Too Old
|
||||
|
||||
The backoffice now refuses terminals running an app that is too old. Every call to `/devices/...` (register, sync,
|
||||
image upload, command ack) must carry the app version in a header:
|
||||
|
||||
```
|
||||
X-App-Version: 1.4.2+37
|
||||
```
|
||||
|
||||
Use the version from `pubspec.yaml` (read it at runtime with `package_info_plus`: `version` + `+` + `buildNumber`, or
|
||||
just `version`). The build number after `+` is ignored by the backoffice.
|
||||
|
||||
When the app is older than the backoffice's minimum version, or sends no header (every app from before this change),
|
||||
the call fails **before** the token is checked with:
|
||||
|
||||
```
|
||||
HTTP 426
|
||||
{ "error": { "code": "app_update_required", "message": "Update the app to version 1.4.0 or newer" } }
|
||||
```
|
||||
|
||||
### What to Change
|
||||
|
||||
1. **Send the header** on every request to the backoffice: add it where the HTTP client sets the `Authorization`
|
||||
header (e.g. a shared client or interceptor in `lib/services/remote_sync_service.dart`), so register, sync, image
|
||||
uploads and acks all carry it.
|
||||
2. **Handle 426 / `app_update_required`** in the sync loop and on the registration screen:
|
||||
- Stop syncing (don't retry in a loop); keep the till working offline exactly as when the backoffice is unreachable,
|
||||
and keep every outbox (tab events, stock outcomes, acks) so nothing is lost once the app is updated.
|
||||
- Show a clear, persistent banner/dialog: "This app is out of date. Update it to keep syncing with the backoffice",
|
||||
with the server's `message`.
|
||||
- **Don't** treat it as an auth error: never unpair or throw away the device token.
|
||||
- Retry after the app restarts (an updated app restarts anyway).
|
||||
3. Bump the app version in `pubspec.yaml` for the release that adds this, and tell the backoffice that version: it is
|
||||
set as `MIN_APP_VERSION` (env var, or the constant in `apps/api/src/devices/app-version.ts`).
|
||||
@@ -1,163 +0,0 @@
|
||||
## KoolTap Flutter App — Remote Backoffice Integration (Improved Pairing Flow)
|
||||
|
||||
You are implementing the device-side integration for the KoolTap remote tab control system. The Flutter app (bar POS terminal) needs to register with a remote backend, sync open tabs, and process remote close-tab commands.
|
||||
|
||||
### Project Location
|
||||
`C:\Users\bram\StudioProjects\kooltap`
|
||||
|
||||
### Architecture Overview
|
||||
- State management: `provider` (ChangeNotifier pattern)
|
||||
- Routing: `go_router`
|
||||
- Local DB: `drift` (SQLite ORM)
|
||||
- HTTP: `http` package
|
||||
- Auth tokens stored in `flutter_secure_storage`
|
||||
- MVVM pattern: views → viewmodels → services
|
||||
- Models in `lib/models/`
|
||||
- Services in `lib/services/`
|
||||
- ViewModels in `lib/viewmodels/`
|
||||
- Views in `lib/views/`
|
||||
- Config constants in `lib/utils/app_config.dart`
|
||||
- Main app bootstrap in `lib/app/app_bootstrap.dart`
|
||||
|
||||
### Pairing Flow (improved)
|
||||
Instead of typing a UUID venueId, the flow is:
|
||||
|
||||
1. Tablet calls `GET https://api.kooltap.com/api/v1/venues/` (public, no auth)
|
||||
2. Gets list of `[{id, name}]` — admin picks venue from a dropdown
|
||||
3. Admin enters device name (e.g. "Bar terminal 1")
|
||||
4. Admin enters the 6-character registration code (generated from dashboard)
|
||||
5. Tablet calls `POST /devices/register` with venueId + name + code
|
||||
6. Stores deviceId + token + venueId + serverUrl in secure storage
|
||||
7. Sync starts automatically
|
||||
|
||||
### Existing Sync Infrastructure (already built)
|
||||
Do NOT rewrite these files, just modify as needed:
|
||||
|
||||
- **`lib/services/remote_sync_service.dart`** (410 lines) — `setCredentials()`, `startSync()`, `stopSync()`, sync loop, command processing, ACKs
|
||||
- **`lib/viewmodels/remote_sync_view_model.dart`** (90 lines) — ChangeNotifier wrapper
|
||||
- **`lib/models/remote_command.dart`** (135 lines) — DTOs: `RemoteCommand`, `SyncRequest`, `SyncResponse`, `SnapshotTab`, `CommandAck`
|
||||
- **`lib/utils/app_config.dart`** — URL constants
|
||||
- **`lib/models/payment_method.dart`** — payment enums
|
||||
|
||||
### What to Build
|
||||
|
||||
#### 1. Update Server URL
|
||||
In `lib/utils/app_config.dart`:
|
||||
```dart
|
||||
const kRemoteServerUrl = 'https://api.kooltap.com/api/v1/';
|
||||
```
|
||||
|
||||
#### 2. Add Venue Model
|
||||
Create `lib/models/venue.dart`:
|
||||
```dart
|
||||
class Venue {
|
||||
final String id;
|
||||
final String name;
|
||||
Venue({required this.id, required this.name});
|
||||
factory Venue.fromJson(Map<String, dynamic> json) => Venue(id: json['id'], name: json['name']);
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Update RemoteSyncService — Add venueId Storage
|
||||
In `lib/services/remote_sync_service.dart`:
|
||||
- Add `venueId` parameter to `setCredentials()` signature
|
||||
- Store venueId in FlutterSecureStorage alongside deviceId/token/serverUrl
|
||||
- Add `String? get venueId` getter
|
||||
- Update `isConfigured` to also check venueId exists
|
||||
- Add `clearCredentials()` method (clears all stored credentials)
|
||||
|
||||
#### 4. Registration ViewModel
|
||||
Create `lib/viewmodels/registration_view_model.dart`:
|
||||
- `Future<List<Venue>> fetchVenues()` — calls `GET /api/v1/venues/`
|
||||
- `Future<void> registerDevice(String venueId, String name, String code)` — calls `POST /devices/register`, then stores credentials via `RemoteSyncService.setCredentials()`
|
||||
- States: `initial`, `loading`, `venuesLoaded`, `error`, `registered`
|
||||
- Expose as ChangeNotifier
|
||||
|
||||
#### 5. Registration UI
|
||||
Create `lib/views/registration_view.dart` — a full-screen registration flow with steps:
|
||||
|
||||
**Step 1 — Select venue:**
|
||||
- Dropdown list of venues fetched from API
|
||||
- Show loading spinner while fetching
|
||||
- Show error + retry if fetch fails
|
||||
- "Next" button when venue selected
|
||||
|
||||
**Step 2 — Device details:**
|
||||
- Device name text field (with default "Bar terminal 1")
|
||||
- Registration code text field (6 characters)
|
||||
- "Register" button
|
||||
- Loading state while registering
|
||||
- Error display if registration fails
|
||||
|
||||
**Step 3 — Success screen:**
|
||||
- Success animation/message
|
||||
- "Start using KoolTap" button → navigates to main bar screen and triggers sync
|
||||
|
||||
#### 6. Wire Registration into Navigation
|
||||
In `lib/app/app_bootstrap.dart`:
|
||||
- On app startup, check `remoteSyncService.isConfigured`
|
||||
- If NOT configured: show registration screen instead of main bar screen
|
||||
- If configured: proceed to main bar screen as normal
|
||||
|
||||
In `lib/app/router.dart`:
|
||||
- Add route for registration screen as initial route when not configured
|
||||
|
||||
#### 7. Disconnect Option
|
||||
In the **Settings screen** (`lib/views/settings_view.dart`) or **Admin screen**:
|
||||
- If configured, show "Connected to [venue name]" with device name
|
||||
- "Disconnect" button → calls `clearCredentials()`, navigates back to registration screen
|
||||
|
||||
### API Endpoints Summary
|
||||
|
||||
| Method | Endpoint | Auth | Purpose |
|
||||
|--------|----------|------|---------|
|
||||
| GET | `/api/v1/venues/` | None | List venues (returns `[{id, name}]`) |
|
||||
| POST | `/api/v1/devices/register` | None | Register device (body: venueId, name, registrationCode) |
|
||||
| POST | `/api/v1/devices/:id/sync` | Bearer token | Sync tabs, receive commands |
|
||||
| POST | `/api/v1/devices/:id/commands/:cmdId/ack` | Bearer token | Acknowledge command |
|
||||
|
||||
### Registration API Details
|
||||
|
||||
**Venues list:**
|
||||
```
|
||||
GET https://api.kooltap.com/api/v1/venues/
|
||||
Response: [{"id": "uuid", "name": "KoolTap Demo Bar"}, ...]
|
||||
```
|
||||
|
||||
**Device registration:**
|
||||
```
|
||||
POST https://api.kooltap.com/api/v1/devices/register
|
||||
{
|
||||
"venueId": "uuid-from-venue-list",
|
||||
"name": "Bar terminal 1",
|
||||
"registrationCode": "A8F3C2"
|
||||
}
|
||||
Response 201:
|
||||
{
|
||||
"deviceId": "uuid",
|
||||
"token": "dev_abc123...",
|
||||
"tokenExpiresAt": "2026-..."
|
||||
}
|
||||
```
|
||||
|
||||
The registration code is generated from the dashboard (Devices → Add Device → generates 6-char code valid for 10 minutes).
|
||||
|
||||
### Files to Create
|
||||
- `lib/models/venue.dart`
|
||||
- `lib/viewmodels/registration_view_model.dart`
|
||||
- `lib/views/registration_view.dart`
|
||||
|
||||
### Files to Modify
|
||||
- `lib/utils/app_config.dart` — update URL
|
||||
- `lib/services/remote_sync_service.dart` — add venueId + clearCredentials
|
||||
- `lib/app/app_bootstrap.dart` — check isConfigured, route to registration if not
|
||||
- `lib/app/router.dart` — add registration route
|
||||
- `lib/views/admin_screen_view.dart` or `lib/views/settings_view.dart` — add disconnect option
|
||||
|
||||
### Important Notes
|
||||
- Do NOT break the existing sync loop — registration only configures credentials
|
||||
- The existing sync sends `SnapshotTab` and processes `close_tab` commands stays unchanged
|
||||
- Use `flutter_secure_storage` for persisting credentials
|
||||
- Handle loading/error states in the UI
|
||||
- After registration, call `remoteSyncViewModel.start()` to begin syncing
|
||||
- Tabs appear on the dashboard within ~90 seconds of first sync
|
||||
@@ -1,88 +0,0 @@
|
||||
## KoolTap Flutter App — Planned Tab Sync
|
||||
|
||||
Add support for **planned tab names** that are synced from the server. The server now returns a `plannedTabNames` list in the sync response. The app should create open tabs for any planned names that don't already have an open tab.
|
||||
|
||||
### Background
|
||||
|
||||
The dashboard admin can manage a list of "planned customers" (e.g. "Jan", "Marie", "Thomas"). These names are sent to the device in every sync response. The device should ensure those tabs exist — creating new ones if a planned name doesn't already have an open tab.
|
||||
|
||||
### Existing Files (read these first)
|
||||
|
||||
- `lib/models/remote_command.dart` — contains `SyncResponse`, `RemoteCommand`, `SnapshotTab`, etc.
|
||||
- `lib/services/remote_sync_service.dart` — the sync loop that processes the sync response
|
||||
- `lib/services/bar_tab_service.dart` — creates and manages tabs (has `createTab(String customerName)`)
|
||||
- `lib/models/bar_tab.dart` — the `BarTab` model
|
||||
- `lib/database/app_database.dart` — the Drift database schema
|
||||
- `lib/viewmodels/remote_sync_view_model.dart` — wraps the sync service as `ChangeNotifier`
|
||||
- `lib/utils/app_config.dart` — server URL constant
|
||||
|
||||
### What to Change
|
||||
|
||||
#### 1. Update `SyncResponse` model
|
||||
In `lib/models/remote_command.dart`, add `plannedTabNames` to the existing `SyncResponse` class (it already exists — add the field):
|
||||
|
||||
```dart
|
||||
class SyncResponse {
|
||||
final String cursor;
|
||||
final List<RemoteCommand> commands;
|
||||
final List<String> plannedTabNames; // ← add this
|
||||
|
||||
SyncResponse({
|
||||
required this.cursor,
|
||||
required this.commands,
|
||||
required this.plannedTabNames, // ← add this
|
||||
});
|
||||
|
||||
factory SyncResponse.fromJson(Map<String, dynamic> json) => SyncResponse(
|
||||
cursor: json['cursor'] as String,
|
||||
commands: (json['commands'] as List)
|
||||
.map((e) => RemoteCommand.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
plannedTabNames: (json['plannedTabNames'] as List?) // ← add this
|
||||
?.map((e) => e as String)
|
||||
.toList() ?? [],
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Process planned names in the sync service
|
||||
In `lib/services/remote_sync_service.dart`, inside the `_performSync()` method (or wherever the sync response is handled), add logic to process `plannedTabNames` **after** commands are processed but **before** updating the cursor.
|
||||
|
||||
The logic:
|
||||
```dart
|
||||
// After processing commands and before updating cursor:
|
||||
// Ensure planned tabs exist
|
||||
for (final name in response.plannedTabNames) {
|
||||
final hasOpenTab = openTabs.any((t) => t.customerName == name);
|
||||
if (!hasOpenTab) {
|
||||
await barTabService.createTab(name);
|
||||
}
|
||||
}
|
||||
// Also: if a tab is open for a customer NOT in plannedTabNames,
|
||||
// should we close it? Probably not — just leave it.
|
||||
```
|
||||
|
||||
Where `openTabs` is the list of currently open tabs (already available from `barTabService.getOpenTabs()` or similar). Look for `getOpenTabs()` in `BarTabService` or access the local database.
|
||||
|
||||
#### 3. Handle the first sync (no existing tabs)
|
||||
On first sync, the device might have zero open tabs and receive a list of planned names. The code should create tabs for all planned names.
|
||||
|
||||
### Important Notes
|
||||
|
||||
- `BarTabService.createTab(String customerName)` already exists — it opens a new tab with version 1. Call it directly.
|
||||
- Do NOT close existing tabs that aren't in the planned list — planned tabs are additive only
|
||||
- If a planned name already has an open tab (same `customerName`), skip creating it
|
||||
- The sync service already tracks `openTabs` — it sends them in the `SyncRequest` via `SnapshotTab`. You can get the current open tabs from the local database using `BarTabService.getOpenTabs()` or `BarTabService.openTabs` stream
|
||||
- This logic should run inside the existing sync loop, after processing commands but before saving the cursor
|
||||
|
||||
### Testing
|
||||
|
||||
- Add "Jan" and "Marie" as planned names in the dashboard
|
||||
- Run a sync on the Flutter app — tabs for "Jan" and "Marie" should appear
|
||||
- Remove "Jan" from planned names and re-sync — the "Jan" tab should NOT be closed (planned tabs are additive only)
|
||||
- Add a new name "Thomas" — a new tab should appear on next sync
|
||||
|
||||
### Files to Modify
|
||||
|
||||
- `lib/models/remote_command.dart` — add `plannedTabNames` field to `SyncResponse`
|
||||
- `lib/services/remote_sync_service.dart` — add planned tab creation logic in sync loop
|
||||
@@ -1,166 +0,0 @@
|
||||
## KoolTap Flutter App — Report Tab Events for Sales Statistics
|
||||
|
||||
The server can now store **what happens on tabs**: lines added, lines removed and settlements. This is used
|
||||
for sales statistics (sales per night, per product, per hour, cash vs Payconiq). Add an **event outbox** to
|
||||
the app: every change to a tab writes an event to a local table, the sync sends pending events to the
|
||||
server, and the app deletes them once the server confirms them.
|
||||
|
||||
Nothing else about the sync changes: `openTabs`, commands, acks and `plannedTabNames` keep working exactly
|
||||
as they do now.
|
||||
|
||||
### Existing Files (read these first)
|
||||
|
||||
- `lib/models/remote_command.dart` — `SyncResponse`, `RemoteCommand`, `SnapshotTab` and the sync request
|
||||
- `lib/services/remote_sync_service.dart` — the sync loop (`_performSync()` or similar)
|
||||
- `lib/services/bar_tab_service.dart` — creates and changes tabs, adds/removes lines, settles tabs
|
||||
- `lib/models/bar_tab.dart` — the `BarTab` model and its lines
|
||||
- `lib/database/app_database.dart` — the Drift database schema and migrations
|
||||
- The code that processes a `close_tab` remote command (search for `close_tab`)
|
||||
|
||||
Before writing code, find **every** place where a tab's lines or balance change: adding a product, changing
|
||||
a line's quantity, removing a line, changing a price, settling (locally and through a remote `close_tab`
|
||||
command) and deleting a tab. List them in your plan.
|
||||
|
||||
### The Events
|
||||
|
||||
Every event is a JSON object with these common fields:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | string (UUID v4) | Generated by the app when the event is created. Never reuse. |
|
||||
| `type` | string | `line_added`, `line_removed` or `settled` |
|
||||
| `tabId` | string | The same id the app sends as `SnapshotTab.id` |
|
||||
| `customerName` | string | The tab's customer name at that moment |
|
||||
| `occurredAt` | string | `DateTime.now().toUtc().toIso8601String()` at the moment it happened |
|
||||
|
||||
`line_added` and `line_removed` add:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `productName` | string | As shown on the tab |
|
||||
| `unitPriceInCents` | int | Price per piece at that moment |
|
||||
| `quantity` | int | How many were added or removed. **Always positive** (≥ 1) |
|
||||
|
||||
`settled` adds:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `amountInCents` | int | The amount that was settled |
|
||||
| `paymentMethod` | string | `cash`, `payconiq`, `payment_pending` or `other`. Map the app's own payment options to these. |
|
||||
| `commandId` | string (UUID), optional | The remote `close_tab` command id, when the settlement came from the dashboard |
|
||||
| `items` | list, optional | The lines that were settled: `[{ "productName", "quantity", "unitPriceInCents" }]` |
|
||||
|
||||
Examples:
|
||||
|
||||
```json
|
||||
{ "id": "6f1c2c0e-6a2b-4c1e-9a4c-1f2a3b4c5d6e", "type": "line_added", "tabId": "a1b2", "customerName": "Jan",
|
||||
"occurredAt": "2026-10-24T21:41:03.120Z", "productName": "Cola", "unitPriceInCents": 250, "quantity": 2 }
|
||||
|
||||
{ "id": "7a2d3e4f-5b6c-4d7e-8f9a-0b1c2d3e4f5a", "type": "settled", "tabId": "a1b2", "customerName": "Jan",
|
||||
"occurredAt": "2026-10-24T23:55:00.000Z", "amountInCents": 500, "paymentMethod": "payconiq",
|
||||
"items": [{ "productName": "Cola", "quantity": 2, "unitPriceInCents": 250 }] }
|
||||
```
|
||||
|
||||
### When to Write Which Event
|
||||
|
||||
- **Product added to a tab** → `line_added` with the quantity added.
|
||||
- **Quantity of a line increased** (e.g. 2 → 5) → `line_added` with `quantity: 3`.
|
||||
- **Quantity of a line decreased or a line removed** (e.g. 5 → 1) → `line_removed` with `quantity: 4`.
|
||||
- **Price of a line changed** → `line_removed` for the old line (old price) and `line_added` for the new one.
|
||||
- **Tab settled** (paid; the tab stays open with a zero balance) → **one** `settled` event with the amount,
|
||||
payment method and the settled lines. Do **not** also write `line_removed` events for the lines that the
|
||||
settlement clears: those drinks were sold, not taken back.
|
||||
- **Remote `close_tab` command processed** → the same `settled` event, with `paymentMethod` from the command
|
||||
and `commandId` set to the command's id. Write it only when the settlement actually happened (not when the
|
||||
command fails or conflicts), and only once, even if the same command is received again.
|
||||
- **Tab deleted while it still has lines** → `line_removed` for each remaining line (it was a mistake).
|
||||
- **Customer renamed** → no event; later events just use the new name.
|
||||
|
||||
### 1. Outbox Table (Drift)
|
||||
|
||||
In `lib/database/app_database.dart` add a table, bump `schemaVersion` and add a migration step that
|
||||
creates it for existing installs (existing data must stay):
|
||||
|
||||
```dart
|
||||
class TabEvents extends Table {
|
||||
TextColumn get id => text()(); // the event's UUID
|
||||
TextColumn get payload => text()(); // the event as JSON
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Write Events Together with the Change
|
||||
|
||||
In `BarTabService` (and wherever else tabs change), insert the event **in the same database transaction**
|
||||
as the change itself. That way a crash can never record a sale without the event, or an event without the
|
||||
sale. Put the JSON building in one small helper (e.g. `TabEventFactory` or methods on the service) instead
|
||||
of building maps in many places.
|
||||
|
||||
### 3. Send Events in the Sync
|
||||
|
||||
In the sync request, add an `events` field with the **oldest 500** events from the outbox (ordered by
|
||||
`createdAt`), as a list of the stored JSON objects:
|
||||
|
||||
```json
|
||||
{ "deviceId": "…", "openTabs": [ … ], "events": [ { … }, { … } ] }
|
||||
```
|
||||
|
||||
Read the events **before** building `openTabs`, so the snapshot is never older than the events.
|
||||
|
||||
### 4. Handle the Response
|
||||
|
||||
Add to `SyncResponse` (both optional, default to empty lists so an older server still works):
|
||||
|
||||
```dart
|
||||
final List<String> acknowledgedEventIds; // json['acknowledgedEventIds']
|
||||
final List<RejectedEvent> rejectedEvents; // json['rejectedEvents']: [{ "id": String?, "reason": String }]
|
||||
```
|
||||
|
||||
After a successful sync:
|
||||
- Delete every event in `acknowledgedEventIds` from the outbox.
|
||||
- Delete every event in `rejectedEvents` that has an id too, and log `id` + `reason` (debug log is fine).
|
||||
Re-sending won't help: it's a bug in the app that should be fixed.
|
||||
- Events created while the sync was in flight are not in either list; they stay and go with the next sync.
|
||||
|
||||
If the sync request fails (network, 5xx), delete nothing; the same events are sent again next time. The
|
||||
server stores each event id only once, so re-sending is safe.
|
||||
|
||||
If the outbox has more than 500 events (e.g. after a long time offline), they drain 500 per sync; no
|
||||
special handling needed.
|
||||
|
||||
### Important Notes
|
||||
|
||||
- Generate ids with the `uuid` package (`const Uuid().v4()`), already used in the app if available; add it
|
||||
otherwise.
|
||||
- Always send `occurredAt` in UTC with the `Z` suffix.
|
||||
- Amounts are integers in cents everywhere, like `SnapshotTab.totalInCents`.
|
||||
- Don't change how tabs, versions, `openTabs`, commands or planned tabs work.
|
||||
- Keep the outbox in the local database, never only in memory: events must survive an app restart and a
|
||||
tablet that is offline for a whole evening.
|
||||
|
||||
### Testing
|
||||
|
||||
Unit tests:
|
||||
- Adding a product, changing a quantity up and down, and removing a line write the right events with the
|
||||
right quantities.
|
||||
- Settling writes one `settled` event and no `line_removed` events.
|
||||
- A processed `close_tab` command writes a `settled` event with its `commandId` and payment method, once.
|
||||
- The sync sends at most 500 events, oldest first, and deletes exactly the acknowledged and rejected ids.
|
||||
- A failed sync deletes nothing.
|
||||
|
||||
Manual check against the server:
|
||||
1. Add a few drinks to a tab, remove one, and settle it with Payconiq.
|
||||
2. After the next sync the outbox is empty.
|
||||
3. On the server: `SELECT type, "productName", "quantityDelta", "amountInCents", "paymentMethod" FROM "TabEvent" ORDER BY "occurredAt";`
|
||||
shows the additions, the removal (negative `quantityDelta`) and the settlement.
|
||||
4. Turn off wifi, add drinks, turn it back on: the events arrive with their original `occurredAt`.
|
||||
|
||||
### Files to Modify
|
||||
|
||||
- `lib/database/app_database.dart` — `TabEvents` table, schema version and migration
|
||||
- `lib/services/bar_tab_service.dart` — write events with every change to a tab's lines and on settlement
|
||||
- The remote `close_tab` handling — write the `settled` event with `commandId`
|
||||
- `lib/models/remote_command.dart` — `events` in the sync request, `acknowledgedEventIds` and `rejectedEvents` in `SyncResponse`
|
||||
- `lib/services/remote_sync_service.dart` — send outbox events, delete acknowledged and rejected ones
|
||||
@@ -1,268 +0,0 @@
|
||||
## KoolTap Flutter App — Stock: Track It, Report It, Apply Changes from the Dashboard
|
||||
|
||||
The dashboard (backoffice) now has a **Stock** page. Managers can see how much of every product is left, set a
|
||||
low stock warning level, register deliveries, take stock out (breakage, own use) and enter stock counts.
|
||||
|
||||
The **terminal stays the owner of the stock**. It counts sales down itself, also while offline, and reports
|
||||
its product catalog with the current stock on every sync. The dashboard never overwrites the stock directly:
|
||||
it sends **stock adjustments** through the sync response, the terminal applies them and reports the outcome
|
||||
in the next sync.
|
||||
|
||||
Add three things to the app:
|
||||
|
||||
1. **Stock tracking**: a nullable stock per product that goes down when drinks are put on a tab and back up when
|
||||
they are taken off.
|
||||
2. **Report the catalog**: send the complete product list with stock in every sync.
|
||||
3. **Apply stock adjustments** from the sync response, exactly once each, and report the outcome through a
|
||||
small outbox.
|
||||
|
||||
Nothing else about the sync changes: `openTabs`, `events`, commands, acks and `plannedTabNames` keep working
|
||||
exactly as they do now.
|
||||
|
||||
### Existing Files (read these first)
|
||||
|
||||
- `lib/models/remote_command.dart` — `SyncResponse`, `RemoteCommand`, `SnapshotTab` and the sync request
|
||||
- `lib/services/remote_sync_service.dart` — the sync loop (`_performSync()` or similar)
|
||||
- `lib/services/bar_tab_service.dart` — creates and changes tabs, adds/removes lines, settles tabs, and writes
|
||||
the tab events (`line_added`, `line_removed`, `settled`) to the event outbox
|
||||
- `lib/database/app_database.dart` — the Drift database schema and migrations (has the `TabEvents` outbox)
|
||||
- The product model, its table and the screen(s) where products are managed (search for `Product`, `products`,
|
||||
`price`)
|
||||
|
||||
Before writing code:
|
||||
- Find the product model and table. Note how a product is identified (its id must be **stable**: the same
|
||||
product must keep the same id across app restarts and edits) and whether it already has a stock or category
|
||||
field.
|
||||
- Find **every** place that writes a `line_added` or `line_removed` event. Stock changes in exactly those
|
||||
places. List them in your plan.
|
||||
|
||||
### 1. Stock per Product (Drift)
|
||||
|
||||
If the product table has no stock column yet, add one, bump `schemaVersion` and add a migration step
|
||||
(existing data must stay):
|
||||
|
||||
```dart
|
||||
IntColumn get stock => integer().nullable()(); // null = stock is not tracked for this product
|
||||
```
|
||||
|
||||
- `null` means **not tracked**: nothing happens to it on sales, and it is shown as "not tracked" on the
|
||||
dashboard. All existing products start as `null`.
|
||||
- The value **may go negative**. Never block or warn-block a sale because stock is 0 or below: the bar must
|
||||
keep serving, the count was simply off.
|
||||
- If the product table already has a stock field, use it instead of adding a new one, and map its meaning to
|
||||
the rules above (e.g. a separate "track stock" flag → send `null` when tracking is off).
|
||||
|
||||
### 2. Change Stock Together with the Tab Events
|
||||
|
||||
Every time the app writes a tab event for a line, change the stock of that product **in the same database
|
||||
transaction** (only when the product's stock is not `null`):
|
||||
|
||||
| What happens on the tab | Event written (already exists) | Stock change |
|
||||
|---|---|---|
|
||||
| Product added / quantity increased by N | `line_added` with `quantity: N` | `stock -= N` |
|
||||
| Quantity decreased by N / line removed | `line_removed` with `quantity: N` | `stock += N` |
|
||||
| Price of a line changed | `line_removed` + `line_added` | net zero (do both, they cancel out) |
|
||||
| Tab deleted while it still has lines | `line_removed` per line | `stock += quantity` per line |
|
||||
| Tab settled (locally or by a remote `close_tab`) | `settled` | **no change** — the drinks were already counted down when they were added |
|
||||
| Customer renamed | none | none |
|
||||
|
||||
Lines are matched to products by the product the line was created from. If a line has no link to a product
|
||||
(e.g. a free-text line, or the product was deleted since), change no stock for it.
|
||||
|
||||
Put this in the same helper that writes the line events (e.g. `TabEventFactory` or the methods on
|
||||
`BarTabService`), so a line event and its stock change can never drift apart.
|
||||
|
||||
Optional, if it fits the existing UI: show the stock on the product buttons when tracked (e.g. a small
|
||||
number in a corner, red at 0 or below). Optional too: a local "set stock" action on the product management
|
||||
screen — a local change is simply part of the stock that the next sync reports.
|
||||
|
||||
### 3. Report the Catalog in the Sync
|
||||
|
||||
Add a `products` field to the sync request with **all** products (also untracked ones and products that are
|
||||
hidden/inactive but still exist), every sync:
|
||||
|
||||
```json
|
||||
{
|
||||
"deviceId": "…",
|
||||
"openTabs": [ … ],
|
||||
"events": [ … ],
|
||||
"products": [
|
||||
{ "id": "12", "name": "Cola", "category": "Frisdrank", "priceInCents": 250, "stock": 48 },
|
||||
{ "id": "13", "name": "Water", "category": null, "priceInCents": 200, "stock": null }
|
||||
],
|
||||
"stockAdjustmentResults": [ … ]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | string | The product's stable id. If it is an int in the app, send `id.toString()`. Max 100 chars. |
|
||||
| `name` | string | As shown on the terminal. Not empty, max 200 chars. |
|
||||
| `category` | string or null | The product's category/group name if the app has one, else `null` |
|
||||
| `priceInCents` | int ≥ 0 | Current price |
|
||||
| `stock` | int or null | Current stock, may be negative; `null` when not tracked. **Always send the key**, also when null. |
|
||||
|
||||
- This is the **complete** list: a product left out is removed from the dashboard. Never send a partial list.
|
||||
- Read the products in the same database read as the open tabs and events (after applying the stock
|
||||
adjustments of the previous response, see step 4), so the stock matches the events that are sent.
|
||||
- At most 2000 products. The server skips an invalid product (and logs it) instead of failing the sync.
|
||||
|
||||
### 4. Apply Stock Adjustments from the Response
|
||||
|
||||
The sync response now contains:
|
||||
|
||||
```json
|
||||
{
|
||||
"stockAdjustments": [
|
||||
{ "id": "5e0b8b4e-1d2c-4f3a-9b8c-7d6e5f4a3b2c", "productId": "12", "type": "add", "quantity": 24,
|
||||
"note": "Delivery brewery", "requestedBy": "bram@example.com", "createdAt": "2026-10-24T15:02:11.000Z" },
|
||||
{ "id": "6f1c9c5f-2e3d-4a4b-8c9d-8e7f6a5b4c3d", "productId": "13", "type": "set", "quantity": 30,
|
||||
"note": null, "requestedBy": "bram@example.com", "createdAt": "2026-10-24T15:03:40.000Z" }
|
||||
],
|
||||
"acknowledgedStockAdjustmentResultIds": [ "…" ],
|
||||
"rejectedStockAdjustmentResults": [ { "id": "…", "reason": "…" } ]
|
||||
}
|
||||
```
|
||||
|
||||
- `type: "add"` → `stock += quantity`. `quantity` is never 0 and **can be negative** (stock taken out:
|
||||
breakage, own use).
|
||||
- `type: "set"` → `stock = quantity` (a stock count, always ≥ 0). On a product whose stock is `null` this
|
||||
**starts tracking** it.
|
||||
- They arrive **oldest first**. Apply them in that order.
|
||||
- The server sends **every** pending adjustment on **every** sync until it has received the outcome. The same
|
||||
adjustment will therefore arrive several times: **apply each id only once**.
|
||||
|
||||
Add an outbox table for the outcomes, which doubles as the record of what was already applied:
|
||||
|
||||
```dart
|
||||
class StockAdjustmentResults extends Table {
|
||||
TextColumn get id => text()(); // the adjustment id from the server
|
||||
TextColumn get payload => text()(); // the result as JSON, see below
|
||||
BoolColumn get acknowledged => boolean().withDefault(const Constant(false))();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
For each adjustment in `stockAdjustments`, in order, in **one database transaction per adjustment**:
|
||||
|
||||
1. If a row with this id exists in `StockAdjustmentResults` → skip it (already applied or rejected).
|
||||
2. Look up the product by `productId`:
|
||||
- Not found → result `{ "id", "status": "rejected", "reason": "unknown_product" }`.
|
||||
- `type == "add"` and the product's stock is `null` → result
|
||||
`{ "id", "status": "rejected", "reason": "stock_not_tracked" }`.
|
||||
- Otherwise apply it and write result `{ "id", "status": "applied", "stockAfter": <the new stock> }`.
|
||||
3. Insert the result row (`acknowledged = false`) in the same transaction as the stock change, so a crash can
|
||||
never apply an adjustment without remembering it (it would be applied twice) or the other way around.
|
||||
|
||||
The reasons `unknown_product` and `stock_not_tracked` are shown translated on the dashboard; any other
|
||||
reason string is shown as is.
|
||||
|
||||
If any adjustment was applied, start the next sync right away (instead of waiting for the normal interval),
|
||||
so the dashboard shows the new stock within seconds. Don't loop: one extra sync is enough.
|
||||
|
||||
### 5. Send the Outcomes and Clean Up
|
||||
|
||||
In the sync request, add `stockAdjustmentResults` with the payloads of the **oldest 500** rows where
|
||||
`acknowledged = false` (ordered by `createdAt`):
|
||||
|
||||
```json
|
||||
"stockAdjustmentResults": [
|
||||
{ "id": "5e0b8b4e-1d2c-4f3a-9b8c-7d6e5f4a3b2c", "status": "applied", "stockAfter": 72 },
|
||||
{ "id": "6f1c9c5f-2e3d-4a4b-8c9d-8e7f6a5b4c3d", "status": "rejected", "reason": "unknown_product" }
|
||||
]
|
||||
```
|
||||
|
||||
After a successful sync:
|
||||
- Set `acknowledged = true` for every id in `acknowledgedStockAdjustmentResultIds`.
|
||||
- Do the same for every id in `rejectedStockAdjustmentResults` and log `id` + `reason` (debug log is fine):
|
||||
re-sending won't help, it's a bug in the app.
|
||||
- **Don't delete** acknowledged rows right away: they are the record that prevents applying an adjustment
|
||||
twice if it is still in a response that was computed before the server saw the outcome. Delete
|
||||
acknowledged rows older than 30 days (e.g. once per app start).
|
||||
|
||||
If the sync request fails (network, 5xx), change nothing; the same results are sent again next time. The
|
||||
server records each outcome once, so re-sending is safe. A late outcome is still recorded on the server even
|
||||
if the adjustment was cancelled on the dashboard in the meantime: what the terminal did is the truth.
|
||||
|
||||
### Order Within One Sync
|
||||
|
||||
1. Read events, open tabs, **products** and unacknowledged **stock results** (one consistent read).
|
||||
2. Send the request.
|
||||
3. On success: delete acknowledged/rejected events (existing), mark acknowledged/rejected stock results.
|
||||
4. Process commands (existing), planned tab names (existing), then **apply `stockAdjustments`**.
|
||||
5. If any adjustment was applied, schedule one immediate follow-up sync.
|
||||
|
||||
### Models (`lib/models/remote_command.dart`)
|
||||
|
||||
Add to the request: `List<Map<String, dynamic>> products` and `List<Map<String, dynamic>> stockAdjustmentResults`.
|
||||
|
||||
Add to `SyncResponse` (all optional, default to empty lists so an older server still works):
|
||||
|
||||
```dart
|
||||
final List<StockAdjustment> stockAdjustments; // json['stockAdjustments']
|
||||
final List<String> acknowledgedStockAdjustmentResultIds; // json['acknowledgedStockAdjustmentResultIds']
|
||||
final List<RejectedEvent> rejectedStockAdjustmentResults; // json['rejectedStockAdjustmentResults']: [{ "id": String?, "reason": String }]
|
||||
```
|
||||
|
||||
```dart
|
||||
class StockAdjustment {
|
||||
final String id;
|
||||
final String productId;
|
||||
final String type; // 'add' | 'set'; ignore (don't apply, don't report) any other type
|
||||
final int quantity;
|
||||
final String? note;
|
||||
final String requestedBy;
|
||||
final DateTime createdAt;
|
||||
}
|
||||
```
|
||||
|
||||
Reuse the existing `RejectedEvent` class for `rejectedStockAdjustmentResults` (same shape).
|
||||
|
||||
### Important Notes
|
||||
|
||||
- Amounts are integers in cents everywhere, like `SnapshotTab.totalInCents`.
|
||||
- Stock is a whole number of pieces (bottles, cans, glasses…), never a fraction.
|
||||
- Stock tracking must never slow down or block putting drinks on a tab.
|
||||
- Keep all of this in the local database, never only in memory: an adjustment received just before the tablet
|
||||
dies must not be applied twice or lost.
|
||||
- Don't change how tabs, versions, `openTabs`, `events`, commands or planned tabs work.
|
||||
|
||||
### Testing
|
||||
|
||||
Unit tests:
|
||||
- Adding 3 Cola to a tab lowers Cola's stock by 3; reducing the line to 1 raises it by 2; deleting the tab with
|
||||
the line raises it by 1. All in the same transaction as the matching event.
|
||||
- Settling a tab (locally and via `close_tab`) does not change stock.
|
||||
- A product with `stock == null` never changes on sales.
|
||||
- Stock can go below 0 and the sale still goes through.
|
||||
- `add` +24 on stock 48 → 72, result `applied` with `stockAfter: 72`. `add` −6 → 42.
|
||||
- `set` 30 on an untracked product → stock 30 (tracking starts), result `applied`.
|
||||
- `add` on an untracked product → `rejected` / `stock_not_tracked`, stock stays `null`.
|
||||
- Unknown `productId` → `rejected` / `unknown_product`.
|
||||
- The same adjustment received twice (also in two different sync responses) is applied once.
|
||||
- The sync sends the complete product list (stock `null` included as a key) and at most 500 unacknowledged
|
||||
results, oldest first; it marks exactly the acknowledged and rejected ids; a failed sync changes nothing.
|
||||
|
||||
Manual check against the server:
|
||||
1. Sync once: the dashboard's Stock page lists all products, all "Not tracked".
|
||||
2. On the dashboard click **Track stock** on Cola and count 48. Within a sync or two the terminal shows 48 and
|
||||
the dashboard history shows the change as **Applied** with "→ 48".
|
||||
3. Put 2 Cola on a tab: after the next sync the dashboard shows 46.
|
||||
4. **Register delivery** with 24 Cola: the terminal shows 70.
|
||||
5. Turn off wifi, send a delivery from the dashboard, sell a few Cola on the terminal, turn wifi back on: the
|
||||
delivery is applied once, the sales are counted down, and the dashboard ends up with the same number as the
|
||||
terminal.
|
||||
6. On the server: `SELECT "productName", type, quantity, status, "stockAfter", result FROM "StockAdjustment" ORDER BY "createdAt";`
|
||||
|
||||
### Files to Modify
|
||||
|
||||
- `lib/database/app_database.dart` — `stock` on the product table (if missing), `StockAdjustmentResults` table,
|
||||
schema version and migration
|
||||
- `lib/services/bar_tab_service.dart` (and the event helper) — change stock together with every line event
|
||||
- `lib/models/remote_command.dart` — `products` and `stockAdjustmentResults` in the sync request;
|
||||
`stockAdjustments`, `acknowledgedStockAdjustmentResultIds` and `rejectedStockAdjustmentResults` in `SyncResponse`
|
||||
- `lib/services/remote_sync_service.dart` — send products and results, apply adjustments, mark results
|
||||
acknowledged, prune old ones, one immediate follow-up sync after applying
|
||||
- Optional: the product grid / product management screen to show (and locally set) stock
|
||||
+19
-55
@@ -1,63 +1,27 @@
|
||||
# Local Development
|
||||
# Local development
|
||||
|
||||
## Prerequisites
|
||||
- Node.js >= 18
|
||||
- pnpm >= 8
|
||||
|
||||
## Setup
|
||||
Needs Node.js 24, pnpm (`corepack enable`) and Docker.
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone <repo>
|
||||
cd kooltap_backoffice
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Copy environment variables
|
||||
docker compose up -d # PostgreSQL on localhost:5432, with kooltap and kooltap_test
|
||||
cp .env.example apps/api/.env
|
||||
|
||||
# Generate Prisma client
|
||||
cd apps/api
|
||||
pnpm db:generate
|
||||
|
||||
# Run migrations
|
||||
pnpm db:migrate
|
||||
|
||||
# Seed demo data
|
||||
pnpm db:seed
|
||||
cd ../..
|
||||
echo 'NEXT_PUBLIC_API_URL=http://localhost:3001/api/v1' > apps/dashboard/.env.local
|
||||
pnpm install # also generates the Prisma client
|
||||
pnpm db:migrate # applies migrations; makes a new one when the schema changed
|
||||
pnpm db:seed # demo venues, a user per role, a terminal, products and tabs
|
||||
pnpm dev # API on :3001, dashboard on :3000
|
||||
```
|
||||
|
||||
## Running
|
||||
The seed prints the accounts to sign in with, and the demo terminal's token: `pnpm simulate` uses it to play a
|
||||
terminal.
|
||||
|
||||
```bash
|
||||
# Start both API and Dashboard
|
||||
pnpm dev
|
||||
```
|
||||
## Changing things
|
||||
|
||||
Or individually:
|
||||
```bash
|
||||
cd apps/api && pnpm dev
|
||||
cd apps/dashboard && pnpm dev
|
||||
```
|
||||
|
||||
## Database Commands
|
||||
|
||||
```bash
|
||||
pnpm db:generate # Generate Prisma client
|
||||
pnpm db:migrate # Run migrations
|
||||
pnpm db:seed # Seed demo data
|
||||
pnpm db:reset # Reset database and re-seed
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### API (`apps/api/.env`)
|
||||
- `DATABASE_URL` - PostgreSQL connection URL (`docker compose up postgres` starts one)
|
||||
- `JWT_SECRET` - JWT signing secret
|
||||
- `PORT` - API port (default: 3001)
|
||||
- `CORS_ORIGIN` - Allowed CORS origin (default: http://localhost:3000)
|
||||
|
||||
### Dashboard
|
||||
- `NEXT_PUBLIC_API_URL` - API base URL (default: http://localhost:3001/api/v1)
|
||||
- **The database:** edit `apps/api/prisma/schema.prisma`, then `pnpm db:migrate` names and writes a migration.
|
||||
`pnpm db:reset` starts over with the seed.
|
||||
- **The API or the terminal protocol:** change the schemas and types in `packages/shared/src` first; the API and the
|
||||
dashboard follow from the type errors. Protocol changes go in [terminal-protocol.md](terminal-protocol.md) too, and
|
||||
raise `MIN_APP_VERSION` when older apps can't keep up.
|
||||
- **Text on the dashboard:** `apps/dashboard/src/locales/en.json` and `nl.json` (a test checks both have every key).
|
||||
- **Emails:** `apps/api/templates/emails`, see its README.
|
||||
EOF
|
||||
|
||||
+40
-81
@@ -1,97 +1,56 @@
|
||||
# KoolTap Production Deployment
|
||||
# Production on Coolify
|
||||
|
||||
## Option A: Docker (recommended)
|
||||
KoolTap is deployed with Coolify's Docker Compose build pack, from `docker-compose.coolify.yml`.
|
||||
|
||||
The production setup uses PostgreSQL and configurable secrets.
|
||||
## Once
|
||||
|
||||
### 1. Set environment variables
|
||||
1. Create a **PostgreSQL** database resource in Coolify and turn on its scheduled backups.
|
||||
2. Create a **Docker Compose** resource from this repository with `docker-compose.coolify.yml`.
|
||||
3. Give the services their domains, on one site so the session cookie reaches the API:
|
||||
- `api` → `https://api.example.com:3001`
|
||||
- `dashboard` → `https://example.com:3000`
|
||||
4. Set the environment variables:
|
||||
|
||||
Create `.env` in the project root:
|
||||
| Variable | |
|
||||
|---|---|
|
||||
| `DATABASE_URL` | The database resource's internal connection URL |
|
||||
| `API_URL` | `https://api.example.com` (baked into the dashboard at build time) |
|
||||
| `DASHBOARD_URL` | `https://example.com` (CORS and links) |
|
||||
| `RESEND_API_KEY`, `RESEND_FROM_EMAIL` | Email; without a key nothing is sent |
|
||||
| `TRUST_PROXY` | 1 behind Coolify's proxy, 2 with Cloudflare proxying too |
|
||||
| `MIN_APP_VERSION` | Optional: make older terminal apps update |
|
||||
|
||||
```env
|
||||
# Database
|
||||
DB_PASSWORD=<generate-a-strong-password>
|
||||
`JWT_SECRET` is generated by Coolify (`SERVICE_BASE64_64_JWT`).
|
||||
5. Deploy. The API applies the database migrations before it starts.
|
||||
6. Create the first venue and its admin, in the API container's terminal:
|
||||
|
||||
# JWT secret — generate with: openssl rand -hex 64
|
||||
JWT_SECRET=<64-char-hex-string>
|
||||
```bash
|
||||
node dist/cli/setup.js "My Venue" admin@myvenue.com '<a strong password>'
|
||||
```
|
||||
|
||||
# URLs — set your actual domain/port
|
||||
DASHBOARD_URL=https://dashboard.yourdomain.com
|
||||
API_URL=https://api.yourdomain.com
|
||||
DASHBOARD_PORT=3000
|
||||
API_PORT=3001
|
||||
```
|
||||
It does nothing when there is a venue already. Superadmins belong to no venue:
|
||||
`node dist/cli/superadmin.js <email> --create "<name>" <password>` adds one, `superadmin.js <email>` promotes a user,
|
||||
and `--revoke <venue id>` makes a superadmin an admin of that venue.
|
||||
|
||||
### 2. Build and start
|
||||
## Switching over from 1.x
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up --build -d
|
||||
```
|
||||
2.0 starts with an empty database: nothing comes over from the 1.x backoffice.
|
||||
|
||||
### 3. Create the venue and admin (first time only)
|
||||
1. Before switching, let every tablet sync and settle (or write down) the open tabs. Export what you want to keep.
|
||||
2. Deploy 2.0 on a new, empty database and set up the venues, users and products.
|
||||
3. A 1.x tablet that syncs with it gets `426` and shows "update the app"; its unsent sales stay on the tablet.
|
||||
4. Update the app on each tablet to 2.0. It keeps its settings (PIN, theme, language, rows) and starts unpaired, with
|
||||
empty tabs and history: pair it with a code from Settings and it loads the venue's catalog and open tabs.
|
||||
|
||||
Migrations run automatically when the API container starts.
|
||||
## Backups
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml exec api node prisma/setup.js "My Venue" admin@myvenue.com '<strong password>'
|
||||
```
|
||||
Back up the database (Coolify does this) **and** the `images` volume, which holds the product pictures.
|
||||
|
||||
`setup.js` does nothing when a venue already exists. `--force` wipes ALL data and starts over.
|
||||
## Every deploy
|
||||
|
||||
### 4. Set up a reverse proxy (nginx/Caddy)
|
||||
Push; Coolify builds both images and restarts. Migrations run on start. If one fails the API doesn't start and the
|
||||
superadmin page's System tab lists it; fix it and redeploy.
|
||||
|
||||
Example Caddyfile:
|
||||
```
|
||||
dashboard.yourdomain.com {
|
||||
reverse_proxy dashboard:3000
|
||||
}
|
||||
## Health
|
||||
|
||||
api.yourdomain.com {
|
||||
reverse_proxy api:3001
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Option B: Manual (Node.js + PostgreSQL)
|
||||
|
||||
### 1. Install PostgreSQL
|
||||
|
||||
Create database and user:
|
||||
```sql
|
||||
CREATE DATABASE kooltap;
|
||||
CREATE USER kooltap WITH PASSWORD 'your-password';
|
||||
GRANT ALL PRIVILEGES ON DATABASE kooltap TO kooltap;
|
||||
```
|
||||
|
||||
### 2. Configure environment
|
||||
|
||||
```env
|
||||
DATABASE_URL="postgresql://kooltap:your-password@localhost:5432/kooltap"
|
||||
JWT_SECRET="<random-64-char-string>"
|
||||
PORT=3001
|
||||
CORS_ORIGIN="https://dashboard.yourdomain.com"
|
||||
```
|
||||
|
||||
### 3. Build and run
|
||||
|
||||
```bash
|
||||
cd apps/api
|
||||
pnpm install
|
||||
npx prisma generate
|
||||
npx prisma migrate deploy
|
||||
pnpm build
|
||||
node dist/main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] JWT secrets are 64+ random hex characters
|
||||
- [ ] DB_PASSWORD is strong and unique
|
||||
- [ ] HTTPS is terminated at reverse proxy
|
||||
- [ ] CORS_ORIGIN is set to the exact dashboard URL (no wildcard)
|
||||
- [ ] API is not exposed directly to the internet (reverse proxy only)
|
||||
- [ ] Rate limiting is configured
|
||||
- [ ] Audit logs are monitored
|
||||
`GET /api/v1/health` answers 503 when the database is unreachable; the compose file uses it as the healthcheck.
|
||||
|
||||
+28
-50
@@ -1,60 +1,38 @@
|
||||
# Security
|
||||
|
||||
## Authentication
|
||||
## Dashboard sessions
|
||||
|
||||
### Device Tokens
|
||||
- Format: `dev_` + 48 random bytes, base64url-encoded
|
||||
- Stored as SHA-256 hash in database
|
||||
- Raw token returned only once during registration
|
||||
- Expire after 90 days
|
||||
- Revocation immediately invalidates token
|
||||
- Passwords hashed with bcrypt (cost 12), at least 8 characters; emails stored lowercased.
|
||||
- Sign-in sets two httpOnly, SameSite=Lax cookies (Secure in production): a JWT access token for 15 minutes, and a
|
||||
refresh token for 30 days, sent only to `/api/v1/auth`. No script can read either.
|
||||
- A refresh token works once: refreshing revokes it and issues a new pair. Signing out revokes it.
|
||||
- Every request looks the user up: a deleted user, a changed role or a forced sign-out (`sessionsRevokedAt`) applies at
|
||||
once instead of when the access token expires.
|
||||
- Sign-in answers the same, in the same time, for an unknown email and a wrong password.
|
||||
|
||||
### Client Authentication
|
||||
- Passwords hashed with bcrypt (10 rounds)
|
||||
- JWT access tokens (15 minute expiration)
|
||||
- Refresh tokens (30 days) with rotation and revocation; a refresh token works only once
|
||||
- The dashboard keeps both tokens in `SameSite=Lax` cookies (`Secure` over HTTPS). They are readable by
|
||||
the dashboard's JavaScript, so an XSS bug would expose them; the short access token lifetime limits the damage
|
||||
## Terminals
|
||||
|
||||
## Authorization
|
||||
- Paired with a single-use registration code that expires after 10 minutes.
|
||||
- Token: `kt_` + 48 random bytes; only its SHA-256 hash is stored, and it is shown once. It expires after 90 days.
|
||||
- Unpairing on the dashboard stops the token at once.
|
||||
|
||||
### Role-Based Access
|
||||
- `admin`: Full access, including users and pairing or unpairing terminals
|
||||
- `manager` (the people running the venue): products and their pictures, stock, tabs, planned customers and
|
||||
reminders. Accounts and tokens with the former role `viewer` are treated as managers
|
||||
- `superadmin` (KoolTap itself): everything an admin may do, in every venue, plus the KoolTap Admin page (`/admin`
|
||||
endpoints). Only a superadmin can make someone a superadmin; the first one is made with
|
||||
`pnpm --filter @kooltap/api run superadmin <email>`. A superadmin viewing a venue is recorded in that venue's audit log,
|
||||
and every admin action is audited
|
||||
- Signing a user out from the admin page sets `sessionsRevokedAt`: access tokens issued before it are refused at once
|
||||
- Product pictures are public by id (`GET /images/:imageId`); the id is a SHA-256 hash, so it can't be guessed
|
||||
## Roles
|
||||
|
||||
### Venue Isolation
|
||||
- Every user and device is scoped to exactly one venue; only a superadmin can reach other venues
|
||||
- All service methods validate venue context from authentication
|
||||
- Cross-venue access is blocked at the service layer
|
||||
- `manager` runs the venue: tabs, customers, reminders, products and stock.
|
||||
- `admin` also manages the venue's users and terminals.
|
||||
- `superadmin` runs KoolTap and belongs to no venue: the admin page (`/admin` endpoints), and any venue they view, where
|
||||
they act as its admin. The database enforces that superadmins, and only they, have no venue. Only a superadmin can make
|
||||
someone a superadmin; the first one is made with `node dist/cli/superadmin.js <email> --create "<name>" <password>`
|
||||
(or by promoting a user). Viewing a venue is recorded in that venue's audit log, and every admin action is audited.
|
||||
|
||||
## API Security
|
||||
Every venue route checks that the user belongs to the venue in the URL (or is a superadmin). Product pictures are
|
||||
public by id, a SHA-256 hash that can't be guessed.
|
||||
|
||||
### Headers
|
||||
- Helmet for security headers
|
||||
- Strict CORS (dashboard origin only)
|
||||
- Body size limits enforced
|
||||
## API
|
||||
|
||||
### Rate Limiting
|
||||
- Login and device registration: 10 requests per minute per client
|
||||
- General: 100 requests per minute per client
|
||||
- Device sync and command acks are not rate limited (they are authenticated by the device token)
|
||||
|
||||
### Error Handling
|
||||
- Consistent error format never exposes internals
|
||||
- No secrets in error messages
|
||||
- No stack traces in production
|
||||
|
||||
## Audit Trail
|
||||
|
||||
All sensitive operations are logged:
|
||||
- Command creation, acknowledgement, expiration
|
||||
- Device registration and revocation
|
||||
- Snapshot conflicts
|
||||
- No sensitive data (passwords, tokens) in audit logs
|
||||
- Every body and query is validated with a zod schema; unknown fields are dropped.
|
||||
- Helmet headers; CORS only for the dashboard's origin.
|
||||
- Rate limits: 10 sign-ins or pairings per minute per client, 100 requests per minute otherwise. Terminal syncs are
|
||||
not limited (they are authenticated by the device token).
|
||||
- Errors are `{ error: { code, message } }`; internal errors are logged and never shown.
|
||||
- The realtime socket authenticates with the session cookie and only carries topic names, never data.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Terminal protocol (v2)
|
||||
|
||||
How a bar terminal (the Flutter app) talks to the backoffice. The schemas are the source of truth:
|
||||
`packages/shared/src/terminal.ts`. `pnpm schema` writes them as JSON Schema to `packages/shared/schema/` for
|
||||
generating the app's models.
|
||||
|
||||
v2 is not compatible with v1: the API refuses apps older than 2.0.0 with `426 app_update_required`, also on the v1
|
||||
routes (`/devices/...`, `GET /venues`), so a 1.x tablet keeps its unsent sales and asks to be updated.
|
||||
|
||||
## Every request
|
||||
|
||||
- `x-app-version: <the app's version>`, e.g. `2.0.0+41`. Older or missing: 426, and the app asks to be updated.
|
||||
- After pairing: `Authorization: Bearer <token>`. 401 `device_revoked` or `token_expired`: pair again.
|
||||
- Errors are `{ "error": { "code": "...", "message": "..." } }`.
|
||||
|
||||
## Pairing: `POST /api/v1/terminal/register`
|
||||
|
||||
```json
|
||||
{ "registrationCode": "8F3A21C0", "name": "Bar terminal 1" }
|
||||
```
|
||||
|
||||
The code comes from the dashboard (Settings, or the superadmin page) and works once, for 10 minutes. It says which
|
||||
venue the terminal is for. Returns `{ deviceId, venueId, venueName, token, tokenExpiresAt }`; the token is shown only
|
||||
this once and lasts 90 days.
|
||||
|
||||
## Sync: `POST /api/v1/terminal/sync`
|
||||
|
||||
```json
|
||||
{
|
||||
"events": [ ... ],
|
||||
"settlementsCursor": "1234",
|
||||
"catalogVersion": "6.1761657600000"
|
||||
}
|
||||
```
|
||||
|
||||
- `events`: the outbox, oldest first, at most 1000. Keep an event until its id is in `acknowledgedEventIds`;
|
||||
sending one again is harmless.
|
||||
- `settlementsCursor`: from the previous response; leave it out on a new terminal to get the last 30 days.
|
||||
- `catalogVersion`: from the previous response; the catalog is only sent when it changed.
|
||||
|
||||
The response:
|
||||
|
||||
| Field | |
|
||||
|---|---|
|
||||
| `acknowledgedEventIds` | Applied now or before: drop them from the outbox |
|
||||
| `rejectedEvents` | `{ id, reason }`: invalid and never applied. Log and drop them, sending them again won't help |
|
||||
| `tabs` | Every open tab of the venue, with its lines and drinks and the email of the customer with that name: replace your own with these |
|
||||
| `catalog` | Every product, archived ones included (hide those), or `null` when `catalogVersion` is current |
|
||||
| `catalogVersion` | Send it next time |
|
||||
| `stock` | `{ productId, stock, countedAt }` for every product; `stock` null: not tracked. Can be negative |
|
||||
| `settlements` | New settlements of the venue (from every terminal and the dashboard), oldest first |
|
||||
| `settlementsCursor` | Send it next time |
|
||||
|
||||
Then replay the outbox events that were not acknowledged on top of `tabs` and `stock`, with the rules below, so the
|
||||
terminal shows what it did offline.
|
||||
|
||||
## Events
|
||||
|
||||
Every event has `id` (a UUID the terminal makes), `type` and `occurredAt` (ISO 8601). A clock that runs more than 5
|
||||
minutes ahead is capped at the server's time. Tab, line, product and event ids are UUIDs; order ids are any string
|
||||
unique within the tab. Unknown fields are ignored.
|
||||
|
||||
| type | fields | effect |
|
||||
|---|---|---|
|
||||
| `tab_opened` | `tabId`, `customerName`, `email`? | Opens the tab, unless it is open already. With an email, adds the customer to the venue (or fills in their email) |
|
||||
| `tab_renamed` | `tabId`, `customerName` | |
|
||||
| `tab_deleted` | `tabId` | Deletes the tab without settling: what is on it is not paid and stays out of the stock |
|
||||
| `line_added` | `tabId`, `customerName`, `lineId`, `orderId`, `productId`, `productName`, `unitPriceInCents`, `quantity` ≥ 1 | Adds `quantity` drinks with ids `<event id>:0`, `:1`, … Reopens a tab deleted elsewhere. Counts the stock down |
|
||||
| `line_removed` | `tabId`, `lineId`, `orderId`, `quantity` ≥ 1 | Takes off the latest drinks of that order on the line, never more than are on it; those go back into the stock |
|
||||
| `settled` | `tabId`, `customerName`, `amountInCents`, `paymentMethod`, `items: [{ lineId, productId, productName, unitPriceInCents, quantity }]` | Settles the oldest `quantity` drinks of each line, with the drinks taken off it. A line the server doesn't know is kept in the history as it was sent. `paymentMethod`: `cash`, `payconiq`, `other` |
|
||||
| `stock_set` | `productId`, `quantity` (≥ 0, or null to stop tracking) | A count. The latest count wins, also when an older one arrives later; drinks rung up before it are in it |
|
||||
| `product_created` | `productId`, `product: { name, priceInCents, category?, lowStockThreshold?, imageId? }` | A product made on the terminal, sellable right away under its id. Its starting stock is a `stock_set` |
|
||||
| `product_updated` | `productId`, `product: { ...only the fields that changed }` | Per field, the latest change wins, so offline edits of different fields both survive |
|
||||
| `product_archived`, `product_restored` | `productId` | Hides (deletes) or brings back a product |
|
||||
|
||||
Drinks a terminal rings up on a tab that was settled elsewhere meanwhile stay on the tab: no sale is lost.
|
||||
|
||||
## Pictures: `POST /api/v1/terminal/images`
|
||||
|
||||
Multipart, the picture as `file` (at most 10 MB). Returns `{ imageId }`; link it with a `product_updated` event. The
|
||||
picture is at `GET /api/v1/images/<imageId>`, public and cacheable forever.
|
||||
+22
-43
@@ -1,56 +1,35 @@
|
||||
# Testing
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# All unit tests
|
||||
pnpm test
|
||||
|
||||
# E2E tests
|
||||
pnpm test:e2e
|
||||
|
||||
# Specific test file
|
||||
cd apps/api
|
||||
npx jest --testPathPattern="auth.service.spec.ts"
|
||||
pnpm test # every package's unit tests
|
||||
pnpm test:e2e # the API against a real database
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
## Unit tests
|
||||
|
||||
### Unit Tests
|
||||
Located in `apps/api/src/` alongside source files with `.spec.ts` suffix.
|
||||
- Authentication service tests
|
||||
- Device token generation and validation
|
||||
- Command creation and acknowledgement
|
||||
- Venue isolation enforcement
|
||||
- Rate limiting
|
||||
- `apps/api/src/**/*.spec.ts` (Jest): the rules in `ledger/` (tabs, drinks, settling, stock, product merges), event
|
||||
parsing, the app version gate, consistency checks, email templates and image processing. Pure, no database.
|
||||
- `apps/dashboard/src/**/*.test.ts` (Vitest): the customer import, amount parsing, and that every translation key the
|
||||
dashboard uses exists in English and Dutch.
|
||||
|
||||
### Integration/E2E Tests
|
||||
Located in `apps/api/test/` with `.e2e-spec.ts` suffix.
|
||||
- Login flow
|
||||
- Device registration and sync
|
||||
- Command lifecycle (create → sync → ack)
|
||||
- Device revocation
|
||||
- Cross-venue access prevention
|
||||
## End-to-end tests
|
||||
|
||||
## Writing Tests
|
||||
`apps/api/test/api.e2e-spec.ts` starts the whole API against the database in `DATABASE_URL` (migrated, and
|
||||
**wiped** by the test) and plays a terminal and the dashboard together: pairing, syncing events once, settling on a
|
||||
terminal and on the dashboard, customers getting tabs, stock movements, unpairing, venue access, and the superadmin
|
||||
page.
|
||||
|
||||
```typescript
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AuthService } from './auth.service';
|
||||
Locally, `docker compose up -d` creates a `kooltap_test` database too:
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
```bash
|
||||
cd apps/api
|
||||
DATABASE_URL=postgresql://kooltap:kooltap@localhost:5432/kooltap_test npx prisma migrate deploy
|
||||
DATABASE_URL=postgresql://kooltap:kooltap@localhost:5432/kooltap_test pnpm test:e2e
|
||||
```
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [AuthService, /* mocks */],
|
||||
}).compile();
|
||||
CI runs both, checks that the Prisma schema matches the migrations, builds everything and exports the terminal
|
||||
protocol's JSON Schema.
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
});
|
||||
## By hand
|
||||
|
||||
it('should generate valid device tokens', () => {
|
||||
// ...
|
||||
});
|
||||
});
|
||||
```
|
||||
`pnpm simulate` is a terminal on the command line: open tabs, ring up drinks and settle, and watch the dashboard follow.
|
||||
|
||||
Reference in New Issue
Block a user