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 { // `:`, 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 .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]) }