+126
-227
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user