Files
kooltab-backoffice/apps/api/prisma/schema.prisma
T
brammie15 314717ba3a
CI / build-and-test (push) Canceled after 0s
feat: move to V2
2026-10-29 11:21:52 +01:00

358 lines
12 KiB
Plaintext

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 = ""
}
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()) @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[]
}
// 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? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue? @relation(fields: [venueId], references: [id], onDelete: Cascade)
refreshTokens RefreshToken[]
settlements Settlement[]
stockMovements StockMovement[]
@@index([venueId])
}
model RefreshToken {
id String @id @default(uuid()) @db.Uuid
userId String @db.Uuid
tokenHash String @unique
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])
}
// 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], 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)
@@index([venueId])
}
// 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], onDelete: Cascade)
reminders Reminder[]
@@unique([venueId, nameKey])
}
// 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 {
// 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 @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)
updatedAt DateTime @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
units TabUnit[]
@@index([venueId])
}
// 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 @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? @db.Uuid
settledByUserId String? @db.Uuid
createdAt DateTime @default(now()) @db.Timestamptz(3)
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])
}
// 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 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])
}