feat: move to V2
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-29 11:21:52 +01:00
parent 6342b8451e
commit 314717ba3a
244 changed files with 11615 additions and 15292 deletions
+126 -227
View File
@@ -1,249 +1,148 @@
import * as bcrypt from 'bcrypt';
import { PrismaClient } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid';
import * as crypto from 'crypto';
import { ensureDefaultCustomers } from '../src/planned-tabs/default-customers';
/**
* Demo data for local development: two venues, a user of every role, a paired terminal, a catalog with stock, open
* tabs and some paid ones. Wipes the database first.
*
* pnpm db:seed
*/
import 'dotenv/config';
import { createHash, randomUUID } from 'crypto';
import { hashPassword } from '../src/auth/auth.service';
import { createPrismaClient } from '../src/prisma/prisma.service';
import { ensureDefaultCustomers } from '../src/customers/default-customers';
import { createdFieldTimes } from '../src/ledger/product-fields';
const prisma = new PrismaClient();
const prisma = createPrismaClient();
const minutesAgo = (minutes: number) => new Date(Date.now() - minutes * 60 * 1000);
const CATALOG = [
{ name: 'Jupiler', category: 'Bier', priceInCents: 280, buyPriceInCents: 110, stock: 96, desiredStock: 144 },
{ name: 'Duvel', category: 'Bier', priceInCents: 450, buyPriceInCents: 190, stock: 24, desiredStock: 48 },
{ name: 'Cola', category: 'Frisdrank', priceInCents: 250, buyPriceInCents: 80, stock: 6, desiredStock: 48 },
{ name: 'Spa rood', category: 'Frisdrank', priceInCents: 230, buyPriceInCents: 70, stock: 30, desiredStock: 48 },
{ name: 'Koffie', category: 'Warm', priceInCents: 250, buyPriceInCents: null, stock: null, desiredStock: null },
{ name: 'Chips', category: null, priceInCents: 200, buyPriceInCents: 60, stock: 18, desiredStock: 30 },
];
/** A tab's drinks: [catalog index, quantity, minutes ago] per order line. */
const TABS: { customerName: string; lines: [number, number, number][] }[] = [
{ customerName: 'Jan', lines: [[0, 4, 90], [5, 1, 90], [1, 2, 20]] },
{ customerName: 'Marie', lines: [[2, 2, 45], [3, 1, 45]] },
{ customerName: 'Thomas', lines: [[1, 3, 120], [0, 2, 60]] },
{ customerName: 'Lisa', lines: [[4, 1, 10]] },
];
async function main() {
console.log('Seeding database...');
// Clean existing data so seed is idempotent
await prisma.auditLog.deleteMany();
await prisma.tabEvent.deleteMany();
await prisma.tabReminder.deleteMany();
await prisma.plannedTab.deleteMany();
await prisma.remoteCommand.deleteMany();
await prisma.settlement.deleteMany();
await prisma.tab.deleteMany();
await prisma.stockAdjustment.deleteMany();
await prisma.product.deleteMany();
await prisma.productImage.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: 'KoolTap Demo Bar' },
});
await ensureDefaultCustomers(prisma, venueId);
console.log(`Created venue: KoolTap Demo Bar (${venueId})`);
const venue = await prisma.venue.create({ data: { name: 'KoolTap Demo Bar', cashPaymentEmails: [] } });
await ensureDefaultCustomers(prisma, venue.id);
const festival = await prisma.venue.create({ data: { name: 'KoolTap Festival Bar' } });
await ensureDefaultCustomers(prisma, festival.id);
// Create admin user
const adminHash = await bcrypt.hash('Admin123!', 10);
await prisma.clientUser.create({
data: {
id: uuidv4(),
venueId,
email: 'admin@example.com',
passwordHash: adminHash,
name: 'Eigenaar',
role: 'admin',
},
});
console.log('Created admin user: admin@example.com / Admin123!');
// Create viewer user
const viewerHash = await bcrypt.hash('Viewer123!', 10);
await prisma.clientUser.create({
data: {
id: uuidv4(),
venueId,
email: 'viewer@example.com',
passwordHash: viewerHash,
name: 'Bartender',
role: 'manager',
},
});
console.log('Created viewer user: viewer@example.com / Viewer123!');
// Runs KoolTap itself: every venue, and the admin page
const superadminHash = await bcrypt.hash('Super123!', 10);
await prisma.clientUser.create({
data: {
id: uuidv4(),
venueId,
email: 'superadmin@example.com',
passwordHash: superadminHash,
name: 'KoolTap',
role: 'superadmin',
},
});
console.log('Created superadmin user: superadmin@example.com / Super123!');
// A second venue, so the admin page has more than one
const secondVenueId = uuidv4();
await prisma.venue.create({ data: { id: secondVenueId, name: 'KoolTap Festival Bar' } });
await ensureDefaultCustomers(prisma, secondVenueId);
await prisma.clientUser.create({
data: {
id: uuidv4(),
venueId: secondVenueId,
email: 'festival@example.com',
passwordHash: adminHash,
name: 'Festival',
role: 'admin',
},
});
console.log('Created venue: KoolTap Festival Bar, admin festival@example.com / Admin123!');
// Create one device
const deviceId = uuidv4();
const token = 'dev_' + crypto.randomBytes(48).toString('base64url');
await prisma.device.create({
data: {
id: deviceId,
venueId,
name: 'Bar terminal 1',
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),
},
});
console.log(`Created device: Bar terminal 1 (${deviceId})`);
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) },
{ 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) },
const users = [
{ venueId: venue.id, email: 'admin@example.com', password: 'Admin123!', name: 'Eigenaar', role: 'admin' as const },
{ venueId: venue.id, email: 'manager@example.com', password: 'Manager123!', name: 'Bartender', role: 'manager' as const },
// Above the venues: views one to act in it as its admin
{ venueId: null, email: 'superadmin@example.com', password: 'Super123!', name: 'KoolTap', role: 'superadmin' as const },
{ venueId: festival.id, email: 'festival@example.com', password: 'Admin123!', name: 'Festival', role: 'admin' as const },
];
for (const { password, ...user } of users) {
await prisma.user.create({ data: { ...user, passwordHash: await hashPassword(password) } });
}
const manager = await prisma.user.findUniqueOrThrow({ where: { email: 'manager@example.com' } });
for (const tab of tabsData) {
// One line with itemCount units, priced so the tab adds up to about totalInCents
const lineId = uuidv4();
const addedAt = tab.reportedAt.toISOString();
await prisma.tab.create({
// A known token, so `scripts/terminal-simulator.ts` can sync as this terminal without pairing
const token = 'kt_demo-terminal-token';
const device = await prisma.device.create({
data: {
venueId: venue.id,
name: 'Bar terminal 1',
tokenHash: createHash('sha256').update(token).digest('hex'),
tokenPrefix: token.slice(0, 8),
tokenExpiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
lastSeenAt: minutesAgo(1),
},
});
const now = new Date();
const products = [];
for (const { stock, ...fields } of CATALOG) {
const product = await prisma.product.create({
data: {
venueId,
id: tab.tabId,
customerName: tab.customerName,
openedAt: tab.reportedAt,
version: tab.version,
lines: [{
id: lineId,
productId: null,
productName: 'Pils',
unitPriceInCents: Math.round(tab.totalInCents / tab.itemCount),
units: Array.from({ length: tab.itemCount }, (_, i) => ({ id: `${lineId}:${i}`, addedAt, removedAt: null })),
}],
id: randomUUID(),
venueId: venue.id,
...fields,
stock,
countedAt: stock === null ? null : minutesAgo(24 * 60),
lowStockThreshold: stock === null ? null : 12,
fieldUpdatedAt: createdFieldTimes({ ...fields, archived: false }, now),
updatedAt: now,
},
});
products.push(product);
if (stock !== null) {
await prisma.stockMovement.create({
data: { venueId: venue.id, productId: product.id, type: 'count', quantity: stock, stockAfter: stock, userId: manager.id, createdAt: minutesAgo(24 * 60) },
});
}
}
console.log('Created 6 open tabs');
// Create sample commands
const now = new Date();
// Completed command
await prisma.remoteCommand.create({
data: {
id: uuidv4(),
venueId,
deviceId,
targetTabId: tabsData[1].tabId,
type: 'close_tab',
expectedVersion: 3,
paymentMethod: 'cash',
requestedBy: 'Eigenaar',
status: 'completed',
result: 'Tab closed successfully',
acknowledgedAt: new Date(now.getTime() - 30 * 60 * 1000),
createdAt: new Date(now.getTime() - 35 * 60 * 1000),
expiresAt: new Date(now.getTime() + 23 * 60 * 60 * 1000),
},
await prisma.customer.createMany({
data: ['Jan', 'Marie', 'Thomas'].map((name, index) => ({
venueId: venue.id, name, nameKey: name.toLowerCase(), email: `${name.toLowerCase()}@example.com`, position: index + 1, tabOpenedAt: now,
})),
});
// Pending command
await prisma.remoteCommand.create({
data: {
id: uuidv4(),
venueId,
deviceId,
targetTabId: tabsData[0].tabId,
type: 'close_tab',
expectedVersion: 5,
paymentMethod: 'payconiq',
requestedBy: 'Eigenaar',
status: 'pending',
createdAt: new Date(now.getTime() - 5 * 60 * 1000),
expiresAt: new Date(now.getTime() + 23 * 60 * 60 * 1000),
},
});
// Open tabs, and for each customer one paid tab from last week
for (const [index, { customerName, lines }] of TABS.entries()) {
const tab = await prisma.tab.create({
data: { id: randomUUID(), venueId: venue.id, customerName, openedAt: minutesAgo(180), version: lines.length, updatedAt: now },
});
await prisma.tabUnit.createMany({ data: lines.flatMap(([productIndex, quantity, ago]) => units(venue.id, products[productIndex], quantity, minutesAgo(ago), randomUUID(), { tabId: tab.id })) });
// Failed command
await prisma.remoteCommand.create({
data: {
id: uuidv4(),
venueId,
deviceId,
targetTabId: tabsData[4].tabId,
type: 'close_tab',
expectedVersion: 4,
paymentMethod: 'payment_pending',
requestedBy: 'Bartender',
status: 'failed',
result: 'Tab could not be closed',
acknowledgedAt: new Date(now.getTime() - 60 * 60 * 1000),
createdAt: new Date(now.getTime() - 65 * 60 * 1000),
expiresAt: new Date(now.getTime() + 23 * 60 * 60 * 1000),
},
});
const settledAt = minutesAgo((index + 1) * 24 * 60 * 2);
const product = products[index % products.length];
const settlement = await prisma.settlement.create({
data: {
id: randomUUID(), venueId: venue.id, tabId: tab.id, customerName, settledAt, paymentMethod: index % 2 === 0 ? 'cash' : 'payconiq',
amountInCents: 3 * product.priceInCents, deviceId: device.id,
},
});
await prisma.tabUnit.createMany({ data: units(venue.id, product, 3, minutesAgo((index + 1) * 24 * 60 * 2 + 60), randomUUID(), { settlementId: settlement.id }) });
}
// Conflict command
await prisma.remoteCommand.create({
data: {
id: uuidv4(),
venueId,
deviceId,
targetTabId: tabsData[2].tabId,
type: 'close_tab',
expectedVersion: 7,
paymentMethod: 'cash',
requestedBy: 'Eigenaar',
status: 'conflict',
result: 'Version mismatch: expected 7, got 8',
acknowledgedAt: new Date(now.getTime() - 2 * 60 * 60 * 1000),
createdAt: new Date(now.getTime() - 2 * 60 * 60 * 1000 - 5 * 60 * 1000),
expiresAt: new Date(now.getTime() + 22 * 60 * 60 * 1000),
},
});
console.log('Seeded the demo database. Sign in with:');
for (const user of users) console.log(` ${user.role.padEnd(10)} ${user.email} / ${user.password}`);
console.log(`Terminal token for the simulator: ${token}`);
}
console.log('Created sample commands: completed, pending, failed, conflict');
// Create audit log entries
await prisma.auditLog.create({
data: {
id: uuidv4(),
venueId,
actorType: 'client_user',
actorId: 'admin',
action: 'command_created',
details: JSON.stringify({ commandId: 'sample', tabId: tabsData[0].tabId }),
},
});
console.log('Seeding complete!');
console.log('');
console.log('Demo credentials:');
console.log(' Admin: admin@example.com / Admin123!');
console.log(' Viewer: viewer@example.com / Viewer123!');
console.log(' Superadmin: superadmin@example.com / Super123!');
console.log(`\nDevice token (for simulator): ${token}`);
function units(
venueId: string,
product: { id: string; name: string; priceInCents: number },
quantity: number,
addedAt: Date,
lineId: string,
where: { tabId?: string; settlementId?: string },
) {
const eventId = randomUUID();
return Array.from({ length: quantity }, (_, n) => ({
id: `${eventId}:${n}`,
venueId,
...where,
lineId,
productId: product.id,
productName: product.name,
unitPriceInCents: product.priceInCents,
orderId: `order-${addedAt.getTime()}`,
addedAt,
}));
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());