feat: add alot

This commit is contained in:
2026-10-08 04:12:36 +02:00
parent ccf5165417
commit fc607cb7bd
99 changed files with 4981 additions and 3655 deletions
+523
View File
@@ -0,0 +1,523 @@
# Remote Tab Control — Backend Specification
This document describes the backend API and database requirements for the KoolTap remote tab control feature. The bar app (Flutter) is the sync client; the backend serves both the bar app API and a web dashboard.
---
## Database Schema
### `venues`
| Column | Type | Notes |
|--------|------|-------|
| `id` | `uuid` | Primary key |
| `name` | `text` | Venue name |
| `created_at` | `timestamptz` | |
| `updated_at` | `timestamptz` | |
### `devices`
| Column | Type | Notes |
|--------|------|-------|
| `id` | `uuid` | Primary key, assigned by server on registration |
| `venue_id` | `uuid` | FK → `venues.id` |
| `name` | `text` | Human-readable label set during registration |
| `token_hash` | `text` | BCrypt or SHA-256 hash of the device bearer token |
| `token_prefix` | `text` | First 8 chars of the raw token for support identification |
| `token_expires_at` | `timestamptz` | Token expiry |
| `last_seen_at` | `timestamptz` | Updated on every sync |
| `revoked_at` | `timestamptz?` | Null while active |
| `created_at` | `timestamptz` | |
Index: `(venue_id, revoked_at)`.
### `client_users`
| Column | Type | Notes |
|--------|------|-------|
| `id` | `uuid` | Primary key |
| `venue_id` | `uuid` | FK → `venues.id` |
| `email` | `text` | Login identifier |
| `password_hash` | `text` | |
| `name` | `text` | Display name, logged as `requested_by` on commands |
| `role` | `text` | `admin` or `viewer` |
| `created_at` | `timestamptz` | |
Unique: `(email)`.
A client user is scoped to one venue. A venue may have many client users.
### `open_tab_snapshots`
Stores the last-known open-tab state reported by each device.
| Column | Type | Notes |
|--------|------|-------|
| `id` | `uuid` | Primary key |
| `device_id` | `uuid` | FK → `devices.id` |
| `tab_id` | `text` | The tab's ID in the bar app's local database |
| `customer_name` | `text` | |
| `version` | `int` | Tab version at time of snapshot |
| `total_in_cents` | `int` | |
| `item_count` | `int` | |
| `reported_at` | `timestamptz` | When the device reported this state |
Index: `(device_id)`.
Index: `(tab_id, reported_at)`.
The server keeps only the most recent snapshot per `(device_id, tab_id)`. On each sync upload, upsert by `(device_id, tab_id)`.
### `tab_ownership`
Resolves which device currently owns a tab.
| Column | Type | Notes |
|--------|------|-------|
| `tab_id` | `text` | The bar app's tab ID |
| `venue_id` | `uuid` | FK → `venues.id` |
| `device_id` | `uuid` | FK → `devices.id` |
| `last_reported_at` | `timestamptz` | |
Primary key: `(venue_id, tab_id)`.
Updated on every snapshot upload. If the same `tab_id` is reported by a different `device_id`, the most recent `reported_at` wins — the older one is flagged in the audit log.
### `remote_commands`
| Column | Type | Notes |
|--------|------|-------|
| `id` | `uuid` | Primary key |
| `venue_id` | `uuid` | FK → `venues.id` |
| `device_id` | `uuid` | FK → `devices.id`, resolved from `tab_ownership` |
| `target_tab_id` | `text` | |
| `type` | `text` | Currently only `close_tab` |
| `expected_version` | `int` | Sent by the dashboard, validated by the bar app |
| `payment_method` | `text` | `cash`, `payconiq`, or `payment_pending` |
| `requested_by` | `text` | Client user's display name |
| `status` | `text` | `pending`, `completed`, `failed`, `conflict`, `expired` |
| `result` | `text?` | Free-text result from the bar app |
| `acknowledged_at` | `timestamptz?` | When the bar app acked |
| `created_at` | `timestamptz` | |
| `expires_at` | `timestamptz` | `created_at + 24 hours` |
Index: `(device_id, status, created_at)`.
Index: `(venue_id, status, created_at)` for the dashboard.
### `audit_log`
| Column | Type | Notes |
|--------|------|-------|
| `id` | `uuid` | Primary key |
| `venue_id` | `uuid` | FK → `venues.id` |
| `actor_type` | `text` | `client_user` or `device` or `system` |
| `actor_id` | `text` | The ID of who did it |
| `action` | `text` | `command_created`, `command_acknowledged`, `command_expired`, `device_registered`, `device_revoked`, `snapshot_conflict` |
| `details` | `jsonb?` | Arbitrary context |
| `created_at` | `timestamptz` | |
Index: `(venue_id, created_at)`.
---
## API Endpoints
Base URL: `https://api.kooltap.com/api/v1`
All endpoints require HTTPS. The bar app endpoints require a valid device bearer token. The dashboard endpoints require a valid client session token.
---
### 1. Device Registration
**`POST /devices/register`**
Registers a new bar device and returns a bearer token. The device calls this once during setup (initiated from the Admin screen with an Admin PIN + a registration code from the dashboard).
**Request:**
```json
{
"venueId": "uuid",
"name": "Bar terminal 1",
"registrationCode": "ABC123"
}
```
**Response (201):**
```json
{
"deviceId": "uuid",
"token": "dev_abc123...",
"tokenExpiresAt": "2026-01-01T00:00:00Z"
}
```
**Response (401):** Invalid registration code or venue mismatch.
The raw token is returned only once. The device stores it in secure storage. The server stores only the hash.
---
### 2. Sync (Bar App → Server)
**`POST /devices/:deviceId/sync`**
Two-direction sync: the bar app uploads its current open-tab snapshot and receives any pending commands.
**Headers:** `Authorization: Bearer <device_token>`
**Request:**
```json
{
"deviceId": "uuid",
"cursor": "optional-cursor-from-last-response",
"openTabs": [
{
"id": "local-tab-uuid",
"customerName": "Jan",
"version": 5,
"totalInCents": 2450,
"itemCount": 3
}
]
}
```
- `cursor` is opaque to the device. The server uses it to determine which commands the device has already seen. If absent, the server returns all pending commands.
- `openTabs` is the full list of currently open tabs. The server upserts `open_tab_snapshots` and `tab_ownership`.
**Response (200):**
```json
{
"cursor": "new-cursor-value",
"commands": [
{
"id": "cmd-uuid",
"type": "close_tab",
"tabId": "local-tab-uuid",
"expectedVersion": 5,
"paymentMethod": "cash",
"requestedBy": "Eigenaar",
"createdAt": "2026-09-16T21:00:00Z"
}
]
}
```
If no commands are pending, `commands` is an empty array.
**Server-side logic:**
1. Validate the device token and look up `venue_id`.
2. Upsert each reported open tab into `open_tab_snapshots` by `(device_id, tab_id)`.
3. Upsert `tab_ownership` by `(venue_id, tab_id)`. If the tab was previously owned by a different device, log a `snapshot_conflict` audit event.
4. Update `devices.last_seen_at`.
5. Query `remote_commands` where `device_id` matches, `status = 'pending'`, and `created_at > cursor_cutoff`. Exclude commands that appear before the cursor.
6. Mark any commands past `expires_at` as `expired`.
7. Return the new cursor (e.g. ISO 8601 timestamp of `now()`) and the pending command list.
**Rate limiting:** One request per 30 seconds per device. Return `429` with `Retry-After`.
---
### 3. Command Acknowledgement
**`POST /devices/:deviceId/commands/:commandId/ack`**
The bar app calls this after it has processed a command (success, failure, or conflict).
**Headers:** `Authorization: Bearer <device_token>`
**Request:**
```json
{
"commandId": "cmd-uuid",
"status": "completed",
"result": "Tab closed successfully"
}
```
`status` is one of: `completed`, `failed`, `conflict`, `expired`.
**Response (200):**
```json
{
"ok": true
}
```
**Server-side logic:**
1. Update `remote_commands.status` and `remote_commands.result` and `remote_commands.acknowledged_at`.
2. Write an audit log entry.
The ack endpoint is idempotent. A second ack with the same `commandId` is accepted but does not change anything.
---
### 4. Create Command (Dashboard)
**`POST /venues/:venueId/commands`**
The dashboard calls this when a client user requests a remote tab close.
**Headers:** `Authorization: Bearer <client_session_token>`
**Request:**
```json
{
"tabId": "local-tab-uuid",
"type": "close_tab",
"expectedVersion": 5,
"paymentMethod": "cash"
}
```
The `paymentMethod` must be one of `cash`, `payconiq`, or `payment_pending`. The dashboard must require the client to explicitly select one — never default to `cash` silently.
**Response (201):**
```json
{
"id": "cmd-uuid",
"status": "pending"
}
```
**Server-side logic:**
1. Validate the client session and look up `venue_id`.
2. Resolve the target `device_id` from `tab_ownership` using `(venue_id, tabId)`.
3. If no device owns this tab, return `404` with a message that the tab is not associated with any device.
4. If the owning device has not been seen (last_seen_at) in the last 10 minutes, return `200` but include a warning: `"warning": "Device has not been seen recently"`. The dashboard should display this warning.
5. Insert into `remote_commands` with `status = 'pending'`, `expires_at = now() + 24 hours`, and `device_id` from step 2.
6. Write an audit log entry.
**Rate limiting:** 10 requests per minute per client user. Return `429` with `Retry-After`.
---
### 5. Dashboard: List Open Tabs
**`GET /venues/:venueId/tabs`**
Returns the most recent snapshot of every open tab across all devices in the venue.
**Headers:** `Authorization: Bearer <client_session_token>`
**Response (200):**
```json
{
"tabs": [
{
"tabId": "local-tab-uuid",
"customerName": "Jan",
"version": 5,
"totalInCents": 2450,
"itemCount": 3,
"deviceId": "device-uuid",
"deviceName": "Bar terminal 1",
"deviceLastSeenAt": "2026-09-17T20:30:00Z",
"lastReportedAt": "2026-09-17T20:30:00Z"
}
]
}
```
The dashboard should show a warning next to tabs whose `deviceLastSeenAt` is more than 5 minutes ago.
---
### 6. Dashboard: List Devices
**`GET /venues/:venueId/devices`**
**Headers:** `Authorization: Bearer <client_session_token>`
**Response (200):**
```json
{
"devices": [
{
"id": "uuid",
"name": "Bar terminal 1",
"lastSeenAt": "2026-09-17T20:30:00Z",
"revokedAt": null
}
]
}
```
---
### 7. Dashboard: Revoke Device
**`POST /venues/:venueId/devices/:deviceId/revoke`**
**Headers:** `Authorization: Bearer <client_session_token>`
**Role required:** `admin`
**Request:** (empty body)
**Response (200):**
```json
{
"ok": true
}
```
**Server-side logic:**
1. Set `devices.revoked_at` to `now()`.
2. The device's token is immediately invalid. The next sync from this device returns `401`.
3. Any pending commands for this device are set to `expired`.
4. Write an audit log entry.
---
### 8. Dashboard: Command History
**`GET /venues/:venueId/commands`**
**Query params:** `?status=pending&limit=50&offset=0`
**Headers:** `Authorization: Bearer <client_session_token>`
**Response (200):**
```json
{
"commands": [
{
"id": "uuid",
"tabId": "local-tab-uuid",
"type": "close_tab",
"status": "pending",
"paymentMethod": "cash",
"requestedBy": "Eigenaar",
"createdAt": "2026-09-16T21:00:00Z",
"processedAt": null,
"result": null
}
],
"total": 1
}
```
---
## Authentication & Security
### Device Tokens
- Format: `dev_` prefix + 48 random bytes, base64-url-encoded (64 chars).
- Stored as a SHA-256 hash in `devices.token_sh`.
- Expire after 90 days. The device receives a new token on re-registration.
- Revocation sets `evoked_at` immediately.
### Client Authentication
- Standard email/password with bcrypt.
- Sessions via JWT with 24-hour expiry and refresh token.
- Optional 2FA (TOTP) for admin role.
### General Rules
- Every endpoint validates the caller's venue scope. A device or client from venue A cannot access data from venue B.
- No public endpoint accepts a `tabId` without authentication.
- CORS is restricted to the dashboard origin only.
---
## Command Expiry
A `close_tab` command expires 24 hours after creation.
**Mechanism:**
- The `expires_at` column is set on insert.
- The sync endpoint queries `WHERE status = 'pending' AND expires_at > now()`. Any rows where `expires_at <= now()` are updated to `status = 'expired'` before returning.
- A separate cron job (every hour) marks expired commands and writes audit log entries for housekeeping.
---
## Error Responses
All errors follow this shape:
```json
{
"error": {
"code": "device_not_found",
"message": "No device found with the given ID"
}
}
```
HTTP status codes:
| Code | Meaning |
|------|---------|
| 200 | Success |
| 201 | Created |
| 400 | Bad reqest (validation) |
| 401 | Unauthorized (missing or expired token) |
| 403 | Forbidden (wrong role) |
| 404 | Not found |
| 409 | Conflict (version mismatch etc.) |
| 429 | Too many request (`Retry-After` header) |
---
## Sequence: Full Close Flow
```
Dashboard client selects tab → clicks "Close"
│
▼
Dashboard POST /venues/:id/commands { tabId, expectedVersion, paymentMethod }
│
▼
Server resolves deviceId from tab_ownership
Server inserts remote_command (status=pending)
Server returns commandId + status=pending
│
▼
Bar app syncs (every 90s or on resume)
POST /devices/:id/sync { openTabs, cursor }
│
▼
Server returns [pending command]
│
▼
Bar app processes command:
├─ Tab not found → ack conflict
├── Tab already closed → ack conflict
├── Version mismatch → ack conflict
└── Success → closes tab → ak completed
│
▼
Bar app POST /devices/:id/commands/:id/ack { status, result }
│
▼
Server updates command status + audit log
```essages notification
The bar app shows a toast. The dashboard sees `status = completed` on next poll.
---
## Implementation Priority
1. **Phase 1** — `venues`, `devices`, `client_users` tables + device registration + basic auth
2. **Phase 2** — `open_tab_snapshots` + `tab_ownership` + sync endpoint (ignore commands, just store state)
3. **Phase 3** — `remote_commands` + `audit_log` + command creation + command ack + full sync loop
4. **Phase 4** — Dashboard read endpoints (list tabs, list devices, command history)
5. **Phase 5** — Device revocation + rate limiting + command expiry cron
6. **Phase 6** — Optional: 2FA, refresh tokens, outbound state push after mutations