feat: add plannedTabs

This commit is contained in:
2026-09-20 12:20:55 +02:00
parent 4eb9bf7643
commit db3d8f30f0
28 changed files with 1172 additions and 782 deletions
@@ -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");
@@ -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;
@@ -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;
@@ -1,3 +1,3 @@
# Please do not edit this file manually # Please do not edit this file manually
# It should be added in your version-control system (i.e. Git) # It should be added in your version-control system (i.e. Git)
provider = "sqlite" provider = "postgresql"
+15 -1
View File
@@ -19,11 +19,12 @@ model Venue {
remoteCommands RemoteCommand[] remoteCommands RemoteCommand[]
auditLogs AuditLog[] auditLogs AuditLog[]
regCodes RegistrationCode[] regCodes RegistrationCode[]
plannedTabs PlannedTab[]
} }
model Device { model Device {
id String @id @default(uuid()) id String @id @default(uuid())
venueId String venueId String @unique
name String name String
tokenHash String @unique tokenHash String @unique
tokenPrefix String tokenPrefix String
@@ -142,3 +143,16 @@ model RefreshToken {
@@index([userId]) @@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])
}
+15 -1
View File
@@ -19,11 +19,12 @@ model Venue {
remoteCommands RemoteCommand[] remoteCommands RemoteCommand[]
auditLogs AuditLog[] auditLogs AuditLog[]
regCodes RegistrationCode[] regCodes RegistrationCode[]
plannedTabs PlannedTab[]
} }
model Device { model Device {
id String @id @default(uuid()) id String @id @default(uuid())
venueId String venueId String @unique
name String name String
tokenHash String @unique tokenHash String @unique
tokenPrefix String tokenPrefix String
@@ -142,3 +143,16 @@ model RefreshToken {
@@index([userId]) @@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])
}
+18 -80
View File
@@ -55,103 +55,43 @@ async function main() {
}); });
console.log('Created viewer user: viewer@example.com / Viewer123!'); console.log('Created viewer user: viewer@example.com / Viewer123!');
// Create devices // Create one device
const device1Id = uuidv4(); const deviceId = uuidv4();
const device2Id = uuidv4(); const token = 'dev_' + crypto.randomBytes(48).toString('base64url');
const token1 = 'dev_' + crypto.randomBytes(48).toString('base64url');
const token2 = 'dev_' + crypto.randomBytes(48).toString('base64url');
await prisma.device.create({ await prisma.device.create({
data: { data: {
id: device1Id, id: deviceId,
venueId, venueId,
name: 'Bar terminal 1', name: 'Bar terminal 1',
tokenHash: crypto.createHash('sha256').update(token1).digest('hex'), tokenHash: crypto.createHash('sha256').update(token).digest('hex'),
tokenPrefix: token1.substring(0, 8), tokenPrefix: token.substring(0, 8),
tokenExpiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), 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({ console.log(`Created device: Bar terminal 1 (${deviceId})`);
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 (${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 = [ const tabsData = [
{ tabId: uuidv4(), customerName: 'Jan', version: 5, totalInCents: 2450, itemCount: 3, reportedAt: new Date(Date.now() - 2 * 60 * 1000) }, { 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: '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: '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) }, { 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: '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) }, { 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({ await prisma.tabOwnership.create({
data: { data: { tabId: tab.tabId, venueId, deviceId, lastReportedAt: tab.reportedAt },
tabId: tab.tabId,
venueId,
deviceId: device2Id,
lastReportedAt: tab.reportedAt,
},
}); });
await prisma.openTabSnapshot.create({ await prisma.openTabSnapshot.create({
data: { data: { deviceId, tabId: tab.tabId, customerName: tab.customerName, version: tab.version, totalInCents: tab.totalInCents, itemCount: tab.itemCount, reportedAt: tab.reportedAt },
deviceId: device2Id,
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 // Create sample commands
const now = new Date(); const now = new Date();
@@ -161,7 +101,7 @@ async function main() {
data: { data: {
id: uuidv4(), id: uuidv4(),
venueId, venueId,
deviceId: device1Id, deviceId,
targetTabId: tabsData[1].tabId, targetTabId: tabsData[1].tabId,
type: 'close_tab', type: 'close_tab',
expectedVersion: 3, expectedVersion: 3,
@@ -180,7 +120,7 @@ async function main() {
data: { data: {
id: uuidv4(), id: uuidv4(),
venueId, venueId,
deviceId: device1Id, deviceId,
targetTabId: tabsData[0].tabId, targetTabId: tabsData[0].tabId,
type: 'close_tab', type: 'close_tab',
expectedVersion: 5, expectedVersion: 5,
@@ -197,8 +137,8 @@ async function main() {
data: { data: {
id: uuidv4(), id: uuidv4(),
venueId, venueId,
deviceId: device2Id, deviceId,
targetTabId: staleTabs[0].tabId, targetTabId: tabsData[4].tabId,
type: 'close_tab', type: 'close_tab',
expectedVersion: 4, expectedVersion: 4,
paymentMethod: 'payment_pending', paymentMethod: 'payment_pending',
@@ -216,7 +156,7 @@ async function main() {
data: { data: {
id: uuidv4(), id: uuidv4(),
venueId, venueId,
deviceId: device1Id, deviceId,
targetTabId: tabsData[2].tabId, targetTabId: tabsData[2].tabId,
type: 'close_tab', type: 'close_tab',
expectedVersion: 7, expectedVersion: 7,
@@ -249,9 +189,7 @@ async function main() {
console.log('Demo credentials:'); console.log('Demo credentials:');
console.log(' Admin: admin@example.com / Admin123!'); console.log(' Admin: admin@example.com / Admin123!');
console.log(' Viewer: viewer@example.com / Viewer123!'); console.log(' Viewer: viewer@example.com / Viewer123!');
console.log(`\nDevice tokens (for simulator):`); console.log(`\nDevice token (for simulator): ${token}`);
console.log(` Bar terminal 1: ${token1}`);
console.log(` Bar terminal 2: ${token2}`);
} }
main() main()
+60
View File
@@ -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);
});
+2
View File
@@ -10,6 +10,7 @@ import { TabsModule } from './tabs/tabs.module';
import { CommandsModule } from './commands/commands.module'; import { CommandsModule } from './commands/commands.module';
import { AuditModule } from './audit/audit.module'; import { AuditModule } from './audit/audit.module';
import { JobsModule } from './jobs/jobs.module'; import { JobsModule } from './jobs/jobs.module';
import { PlannedTabsModule } from './planned-tabs/planned-tabs.module';
import { HealthController } from './health.controller'; import { HealthController } from './health.controller';
@Module({ @Module({
@@ -24,6 +25,7 @@ import { HealthController } from './health.controller';
CommandsModule, CommandsModule,
AuditModule, AuditModule,
JobsModule, JobsModule,
PlannedTabsModule,
], ],
controllers: [HealthController], controllers: [HealthController],
providers: [ providers: [
+36
View File
@@ -132,6 +132,15 @@ export class AuthService {
const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken(); const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken();
const deviceId = uuidv4(); 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({ await this.prisma.device.create({
data: { data: {
id: deviceId, id: deviceId,
@@ -153,6 +162,33 @@ export class AuthService {
return { deviceId, token: rawToken, tokenExpiresAt: expiresAt }; 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<any> { async validateDevice(deviceId: string, token: string): Promise<any> {
const hash = crypto.createHash('sha256').update(token).digest('hex'); const hash = crypto.createHash('sha256').update(token).digest('hex');
const device = await this.prisma.device.findUnique({ where: { id: deviceId } }); const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
+8
View File
@@ -142,6 +142,13 @@ export class DevicesService {
orderBy: { createdAt: 'asc' }, 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 { return {
cursor, cursor,
commands: commands.map((c) => ({ commands: commands.map((c) => ({
@@ -153,6 +160,7 @@ export class DevicesService {
requestedBy: c.requestedBy, requestedBy: c.requestedBy,
createdAt: c.createdAt, createdAt: c.createdAt,
})), })),
plannedTabNames: plannedTabs.map((t) => t.customerName),
}; };
} }
@@ -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);
}
}
@@ -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 {}
@@ -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 };
}
}
+36 -15
View File
@@ -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 { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { VenuesService } from './venues.service'; import { VenuesService } from './venues.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; 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'; import { PrismaService } from '../prisma/prisma.service';
@ApiTags('Venues') @ApiTags('Venues')
@@ -9,6 +12,7 @@ import { PrismaService } from '../prisma/prisma.service';
export class VenuesController { export class VenuesController {
constructor( constructor(
private venuesService: VenuesService, private venuesService: VenuesService,
private authService: AuthService,
private prisma: PrismaService, private prisma: PrismaService,
) {} ) {}
@@ -26,24 +30,41 @@ export class VenuesController {
return this.venuesService.findOne(venueId); 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') @Get(':venueId/devices')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'List devices for a venue' }) @ApiOperation({ summary: 'Get venue device info' })
async findDevices(@Param('venueId') venueId: string) { async findDevices(@Param('venueId') venueId: string) {
const devices = await this.prisma.device.findMany({ const device = await this.prisma.device.findFirst({
where: { venueId }, where: { venueId, revokedAt: null },
select: { select: { id: true, name: true, lastSeenAt: true, createdAt: true },
id: true,
name: true,
lastSeenAt: true,
revokedAt: true,
createdAt: true,
tokenExpiresAt: true,
tokenPrefix: true,
},
orderBy: { createdAt: 'desc' },
}); });
return { devices }; return { device };
} }
} }
+2
View File
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { VenuesController } from './venues.controller'; import { VenuesController } from './venues.controller';
import { VenuesService } from './venues.service'; import { VenuesService } from './venues.service';
@Module({ @Module({
imports: [AuthModule],
controllers: [VenuesController], controllers: [VenuesController],
providers: [VenuesService], providers: [VenuesService],
exports: [VenuesService], exports: [VenuesService],
@@ -87,26 +87,22 @@ export default function CommandsPage() {
<> <>
<div className="rounded-md border mb-4"> <div className="rounded-md border mb-4">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Tab</TableHead> <TableHead>Tab</TableHead>
<TableHead>Customer</TableHead> <TableHead>Requested By</TableHead>
<TableHead>Device</TableHead> <TableHead>Payment</TableHead>
<TableHead>Requested By</TableHead> <TableHead>Created</TableHead>
<TableHead>Payment</TableHead> <TableHead>Status</TableHead>
<TableHead>Created</TableHead> <TableHead>Processed</TableHead>
<TableHead>Status</TableHead> <TableHead>Result</TableHead>
<TableHead>Processed</TableHead> </TableRow>
<TableHead>Result</TableHead> </TableHeader>
</TableRow> <TableBody>
</TableHeader> {commands.map((cmd: any) => (
<TableBody> <TableRow key={cmd.id}>
{commands.map((cmd: any) => ( <TableCell className="font-mono text-xs">{cmd.tabId.substring(0, 8)}</TableCell>
<TableRow key={cmd.id}> <TableCell>{cmd.requestedBy}</TableCell>
<TableCell className="font-mono text-xs">{cmd.tabId.substring(0, 8)}</TableCell>
<TableCell>-</TableCell>
<TableCell className="text-xs">{cmd.deviceId?.substring(0, 8)}</TableCell>
<TableCell>{cmd.requestedBy}</TableCell>
<TableCell><PaymentMethodBadge method={cmd.paymentMethod} /></TableCell> <TableCell><PaymentMethodBadge method={cmd.paymentMethod} /></TableCell>
<TableCell>{formatRelativeTime(cmd.createdAt)}</TableCell> <TableCell>{formatRelativeTime(cmd.createdAt)}</TableCell>
<TableCell><CommandStatusBadge status={cmd.status} /></TableCell> <TableCell><CommandStatusBadge status={cmd.status} /></TableCell>
@@ -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<string | null>(null);
const [userRole, setUserRole] = useState<string | null>(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 <LoadingState />;
if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={(error as any).message || 'Failed to load devices'} />;
return (
<div>
<PageHeader
title="Devices"
description={`${devices.length} device${devices.length !== 1 ? 's' : ''} registered`}
actions={
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: ['devices'] })}
>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
{userRole === 'admin' && (
<Button size="sm" onClick={handleGenerateCode} disabled={generating}>
<Plus className="h-4 w-4 mr-2" />
{generating ? 'Generating...' : 'Add Device'}
</Button>
)}
</div>
}
/>
{devices.length === 0 ? (
<EmptyState title="No devices registered" description="Register a device using the Add Device button to get started." />
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>ID</TableHead>
<TableHead>Last Seen</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{devices.map((device: any) => (
<TableRow key={device.id}>
<TableCell className="font-medium">{device.name}</TableCell>
<TableCell className="text-xs text-muted-foreground font-mono">
{device.id.substring(0, 8)}...
</TableCell>
<TableCell>{formatRelativeTime(device.lastSeenAt)}</TableCell>
<TableCell>
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} revokedAt={device.revokedAt} />
</TableCell>
<TableCell>{formatRelativeTime(device.createdAt)}</TableCell>
<TableCell className="text-right">
{device.revokedAt ? (
<span className="text-sm text-muted-foreground">Revoked</span>
) : userRole === 'admin' ? (
<Button
size="sm"
variant="destructive"
onClick={() => handleRevoke(device)}
>
Revoke
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{revokeDialog.device && (
<ConfirmDialog
open={revokeDialog.open}
onOpenChange={(open) => !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 && (
<Dialog open={regDialog.open} onOpenChange={(open) => !open && setRegDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Device Registration</DialogTitle>
<DialogDescription>
Share this code with the bar terminal to register it.
</DialogDescription>
</DialogHeader>
<div className="py-6 space-y-4">
<div className="text-center">
<p className="text-sm text-muted-foreground mb-2">Registration Code</p>
<div className="text-4xl font-bold tracking-widest text-primary bg-primary/5 py-4 rounded-lg">
{regDialog.code}
</div>
</div>
<div className="text-center text-sm text-muted-foreground">
Expires at {new Date(regDialog.expiresAt).toLocaleTimeString()}
</div>
<div className="space-y-1 text-sm">
<p className="font-medium">Instructions:</p>
<ol className="list-decimal list-inside text-muted-foreground space-y-1">
<li>Open Admin on the bar terminal</li>
<li>Enter the Admin PIN</li>
<li>Enter this registration code</li>
<li>Give the terminal a name</li>
<li>Complete registration</li>
</ol>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setRegDialog(null)}>
Close
</Button>
<Button onClick={copyCode}>
<Copy className="h-4 w-4 mr-2" />
Copy code
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
}
+1 -1
View File
@@ -21,7 +21,7 @@ import { toast } from '@/components/ui/toast';
const navItems = [ const navItems = [
{ href: '/dashboard', label: 'Overview', icon: LayoutDashboard }, { href: '/dashboard', label: 'Overview', icon: LayoutDashboard },
{ href: '/dashboard/tabs', label: 'Open Tabs', icon: ClipboardList }, { 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/commands', label: 'Command History', icon: Terminal },
{ href: '/dashboard/settings', label: 'Settings', icon: Settings }, { href: '/dashboard/settings', label: 'Settings', icon: Settings },
]; ];
+38 -91
View File
@@ -9,10 +9,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { LoadingState } from '@/components/loading-state'; import { LoadingState } from '@/components/loading-state';
import { CommandStatusBadge } from '@/components/command-status-badge'; import { CommandStatusBadge } from '@/components/command-status-badge';
import { DeviceStatusBadge } from '@/components/device-status-badge'; import { formatRelativeTime } from '@/lib/utils';
import { formatRelativeTime, formatCents } from '@/lib/utils'; import { ClipboardList, Terminal, Monitor } from 'lucide-react';
import { getDeviceStatus } from '@/lib/device-status';
import { ClipboardList, Monitor, Terminal, AlertTriangle } from 'lucide-react';
export default function DashboardPage() { export default function DashboardPage() {
const router = useRouter(); const router = useRouter();
@@ -37,8 +35,8 @@ export default function DashboardPage() {
refetchInterval: 10000, refetchInterval: 10000,
}); });
const devicesQuery = useQuery({ const deviceQuery = useQuery({
queryKey: ['devices', venueId], queryKey: ['device', venueId],
queryFn: () => api(`/venues/${venueId}/devices`), queryFn: () => api(`/venues/${venueId}/devices`),
enabled: !!venueId, enabled: !!venueId,
refetchInterval: 30000, refetchInterval: 30000,
@@ -54,64 +52,39 @@ export default function DashboardPage() {
if (!venueId) return <LoadingState />; if (!venueId) return <LoadingState />;
const tabs = tabsQuery.data?.tabs || []; const tabs = tabsQuery.data?.tabs || [];
const devices = devicesQuery.data?.devices || []; const device = deviceQuery.data?.device;
const commands = commandsQuery.data?.commands || []; 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 pendingCommands = commands.filter((c: any) => c.status === 'pending');
const commandsToday = commands.length; const isLoading = tabsQuery.isLoading || deviceQuery.isLoading || commandsQuery.isLoading;
const isLoading = tabsQuery.isLoading || devicesQuery.isLoading || commandsQuery.isLoading;
return ( return (
<div> <div>
<PageHeader <PageHeader title="Overview" description="Real-time overview of your venue" />
title="Overview"
description="Real-time overview of your venue"
/>
{isLoading ? ( {isLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-8"> <div className="grid gap-4 md:grid-cols-3 mb-8">
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
<Card key={i}> <Card key={i}><CardHeader className="flex flex-row items-center justify-between pb-2"><Skeleton className="h-4 w-24" /><Skeleton className="h-4 w-4" /></CardHeader><CardContent><Skeleton className="h-8 w-16" /></CardContent></Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-4" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
))} ))}
</div> </div>
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-8"> <div className="grid gap-4 md:grid-cols-3 mb-8">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Open Tabs</CardTitle> <CardTitle className="text-sm font-medium">Open Tabs</CardTitle>
<ClipboardList className="h-4 w-4 text-muted-foreground" /> <ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent><div className="text-2xl font-bold">{tabs.length}</div></CardContent>
<div className="text-2xl font-bold">{tabs.length}</div>
</CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Online Devices</CardTitle> <CardTitle className="text-sm font-medium">Device</CardTitle>
<Monitor className="h-4 w-4 text-green-600" /> <Monitor className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold text-green-600">{onlineDevices.length}</div> <div className="text-2xl font-bold">{device?.name || 'Not connected'}</div>
</CardContent> {device && <p className="text-xs text-muted-foreground mt-1">Last seen {formatRelativeTime(device.lastSeenAt)}</p>}
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Stale/Offline Devices</CardTitle>
<AlertTriangle className="h-4 w-4 text-yellow-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-600">{staleDevices.length}</div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@@ -119,60 +92,34 @@ export default function DashboardPage() {
<CardTitle className="text-sm font-medium">Pending Commands</CardTitle> <CardTitle className="text-sm font-medium">Pending Commands</CardTitle>
<Terminal className="h-4 w-4 text-muted-foreground" /> <Terminal className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent><div className="text-2xl font-bold">{pendingCommands.length}</div></CardContent>
<div className="text-2xl font-bold">{pendingCommands.length}</div>
</CardContent>
</Card> </Card>
</div> </div>
)} )}
<div className="grid gap-6 md:grid-cols-2"> <Card>
<Card> <CardHeader><CardTitle className="text-lg">Recent Commands</CardTitle></CardHeader>
<CardHeader> <CardContent>
<CardTitle className="text-lg">Recent Commands</CardTitle> {commands.length === 0 ? (
</CardHeader> <p className="text-sm text-muted-foreground">No commands yet</p>
<CardContent> ) : (
{commands.length === 0 ? ( <div className="space-y-3">
<p className="text-sm text-muted-foreground">No commands yet</p> {commands.map((cmd: any) => (
) : ( <div key={cmd.id} className="flex items-center justify-between text-sm">
<div className="space-y-3"> <div>
{commands.map((cmd: any) => ( <span className="font-medium">{cmd.tabId?.substring(0, 8)}</span>
<div key={cmd.id} className="flex items-center justify-between text-sm"> <span className="text-muted-foreground ml-2">{cmd.requestedBy}</span>
<div>
<span className="font-medium">{cmd.tabId?.substring(0, 8)}</span>
<span className="text-muted-foreground ml-2">{cmd.requestedBy}</span>
</div>
<div className="flex items-center gap-2">
<CommandStatusBadge status={cmd.status} />
<span className="text-muted-foreground">{formatRelativeTime(cmd.createdAt)}</span>
</div>
</div> </div>
))} <div className="flex items-center gap-2">
</div> <CommandStatusBadge status={cmd.status} />
)} <span className="text-muted-foreground">{formatRelativeTime(cmd.createdAt)}</span>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Device Health</CardTitle>
</CardHeader>
<CardContent>
{devices.length === 0 ? (
<p className="text-sm text-muted-foreground">No devices registered</p>
) : (
<div className="space-y-3">
{devices.map((device: any) => (
<div key={device.id} className="flex items-center justify-between text-sm">
<span className="font-medium">{device.name}</span>
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} revokedAt={device.revokedAt} />
</div> </div>
))} </div>
</div> ))}
)} </div>
</CardContent> )}
</Card> </CardContent>
</div> </Card>
</div> </div>
); );
} }
@@ -1,66 +1,168 @@
'use client'; '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 { 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 { 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() { export default function SettingsPage() {
const router = useRouter();
const [venueId, setVenueId] = useState<string | null>(null);
const [userRole, setUserRole] = useState<string | null>(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 ( return (
<div> <div>
<PageHeader title="Settings" description="Venue and account settings" /> <PageHeader title="Settings" description="Venue and device management" />
<div className="grid gap-6"> <div className="grid gap-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Venue Information</CardTitle> <CardTitle>Venue</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2 text-sm"> <CardContent className="text-sm">
<div className="flex justify-between"> <p className="font-medium">{venue?.name || 'Loading...'}</p>
<span className="text-muted-foreground">Venue name</span> <p className="text-muted-foreground font-mono text-xs mt-1">ID: {venueId}</p>
<span>KoolTab Demo Bar</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Plan</span>
<Badge variant="secondary">Demo</Badge>
</div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Demo Credentials</CardTitle> <CardTitle>Device</CardTitle>
<CardDescription>
{device
? `Paired as "${device.name}" — last seen ${formatRelativeTime(device.lastSeenAt)}`
: 'No device paired yet'}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 text-sm"> <CardContent>
<div className="p-3 bg-muted rounded-md"> {device ? (
<p className="font-medium mb-1">Admin account</p> <div className="flex items-center gap-2 text-sm">
<p className="text-muted-foreground">admin@example.com / Admin123!</p> <Monitor className="h-4 w-4 text-green-600" />
</div> <Badge variant="success">Connected</Badge>
<div className="p-3 bg-muted rounded-md"> <span className="text-muted-foreground">Since {formatDateTime(device.createdAt)}</span>
<p className="font-medium mb-1">Viewer account</p> </div>
<p className="text-muted-foreground">viewer@example.com / Viewer123!</p> ) : (
</div> <p className="text-sm text-muted-foreground mb-3">
<p className="text-xs text-muted-foreground"> Pair a bar terminal to start receiving tabs.
These credentials are for demo purposes only. Do not use in production. </p>
</p> )}
{userRole === 'admin' && (
<Button onClick={handleGenerateCode} disabled={generating} size="sm" className="mt-3">
<Plus className="h-4 w-4 mr-2" />
{generating ? 'Generating...' : device ? 'Re-pair Device' : 'Pair Device'}
</Button>
)}
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>API Information</CardTitle> <CardTitle>API</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2 text-sm"> <CardContent className="space-y-2 text-sm">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">API Base URL</span> <span className="text-muted-foreground">Base URL</span>
<span className="font-mono text-xs">{process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'}</span> <span className="font-mono text-xs">{process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Swagger Docs</span> <span className="text-muted-foreground">Swagger</span>
<span className="font-mono text-xs">{process.env.NEXT_PUBLIC_API_URL?.replace('/api/v1', '/api/docs') || 'http://localhost:3001/api/docs'}</span> <span className="font-mono text-xs">
{process.env.NEXT_PUBLIC_API_URL?.replace('/api/v1', '/api/docs') || 'http://localhost:3001/api/docs'}
</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{regDialog && (
<Dialog open={regDialog.open} onOpenChange={(open) => !open && setRegDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Pair a Device</DialogTitle>
<DialogDescription>
Enter this code on the bar terminal to connect it to your venue.
</DialogDescription>
</DialogHeader>
<div className="py-6 space-y-4">
<div className="text-center">
<p className="text-sm text-muted-foreground mb-2">Registration Code</p>
<div className="text-4xl font-bold tracking-widest text-primary bg-primary/5 py-4 rounded-lg">
{regDialog.code}
</div>
</div>
<div className="text-center text-sm text-muted-foreground">
Expires at {new Date(regDialog.expiresAt).toLocaleTimeString()}
</div>
<div className="space-y-1 text-sm">
<p className="font-medium">Instructions:</p>
<ol className="list-decimal list-inside text-muted-foreground space-y-1">
<li>Open the bar terminal app</li>
<li>Select your venue from the list</li>
<li>Enter this registration code</li>
<li>Give the terminal a name</li>
<li>Complete registration</li>
</ol>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setRegDialog(null)}>Close</Button>
<Button onClick={copyCode}><Copy className="h-4 w-4 mr-2" />Copy Code</Button>
</div>
</DialogContent>
</Dialog>
)}
</div> </div>
); );
} }
+111 -149
View File
@@ -3,23 +3,21 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; 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 { 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; 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 { toast } from '@/components/ui/toast';
import { formatCents, formatRelativeTime } from '@/lib/utils'; import { formatCents, formatRelativeTime } from '@/lib/utils';
import { getDeviceStatus } from '@/lib/device-status'; import { RefreshCw, Plus, X, Users } from 'lucide-react';
import { RefreshCw, XCircle, AlertTriangle } from 'lucide-react';
export default function TabsPage() { export default function TabsPage() {
const router = useRouter(); const router = useRouter();
@@ -29,17 +27,15 @@ export default function TabsPage() {
const [paymentMethod, setPaymentMethod] = useState<string>(''); const [paymentMethod, setPaymentMethod] = useState<string>('');
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [pendingCommandIds, setPendingCommandIds] = useState<Set<string>>(new Set()); const [pendingCommandIds, setPendingCommandIds] = useState<Set<string>>(new Set());
const [plannedName, setPlannedName] = useState('');
const [userRole, setUserRole] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const cookie = document.cookie const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token='));
.split('; ') if (!cookie) { router.push('/login'); return; }
.find((row) => row.startsWith('access_token='));
if (!cookie) {
router.push('/login');
return;
}
const payload = JSON.parse(atob(cookie.split('.')[1])); const payload = JSON.parse(atob(cookie.split('.')[1]));
setVenueId(payload.venueId); setVenueId(payload.venueId);
setUserRole(payload.role);
}, [router]); }, [router]);
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
@@ -49,7 +45,6 @@ export default function TabsPage() {
refetchInterval: 10000, refetchInterval: 10000,
}); });
// Poll pending commands to check their status
const commandsQuery = useQuery({ const commandsQuery = useQuery({
queryKey: ['commands', venueId, 'pending'], queryKey: ['commands', venueId, 'pending'],
queryFn: () => api(`/venues/${venueId}/commands?status=pending`), queryFn: () => api(`/venues/${venueId}/commands?status=pending`),
@@ -57,6 +52,12 @@ export default function TabsPage() {
refetchInterval: 3000, refetchInterval: 3000,
}); });
const plannedQuery = useQuery({
queryKey: ['planned-tabs', venueId],
queryFn: () => plannedTabsApi.list(venueId!),
enabled: !!venueId,
});
useEffect(() => { useEffect(() => {
if (commandsQuery.data?.commands) { if (commandsQuery.data?.commands) {
for (const cmd of commandsQuery.data.commands) { for (const cmd of commandsQuery.data.commands) {
@@ -64,11 +65,7 @@ export default function TabsPage() {
const newSet = new Set(pendingCommandIds); const newSet = new Set(pendingCommandIds);
newSet.delete(cmd.id); newSet.delete(cmd.id);
setPendingCommandIds(newSet); setPendingCommandIds(newSet);
toast({ 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' });
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: ['tabs'] });
queryClient.invalidateQueries({ queryKey: ['commands'] }); queryClient.invalidateQueries({ queryKey: ['commands'] });
} }
@@ -81,44 +78,41 @@ export default function TabsPage() {
commandsApi.create(venueId!, { ...data, type: 'close_tab' }), commandsApi.create(venueId!, { ...data, type: 'close_tab' }),
onSuccess: (result) => { onSuccess: (result) => {
setPendingCommandIds(new Set([...pendingCommandIds, result.id])); setPendingCommandIds(new Set([...pendingCommandIds, result.id]));
toast({ toast({ title: 'Close request sent', description: result.warning || 'Command sent to the terminal', variant: result.warning ? 'default' : 'success' });
title: 'Close request sent',
description: result.warning || 'Command has been created and sent to the device',
variant: result.warning ? 'default' : 'success',
});
queryClient.invalidateQueries({ queryKey: ['commands'] }); queryClient.invalidateQueries({ queryKey: ['commands'] });
}, },
onError: (err: any) => { onError: (err: any) => {
toast({ toast({ title: 'Failed', description: err.message || 'An error occurred', variant: 'destructive' });
title: 'Failed to create command',
description: err.message || 'An error occurred',
variant: 'destructive',
});
}, },
}); });
function handleCloseTab(tab: any) {
setCloseDialog({ open: true, tab });
setPaymentMethod('');
}
function confirmClose() { function confirmClose() {
if (!paymentMethod) return; if (!paymentMethod) return;
setCreating(true); setCreating(true);
createCommandMutation.mutate( createCommandMutation.mutate({ tabId: closeDialog.tab.tabId, expectedVersion: closeDialog.tab.version, paymentMethod }, {
{ onSettled: () => { setCreating(false); setCloseDialog({ open: false, tab: null }); setPaymentMethod(''); },
tabId: closeDialog.tab.tabId, });
expectedVersion: closeDialog.tab.version, }
paymentMethod,
}, async function addPlannedName() {
{ if (!plannedName.trim()) return;
onSettled: () => { try {
setCreating(false); await plannedTabsApi.create(venueId!, plannedName.trim());
setCloseDialog({ open: false, tab: null }); setPlannedName('');
setPaymentMethod(''); 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 <LoadingState />; if (!venueId) return <LoadingState />;
@@ -131,21 +125,53 @@ export default function TabsPage() {
<div> <div>
<PageHeader <PageHeader
title="Open Tabs" title="Open Tabs"
description={`${tabs.length} open tab${tabs.length !== 1 ? 's' : ''} across all devices`} description={`${tabs.length} open tab${tabs.length !== 1 ? 's' : ''}`}
actions={ actions={<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['tabs'] })}><RefreshCw className="h-4 w-4 mr-2" />Refresh</Button>}
<Button
variant="outline"
size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: ['tabs'] })}
>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
}
/> />
{/* Planned Customers */}
<Card className="mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2"><Users className="h-4 w-4" /> Planned Customers</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-3">
These names are synced to the terminal. It will create tabs for these customers automatically.
</p>
{userRole === 'admin' && (
<div className="flex gap-2 mb-3">
<Input
placeholder="Add customer name..."
value={plannedName}
onChange={(e) => setPlannedName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addPlannedName()}
className="max-w-xs"
/>
<Button size="sm" onClick={addPlannedName} disabled={!plannedName.trim()}>
<Plus className="h-4 w-4" />
</Button>
</div>
)}
<div className="flex flex-wrap gap-2">
{plannedQuery.data?.plannedTabs?.length === 0 && (
<p className="text-sm text-muted-foreground">No planned customers. Add names above and they'll sync to the terminal.</p>
)}
{(plannedQuery.data?.plannedTabs || []).map((pt: any) => (
<div key={pt.id} className="flex items-center gap-1 px-3 py-1.5 bg-secondary rounded-full text-sm">
{pt.customerName}
{userRole === 'admin' && (
<button onClick={() => removePlannedName(pt.id)} className="text-muted-foreground hover:text-destructive ml-1">
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</CardContent>
</Card>
{tabs.length === 0 ? ( {tabs.length === 0 ? (
<EmptyState title="No open tabs" description="All tabs are closed. Open tabs will appear here when devices sync." /> <EmptyState title="No open tabs" description="Open tabs will appear here when the terminal syncs." />
) : ( ) : (
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
@@ -154,117 +180,53 @@ export default function TabsPage() {
<TableHead>Customer</TableHead> <TableHead>Customer</TableHead>
<TableHead>Amount</TableHead> <TableHead>Amount</TableHead>
<TableHead>Items</TableHead> <TableHead>Items</TableHead>
<TableHead>Device</TableHead>
<TableHead>Device Status</TableHead>
<TableHead>Version</TableHead> <TableHead>Version</TableHead>
<TableHead>Last Reported</TableHead> <TableHead>Last Reported</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{tabs.map((tab: any) => { {tabs.map((tab: any) => (
const deviceStale = getDeviceStatus(tab.deviceLastSeenAt, tab.deviceRevokedAt) === 'stale' <TableRow key={tab.tabId}>
|| getDeviceStatus(tab.deviceLastSeenAt, tab.deviceRevokedAt) === 'offline'; <TableCell className="font-medium">{tab.customerName}</TableCell>
return ( <TableCell>{formatCents(tab.totalInCents)}</TableCell>
<TableRow key={tab.tabId}> <TableCell>{tab.itemCount}</TableCell>
<TableCell className="font-medium">{tab.customerName}</TableCell> <TableCell>{tab.version}</TableCell>
<TableCell>{formatCents(tab.totalInCents)}</TableCell> <TableCell>{formatRelativeTime(tab.lastReportedAt)}</TableCell>
<TableCell>{tab.itemCount}</TableCell> <TableCell className="text-right">
<TableCell>{tab.deviceName}</TableCell> <Button size="sm" onClick={() => { setCloseDialog({ open: true, tab }); setPaymentMethod(''); }}>Close</Button>
<TableCell> </TableCell>
<DeviceStatusBadge lastSeenAt={tab.deviceLastSeenAt} revokedAt={tab.deviceRevokedAt} /> </TableRow>
</TableCell> ))}
<TableCell>{tab.version}</TableCell>
<TableCell>{formatRelativeTime(tab.lastReportedAt)}</TableCell>
<TableCell className="text-right">
<Button
size="sm"
onClick={() => handleCloseTab(tab)}
disabled={!!tab.deviceRevokedAt}
>
{tab.deviceRevokedAt ? 'Device revoked' : 'Close'}
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
)} )}
{/* Close Tab Dialog */}
{closeDialog.tab && ( {closeDialog.tab && (
<Dialog open={closeDialog.open} onOpenChange={(open) => !open && setCloseDialog({ open: false, tab: null })}> <Dialog open={closeDialog.open} onOpenChange={(open) => !open && setCloseDialog({ open: false, tab: null })}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Close {closeDialog.tab.customerName}'s tab?</DialogTitle> <DialogTitle>Close {closeDialog.tab.customerName}'s tab?</DialogTitle>
<DialogDescription> <DialogDescription>Send a close request to the terminal.</DialogDescription>
This will send a close request to the bar terminal.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3 py-4"> <div className="space-y-3 py-4">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm"><span className="text-muted-foreground">Amount</span><span className="font-semibold">{formatCents(closeDialog.tab.totalInCents)}</span></div>
<span className="text-muted-foreground">Amount</span> <div className="flex justify-between text-sm"><span className="text-muted-foreground">Items</span><span>{closeDialog.tab.itemCount}</span></div>
<span className="font-semibold">{formatCents(closeDialog.tab.totalInCents)}</span> <div className="flex justify-between text-sm"><span className="text-muted-foreground">Version</span><span>{closeDialog.tab.version}</span></div>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Items</span>
<span>{closeDialog.tab.itemCount}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Device</span>
<span>{closeDialog.tab.deviceName}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Version</span>
<span>{closeDialog.tab.version}</span>
</div>
{closeDialog.tab.deviceLastSeenAt && getDeviceStatus(closeDialog.tab.deviceLastSeenAt) !== 'online' && (
<div className="flex items-center gap-2 text-sm text-yellow-600 bg-yellow-50 p-3 rounded-md">
<AlertTriangle className="h-4 w-4" />
Device has not been seen recently
</div>
)}
<div className="space-y-3 pt-2"> <div className="space-y-3 pt-2">
<Label className="text-base font-semibold">Payment method</Label> <Label className="text-base font-semibold">Payment method</Label>
<RadioGroup value={paymentMethod} onValueChange={setPaymentMethod}> <RadioGroup value={paymentMethod} onValueChange={setPaymentMethod}>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2"><RadioGroupItem value="cash" id="cash" /><Label htmlFor="cash">Cash</Label></div>
<RadioGroupItem value="cash" id="cash" /> <div className="flex items-center space-x-2"><RadioGroupItem value="payconiq" id="payconiq" /><Label htmlFor="payconiq">Payconiq</Label></div>
<Label htmlFor="cash">Cash</Label> <div className="flex items-center space-x-2"><RadioGroupItem value="payment_pending" id="payment_pending" /><Label htmlFor="payment_pending">Payment pending</Label></div>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="payconiq" id="payconiq" />
<Label htmlFor="payconiq">Payconiq</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="payment_pending" id="payment_pending" />
<Label htmlFor="payment_pending">Payment pending</Label>
</div>
</RadioGroup> </RadioGroup>
{!paymentMethod && ( {!paymentMethod && <p className="text-xs text-destructive">Please select a payment method</p>}
<p className="text-xs text-destructive">Please select a payment method</p>
)}
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button variant="outline" onClick={() => setCloseDialog({ open: false, tab: null })} disabled={creating}>Cancel</Button>
variant="outline" <Button onClick={confirmClose} disabled={creating || !paymentMethod}>{creating ? 'Sending...' : 'Send close request'}</Button>
onClick={() => setCloseDialog({ open: false, tab: null })}
disabled={creating}
>
Cancel
</Button>
<Button
onClick={confirmClose}
disabled={creating || !paymentMethod}
>
{creating ? 'Sending...' : 'Send close request'}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -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<string | null>(null);
const [userRole, setUserRole] = useState<string | null>(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 <LoadingState />;
if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={(error as any).message || 'Failed to load users'} />;
return (
<div>
<PageHeader
title="Users"
description={`${users.length} user${users.length !== 1 ? 's' : ''} registered`}
actions={
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['users'] })}>
<RefreshCw className="h-4 w-4 mr-2" /> Refresh
</Button>
{userRole === 'admin' && (
<Button size="sm" onClick={() => setAddDialog(true)}>
<Plus className="h-4 w-4 mr-2" /> Add User
</Button>
)}
</div>
}
/>
{users.length === 0 ? (
<EmptyState title="No users" description="Add users who can manage tabs from this dashboard." />
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((u: any) => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.name}</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell><Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>{u.role}</Badge></TableCell>
<TableCell>{formatRelativeTime(u.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<Dialog open={addDialog} onOpenChange={setAddDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add User</DialogTitle>
<DialogDescription>Create a new user who can access the dashboard.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="Bartender name" />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} placeholder="email@example.com" />
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" value={form.password} onChange={e => setForm({ ...form, password: e.target.value })} placeholder="Min 6 characters" />
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select value={form.role} onValueChange={v => setForm({ ...form, role: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="viewer">Viewer (can close tabs)</SelectItem>
<SelectItem value="admin">Admin (full access)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddDialog(false)} disabled={submitting}>Cancel</Button>
<Button onClick={handleCreateUser} disabled={submitting || !form.email || !form.password || !form.name}>
{submitting ? 'Creating...' : 'Create User'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+21 -4
View File
@@ -128,13 +128,19 @@ export const tabsApi = {
// Devices API // Devices API
export const devicesApi = { export const devicesApi = {
list: (venueId: string) => get: (venueId: string) =>
api<{ devices: any[] }>(`/venues/${venueId}/devices`), api<{ device: { id: string; name: string; lastSeenAt: string; createdAt: string } | null }>(`/venues/${venueId}/devices`),
revoke: (venueId: string, deviceId: string) =>
api(`/venues/${venueId}/devices/${deviceId}/revoke`, { method: 'POST' }),
generateCode: () => api<{ code: string; expiresAt: string }>('/devices/generation-code'), 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 // Commands API
export const commandsApi = { export const commandsApi = {
list: (venueId: string, params?: { status?: string; limit?: number; offset?: number }) => { 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}`), 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 }; export { setTokens, clearTokens };
+88
View File
@@ -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<RemoteCommand> commands;
final List<String> plannedTabNames; // ← add this
SyncResponse({
required this.cursor,
required this.commands,
required this.plannedTabNames, // ← add this
});
factory SyncResponse.fromJson(Map<String, dynamic> json) => SyncResponse(
cursor: json['cursor'] as String,
commands: (json['commands'] as List)
.map((e) => RemoteCommand.fromJson(e as Map<String, dynamic>))
.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
+9 -9
View File
@@ -61,12 +61,21 @@ importers:
passport-jwt: passport-jwt:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
prisma:
specifier: ^5.10.0
version: 5.22.0
reflect-metadata: reflect-metadata:
specifier: ^0.2.1 specifier: ^0.2.1
version: 0.2.2 version: 0.2.2
rxjs: rxjs:
specifier: ^7.8.1 specifier: ^7.8.1
version: 7.8.2 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: uuid:
specifier: ^9.0.0 specifier: ^9.0.0
version: 9.0.1 version: 9.0.1
@@ -101,9 +110,6 @@ importers:
jest: jest:
specifier: ^29.7.0 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)) 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: rimraf:
specifier: ^5.0.5 specifier: ^5.0.5
version: 5.0.10 version: 5.0.10
@@ -116,12 +122,6 @@ importers:
ts-loader: ts-loader:
specifier: ^9.5.1 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)) 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: apps/dashboard:
dependencies: dependencies:
+43
View File
@@ -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"
+40
View File
@@ -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"