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
+304 -278
View File
@@ -1,331 +1,357 @@
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "cjs"
// Compiled to CommonJS by tsc: imports without an extension. Set, not inferred: the client is generated during
// install, before the tsconfig it would be inferred from is there
importFileExtension = ""
}
generator client {
provider = "prisma-client-js"
datasource db {
provider = "postgresql"
}
enum Role {
admin
manager
superadmin
}
enum PaymentMethod {
cash
payconiq
other
}
enum StockMovementType {
add
count
}
enum ActorType {
user
device
system
}
enum TerminalEventType {
tab_opened
tab_renamed
tab_deleted
line_added
line_removed
settled
stock_set
product_created
product_updated
product_archived
product_restored
}
model Venue {
id String @id @default(uuid())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
devices Device[]
clientUsers ClientUser[]
remoteCommands RemoteCommand[]
auditLogs AuditLog[]
regCodes RegistrationCode[]
plannedTabs PlannedTab[]
tabReminders TabReminder[]
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?
// Get an email for every tab paid in cash, on a terminal or on the dashboard
id String @id @default(uuid()) @db.Uuid
name String
// Who gets an email for every tab paid in cash, on a terminal or on the dashboard
cashPaymentEmails String[] @default([])
createdAt DateTime @default(now()) @db.Timestamptz(3)
users User[]
devices Device[]
registrationCodes RegistrationCode[]
customers Customer[]
tabs Tab[]
settlements Settlement[]
units TabUnit[]
products Product[]
stockMovements StockMovement[]
tabEvents TabEvent[]
reminders Reminder[]
auditLog AuditLog[]
}
model Device {
id String @id @default(uuid())
// A venue can have several terminals
venueId String
name String
tokenHash String @unique
tokenPrefix String
tokenExpiresAt DateTime
lastSeenAt DateTime @default(now())
revokedAt DateTime?
createdAt DateTime @default(now())
venue Venue @relation(fields: [venueId], references: [id])
remoteCommands RemoteCommand[]
tabEvents TabEvent[]
settlements Settlement[]
@@index([venueId, revokedAt])
}
model ClientUser {
id String @id @default(uuid())
venueId String
email String @unique
passwordHash String
name String
// admin (the owner: users and terminals) | manager (runs the venue: products, stock, tabs, reminders) |
// superadmin (runs KoolTap itself: every venue, and the admin page)
role String @default("manager")
createdAt DateTime @default(now())
// A venue's admin or manager, or a superadmin, who runs KoolTap itself and belongs to no venue: they view a venue to
// act in it as its admin
model User {
id String @id @default(uuid()) @db.Uuid
// Null exactly for a superadmin
venueId String? @db.Uuid
// Stored lowercased
email String @unique
passwordHash String
name String
role Role @default(manager)
// Access tokens issued before this are refused, so a forced logout applies at once instead of when they expire
sessionsRevokedAt DateTime?
sessionsRevokedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id])
venue Venue? @relation(fields: [venueId], references: [id], onDelete: Cascade)
refreshTokens RefreshToken[]
settlements Settlement[]
stockMovements StockMovement[]
@@index([venueId])
}
model RegistrationCode {
id String @id @default(uuid())
venueId String
code String @unique
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
venue Venue @relation(fields: [venueId], references: [id])
@@index([venueId])
}
// 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
// Null for a command the server carried out itself
deviceId String?
targetTabId String
customerName String?
amountInCents Int?
type String @default("close_tab")
expectedVersion Int
paymentMethod String
requestedBy String
status String @default("pending")
result String?
acknowledgedAt DateTime?
createdAt DateTime @default(now())
expiresAt DateTime
venue Venue @relation(fields: [venueId], references: [id])
device Device? @relation(fields: [deviceId], references: [id])
@@index([deviceId, status, createdAt])
@@index([venueId, status, createdAt])
}
model AuditLog {
id String @id @default(uuid())
venueId String
actorType String
actorId String
action String
details String?
createdAt DateTime @default(now())
venue Venue @relation(fields: [venueId], references: [id])
@@index([venueId, createdAt])
}
model RefreshToken {
id String @id @default(uuid())
userId String
id String @id @default(uuid()) @db.Uuid
userId String @db.Uuid
tokenHash String @unique
expiresAt DateTime
revokedAt DateTime?
createdAt DateTime @default(now())
expiresAt DateTime @db.Timestamptz(3)
revokedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model PlannedTab {
id String @id @default(uuid())
venueId String
customerName String
// Lowercased, whitespace-collapsed name: customers are matched on this everywhere
customerNameKey String
email String?
position Int @default(0)
createdAt DateTime @default(now())
// Set once the terminal reports an open tab with this name; it is then no longer sent to devices
deliveredAt DateTime?
// A bar terminal, paired with a registration code
model Device {
id String @id @default(uuid()) @db.Uuid
venueId String @db.Uuid
name String
tokenHash String @unique
tokenPrefix String
tokenExpiresAt DateTime @db.Timestamptz(3)
lastSeenAt DateTime @default(now()) @db.Timestamptz(3)
revokedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id])
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
tabEvents TabEvent[]
settlements Settlement[]
@@index([venueId, revokedAt])
}
model RegistrationCode {
id String @id @default(uuid()) @db.Uuid
venueId String @db.Uuid
code String @unique
expiresAt DateTime @db.Timestamptz(3)
usedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
@@unique([venueId, customerNameKey])
@@index([venueId])
}
model TabReminder {
id String @id @default(uuid())
venueId String
tabId String
customerName String
email String
amountInCents Int
sentAt DateTime @default(now())
status String @default("sent")
// A regular of the venue. Tabs are matched to customers by name: the next sync opens a tab for a customer that has
// none, and the customer's email is used for reminders
model Customer {
id String @id @default(uuid()) @db.Uuid
venueId String @db.Uuid
name String
// Lowercased, whitespace-collapsed name: customers are matched on this everywhere
nameKey String
email String?
position Int @default(0)
// Null until the next sync opens their tab
tabOpenedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id])
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
reminders Reminder[]
@@index([venueId, sentAt])
@@index([venueId, tabId])
@@unique([venueId, nameKey])
}
// 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
// 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?
// line_added / line_removed: the order on the terminal the drinks belong to
orderId String?
productId String?
productName String?
unitPriceInCents Int?
// Signed: positive for line_added, negative for line_removed
quantityDelta Int?
// settled only
amountInCents Int?
paymentMethod String?
commandId String?
items Json?
// stock_set: the counted stock, null to stop tracking
stockSetTo Int?
// product_created / product_updated: the product's fields as sent by the terminal
productData Json?
occurredAt DateTime
receivedAt DateTime @default(now())
venue Venue @relation(fields: [venueId], references: [id])
device Device @relation(fields: [deviceId], references: [id])
@@index([venueId, occurredAt])
@@index([venueId, type, occurredAt])
}
// The venue's products and their stock. The dashboard manages the catalog and the terminals get it on every sync;
// the first terminal's own catalog was taken over once. The stock follows the terminals' line events and stock
// counts and the dashboard's StockAdjustments.
model Product {
venueId String
// Generated by the server, or by the terminal that reported it before the dashboard managed the catalog
productId String
name String
category String?
// The sell price: what the terminals charge
priceInCents Int
// What the bar pays for one; null when not entered. Kept on the dashboard only, the terminals never get it
buyPriceInCents Int?
// 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?
// Warn when the stock drops to this level
lowStockThreshold Int?
// How many the bar wants in stock; the dashboard shows what to order to get there. Not sent to the terminals
desiredStock Int?
// When it was created or last changed
reportedAt DateTime
// Deleted on the dashboard: hidden on the terminals, kept because tabs and history refer to it
archivedAt DateTime?
imageId String?
// When each field was last changed ({ name: ISO date, ... }): a change from the dashboard or an (offline)
// terminal only wins over a later one per field, so edits of different fields never overwrite each other
fieldUpdatedAt Json?
image ProductImage? @relation(fields: [imageId], references: [id])
venue Venue @relation(fields: [venueId], references: [id])
@@id([venueId, productId])
}
// 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
productId String
productName String
// add (signed: a delivery, or breakage when negative) | set (a stock count)
type String
quantity Int
note String?
requestedBy String
requestedById String
// pending | applied | rejected | cancelled
status String @default("pending")
// The stock right after applying it
stockAfter Int?
result String?
createdAt DateTime @default(now())
processedAt DateTime?
venue Venue @relation(fields: [venueId], references: [id])
@@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.
// An open tab: the source of truth the terminals replace their own tabs with on every sync. Tabs are never closed,
// only emptied by settlements; deleting one drops the drinks still on it
model Tab {
venueId String
// Generated by the terminal that opened it
id String
// Generated by the terminal that opened it, or by the server for a customer's tab
id String @id @db.Uuid
venueId String @db.Uuid
customerName String
openedAt DateTime
openedAt DateTime @db.Timestamptz(3)
// 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
updatedAt DateTime @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id])
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
units TabUnit[]
@@id([venueId, id])
@@index([venueId])
}
// 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.
// A paid tab, settled 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
id String @id @db.Uuid
sequence Int @unique @default(autoincrement())
venueId String @db.Uuid
// The tab may be deleted since
tabId String @db.Uuid
customerName String
settledAt DateTime @db.Timestamptz(3)
paymentMethod PaymentMethod
// What was charged; the units are what the server had on the tab
amountInCents Int
// Who settled it: a terminal, or a dashboard user
deviceId String?
requestedBy String?
commandId String?
createdAt DateTime @default(now())
deviceId String? @db.Uuid
settledByUserId String? @db.Uuid
createdAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id])
device Device? @relation(fields: [deviceId], references: [id])
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
device Device? @relation(fields: [deviceId], references: [id], onDelete: SetNull)
settledBy User? @relation(fields: [settledByUserId], references: [id], onDelete: SetNull)
units TabUnit[]
@@index([venueId, sequence])
@@index([venueId, settledAt])
}
// A product picture, resized and cropped on upload. The id is the SHA-256 of the stored bytes, so the picture at
// an address never changes and can be cached forever, and the same picture is stored once.
// One drink ever rung up. It is on an open tab until a settlement takes it; a drink taken off again keeps its
// removedAt and moves with the settlement too. Lines are the units sharing a lineId
model TabUnit {
// `<line_added event id>:<index>`, derived the same way on the terminals
id String @id
venueId String @db.Uuid
// Exactly one of tabId and settlementId is set
tabId String? @db.Uuid
settlementId String? @db.Uuid
lineId String
productId String? @db.Uuid
// As rung up: a later change to the product doesn't change what was sold
productName String
unitPriceInCents Int
// The order on the terminal it was rung up in; null only for drinks settled on a terminal the server didn't have
orderId String?
addedAt DateTime @db.Timestamptz(3)
removedAt DateTime? @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
tab Tab? @relation(fields: [tabId], references: [id], onDelete: Cascade)
settlement Settlement? @relation(fields: [settlementId], references: [id], onDelete: Cascade)
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
@@index([tabId])
@@index([settlementId])
@@index([venueId, addedAt])
@@index([productId])
}
// The venue's catalog and stock. The dashboard and the terminals both change products; per field the latest change
// wins. The stock follows the drinks rung up, the terminals' counts and the dashboard's stock movements
model Product {
// Generated by the server, or by the terminal that created it offline
id String @id @db.Uuid
venueId String @db.Uuid
name String
category String?
// The sell price: what the terminals charge
priceInCents Int
// What the bar pays for one. Dashboard only, the terminals never get it
buyPriceInCents Int?
// Null when stock is not tracked. Can be negative: a sale is never blocked
stock Int?
// When the stock was last counted; drinks rung up before it are already in the count
countedAt DateTime? @db.Timestamptz(3)
// Warn when the stock drops to this level
lowStockThreshold Int?
// How many the bar wants in stock. Dashboard only
desiredStock Int?
imageId String?
// Deleted: hidden on the terminals, kept because tabs and history refer to it
archivedAt DateTime? @db.Timestamptz(3)
// When each field was last changed ({ name: ISO date, ... }), so edits of different fields never overwrite each other
fieldUpdatedAt Json @default("{}")
createdAt DateTime @default(now()) @db.Timestamptz(3)
// The last change to what the terminals get (not the stock): part of the catalog version
updatedAt DateTime @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
image ProductImage? @relation(fields: [imageId], references: [id], onDelete: SetNull)
units TabUnit[]
stockMovements StockMovement[]
@@index([venueId])
}
// A product picture, resized and cropped on upload and stored on disk as <id>.webp. The id is the SHA-256 of the
// bytes, so a picture's address never changes and the same picture is stored once
model ProductImage {
id String @id
contentType String
width Int
height Int
bytes Bytes
createdAt DateTime @default(now())
bytes Int
createdAt DateTime @default(now()) @db.Timestamptz(3)
products Product[]
}
// A stock change made on the dashboard: a delivery, breakage or count
model StockMovement {
id String @id @default(uuid()) @db.Uuid
venueId String @db.Uuid
productId String @db.Uuid
type StockMovementType
quantity Int
stockAfter Int
note String?
userId String? @db.Uuid
createdAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([venueId, createdAt])
@@index([productId, createdAt])
}
// What a terminal reported, as it sent it. The id is generated by the terminal, so an event that is sent again is
// stored, and applied, only once
model TabEvent {
id String @id @db.Uuid
venueId String @db.Uuid
deviceId String @db.Uuid
type TerminalEventType
// The event as sent, after validation
payload Json
occurredAt DateTime @db.Timestamptz(3)
receivedAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
@@index([venueId, occurredAt])
@@index([receivedAt])
}
model Reminder {
id String @id @default(uuid()) @db.Uuid
venueId String @db.Uuid
// Null when the customer was removed since
customerId String? @db.Uuid
customerName String
email String
tabId String @db.Uuid
amountInCents Int
sentAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
@@index([venueId, sentAt])
@@index([customerId])
}
model AuditLog {
id String @id @default(uuid()) @db.Uuid
// Null for actions that are not about one venue (purging, signing everyone out)
venueId String? @db.Uuid
actorType ActorType
// A user or device id; null for the system
actorId String? @db.Uuid
action String
details Json?
createdAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue? @relation(fields: [venueId], references: [id], onDelete: Cascade)
@@index([venueId, createdAt])
@@index([createdAt])
}