feat: move to V2
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-29 11:21:52 +01:00
parent 6342b8451e
commit 314717ba3a
244 changed files with 11615 additions and 15292 deletions
-172
View File
@@ -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
+32 -31
View File
@@ -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
View File
@@ -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`.
-34
View File
@@ -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`).
-163
View File
@@ -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
-88
View File
@@ -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
-166
View File
@@ -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
-268
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+81
View File
@@ -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
View File
@@ -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.