datasource db { provider = "postgresql" url = env("DATABASE_URL") } generator client { provider = "prisma-client-js" } 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? } 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()) // Access tokens issued before this are refused, so a forced logout applies at once instead of when they expire sessionsRevokedAt DateTime? venue Venue @relation(fields: [venueId], references: [id]) @@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 tokenHash String @unique expiresAt DateTime revokedAt DateTime? createdAt DateTime @default(now()) @@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? venue Venue @relation(fields: [venueId], references: [id]) @@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") venue Venue @relation(fields: [venueId], references: [id]) @@index([venueId, sentAt]) @@index([venueId, tabId]) } // 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? 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. 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]) } // 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. model ProductImage { id String @id contentType String width Int height Int bytes Bytes createdAt DateTime @default(now()) products Product[] }