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
# 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[]
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])
}
+15 -1
View File
@@ -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])
}
+18 -80
View File
@@ -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()
+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 { 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: [
+36
View File
@@ -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<any> {
const hash = crypto.createHash('sha256').update(token).digest('hex');
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
+8
View File
@@ -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),
};
}
@@ -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 { 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 };
}
}
+2
View File
@@ -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],