diff --git a/apps/api/prisma/migrations/20260917223716_init/migration.sql b/apps/api/prisma/migrations/20260917223716_init/migration.sql deleted file mode 100644 index ae33e79..0000000 --- a/apps/api/prisma/migrations/20260917223716_init/migration.sql +++ /dev/null @@ -1,152 +0,0 @@ --- CreateTable -CREATE TABLE "Venue" ( - "id" TEXT NOT NULL PRIMARY KEY, - "name" TEXT NOT NULL, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" DATETIME NOT NULL -); - --- CreateTable -CREATE TABLE "Device" ( - "id" TEXT NOT NULL PRIMARY KEY, - "venueId" TEXT NOT NULL, - "name" TEXT NOT NULL, - "tokenHash" TEXT NOT NULL, - "tokenPrefix" TEXT NOT NULL, - "tokenExpiresAt" DATETIME NOT NULL, - "lastSeenAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "revokedAt" DATETIME, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "Device_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue" ("id") ON DELETE RESTRICT ON UPDATE CASCADE -); - --- CreateTable -CREATE TABLE "ClientUser" ( - "id" TEXT NOT NULL PRIMARY KEY, - "venueId" TEXT NOT NULL, - "email" TEXT NOT NULL, - "passwordHash" TEXT NOT NULL, - "name" TEXT NOT NULL, - "role" TEXT NOT NULL DEFAULT 'viewer', - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "ClientUser_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue" ("id") ON DELETE RESTRICT ON UPDATE CASCADE -); - --- CreateTable -CREATE TABLE "RegistrationCode" ( - "id" TEXT NOT NULL PRIMARY KEY, - "venueId" TEXT NOT NULL, - "code" TEXT NOT NULL, - "expiresAt" DATETIME NOT NULL, - "usedAt" DATETIME, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "RegistrationCode_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue" ("id") ON DELETE RESTRICT ON UPDATE CASCADE -); - --- CreateTable -CREATE TABLE "OpenTabSnapshot" ( - "id" TEXT NOT NULL PRIMARY KEY, - "deviceId" TEXT NOT NULL, - "tabId" TEXT NOT NULL, - "customerName" TEXT NOT NULL, - "version" INTEGER NOT NULL, - "totalInCents" INTEGER NOT NULL, - "itemCount" INTEGER NOT NULL, - "reportedAt" DATETIME NOT NULL, - CONSTRAINT "OpenTabSnapshot_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device" ("id") ON DELETE RESTRICT ON UPDATE CASCADE -); - --- CreateTable -CREATE TABLE "TabOwnership" ( - "tabId" TEXT NOT NULL, - "venueId" TEXT NOT NULL, - "deviceId" TEXT NOT NULL, - "lastReportedAt" DATETIME NOT NULL, - - PRIMARY KEY ("venueId", "tabId"), - CONSTRAINT "TabOwnership_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, - CONSTRAINT "TabOwnership_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device" ("id") ON DELETE RESTRICT ON UPDATE CASCADE -); - --- CreateTable -CREATE TABLE "RemoteCommand" ( - "id" TEXT NOT NULL PRIMARY KEY, - "venueId" TEXT NOT NULL, - "deviceId" TEXT NOT NULL, - "targetTabId" TEXT NOT NULL, - "type" TEXT NOT NULL DEFAULT 'close_tab', - "expectedVersion" INTEGER NOT NULL, - "paymentMethod" TEXT NOT NULL, - "requestedBy" TEXT NOT NULL, - "status" TEXT NOT NULL DEFAULT 'pending', - "result" TEXT, - "acknowledgedAt" DATETIME, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "expiresAt" DATETIME NOT NULL, - CONSTRAINT "RemoteCommand_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, - CONSTRAINT "RemoteCommand_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device" ("id") ON DELETE RESTRICT ON UPDATE CASCADE -); - --- CreateTable -CREATE TABLE "AuditLog" ( - "id" TEXT NOT NULL PRIMARY KEY, - "venueId" TEXT NOT NULL, - "actorType" TEXT NOT NULL, - "actorId" TEXT NOT NULL, - "action" TEXT NOT NULL, - "details" TEXT, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "AuditLog_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue" ("id") ON DELETE RESTRICT ON UPDATE CASCADE -); - --- CreateTable -CREATE TABLE "RefreshToken" ( - "id" TEXT NOT NULL PRIMARY KEY, - "userId" TEXT NOT NULL, - "tokenHash" TEXT NOT NULL, - "expiresAt" DATETIME NOT NULL, - "revokedAt" DATETIME, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- CreateIndex -CREATE UNIQUE INDEX "Device_tokenHash_key" ON "Device"("tokenHash"); - --- CreateIndex -CREATE INDEX "Device_venueId_revokedAt_idx" ON "Device"("venueId", "revokedAt"); - --- CreateIndex -CREATE UNIQUE INDEX "ClientUser_email_key" ON "ClientUser"("email"); - --- CreateIndex -CREATE INDEX "ClientUser_venueId_idx" ON "ClientUser"("venueId"); - --- CreateIndex -CREATE UNIQUE INDEX "RegistrationCode_code_key" ON "RegistrationCode"("code"); - --- CreateIndex -CREATE INDEX "RegistrationCode_venueId_idx" ON "RegistrationCode"("venueId"); - --- CreateIndex -CREATE INDEX "OpenTabSnapshot_deviceId_idx" ON "OpenTabSnapshot"("deviceId"); - --- CreateIndex -CREATE INDEX "OpenTabSnapshot_tabId_reportedAt_idx" ON "OpenTabSnapshot"("tabId", "reportedAt"); - --- CreateIndex -CREATE UNIQUE INDEX "OpenTabSnapshot_deviceId_tabId_key" ON "OpenTabSnapshot"("deviceId", "tabId"); - --- CreateIndex -CREATE INDEX "RemoteCommand_deviceId_status_createdAt_idx" ON "RemoteCommand"("deviceId", "status", "createdAt"); - --- CreateIndex -CREATE INDEX "RemoteCommand_venueId_status_createdAt_idx" ON "RemoteCommand"("venueId", "status", "createdAt"); - --- CreateIndex -CREATE INDEX "AuditLog_venueId_createdAt_idx" ON "AuditLog"("venueId", "createdAt"); - --- CreateIndex -CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash"); - --- CreateIndex -CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId"); diff --git a/apps/api/prisma/migrations/20260920072747_init/migration.sql b/apps/api/prisma/migrations/20260920072747_init/migration.sql new file mode 100644 index 0000000..1c8b10a --- /dev/null +++ b/apps/api/prisma/migrations/20260920072747_init/migration.sql @@ -0,0 +1,189 @@ +-- CreateTable +CREATE TABLE "Venue" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Venue_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Device" ( + "id" TEXT NOT NULL, + "venueId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "tokenPrefix" TEXT NOT NULL, + "tokenExpiresAt" TIMESTAMP(3) NOT NULL, + "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revokedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Device_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ClientUser" ( + "id" TEXT NOT NULL, + "venueId" TEXT NOT NULL, + "email" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "name" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'viewer', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ClientUser_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RegistrationCode" ( + "id" TEXT NOT NULL, + "venueId" TEXT NOT NULL, + "code" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RegistrationCode_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OpenTabSnapshot" ( + "id" TEXT NOT NULL, + "deviceId" TEXT NOT NULL, + "tabId" TEXT NOT NULL, + "customerName" TEXT NOT NULL, + "version" INTEGER NOT NULL, + "totalInCents" INTEGER NOT NULL, + "itemCount" INTEGER NOT NULL, + "reportedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OpenTabSnapshot_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TabOwnership" ( + "tabId" TEXT NOT NULL, + "venueId" TEXT NOT NULL, + "deviceId" TEXT NOT NULL, + "lastReportedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TabOwnership_pkey" PRIMARY KEY ("venueId","tabId") +); + +-- CreateTable +CREATE TABLE "RemoteCommand" ( + "id" TEXT NOT NULL, + "venueId" TEXT NOT NULL, + "deviceId" TEXT NOT NULL, + "targetTabId" TEXT NOT NULL, + "type" TEXT NOT NULL DEFAULT 'close_tab', + "expectedVersion" INTEGER NOT NULL, + "paymentMethod" TEXT NOT NULL, + "requestedBy" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "result" TEXT, + "acknowledgedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RemoteCommand_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AuditLog" ( + "id" TEXT NOT NULL, + "venueId" TEXT NOT NULL, + "actorType" TEXT NOT NULL, + "actorId" TEXT NOT NULL, + "action" TEXT NOT NULL, + "details" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RefreshToken" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "revokedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Device_venueId_key" ON "Device"("venueId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Device_tokenHash_key" ON "Device"("tokenHash"); + +-- CreateIndex +CREATE INDEX "Device_venueId_revokedAt_idx" ON "Device"("venueId", "revokedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "ClientUser_email_key" ON "ClientUser"("email"); + +-- CreateIndex +CREATE INDEX "ClientUser_venueId_idx" ON "ClientUser"("venueId"); + +-- CreateIndex +CREATE UNIQUE INDEX "RegistrationCode_code_key" ON "RegistrationCode"("code"); + +-- CreateIndex +CREATE INDEX "RegistrationCode_venueId_idx" ON "RegistrationCode"("venueId"); + +-- CreateIndex +CREATE INDEX "OpenTabSnapshot_deviceId_idx" ON "OpenTabSnapshot"("deviceId"); + +-- CreateIndex +CREATE INDEX "OpenTabSnapshot_tabId_reportedAt_idx" ON "OpenTabSnapshot"("tabId", "reportedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "OpenTabSnapshot_deviceId_tabId_key" ON "OpenTabSnapshot"("deviceId", "tabId"); + +-- CreateIndex +CREATE INDEX "RemoteCommand_deviceId_status_createdAt_idx" ON "RemoteCommand"("deviceId", "status", "createdAt"); + +-- CreateIndex +CREATE INDEX "RemoteCommand_venueId_status_createdAt_idx" ON "RemoteCommand"("venueId", "status", "createdAt"); + +-- CreateIndex +CREATE INDEX "AuditLog_venueId_createdAt_idx" ON "AuditLog"("venueId", "createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash"); + +-- CreateIndex +CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId"); + +-- AddForeignKey +ALTER TABLE "Device" ADD CONSTRAINT "Device_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ClientUser" ADD CONSTRAINT "ClientUser_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RegistrationCode" ADD CONSTRAINT "RegistrationCode_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OpenTabSnapshot" ADD CONSTRAINT "OpenTabSnapshot_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TabOwnership" ADD CONSTRAINT "TabOwnership_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TabOwnership" ADD CONSTRAINT "TabOwnership_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RemoteCommand" ADD CONSTRAINT "RemoteCommand_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RemoteCommand" ADD CONSTRAINT "RemoteCommand_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260920101506_planned_tabs/migration.sql b/apps/api/prisma/migrations/20260920101506_planned_tabs/migration.sql new file mode 100644 index 0000000..09df58d --- /dev/null +++ b/apps/api/prisma/migrations/20260920101506_planned_tabs/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "PlannedTab" ( + "id" TEXT NOT NULL, + "venueId" TEXT NOT NULL, + "customerName" TEXT NOT NULL, + "position" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PlannedTab_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "PlannedTab_venueId_idx" ON "PlannedTab"("venueId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PlannedTab_venueId_customerName_key" ON "PlannedTab"("venueId", "customerName"); + +-- AddForeignKey +ALTER TABLE "PlannedTab" ADD CONSTRAINT "PlannedTab_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml index e5e5c47..fbffa92 100644 --- a/apps/api/prisma/migrations/migration_lock.toml +++ b/apps/api/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually # It should be added in your version-control system (i.e. Git) -provider = "sqlite" \ No newline at end of file +provider = "postgresql" \ No newline at end of file diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 61336b8..38febf0 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -19,11 +19,12 @@ model Venue { remoteCommands RemoteCommand[] auditLogs AuditLog[] regCodes RegistrationCode[] + plannedTabs PlannedTab[] } model Device { id String @id @default(uuid()) - venueId String + venueId String @unique name String tokenHash String @unique tokenPrefix String @@ -141,4 +142,17 @@ model RefreshToken { createdAt DateTime @default(now()) @@index([userId]) +} + +model PlannedTab { + id String @id @default(uuid()) + venueId String + customerName String + position Int @default(0) + createdAt DateTime @default(now()) + + venue Venue @relation(fields: [venueId], references: [id]) + + @@unique([venueId, customerName]) + @@index([venueId]) } \ No newline at end of file diff --git a/apps/api/prisma/schema.sqlite.prisma b/apps/api/prisma/schema.sqlite.prisma index 845a70f..27d7d2c 100644 --- a/apps/api/prisma/schema.sqlite.prisma +++ b/apps/api/prisma/schema.sqlite.prisma @@ -19,11 +19,12 @@ model Venue { remoteCommands RemoteCommand[] auditLogs AuditLog[] regCodes RegistrationCode[] + plannedTabs PlannedTab[] } model Device { id String @id @default(uuid()) - venueId String + venueId String @unique name String tokenHash String @unique tokenPrefix String @@ -141,4 +142,17 @@ model RefreshToken { createdAt DateTime @default(now()) @@index([userId]) +} + +model PlannedTab { + id String @id @default(uuid()) + venueId String + customerName String + position Int @default(0) + createdAt DateTime @default(now()) + + venue Venue @relation(fields: [venueId], references: [id]) + + @@unique([venueId, customerName]) + @@index([venueId]) } \ No newline at end of file diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index f8bdb78..1080b84 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -55,103 +55,43 @@ async function main() { }); console.log('Created viewer user: viewer@example.com / Viewer123!'); - // Create devices - const device1Id = uuidv4(); - const device2Id = uuidv4(); - - const token1 = 'dev_' + crypto.randomBytes(48).toString('base64url'); - const token2 = 'dev_' + crypto.randomBytes(48).toString('base64url'); + // Create one device + const deviceId = uuidv4(); + const token = 'dev_' + crypto.randomBytes(48).toString('base64url'); await prisma.device.create({ data: { - id: device1Id, + id: deviceId, venueId, name: 'Bar terminal 1', - tokenHash: crypto.createHash('sha256').update(token1).digest('hex'), - tokenPrefix: token1.substring(0, 8), + tokenHash: crypto.createHash('sha256').update(token).digest('hex'), + tokenPrefix: token.substring(0, 8), tokenExpiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), - lastSeenAt: new Date(Date.now() - 1 * 60 * 1000), // seen 1 minute ago (online) + lastSeenAt: new Date(Date.now() - 1 * 60 * 1000), }, }); - await prisma.device.create({ - data: { - id: device2Id, - venueId, - name: 'Bar terminal 2', - tokenHash: crypto.createHash('sha256').update(token2).digest('hex'), - tokenPrefix: token2.substring(0, 8), - tokenExpiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), - lastSeenAt: new Date(Date.now() - 15 * 60 * 1000), // seen 15 minutes ago (stale) - }, - }); + console.log(`Created device: Bar terminal 1 (${deviceId})`); - console.log(`Created device: Bar terminal 1 (${device1Id})`); - console.log(`Created device: Bar terminal 2 (${device2Id})`); - console.log(`Device 1 token: ${token1}`); - console.log(`Device 2 token: ${token2}`); - - // Create tab ownership and snapshots for device 1 const tabsData = [ { tabId: uuidv4(), customerName: 'Jan', version: 5, totalInCents: 2450, itemCount: 3, reportedAt: new Date(Date.now() - 2 * 60 * 1000) }, { tabId: uuidv4(), customerName: 'Marie', version: 3, totalInCents: 1800, itemCount: 2, reportedAt: new Date(Date.now() - 5 * 60 * 1000) }, { tabId: uuidv4(), customerName: 'Thomas', version: 7, totalInCents: 4200, itemCount: 5, reportedAt: new Date(Date.now() - 3 * 60 * 1000) }, { tabId: uuidv4(), customerName: 'Lisa', version: 2, totalInCents: 900, itemCount: 1, reportedAt: new Date(Date.now() - 4 * 60 * 1000) }, - ]; - - for (const tab of tabsData) { - await prisma.tabOwnership.create({ - data: { - tabId: tab.tabId, - venueId, - deviceId: device1Id, - lastReportedAt: tab.reportedAt, - }, - }); - - await prisma.openTabSnapshot.create({ - data: { - deviceId: device1Id, - tabId: tab.tabId, - customerName: tab.customerName, - version: tab.version, - totalInCents: tab.totalInCents, - itemCount: tab.itemCount, - reportedAt: tab.reportedAt, - }, - }); - } - - // Create tab ownership and snapshots for device 2 (stale) - const staleTabs = [ { tabId: uuidv4(), customerName: 'Emma', version: 4, totalInCents: 3200, itemCount: 4, reportedAt: new Date(Date.now() - 16 * 60 * 1000) }, { tabId: uuidv4(), customerName: 'David', version: 1, totalInCents: 1500, itemCount: 2, reportedAt: new Date(Date.now() - 20 * 60 * 1000) }, ]; - for (const tab of staleTabs) { + for (const tab of tabsData) { await prisma.tabOwnership.create({ - data: { - tabId: tab.tabId, - venueId, - deviceId: device2Id, - lastReportedAt: tab.reportedAt, - }, + data: { tabId: tab.tabId, venueId, deviceId, lastReportedAt: tab.reportedAt }, }); - await prisma.openTabSnapshot.create({ - data: { - deviceId: device2Id, - tabId: tab.tabId, - customerName: tab.customerName, - version: tab.version, - totalInCents: tab.totalInCents, - itemCount: tab.itemCount, - reportedAt: tab.reportedAt, - }, + data: { deviceId, tabId: tab.tabId, customerName: tab.customerName, version: tab.version, totalInCents: tab.totalInCents, itemCount: tab.itemCount, reportedAt: tab.reportedAt }, }); } - console.log('Created 6 open tabs across 2 devices'); + console.log('Created 6 open tabs'); // Create sample commands const now = new Date(); @@ -161,7 +101,7 @@ async function main() { data: { id: uuidv4(), venueId, - deviceId: device1Id, + deviceId, targetTabId: tabsData[1].tabId, type: 'close_tab', expectedVersion: 3, @@ -180,7 +120,7 @@ async function main() { data: { id: uuidv4(), venueId, - deviceId: device1Id, + deviceId, targetTabId: tabsData[0].tabId, type: 'close_tab', expectedVersion: 5, @@ -197,8 +137,8 @@ async function main() { data: { id: uuidv4(), venueId, - deviceId: device2Id, - targetTabId: staleTabs[0].tabId, + deviceId, + targetTabId: tabsData[4].tabId, type: 'close_tab', expectedVersion: 4, paymentMethod: 'payment_pending', @@ -216,7 +156,7 @@ async function main() { data: { id: uuidv4(), venueId, - deviceId: device1Id, + deviceId, targetTabId: tabsData[2].tabId, type: 'close_tab', expectedVersion: 7, @@ -249,9 +189,7 @@ async function main() { console.log('Demo credentials:'); console.log(' Admin: admin@example.com / Admin123!'); console.log(' Viewer: viewer@example.com / Viewer123!'); - console.log(`\nDevice tokens (for simulator):`); - console.log(` Bar terminal 1: ${token1}`); - console.log(` Bar terminal 2: ${token2}`); + console.log(`\nDevice token (for simulator): ${token}`); } main() diff --git a/apps/api/prisma/setup.js b/apps/api/prisma/setup.js new file mode 100644 index 0000000..7145a5e --- /dev/null +++ b/apps/api/prisma/setup.js @@ -0,0 +1,60 @@ +const { PrismaClient } = require('@prisma/client'); +const bcrypt = require('bcrypt'); +const { v4: uuidv4 } = require('uuid'); +const crypto = require('crypto'); + +const prisma = new PrismaClient(); + +const VENUE_NAME = process.env.SETUP_VENUE_NAME || process.argv[2] || 'My Venue'; +const ADMIN_EMAIL = process.env.SETUP_ADMIN_EMAIL || process.argv[3] || 'admin@example.com'; +const ADMIN_PASSWORD = process.env.SETUP_ADMIN_PASSWORD || process.argv[4] || 'admin123'; + +async function main() { + console.log('=== KoolTab Production Setup ===\n'); + + // Wipe everything + await prisma.auditLog.deleteMany(); + await prisma.remoteCommand.deleteMany(); + await prisma.openTabSnapshot.deleteMany(); + await prisma.tabOwnership.deleteMany(); + await prisma.refreshToken.deleteMany(); + await prisma.registrationCode.deleteMany(); + await prisma.device.deleteMany(); + await prisma.clientUser.deleteMany(); + await prisma.venue.deleteMany(); + console.log('✓ Cleared existing data'); + + // Create venue + const venueId = uuidv4(); + await prisma.venue.create({ data: { id: venueId, name: VENUE_NAME } }); + console.log(`✓ Created venue: ${VENUE_NAME}`); + + // Create admin user + const hash = await bcrypt.hash(ADMIN_PASSWORD, 10); + await prisma.clientUser.create({ + data: { + id: uuidv4(), + venueId, + email: ADMIN_EMAIL, + passwordHash: hash, + name: 'Admin', + role: 'admin', + }, + }); + console.log(`✓ Created admin: ${ADMIN_EMAIL}`); + + console.log('\n=== Setup complete ==='); + console.log(` Venue: ${VENUE_NAME}`); + console.log(` Venue ID: ${venueId}`); + console.log(` Admin: ${ADMIN_EMAIL}`); + console.log(` Password: ${ADMIN_PASSWORD}`); + console.log('\nSave the Venue ID — the Flutter app needs it to find your venue.'); + console.log('Login at https://kooltab.com with the admin credentials above.'); + + await prisma.$disconnect(); +} + +main().catch((err) => { + console.error('Setup failed:', err); + process.exit(1); +}); \ No newline at end of file diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 18c7e80..343547e 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -10,6 +10,7 @@ import { TabsModule } from './tabs/tabs.module'; import { CommandsModule } from './commands/commands.module'; import { AuditModule } from './audit/audit.module'; import { JobsModule } from './jobs/jobs.module'; +import { PlannedTabsModule } from './planned-tabs/planned-tabs.module'; import { HealthController } from './health.controller'; @Module({ @@ -24,6 +25,7 @@ import { HealthController } from './health.controller'; CommandsModule, AuditModule, JobsModule, + PlannedTabsModule, ], controllers: [HealthController], providers: [ diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 0cda806..2189145 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -132,6 +132,15 @@ export class AuthService { const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken(); const deviceId = uuidv4(); + // One device per venue — revoke any existing device + const existing = await this.prisma.device.findFirst({ where: { venueId, revokedAt: null } }); + if (existing) { + await this.prisma.device.update({ + where: { id: existing.id }, + data: { revokedAt: new Date() }, + }); + } + await this.prisma.device.create({ data: { id: deviceId, @@ -153,6 +162,33 @@ export class AuthService { return { deviceId, token: rawToken, tokenExpiresAt: expiresAt }; } + async createUser(venueId: string, email: string, password: string, name: string, role: string) { + const venue = await this.prisma.venue.findUnique({ where: { id: venueId } }); + if (!venue) { + throw new NotFoundException({ code: 'venue_not_found', message: 'Venue not found. Your session may be stale — try logging out and back in.' }); + } + + const existingUser = await this.prisma.clientUser.findUnique({ where: { email } }); + if (existingUser) { + throw new ConflictException({ code: 'email_exists', message: 'A user with this email already exists' }); + } + + const passwordHash = await bcrypt.hash(password, 10); + const user = await this.prisma.clientUser.create({ + data: { + id: uuidv4(), + venueId, + email, + passwordHash, + name, + role, + }, + select: { id: true, email: true, name: true, role: true, venueId: true, createdAt: true }, + }); + + return user; + } + async validateDevice(deviceId: string, token: string): Promise { const hash = crypto.createHash('sha256').update(token).digest('hex'); const device = await this.prisma.device.findUnique({ where: { id: deviceId } }); diff --git a/apps/api/src/devices/devices.service.ts b/apps/api/src/devices/devices.service.ts index 18e1efb..7dcecc9 100644 --- a/apps/api/src/devices/devices.service.ts +++ b/apps/api/src/devices/devices.service.ts @@ -142,6 +142,13 @@ export class DevicesService { orderBy: { createdAt: 'asc' }, }); + // Fetch planned tab names for this venue + const plannedTabs = await this.prisma.plannedTab.findMany({ + where: { venueId: device.venueId }, + orderBy: { position: 'asc' }, + select: { customerName: true }, + }); + return { cursor, commands: commands.map((c) => ({ @@ -153,6 +160,7 @@ export class DevicesService { requestedBy: c.requestedBy, createdAt: c.createdAt, })), + plannedTabNames: plannedTabs.map((t) => t.customerName), }; } diff --git a/apps/api/src/planned-tabs/planned-tabs.controller.ts b/apps/api/src/planned-tabs/planned-tabs.controller.ts new file mode 100644 index 0000000..0a6a1c2 --- /dev/null +++ b/apps/api/src/planned-tabs/planned-tabs.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { PlannedTabsService } from './planned-tabs.service'; + +@ApiTags('Planned Tabs') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('venues/:venueId/planned-tabs') +export class PlannedTabsController { + constructor(private service: PlannedTabsService) {} + + @Get() + @ApiOperation({ summary: 'List planned tab names' }) + async findAll(@Param('venueId') venueId: string) { + return this.service.findAll(venueId); + } + + @Post() + @ApiOperation({ summary: 'Add a planned tab name' }) + async create(@Param('venueId') venueId: string, @Body() dto: { customerName: string }) { + return this.service.create(venueId, dto.customerName); + } + + @Put(':id') + @ApiOperation({ summary: 'Rename a planned tab' }) + async update(@Param('venueId') venueId: string, @Param('id') id: string, @Body() dto: { customerName: string }) { + return this.service.update(venueId, id, dto.customerName); + } + + @Delete(':id') + @ApiOperation({ summary: 'Remove a planned tab' }) + async remove(@Param('venueId') venueId: string, @Param('id') id: string) { + return this.service.remove(venueId, id); + } +} \ No newline at end of file diff --git a/apps/api/src/planned-tabs/planned-tabs.module.ts b/apps/api/src/planned-tabs/planned-tabs.module.ts new file mode 100644 index 0000000..665d9ba --- /dev/null +++ b/apps/api/src/planned-tabs/planned-tabs.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PlannedTabsController } from './planned-tabs.controller'; +import { PlannedTabsService } from './planned-tabs.service'; + +@Module({ + controllers: [PlannedTabsController], + providers: [PlannedTabsService], + exports: [PlannedTabsService], +}) +export class PlannedTabsModule {} \ No newline at end of file diff --git a/apps/api/src/planned-tabs/planned-tabs.service.ts b/apps/api/src/planned-tabs/planned-tabs.service.ts new file mode 100644 index 0000000..78d4570 --- /dev/null +++ b/apps/api/src/planned-tabs/planned-tabs.service.ts @@ -0,0 +1,72 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { v4 as uuidv4 } from 'uuid'; + +@Injectable() +export class PlannedTabsService { + constructor(private prisma: PrismaService) {} + + async findAll(venueId: string) { + const tabs = await this.prisma.plannedTab.findMany({ + where: { venueId }, + orderBy: { position: 'asc' }, + select: { id: true, customerName: true, position: true, createdAt: true }, + }); + return { plannedTabs: tabs }; + } + + async create(venueId: string, customerName: string) { + const name = customerName.trim(); + if (!name) throw new NotFoundException({ code: 'name_required', message: 'Customer name is required' }); + + // Check for duplicate + const existing = await this.prisma.plannedTab.findUnique({ + where: { venueId_customerName: { venueId, customerName: name } }, + }); + if (existing) { + throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' }); + } + + // Get next position + const last = await this.prisma.plannedTab.findFirst({ + where: { venueId }, + orderBy: { position: 'desc' }, + }); + + const tab = await this.prisma.plannedTab.create({ + data: { + id: uuidv4(), + venueId, + customerName: name, + position: (last?.position ?? -1) + 1, + }, + select: { id: true, customerName: true, position: true, createdAt: true }, + }); + + return tab; + } + + async update(venueId: string, id: string, customerName: string) { + const name = customerName.trim(); + if (!name) throw new NotFoundException({ code: 'name_required', message: 'Customer name is required' }); + + const tab = await this.prisma.plannedTab.findFirst({ where: { id, venueId } }); + if (!tab) throw new NotFoundException({ code: 'not_found', message: 'Planned tab not found' }); + + const result = await this.prisma.plannedTab.update({ + where: { id }, + data: { customerName: name }, + select: { id: true, customerName: true, position: true, createdAt: true }, + }); + + return result; + } + + async remove(venueId: string, id: string) { + const tab = await this.prisma.plannedTab.findFirst({ where: { id, venueId } }); + if (!tab) throw new NotFoundException({ code: 'not_found', message: 'Planned tab not found' }); + + await this.prisma.plannedTab.delete({ where: { id } }); + return { ok: true }; + } +} \ No newline at end of file diff --git a/apps/api/src/venues/venues.controller.ts b/apps/api/src/venues/venues.controller.ts index bc4046b..0d79303 100644 --- a/apps/api/src/venues/venues.controller.ts +++ b/apps/api/src/venues/venues.controller.ts @@ -1,7 +1,10 @@ -import { Controller, Get, Param, UseGuards } from '@nestjs/common'; +import { Controller, Get, Param, UseGuards, Post, Body } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { VenuesService } from './venues.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { AuthService } from '../auth/auth.service'; import { PrismaService } from '../prisma/prisma.service'; @ApiTags('Venues') @@ -9,6 +12,7 @@ import { PrismaService } from '../prisma/prisma.service'; export class VenuesController { constructor( private venuesService: VenuesService, + private authService: AuthService, private prisma: PrismaService, ) {} @@ -26,24 +30,41 @@ export class VenuesController { return this.venuesService.findOne(venueId); } + @Get(':venueId/users') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin') + @ApiOperation({ summary: 'List users for a venue' }) + async findUsers(@Param('venueId') venueId: string) { + const users = await this.prisma.clientUser.findMany({ + where: { venueId }, + select: { id: true, email: true, name: true, role: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + }); + return { users }; + } + + @Post(':venueId/users') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin') + @ApiOperation({ summary: 'Create a new user for the venue' }) + async createUser( + @Param('venueId') venueId: string, + @Body() dto: { email: string; password: string; name: string; role: string }, + ) { + return this.authService.createUser(venueId, dto.email, dto.password, dto.name, dto.role); + } + @Get(':venueId/devices') @ApiBearerAuth() @UseGuards(JwtAuthGuard) - @ApiOperation({ summary: 'List devices for a venue' }) + @ApiOperation({ summary: 'Get venue device info' }) async findDevices(@Param('venueId') venueId: string) { - const devices = await this.prisma.device.findMany({ - where: { venueId }, - select: { - id: true, - name: true, - lastSeenAt: true, - revokedAt: true, - createdAt: true, - tokenExpiresAt: true, - tokenPrefix: true, - }, - orderBy: { createdAt: 'desc' }, + const device = await this.prisma.device.findFirst({ + where: { venueId, revokedAt: null }, + select: { id: true, name: true, lastSeenAt: true, createdAt: true }, }); - return { devices }; + return { device }; } } \ No newline at end of file diff --git a/apps/api/src/venues/venues.module.ts b/apps/api/src/venues/venues.module.ts index 2df63f3..abc4c4a 100644 --- a/apps/api/src/venues/venues.module.ts +++ b/apps/api/src/venues/venues.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; import { VenuesController } from './venues.controller'; import { VenuesService } from './venues.service'; @Module({ + imports: [AuthModule], controllers: [VenuesController], providers: [VenuesService], exports: [VenuesService], diff --git a/apps/dashboard/src/app/dashboard/commands/page.tsx b/apps/dashboard/src/app/dashboard/commands/page.tsx index 04ec126..f655b11 100644 --- a/apps/dashboard/src/app/dashboard/commands/page.tsx +++ b/apps/dashboard/src/app/dashboard/commands/page.tsx @@ -87,26 +87,22 @@ export default function CommandsPage() { <>
- - - Tab - Customer - Device - Requested By - Payment - Created - Status - Processed - Result - - - - {commands.map((cmd: any) => ( - - {cmd.tabId.substring(0, 8)} - - - {cmd.deviceId?.substring(0, 8)} - {cmd.requestedBy} + + + Tab + Requested By + Payment + Created + Status + Processed + Result + + + + {commands.map((cmd: any) => ( + + {cmd.tabId.substring(0, 8)} + {cmd.requestedBy} {formatRelativeTime(cmd.createdAt)} diff --git a/apps/dashboard/src/app/dashboard/devices/page.tsx b/apps/dashboard/src/app/dashboard/devices/page.tsx deleted file mode 100644 index 013d59d..0000000 --- a/apps/dashboard/src/app/dashboard/devices/page.tsx +++ /dev/null @@ -1,229 +0,0 @@ -'use client'; - -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { api, devicesApi } from '@/lib/api/client'; -import { PageHeader } from '@/components/page-header'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { Button } from '@/components/ui/button'; -import { DeviceStatusBadge } from '@/components/device-status-badge'; -import { ConfirmDialog } from '@/components/confirm-dialog'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; -import { EmptyState } from '@/components/empty-state'; -import { LoadingState } from '@/components/loading-state'; -import { ErrorState } from '@/components/error-state'; -import { toast } from '@/components/ui/toast'; -import { formatRelativeTime } from '@/lib/utils'; -import { RefreshCw, Plus, Copy } from 'lucide-react'; - -export default function DevicesPage() { - const router = useRouter(); - const queryClient = useQueryClient(); - const [venueId, setVenueId] = useState(null); - const [userRole, setUserRole] = useState(null); - const [revokeDialog, setRevokeDialog] = useState<{ open: boolean; device: any }>({ open: false, device: null }); - const [revoking, setRevoking] = useState(false); - const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null); - const [generating, setGenerating] = useState(false); - - useEffect(() => { - const cookie = document.cookie - .split('; ') - .find((row) => row.startsWith('access_token=')); - if (!cookie) { - router.push('/login'); - return; - } - const payload = JSON.parse(atob(cookie.split('.')[1])); - setVenueId(payload.venueId); - setUserRole(payload.role); - }, [router]); - - const { data, isLoading, error } = useQuery({ - queryKey: ['devices', venueId], - queryFn: () => api(`/venues/${venueId}/devices`), - enabled: !!venueId, - refetchInterval: 15000, - }); - - const devices = data?.devices || []; - - const revokeMutation = useMutation({ - mutationFn: (deviceId: string) => devicesApi.revoke(venueId!, deviceId), - onSuccess: () => { - toast({ title: 'Device revoked', description: 'Device has been revoked successfully', variant: 'destructive' }); - queryClient.invalidateQueries({ queryKey: ['devices'] }); - }, - onError: (err: any) => { - toast({ title: 'Failed to revoke device', description: err.message, variant: 'destructive' }); - }, - }); - - function handleRevoke(device: any) { - setRevokeDialog({ open: true, device }); - } - - function confirmRevoke() { - setRevoking(true); - revokeMutation.mutate(revokeDialog.device.id, { - onSettled: () => { - setRevoking(false); - setRevokeDialog({ open: false, device: null }); - }, - }); - } - - async function handleGenerateCode() { - setGenerating(true); - try { - const result = await devicesApi.generateCode(); - setRegDialog({ open: true, code: result.code, expiresAt: result.expiresAt }); - } catch (err: any) { - toast({ title: 'Failed to generate code', description: err.message, variant: 'destructive' }); - } finally { - setGenerating(false); - } - } - - function copyCode() { - if (regDialog) { - navigator.clipboard.writeText(regDialog.code); - toast({ title: 'Code copied', description: 'Registration code copied to clipboard' }); - } - } - - if (!venueId) return ; - if (isLoading) return ; - if (error) return ; - - return ( -
- - - {userRole === 'admin' && ( - - )} -
- } - /> - - {devices.length === 0 ? ( - - ) : ( -
-
- - - Name - ID - Last Seen - Status - Created - Actions - - - - {devices.map((device: any) => ( - - {device.name} - - {device.id.substring(0, 8)}... - - {formatRelativeTime(device.lastSeenAt)} - - - - {formatRelativeTime(device.createdAt)} - - {device.revokedAt ? ( - Revoked - ) : userRole === 'admin' ? ( - - ) : null} - - - ))} - -
-
- )} - - {revokeDialog.device && ( - !open && setRevokeDialog({ open: false, device: null })} - title="Revoke device?" - description={`Are you sure you want to revoke "${revokeDialog.device.name}"? This action cannot be undone.`} - confirmLabel="Revoke" - variant="destructive" - onConfirm={confirmRevoke} - loading={revoking} - /> - )} - - {regDialog && ( - !open && setRegDialog(null)}> - - - Device Registration - - Share this code with the bar terminal to register it. - - -
-
-

Registration Code

-
- {regDialog.code} -
-
-
- Expires at {new Date(regDialog.expiresAt).toLocaleTimeString()} -
-
-

Instructions:

-
    -
  1. Open Admin on the bar terminal
  2. -
  3. Enter the Admin PIN
  4. -
  5. Enter this registration code
  6. -
  7. Give the terminal a name
  8. -
  9. Complete registration
  10. -
-
-
- - - - -
-
- )} - - ); -} \ No newline at end of file diff --git a/apps/dashboard/src/app/dashboard/layout.tsx b/apps/dashboard/src/app/dashboard/layout.tsx index 3a66e5a..9d2d476 100644 --- a/apps/dashboard/src/app/dashboard/layout.tsx +++ b/apps/dashboard/src/app/dashboard/layout.tsx @@ -21,7 +21,7 @@ import { toast } from '@/components/ui/toast'; const navItems = [ { href: '/dashboard', label: 'Overview', icon: LayoutDashboard }, { href: '/dashboard/tabs', label: 'Open Tabs', icon: ClipboardList }, - { href: '/dashboard/devices', label: 'Devices', icon: Monitor }, + { href: '/dashboard/users', label: 'Users', icon: Monitor }, { href: '/dashboard/commands', label: 'Command History', icon: Terminal }, { href: '/dashboard/settings', label: 'Settings', icon: Settings }, ]; diff --git a/apps/dashboard/src/app/dashboard/page.tsx b/apps/dashboard/src/app/dashboard/page.tsx index b466e3c..4152966 100644 --- a/apps/dashboard/src/app/dashboard/page.tsx +++ b/apps/dashboard/src/app/dashboard/page.tsx @@ -9,10 +9,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; import { LoadingState } from '@/components/loading-state'; import { CommandStatusBadge } from '@/components/command-status-badge'; -import { DeviceStatusBadge } from '@/components/device-status-badge'; -import { formatRelativeTime, formatCents } from '@/lib/utils'; -import { getDeviceStatus } from '@/lib/device-status'; -import { ClipboardList, Monitor, Terminal, AlertTriangle } from 'lucide-react'; +import { formatRelativeTime } from '@/lib/utils'; +import { ClipboardList, Terminal, Monitor } from 'lucide-react'; export default function DashboardPage() { const router = useRouter(); @@ -37,8 +35,8 @@ export default function DashboardPage() { refetchInterval: 10000, }); - const devicesQuery = useQuery({ - queryKey: ['devices', venueId], + const deviceQuery = useQuery({ + queryKey: ['device', venueId], queryFn: () => api(`/venues/${venueId}/devices`), enabled: !!venueId, refetchInterval: 30000, @@ -54,64 +52,39 @@ export default function DashboardPage() { if (!venueId) return ; const tabs = tabsQuery.data?.tabs || []; - const devices = devicesQuery.data?.devices || []; + const device = deviceQuery.data?.device; const commands = commandsQuery.data?.commands || []; - const onlineDevices = devices.filter((d: any) => !d.revokedAt && getDeviceStatus(d.lastSeenAt) === 'online'); - const staleDevices = devices.filter((d: any) => !d.revokedAt && (getDeviceStatus(d.lastSeenAt) === 'stale' || getDeviceStatus(d.lastSeenAt) === 'offline')); const pendingCommands = commands.filter((c: any) => c.status === 'pending'); - const commandsToday = commands.length; - - const isLoading = tabsQuery.isLoading || devicesQuery.isLoading || commandsQuery.isLoading; + const isLoading = tabsQuery.isLoading || deviceQuery.isLoading || commandsQuery.isLoading; return (
- + {isLoading ? ( -
- {Array.from({ length: 4 }).map((_, i) => ( - - - - - - - - - +
+ {Array.from({ length: 3 }).map((_, i) => ( + ))}
) : ( -
+
Open Tabs - -
{tabs.length}
-
+
{tabs.length}
- Online Devices - + Device + -
{onlineDevices.length}
-
-
- - - Stale/Offline Devices - - - -
{staleDevices.length}
+
{device?.name || 'Not connected'}
+ {device &&

Last seen {formatRelativeTime(device.lastSeenAt)}

}
@@ -119,60 +92,34 @@ export default function DashboardPage() { Pending Commands - -
{pendingCommands.length}
-
+
{pendingCommands.length}
)} -
- - - Recent Commands - - - {commands.length === 0 ? ( -

No commands yet

- ) : ( -
- {commands.map((cmd: any) => ( -
-
- {cmd.tabId?.substring(0, 8)} - {cmd.requestedBy} -
-
- - {formatRelativeTime(cmd.createdAt)} -
+ + Recent Commands + + {commands.length === 0 ? ( +

No commands yet

+ ) : ( +
+ {commands.map((cmd: any) => ( +
+
+ {cmd.tabId?.substring(0, 8)} + {cmd.requestedBy}
- ))} -
- )} - - - - - - Device Health - - - {devices.length === 0 ? ( -

No devices registered

- ) : ( -
- {devices.map((device: any) => ( -
- {device.name} - +
+ + {formatRelativeTime(cmd.createdAt)}
- ))} -
- )} - - -
+
+ ))} +
+ )} + +
); } \ No newline at end of file diff --git a/apps/dashboard/src/app/dashboard/settings/page.tsx b/apps/dashboard/src/app/dashboard/settings/page.tsx index 9eb607e..60a6c0f 100644 --- a/apps/dashboard/src/app/dashboard/settings/page.tsx +++ b/apps/dashboard/src/app/dashboard/settings/page.tsx @@ -1,66 +1,168 @@ 'use client'; +import { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; +import { api, devicesApi } from '@/lib/api/client'; import { PageHeader } from '@/components/page-header'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; +import { toast } from '@/components/ui/toast'; +import { formatRelativeTime, formatDateTime } from '@/lib/utils'; +import { Monitor, Copy, Plus } from 'lucide-react'; export default function SettingsPage() { + const router = useRouter(); + const [venueId, setVenueId] = useState(null); + const [userRole, setUserRole] = useState(null); + const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null); + const [generating, setGenerating] = useState(false); + + useEffect(() => { + const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token=')); + if (!cookie) { router.push('/login'); return; } + const payload = JSON.parse(atob(cookie.split('.')[1])); + setVenueId(payload.venueId); + setUserRole(payload.role); + }, [router]); + + const { data: venue } = useQuery({ + queryKey: ['venue', venueId], + queryFn: () => api(`/venues/${venueId}`), + enabled: !!venueId, + }); + + const { data: deviceData } = useQuery({ + queryKey: ['device', venueId], + queryFn: () => devicesApi.get(venueId!), + enabled: !!venueId, + refetchInterval: 15000, + }); + + const device = deviceData?.device; + + async function handleGenerateCode() { + setGenerating(true); + try { + const result = await devicesApi.generateCode(); + setRegDialog({ open: true, code: result.code, expiresAt: result.expiresAt }); + } catch (err: any) { + toast({ title: 'Failed', description: err.message, variant: 'destructive' }); + } finally { + setGenerating(false); + } + } + + function copyCode() { + if (regDialog) { + navigator.clipboard.writeText(regDialog.code); + toast({ title: 'Copied', description: 'Registration code copied to clipboard' }); + } + } + return (
- +
- Venue Information + Venue - -
- Venue name - KoolTab Demo Bar -
-
- Plan - Demo -
+ +

{venue?.name || 'Loading...'}

+

ID: {venueId}

- Demo Credentials + Device + + {device + ? `Paired as "${device.name}" — last seen ${formatRelativeTime(device.lastSeenAt)}` + : 'No device paired yet'} + - -
-

Admin account

-

admin@example.com / Admin123!

-
-
-

Viewer account

-

viewer@example.com / Viewer123!

-
-

- These credentials are for demo purposes only. Do not use in production. -

+ + {device ? ( +
+ + Connected + Since {formatDateTime(device.createdAt)} +
+ ) : ( +

+ Pair a bar terminal to start receiving tabs. +

+ )} + {userRole === 'admin' && ( + + )}
- API Information + API
- API Base URL + Base URL {process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'}
- Swagger Docs - {process.env.NEXT_PUBLIC_API_URL?.replace('/api/v1', '/api/docs') || 'http://localhost:3001/api/docs'} + Swagger + + {process.env.NEXT_PUBLIC_API_URL?.replace('/api/v1', '/api/docs') || 'http://localhost:3001/api/docs'} +
+ + {regDialog && ( + !open && setRegDialog(null)}> + + + Pair a Device + + Enter this code on the bar terminal to connect it to your venue. + + +
+
+

Registration Code

+
+ {regDialog.code} +
+
+
+ Expires at {new Date(regDialog.expiresAt).toLocaleTimeString()} +
+
+

Instructions:

+
    +
  1. Open the bar terminal app
  2. +
  3. Select your venue from the list
  4. +
  5. Enter this registration code
  6. +
  7. Give the terminal a name
  8. +
  9. Complete registration
  10. +
+
+
+
+ + +
+
+
+ )}
); } \ No newline at end of file diff --git a/apps/dashboard/src/app/dashboard/tabs/page.tsx b/apps/dashboard/src/app/dashboard/tabs/page.tsx index bdb5582..cde375e 100644 --- a/apps/dashboard/src/app/dashboard/tabs/page.tsx +++ b/apps/dashboard/src/app/dashboard/tabs/page.tsx @@ -3,23 +3,21 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { api, commandsApi } from '@/lib/api/client'; +import { api, commandsApi, plannedTabsApi } from '@/lib/api/client'; import { PageHeader } from '@/components/page-header'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { DeviceStatusBadge } from '@/components/device-status-badge'; -import { EmptyState } from '@/components/empty-state'; -import { LoadingState } from '@/components/loading-state'; -import { ErrorState } from '@/components/error-state'; -import { ConfirmDialog } from '@/components/confirm-dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { EmptyState } from '@/components/empty-state'; +import { LoadingState } from '@/components/loading-state'; +import { ErrorState } from '@/components/error-state'; import { toast } from '@/components/ui/toast'; import { formatCents, formatRelativeTime } from '@/lib/utils'; -import { getDeviceStatus } from '@/lib/device-status'; -import { RefreshCw, XCircle, AlertTriangle } from 'lucide-react'; +import { RefreshCw, Plus, X, Users } from 'lucide-react'; export default function TabsPage() { const router = useRouter(); @@ -29,17 +27,15 @@ export default function TabsPage() { const [paymentMethod, setPaymentMethod] = useState(''); const [creating, setCreating] = useState(false); const [pendingCommandIds, setPendingCommandIds] = useState>(new Set()); + const [plannedName, setPlannedName] = useState(''); + const [userRole, setUserRole] = useState(null); useEffect(() => { - const cookie = document.cookie - .split('; ') - .find((row) => row.startsWith('access_token=')); - if (!cookie) { - router.push('/login'); - return; - } + const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token=')); + if (!cookie) { router.push('/login'); return; } const payload = JSON.parse(atob(cookie.split('.')[1])); setVenueId(payload.venueId); + setUserRole(payload.role); }, [router]); const { data, isLoading, error } = useQuery({ @@ -49,7 +45,6 @@ export default function TabsPage() { refetchInterval: 10000, }); - // Poll pending commands to check their status const commandsQuery = useQuery({ queryKey: ['commands', venueId, 'pending'], queryFn: () => api(`/venues/${venueId}/commands?status=pending`), @@ -57,6 +52,12 @@ export default function TabsPage() { refetchInterval: 3000, }); + const plannedQuery = useQuery({ + queryKey: ['planned-tabs', venueId], + queryFn: () => plannedTabsApi.list(venueId!), + enabled: !!venueId, + }); + useEffect(() => { if (commandsQuery.data?.commands) { for (const cmd of commandsQuery.data.commands) { @@ -64,11 +65,7 @@ export default function TabsPage() { const newSet = new Set(pendingCommandIds); newSet.delete(cmd.id); setPendingCommandIds(newSet); - toast({ - title: `Command ${cmd.status}`, - description: `Close request for tab ${cmd.tabId?.substring(0, 8)} is now ${cmd.status}`, - variant: cmd.status === 'completed' ? 'success' : 'destructive', - }); + toast({ title: `Command ${cmd.status}`, description: `Close request for tab ${cmd.tabId?.substring(0, 8)} is now ${cmd.status}`, variant: cmd.status === 'completed' ? 'success' : 'destructive' }); queryClient.invalidateQueries({ queryKey: ['tabs'] }); queryClient.invalidateQueries({ queryKey: ['commands'] }); } @@ -81,44 +78,41 @@ export default function TabsPage() { commandsApi.create(venueId!, { ...data, type: 'close_tab' }), onSuccess: (result) => { setPendingCommandIds(new Set([...pendingCommandIds, result.id])); - toast({ - title: 'Close request sent', - description: result.warning || 'Command has been created and sent to the device', - variant: result.warning ? 'default' : 'success', - }); + toast({ title: 'Close request sent', description: result.warning || 'Command sent to the terminal', variant: result.warning ? 'default' : 'success' }); queryClient.invalidateQueries({ queryKey: ['commands'] }); }, onError: (err: any) => { - toast({ - title: 'Failed to create command', - description: err.message || 'An error occurred', - variant: 'destructive', - }); + toast({ title: 'Failed', description: err.message || 'An error occurred', variant: 'destructive' }); }, }); - function handleCloseTab(tab: any) { - setCloseDialog({ open: true, tab }); - setPaymentMethod(''); - } - function confirmClose() { if (!paymentMethod) return; setCreating(true); - createCommandMutation.mutate( - { - tabId: closeDialog.tab.tabId, - expectedVersion: closeDialog.tab.version, - paymentMethod, - }, - { - onSettled: () => { - setCreating(false); - setCloseDialog({ open: false, tab: null }); - setPaymentMethod(''); - }, - }, - ); + createCommandMutation.mutate({ tabId: closeDialog.tab.tabId, expectedVersion: closeDialog.tab.version, paymentMethod }, { + onSettled: () => { setCreating(false); setCloseDialog({ open: false, tab: null }); setPaymentMethod(''); }, + }); + } + + async function addPlannedName() { + if (!plannedName.trim()) return; + try { + await plannedTabsApi.create(venueId!, plannedName.trim()); + setPlannedName(''); + queryClient.invalidateQueries({ queryKey: ['planned-tabs'] }); + toast({ title: 'Added', description: `"${plannedName.trim()}" added to planned tabs` }); + } catch (err: any) { + toast({ title: 'Failed', description: err.message, variant: 'destructive' }); + } + } + + async function removePlannedName(id: string) { + try { + await plannedTabsApi.remove(venueId!, id); + queryClient.invalidateQueries({ queryKey: ['planned-tabs'] }); + } catch (err: any) { + toast({ title: 'Failed', description: err.message, variant: 'destructive' }); + } } if (!venueId) return ; @@ -131,21 +125,53 @@ export default function TabsPage() {
queryClient.invalidateQueries({ queryKey: ['tabs'] })} - > - - Refresh - - } + description={`${tabs.length} open tab${tabs.length !== 1 ? 's' : ''}`} + actions={} /> + {/* Planned Customers */} + + + Planned Customers + + +

+ These names are synced to the terminal. It will create tabs for these customers automatically. +

+ {userRole === 'admin' && ( +
+ setPlannedName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && addPlannedName()} + className="max-w-xs" + /> + +
+ )} +
+ {plannedQuery.data?.plannedTabs?.length === 0 && ( +

No planned customers. Add names above and they'll sync to the terminal.

+ )} + {(plannedQuery.data?.plannedTabs || []).map((pt: any) => ( +
+ {pt.customerName} + {userRole === 'admin' && ( + + )} +
+ ))} +
+
+
+ {tabs.length === 0 ? ( - + ) : (
@@ -154,117 +180,53 @@ export default function TabsPage() { CustomerAmountItems - Device - Device StatusVersionLast ReportedActions - {tabs.map((tab: any) => { - const deviceStale = getDeviceStatus(tab.deviceLastSeenAt, tab.deviceRevokedAt) === 'stale' - || getDeviceStatus(tab.deviceLastSeenAt, tab.deviceRevokedAt) === 'offline'; - return ( - - {tab.customerName} - {formatCents(tab.totalInCents)} - {tab.itemCount} - {tab.deviceName} - - - - {tab.version} - {formatRelativeTime(tab.lastReportedAt)} - - - - - ); - })} + {tabs.map((tab: any) => ( + + {tab.customerName} + {formatCents(tab.totalInCents)} + {tab.itemCount} + {tab.version} + {formatRelativeTime(tab.lastReportedAt)} + + + + + ))}
)} - {/* Close Tab Dialog */} {closeDialog.tab && ( !open && setCloseDialog({ open: false, tab: null })}> Close {closeDialog.tab.customerName}'s tab? - - This will send a close request to the bar terminal. - + Send a close request to the terminal. -
-
- Amount - {formatCents(closeDialog.tab.totalInCents)} -
-
- Items - {closeDialog.tab.itemCount} -
-
- Device - {closeDialog.tab.deviceName} -
-
- Version - {closeDialog.tab.version} -
- - {closeDialog.tab.deviceLastSeenAt && getDeviceStatus(closeDialog.tab.deviceLastSeenAt) !== 'online' && ( -
- - Device has not been seen recently -
- )} - +
Amount{formatCents(closeDialog.tab.totalInCents)}
+
Items{closeDialog.tab.itemCount}
+
Version{closeDialog.tab.version}
-
- - -
-
- - -
-
- - -
+
+
+
- {!paymentMethod && ( -

Please select a payment method

- )} + {!paymentMethod &&

Please select a payment method

}
- - - + +
diff --git a/apps/dashboard/src/app/dashboard/users/page.tsx b/apps/dashboard/src/app/dashboard/users/page.tsx new file mode 100644 index 0000000..bce2be7 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/users/page.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { usersApi } from '@/lib/api/client'; +import { PageHeader } from '@/components/page-header'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { EmptyState } from '@/components/empty-state'; +import { LoadingState } from '@/components/loading-state'; +import { ErrorState } from '@/components/error-state'; +import { toast } from '@/components/ui/toast'; +import { formatRelativeTime } from '@/lib/utils'; +import { RefreshCw, Plus } from 'lucide-react'; + +export default function UsersPage() { + const router = useRouter(); + const queryClient = useQueryClient(); + const [venueId, setVenueId] = useState(null); + const [userRole, setUserRole] = useState(null); + const [addDialog, setAddDialog] = useState(false); + const [form, setForm] = useState({ email: '', password: '', name: '', role: 'viewer' }); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + const cookie = document.cookie + .split('; ') + .find((row) => row.startsWith('access_token=')); + if (!cookie) { router.push('/login'); return; } + const payload = JSON.parse(atob(cookie.split('.')[1])); + setVenueId(payload.venueId); + setUserRole(payload.role); + }, [router]); + + const { data, isLoading, error } = useQuery({ + queryKey: ['users', venueId], + queryFn: () => usersApi.list(venueId!), + enabled: !!venueId, + }); + + const users = data?.users || []; + + async function handleCreateUser() { + if (!form.email || !form.password || !form.name) return; + setSubmitting(true); + try { + await usersApi.create(venueId!, form); + toast({ title: 'User created', description: `${form.email} added as ${form.role}`, variant: 'success' }); + setAddDialog(false); + setForm({ email: '', password: '', name: '', role: 'viewer' }); + queryClient.invalidateQueries({ queryKey: ['users'] }); + } catch (err: any) { + toast({ title: 'Failed to create user', description: err.message, variant: 'destructive' }); + } finally { + setSubmitting(false); + } + } + + if (!venueId) return ; + if (isLoading) return ; + if (error) return ; + + return ( +
+ + + {userRole === 'admin' && ( + + )} +
+ } + /> + + {users.length === 0 ? ( + + ) : ( +
+ + + + Name + Email + Role + Created + + + + {users.map((u: any) => ( + + {u.name} + {u.email} + {u.role} + {formatRelativeTime(u.createdAt)} + + ))} + +
+
+ )} + + + + + Add User + Create a new user who can access the dashboard. + +
+
+ + setForm({ ...form, name: e.target.value })} placeholder="Bartender name" /> +
+
+ + setForm({ ...form, email: e.target.value })} placeholder="email@example.com" /> +
+
+ + setForm({ ...form, password: e.target.value })} placeholder="Min 6 characters" /> +
+
+ + +
+
+ + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/dashboard/src/lib/api/client.ts b/apps/dashboard/src/lib/api/client.ts index 2ac1246..2e4af29 100644 --- a/apps/dashboard/src/lib/api/client.ts +++ b/apps/dashboard/src/lib/api/client.ts @@ -128,13 +128,19 @@ export const tabsApi = { // Devices API export const devicesApi = { - list: (venueId: string) => - api<{ devices: any[] }>(`/venues/${venueId}/devices`), - revoke: (venueId: string, deviceId: string) => - api(`/venues/${venueId}/devices/${deviceId}/revoke`, { method: 'POST' }), + get: (venueId: string) => + api<{ device: { id: string; name: string; lastSeenAt: string; createdAt: string } | null }>(`/venues/${venueId}/devices`), generateCode: () => api<{ code: string; expiresAt: string }>('/devices/generation-code'), }; +// Users API +export const usersApi = { + list: (venueId: string) => + api<{ users: any[] }>(`/venues/${venueId}/users`), + create: (venueId: string, data: { email: string; password: string; name: string; role: string }) => + api(`/venues/${venueId}/users`, { method: 'POST', body: JSON.stringify(data) }), +}; + // Commands API export const commandsApi = { list: (venueId: string, params?: { status?: string; limit?: number; offset?: number }) => { @@ -160,4 +166,15 @@ export const venuesApi = { get: (id: string) => api<{ id: string; name: string }>(`/venues/${id}`), }; +// Planned Tabs API +export const plannedTabsApi = { + list: (venueId: string) => api<{ plannedTabs: any[] }>(`/venues/${venueId}/planned-tabs`), + create: (venueId: string, customerName: string) => + api(`/venues/${venueId}/planned-tabs`, { method: 'POST', body: JSON.stringify({ customerName }) }), + update: (venueId: string, id: string, customerName: string) => + api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'PUT', body: JSON.stringify({ customerName }) }), + remove: (venueId: string, id: string) => + api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'DELETE' }), +}; + export { setTokens, clearTokens }; \ No newline at end of file diff --git a/docs/flutter-planned-tabs-prompt.md b/docs/flutter-planned-tabs-prompt.md new file mode 100644 index 0000000..0286597 --- /dev/null +++ b/docs/flutter-planned-tabs-prompt.md @@ -0,0 +1,88 @@ +## KoolTab Flutter App — Planned Tab Sync + +Add support for **planned tab names** that are synced from the server. The server now returns a `plannedTabNames` list in the sync response. The app should create open tabs for any planned names that don't already have an open tab. + +### Background + +The dashboard admin can manage a list of "planned customers" (e.g. "Jan", "Marie", "Thomas"). These names are sent to the device in every sync response. The device should ensure those tabs exist — creating new ones if a planned name doesn't already have an open tab. + +### Existing Files (read these first) + +- `lib/models/remote_command.dart` — contains `SyncResponse`, `RemoteCommand`, `SnapshotTab`, etc. +- `lib/services/remote_sync_service.dart` — the sync loop that processes the sync response +- `lib/services/bar_tab_service.dart` — creates and manages tabs (has `createTab(String customerName)`) +- `lib/models/bar_tab.dart` — the `BarTab` model +- `lib/database/app_database.dart` — the Drift database schema +- `lib/viewmodels/remote_sync_view_model.dart` — wraps the sync service as `ChangeNotifier` +- `lib/utils/app_config.dart` — server URL constant + +### What to Change + +#### 1. Update `SyncResponse` model +In `lib/models/remote_command.dart`, add `plannedTabNames` to the existing `SyncResponse` class (it already exists — add the field): + +```dart +class SyncResponse { + final String cursor; + final List commands; + final List plannedTabNames; // ← add this + + SyncResponse({ + required this.cursor, + required this.commands, + required this.plannedTabNames, // ← add this + }); + + factory SyncResponse.fromJson(Map json) => SyncResponse( + cursor: json['cursor'] as String, + commands: (json['commands'] as List) + .map((e) => RemoteCommand.fromJson(e as Map)) + .toList(), + plannedTabNames: (json['plannedTabNames'] as List?) // ← add this + ?.map((e) => e as String) + .toList() ?? [], + ); +} +``` + +#### 2. Process planned names in the sync service +In `lib/services/remote_sync_service.dart`, inside the `_performSync()` method (or wherever the sync response is handled), add logic to process `plannedTabNames` **after** commands are processed but **before** updating the cursor. + +The logic: +```dart +// After processing commands and before updating cursor: +// Ensure planned tabs exist +for (final name in response.plannedTabNames) { + final hasOpenTab = openTabs.any((t) => t.customerName == name); + if (!hasOpenTab) { + await barTabService.createTab(name); + } +} +// Also: if a tab is open for a customer NOT in plannedTabNames, +// should we close it? Probably not — just leave it. +``` + +Where `openTabs` is the list of currently open tabs (already available from `barTabService.getOpenTabs()` or similar). Look for `getOpenTabs()` in `BarTabService` or access the local database. + +#### 3. Handle the first sync (no existing tabs) +On first sync, the device might have zero open tabs and receive a list of planned names. The code should create tabs for all planned names. + +### Important Notes + +- `BarTabService.createTab(String customerName)` already exists — it opens a new tab with version 1. Call it directly. +- Do NOT close existing tabs that aren't in the planned list — planned tabs are additive only +- If a planned name already has an open tab (same `customerName`), skip creating it +- The sync service already tracks `openTabs` — it sends them in the `SyncRequest` via `SnapshotTab`. You can get the current open tabs from the local database using `BarTabService.getOpenTabs()` or `BarTabService.openTabs` stream +- This logic should run inside the existing sync loop, after processing commands but before saving the cursor + +### Testing + +- Add "Jan" and "Marie" as planned names in the dashboard +- Run a sync on the Flutter app — tabs for "Jan" and "Marie" should appear +- Remove "Jan" from planned names and re-sync — the "Jan" tab should NOT be closed (planned tabs are additive only) +- Add a new name "Thomas" — a new tab should appear on next sync + +### Files to Modify + +- `lib/models/remote_command.dart` — add `plannedTabNames` field to `SyncResponse` +- `lib/services/remote_sync_service.dart` — add planned tab creation logic in sync loop \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de416ac..42fb9ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,12 +61,21 @@ importers: passport-jwt: specifier: ^4.0.1 version: 4.0.1 + prisma: + specifier: ^5.10.0 + version: 5.22.0 reflect-metadata: specifier: ^0.2.1 version: 0.2.2 rxjs: specifier: ^7.8.1 version: 7.8.2 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@20.19.43)(typescript@5.9.3) + typescript: + specifier: ^5.4.0 + version: 5.9.3 uuid: specifier: ^9.0.0 version: 9.0.1 @@ -101,9 +110,6 @@ importers: jest: specifier: ^29.7.0 version: 29.7.0(@types/node@20.19.43)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) - prisma: - specifier: ^5.10.0 - version: 5.22.0 rimraf: specifier: ^5.0.5 version: 5.0.10 @@ -116,12 +122,6 @@ importers: ts-loader: specifier: ^9.5.1 version: 9.6.2(typescript@5.9.3)(webpack@5.97.1(postcss@8.5.28)(uglify-js@3.19.3)) - ts-node: - specifier: ^10.9.2 - version: 10.9.2(@types/node@20.19.43)(typescript@5.9.3) - typescript: - specifier: ^5.4.0 - version: 5.9.3 apps/dashboard: dependencies: diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..8045507 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,43 @@ +#!/bin/sh +# KoolTab Production Deploy +# Usage: ./scripts/deploy.sh "My Venue" admin@myvenue.com password + +VENUE_NAME="${1:-${SETUP_VENUE_NAME}}" +ADMIN_EMAIL="${2:-${SETUP_ADMIN_EMAIL}}" +ADMIN_PASSWORD="${3:-${SETUP_ADMIN_PASSWORD}}" + +if [ -z "$VENUE_NAME" ] || [ -z "$ADMIN_EMAIL" ] || [ -z "$ADMIN_PASSWORD" ]; then + echo "Usage: $0 \"Venue Name\" admin@example.com password" + echo " or: SETUP_VENUE_NAME=... SETUP_ADMIN_EMAIL=... SETUP_ADMIN_PASSWORD=... $0" + exit 1 +fi + +echo "=== Deploying KoolTab ===" +echo " Venue: $VENUE_NAME" +echo " Admin: $ADMIN_EMAIL" +echo "" + +# Build and start containers +docker compose -f docker-compose.prod.yml up --build -d || { echo "Deploy failed"; exit 1; } + +# Wait for API to be ready +echo "Waiting for API..." +until docker compose -f docker-compose.prod.yml exec api sh -c "node -e 'require(\"http\").get(\"http://localhost:3001/api/v1/health\",r=>{let b=\"\";r.on(\"data\",c=>b+=c);r.on(\"end\",()=>process.exit(b.includes(\"\\\"status\\\":\\\"ok\\\"\")?0:1))})'" 2>/dev/null; do + sleep 2 +done +echo "✓ API is ready" + +# Seed the database +echo "" +echo "Seeding database..." +docker compose -f docker-compose.prod.yml exec api sh -c " + SETUP_VENUE_NAME='$VENUE_NAME' \ + SETUP_ADMIN_EMAIL='$ADMIN_EMAIL' \ + SETUP_ADMIN_PASSWORD='$ADMIN_PASSWORD' \ + node prisma/setup.js +" || { echo "Seed failed"; exit 1; } + +echo "" +echo "=== Done ===" +echo " Dashboard: https://kooltab.com" +echo " Login: $ADMIN_EMAIL / $ADMIN_PASSWORD" \ No newline at end of file diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100644 index 0000000..d1799ba --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,40 @@ +#!/bin/sh +# KoolTab Production Setup +# Usage: ./scripts/setup.sh "My Venue" admin@myvenue.com mypassword +# +# Or with env vars: +# SETUP_VENUE_NAME="My Venue" SETUP_ADMIN_EMAIL="admin@myvenue.com" SETUP_ADMIN_PASSWORD="mypassword" ./scripts/setup.sh + +VENUE_NAME="${SETUP_VENUE_NAME:-$1}" +ADMIN_EMAIL="${SETUP_ADMIN_EMAIL:-$2}" +ADMIN_PASSWORD="${SETUP_ADMIN_PASSWORD:-$3}" + +if [ -z "$VENUE_NAME" ] || [ -z "$ADMIN_EMAIL" ] || [ -z "$ADMIN_PASSWORD" ]; then + echo "Usage: $0 \"Venue Name\" admin@example.com password" + echo " or: SETUP_VENUE_NAME=... SETUP_ADMIN_EMAIL=... SETUP_ADMIN_PASSWORD=... $0" + exit 1 +fi + +echo "=== KoolTab Production Setup ===" +echo "" +echo " Venue: $VENUE_NAME" +echo " Admin: $ADMIN_EMAIL" +echo "" + +# Step 1: Apply database schema +echo "[1/3] Applying database schema..." +npx prisma db push --accept-data-loss 2>&1 || { echo "FAILED"; exit 1; } +echo " ✓ Schema applied" + +# Step 2: Generate Prisma client +echo "[2/3] Generating Prisma client..." +npx prisma generate 2>&1 || { echo "FAILED"; exit 1; } +echo " ✓ Client generated" + +# Step 3: Seed with your data +echo "[3/3] Seeding database..." +SETUP_VENUE_NAME="$VENUE_NAME" SETUP_ADMIN_EMAIL="$ADMIN_EMAIL" SETUP_ADMIN_PASSWORD="$ADMIN_PASSWORD" \ + node prisma/setup.js || { echo "FAILED"; exit 1; } + +echo "" +echo "Setup complete. Login at https://kooltab.com" \ No newline at end of file