feat: make server own tabs
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-20 16:18:36 +02:00
parent 1e4438ec5a
commit 0ca9f4d16a
31 changed files with 2101 additions and 892 deletions
+79 -15
View File
@@ -24,11 +24,17 @@ model Venue {
tabEvents TabEvent[]
products Product[]
stockAdjustments StockAdjustment[]
tabs Tab[]
settlements Settlement[]
// Set on the first sync after the server started holding the tabs: the tabs were then taken over from the
// terminal's own open tabs, once
tabsSeededAt DateTime?
}
model Device {
id String @id @default(uuid())
venueId String @unique
// A venue can have several terminals
venueId String
name String
tokenHash String @unique
tokenPrefix String
@@ -42,6 +48,7 @@ model Device {
tabOwnerships TabOwnership[]
remoteCommands RemoteCommand[]
tabEvents TabEvent[]
settlements Settlement[]
@@index([venueId, revokedAt])
}
@@ -105,10 +112,13 @@ model TabOwnership {
@@id([venueId, tabId])
}
// A request from the dashboard. close_tab used to be executed by the owning terminal; the server now settles
// the tab itself and records the command as completed right away, for the history.
model RemoteCommand {
id String @id @default(uuid())
venueId String
deviceId String
// Null for a command the server carried out itself
deviceId String?
targetTabId String
customerName String?
amountInCents Int?
@@ -122,8 +132,8 @@ model RemoteCommand {
createdAt DateTime @default(now())
expiresAt DateTime
venue Venue @relation(fields: [venueId], references: [id])
device Device @relation(fields: [deviceId], references: [id])
venue Venue @relation(fields: [venueId], references: [id])
device Device? @relation(fields: [deviceId], references: [id])
@@index([deviceId, status, createdAt])
@@index([venueId, status, createdAt])
@@ -188,16 +198,19 @@ model TabReminder {
@@index([venueId, tabId])
}
// What happened on a tab, as reported by the terminal: lines added or removed, and settlements.
// The id is generated by the terminal so a re-sent event is stored only once.
// What happened on a tab, as reported by the terminal. The id is generated by the terminal so a re-sent event is
// stored, and applied to the tab state and stock, only once.
model TabEvent {
id String @id
venueId String
deviceId String
tabId String
customerName String
// line_added | line_removed | settled
// Null for stock_set, which is not about a tab
tabId String?
customerName String?
// tab_opened | tab_renamed | tab_deleted | line_added | line_removed | settled | stock_set
type String
lineId String?
productId String?
productName String?
unitPriceInCents Int?
// Signed: positive for line_added, negative for line_removed
@@ -207,6 +220,8 @@ model TabEvent {
paymentMethod String?
commandId String?
items Json?
// stock_set: the counted stock, null to stop tracking
stockSetTo Int?
occurredAt DateTime
receivedAt DateTime @default(now())
@@ -217,8 +232,9 @@ model TabEvent {
@@index([venueId, type, occurredAt])
}
// The terminal's product catalog with its stock, as last reported by the terminal. The terminal owns the
// stock: it counts sales down offline, so the dashboard only changes it through StockAdjustments.
// The venue's products as reported by its terminals, with the stock. The server owns the stock: it follows the
// terminals' line events and stock counts and the dashboard's StockAdjustments. A product is never removed because
// one terminal doesn't report it.
model Product {
venueId String
// Generated by the terminal
@@ -226,8 +242,10 @@ model Product {
name String
category String?
priceInCents Int
// Null when the terminal doesn't track stock for this product. Can be negative: a sale is never blocked.
// Null when stock is not tracked for this product. Can be negative: a sale is never blocked.
stock Int?
// When the stock was last counted; line events that happened before it are already in the count
countedAt DateTime?
// Dashboard-only setting, never sent to or overwritten by the terminal
lowStockThreshold Int?
reportedAt DateTime
@@ -237,8 +255,8 @@ model Product {
@@id([venueId, productId])
}
// A stock change requested from the dashboard. It is sent to the terminal on every sync until the terminal
// reports it applied or rejected.
// A stock change made on the dashboard. The server applies it right away. Older ones may still have been applied
// by a terminal, which reported the outcome.
model StockAdjustment {
id String @id @default(uuid())
venueId String
@@ -252,7 +270,7 @@ model StockAdjustment {
requestedById String
// pending | applied | rejected | cancelled
status String @default("pending")
// The terminal's stock right after applying it
// The stock right after applying it
stockAfter Int?
result String?
createdAt DateTime @default(now())
@@ -263,3 +281,49 @@ model StockAdjustment {
@@index([venueId, status, createdAt])
@@index([venueId, productId, createdAt])
}
// An open tab of the venue: the source of truth the terminals replace their own tabs with on every sync. Tabs are
// never closed, only emptied by a settlement; tab_deleted removes one.
model Tab {
venueId String
// Generated by the terminal that opened it
id String
customerName String
openedAt DateTime
// Bumped by every change, so the dashboard can refuse to settle a tab that changed since it was shown
version Int @default(0)
// [{ id, productId, productName, unitPriceInCents, units: [{ id, addedAt, removedAt }] }]
lines Json
updatedAt DateTime @updatedAt
venue Venue @relation(fields: [venueId], references: [id])
@@id([venueId, id])
}
// A settlement made on a terminal (id = the settled event's id) or on the dashboard. Terminals read them as a feed
// after their cursor, which is the sequence number.
model Settlement {
id String @id
sequence Int @unique @default(autoincrement())
venueId String
tabId String
customerName String
settledAt DateTime
// cash | payconiq | payment_pending | other
paymentMethod String
amountInCents Int
// The settled lines, in the shape of Tab.lines with each line's settled and removed units
lines Json
// Who settled it: a terminal, or a dashboard user
deviceId String?
requestedBy String?
commandId String?
createdAt DateTime @default(now())
venue Venue @relation(fields: [venueId], references: [id])
device Device? @relation(fields: [deviceId], references: [id])
@@index([venueId, sequence])
@@index([venueId, settledAt])
}