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
+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.