@@ -15,7 +15,6 @@
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:seed": "ts-node prisma/seed.ts",
|
||||
"db:reset": "prisma migrate reset --force && ts-node prisma/seed.ts",
|
||||
"db:use-sqlite": "cp prisma/schema.sqlite.prisma prisma/schema.prisma",
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -55,6 +54,7 @@
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/passport-jwt": "^4.0.0",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"jest": "^29.7.0",
|
||||
"rimraf": "^5.0.5",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- PlannedTab: match customers case-insensitively
|
||||
ALTER TABLE "PlannedTab" ADD COLUMN "customerNameKey" TEXT;
|
||||
|
||||
UPDATE "PlannedTab" SET "customerNameKey" = lower(trim(regexp_replace("customerName", '\s+', ' ', 'g')));
|
||||
|
||||
-- Names that only differ in case or spacing keep the oldest row as the match; later ones get a unique key
|
||||
UPDATE "PlannedTab" p SET "customerNameKey" = p."customerNameKey" || '#' || p."id"
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM "PlannedTab" o
|
||||
WHERE o."venueId" = p."venueId"
|
||||
AND o."customerNameKey" = p."customerNameKey"
|
||||
AND (o."createdAt", o."id") < (p."createdAt", p."id")
|
||||
);
|
||||
|
||||
ALTER TABLE "PlannedTab" ALTER COLUMN "customerNameKey" SET NOT NULL;
|
||||
|
||||
DROP INDEX "PlannedTab_venueId_customerName_key";
|
||||
|
||||
CREATE UNIQUE INDEX "PlannedTab_venueId_customerNameKey_key" ON "PlannedTab"("venueId", "customerNameKey");
|
||||
|
||||
-- RemoteCommand: keep who the tab belonged to, the tab itself disappears once closed
|
||||
ALTER TABLE "RemoteCommand" ADD COLUMN "customerName" TEXT,
|
||||
ADD COLUMN "amountInCents" INTEGER;
|
||||
|
||||
UPDATE "RemoteCommand" c SET "customerName" = s."customerName", "amountInCents" = s."totalInCents"
|
||||
FROM "OpenTabSnapshot" s
|
||||
WHERE s."deviceId" = c."deviceId" AND s."tabId" = c."targetTabId";
|
||||
@@ -34,7 +34,7 @@ model Device {
|
||||
revokedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
tabSnapshots OpenTabSnapshot[]
|
||||
tabOwnerships TabOwnership[]
|
||||
remoteCommands RemoteCommand[]
|
||||
@@ -57,12 +57,12 @@ model ClientUser {
|
||||
}
|
||||
|
||||
model RegistrationCode {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
venueId String
|
||||
code String @unique
|
||||
code String @unique
|
||||
expiresAt DateTime
|
||||
usedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
|
||||
@@ -106,6 +106,8 @@ model RemoteCommand {
|
||||
venueId String
|
||||
deviceId String
|
||||
targetTabId String
|
||||
customerName String?
|
||||
amountInCents Int?
|
||||
type String @default("close_tab")
|
||||
expectedVersion Int
|
||||
paymentMethod String
|
||||
@@ -138,44 +140,46 @@ model AuditLog {
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
tokenHash String @unique
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
revokedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model PlannedTab {
|
||||
id String @id @default(uuid())
|
||||
venueId String
|
||||
customerName String
|
||||
email String?
|
||||
position Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
venueId String
|
||||
customerName String
|
||||
// Lowercased, whitespace-collapsed name: customers are matched on this everywhere
|
||||
customerNameKey String
|
||||
email String?
|
||||
position Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
// Set once the terminal reports an open tab with this name; it is then no longer sent to devices
|
||||
deliveredAt DateTime?
|
||||
deliveredAt DateTime?
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
|
||||
@@unique([venueId, customerName])
|
||||
@@unique([venueId, customerNameKey])
|
||||
@@index([venueId])
|
||||
}
|
||||
|
||||
model TabReminder {
|
||||
id String @id @default(uuid())
|
||||
venueId String
|
||||
tabId String
|
||||
customerName String
|
||||
email String
|
||||
amountInCents Int
|
||||
sentAt DateTime @default(now())
|
||||
status String @default("sent")
|
||||
id String @id @default(uuid())
|
||||
venueId String
|
||||
tabId String
|
||||
customerName String
|
||||
email String
|
||||
amountInCents Int
|
||||
sentAt DateTime @default(now())
|
||||
status String @default("sent")
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
|
||||
@@index([venueId, sentAt])
|
||||
@@index([venueId, tabId])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
model Venue {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
devices Device[]
|
||||
clientUsers ClientUser[]
|
||||
tabOwnerships TabOwnership[]
|
||||
remoteCommands RemoteCommand[]
|
||||
auditLogs AuditLog[]
|
||||
regCodes RegistrationCode[]
|
||||
plannedTabs PlannedTab[]
|
||||
}
|
||||
|
||||
model Device {
|
||||
id String @id @default(uuid())
|
||||
venueId String @unique
|
||||
name String
|
||||
tokenHash String @unique
|
||||
tokenPrefix String
|
||||
tokenExpiresAt DateTime
|
||||
lastSeenAt DateTime @default(now())
|
||||
revokedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
tabSnapshots OpenTabSnapshot[]
|
||||
tabOwnerships TabOwnership[]
|
||||
remoteCommands RemoteCommand[]
|
||||
|
||||
@@index([venueId, revokedAt])
|
||||
}
|
||||
|
||||
model ClientUser {
|
||||
id String @id @default(uuid())
|
||||
venueId String
|
||||
email String @unique
|
||||
passwordHash String
|
||||
name String
|
||||
role String @default("viewer")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
|
||||
@@index([venueId])
|
||||
}
|
||||
|
||||
model RegistrationCode {
|
||||
id String @id @default(uuid())
|
||||
venueId String
|
||||
code String @unique
|
||||
expiresAt DateTime
|
||||
usedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
|
||||
@@index([venueId])
|
||||
}
|
||||
|
||||
model OpenTabSnapshot {
|
||||
id String @id @default(uuid())
|
||||
deviceId String
|
||||
tabId String
|
||||
customerName String
|
||||
version Int
|
||||
totalInCents Int
|
||||
itemCount Int
|
||||
reportedAt DateTime
|
||||
|
||||
device Device @relation(fields: [deviceId], references: [id])
|
||||
|
||||
@@unique([deviceId, tabId])
|
||||
@@index([deviceId])
|
||||
@@index([tabId, reportedAt])
|
||||
}
|
||||
|
||||
model TabOwnership {
|
||||
tabId String
|
||||
venueId String
|
||||
deviceId String
|
||||
lastReportedAt DateTime
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
device Device @relation(fields: [deviceId], references: [id])
|
||||
|
||||
@@id([venueId, tabId])
|
||||
}
|
||||
|
||||
model RemoteCommand {
|
||||
id String @id @default(uuid())
|
||||
venueId String
|
||||
deviceId String
|
||||
targetTabId String
|
||||
type String @default("close_tab")
|
||||
expectedVersion Int
|
||||
paymentMethod String
|
||||
requestedBy String
|
||||
status String @default("pending")
|
||||
result String?
|
||||
acknowledgedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
device Device @relation(fields: [deviceId], references: [id])
|
||||
|
||||
@@index([deviceId, status, createdAt])
|
||||
@@index([venueId, status, createdAt])
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(uuid())
|
||||
venueId String
|
||||
actorType String
|
||||
actorId String
|
||||
action String
|
||||
details String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
|
||||
@@index([venueId, createdAt])
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
revokedAt DateTime?
|
||||
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])
|
||||
}
|
||||
+51
-41
@@ -1,60 +1,70 @@
|
||||
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';
|
||||
const args = process.argv.slice(2).filter((arg) => arg !== '--force');
|
||||
const FORCE = process.argv.includes('--force');
|
||||
const VENUE_NAME = process.env.SETUP_VENUE_NAME || args[0] || 'My Venue';
|
||||
const ADMIN_EMAIL = process.env.SETUP_ADMIN_EMAIL || args[1];
|
||||
const ADMIN_PASSWORD = process.env.SETUP_ADMIN_PASSWORD || args[2];
|
||||
|
||||
async function main() {
|
||||
console.log('=== KoolTap 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');
|
||||
const venues = await prisma.venue.count();
|
||||
if (venues > 0 && !FORCE) {
|
||||
console.log(`The database already has ${venues} venue(s); nothing to do. Pass --force to wipe ALL data and start over.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
|
||||
throw new Error('Usage: node prisma/setup.js "<venue name>" <admin email> <admin password> [--force]');
|
||||
}
|
||||
if (ADMIN_PASSWORD.length < 10) {
|
||||
throw new Error('The admin password must be at least 10 characters');
|
||||
}
|
||||
|
||||
// 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 ===');
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.auditLog.deleteMany();
|
||||
await tx.tabReminder.deleteMany();
|
||||
await tx.plannedTab.deleteMany();
|
||||
await tx.remoteCommand.deleteMany();
|
||||
await tx.openTabSnapshot.deleteMany();
|
||||
await tx.tabOwnership.deleteMany();
|
||||
await tx.refreshToken.deleteMany();
|
||||
await tx.registrationCode.deleteMany();
|
||||
await tx.device.deleteMany();
|
||||
await tx.clientUser.deleteMany();
|
||||
await tx.venue.deleteMany();
|
||||
|
||||
await tx.venue.create({ data: { id: venueId, name: VENUE_NAME } });
|
||||
await tx.clientUser.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
venueId,
|
||||
email: ADMIN_EMAIL,
|
||||
passwordHash: hash,
|
||||
name: 'Admin',
|
||||
role: 'admin',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
console.log('=== 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://kooltap.com with the admin credentials above.');
|
||||
|
||||
await prisma.$disconnect();
|
||||
console.log('\nLog in to the dashboard with the admin credentials you provided.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Setup failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error('Setup failed:', err.message ?? err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getJwtSecret } from './jwt-secret';
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.register({
|
||||
secret: getJwtSecret(),
|
||||
signOptions: { expiresIn: '24h' },
|
||||
signOptions: { expiresIn: '15m' },
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as crypto from 'crypto';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
const hash = (token: string) => crypto.createHash('sha256').update(token).digest('hex');
|
||||
|
||||
type StoredToken = { id: string; userId: string; tokenHash: string; expiresAt: Date; revokedAt: Date | null };
|
||||
|
||||
function createService() {
|
||||
const tokens: StoredToken[] = [];
|
||||
const user = { id: 'u1', email: 'admin@example.com', name: 'Admin', role: 'admin', venueId: 'v1', passwordHash: bcrypt.hashSync('correct-password', 4) };
|
||||
const prisma = {
|
||||
clientUser: {
|
||||
findUnique: jest.fn(async ({ where }: any) => (where.id === user.id || where.email === user.email ? user : null)),
|
||||
},
|
||||
refreshToken: {
|
||||
create: jest.fn(async ({ data }: any) => {
|
||||
tokens.push({ id: `rt${tokens.length}`, revokedAt: null, ...data });
|
||||
}),
|
||||
findUnique: jest.fn(async ({ where }: any) => tokens.find((t) => t.tokenHash === where.tokenHash) ?? null),
|
||||
updateMany: jest.fn(async ({ where, data }: any) => {
|
||||
const matches = tokens.filter((t) =>
|
||||
(where.id === undefined || t.id === where.id)
|
||||
&& (where.tokenHash === undefined || t.tokenHash === where.tokenHash)
|
||||
&& (where.revokedAt !== null || t.revokedAt === null));
|
||||
for (const t of matches) Object.assign(t, data);
|
||||
return { count: matches.length };
|
||||
}),
|
||||
},
|
||||
};
|
||||
const service = new AuthService(prisma as any, new JwtService({ secret: 'test' }), { log: jest.fn() } as any, { notify: jest.fn() } as any);
|
||||
return { service, tokens };
|
||||
}
|
||||
|
||||
describe('AuthService', () => {
|
||||
it('rejects a wrong password', async () => {
|
||||
const { service } = createService();
|
||||
|
||||
await expect(service.login('admin@example.com', 'wrong-password')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('rotates the refresh token on use', async () => {
|
||||
const { service, tokens } = createService();
|
||||
const { refreshToken } = await service.login('admin@example.com', 'correct-password');
|
||||
|
||||
const refreshed = await service.refresh(refreshToken);
|
||||
|
||||
expect(refreshed.refreshToken).not.toBe(refreshToken);
|
||||
expect(tokens.find((t) => t.tokenHash === hash(refreshToken))?.revokedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('accepts a refresh token only once, even for simultaneous requests', async () => {
|
||||
const { service } = createService();
|
||||
const { refreshToken } = await service.login('admin@example.com', 'correct-password');
|
||||
|
||||
const results = await Promise.allSettled([service.refresh(refreshToken), service.refresh(refreshToken)]);
|
||||
|
||||
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
|
||||
expect(results.filter((r) => r.status === 'rejected')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects a refresh token after logout', async () => {
|
||||
const { service } = createService();
|
||||
const { refreshToken } = await service.login('admin@example.com', 'correct-password');
|
||||
|
||||
await service.logout(refreshToken);
|
||||
|
||||
await expect(service.refresh(refreshToken)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,15 @@ export class AuthService {
|
||||
throw new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' });
|
||||
}
|
||||
|
||||
// Revoke first and atomically: of two requests with the same token, only one gets new tokens
|
||||
const claimed = await this.prisma.refreshToken.updateMany({
|
||||
where: { id: stored.id, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
if (claimed.count === 0) {
|
||||
throw new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' });
|
||||
}
|
||||
|
||||
const user = await this.prisma.clientUser.findUnique({ where: { id: stored.userId } });
|
||||
if (!user) {
|
||||
throw new UnauthorizedException({ code: 'user_not_found', message: 'User not found' });
|
||||
@@ -76,11 +85,6 @@ export class AuthService {
|
||||
const newHash = crypto.createHash('sha256').update(newRefreshToken).digest('hex');
|
||||
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
await this.prisma.refreshToken.update({
|
||||
where: { id: stored.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
|
||||
await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { CommandsService } from './commands.service';
|
||||
|
||||
const user = { id: 'u1', name: 'Staff', email: 'staff@example.com' };
|
||||
const dto = { tabId: 't1', type: 'close_tab', expectedVersion: 3, paymentMethod: 'cash' };
|
||||
|
||||
function createService({
|
||||
ownership = { deviceId: 'd1' } as object | null,
|
||||
device = { id: 'd1', revokedAt: null, lastSeenAt: new Date() } as object | null,
|
||||
snapshot = { customerName: 'Jan', totalInCents: 2500, version: 3 } as object | null,
|
||||
pending = null as object | null,
|
||||
} = {}) {
|
||||
const tx = {
|
||||
tabOwnership: { findUnique: jest.fn().mockResolvedValue(ownership) },
|
||||
device: { findUnique: jest.fn().mockResolvedValue(device) },
|
||||
openTabSnapshot: { findUnique: jest.fn().mockResolvedValue(snapshot) },
|
||||
remoteCommand: {
|
||||
findFirst: jest.fn().mockResolvedValue(pending),
|
||||
create: jest.fn(async ({ data }) => data),
|
||||
},
|
||||
auditLog: { create: jest.fn() },
|
||||
};
|
||||
const prisma = { $transaction: jest.fn((fn: (t: typeof tx) => unknown) => Promise.resolve().then(() => fn(tx))) };
|
||||
const realtime = { notify: jest.fn() };
|
||||
const service = new CommandsService(prisma as any, { log: jest.fn() } as any, realtime as any);
|
||||
return { service, prisma, tx, realtime };
|
||||
}
|
||||
|
||||
describe('CommandsService.create', () => {
|
||||
it('queues a close command with the customer and amount it was sent for', async () => {
|
||||
const { service, prisma, tx, realtime } = createService();
|
||||
|
||||
const result = await service.create('v1', dto, user);
|
||||
|
||||
expect(result.status).toBe('pending');
|
||||
expect(tx.remoteCommand.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
venueId: 'v1',
|
||||
deviceId: 'd1',
|
||||
targetTabId: 't1',
|
||||
customerName: 'Jan',
|
||||
amountInCents: 2500,
|
||||
expectedVersion: 3,
|
||||
paymentMethod: 'cash',
|
||||
requestedBy: 'Staff',
|
||||
}),
|
||||
});
|
||||
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: 'Serializable' });
|
||||
expect(realtime.notify).toHaveBeenCalledWith('v1', 'commands');
|
||||
});
|
||||
|
||||
it('rejects a tab no device owns', async () => {
|
||||
const { service } = createService({ ownership: null });
|
||||
|
||||
await expect(service.create('v1', dto, user)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('rejects a tab that changed since it was loaded', async () => {
|
||||
const { service, tx } = createService({ snapshot: { customerName: 'Jan', totalInCents: 3000, version: 4 } });
|
||||
|
||||
await expect(service.create('v1', dto, user)).rejects.toMatchObject({ response: { code: 'version_mismatch' } });
|
||||
expect(tx.remoteCommand.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a tab whose device was revoked', async () => {
|
||||
const { service } = createService({ device: { id: 'd1', revokedAt: new Date(), lastSeenAt: new Date() } });
|
||||
|
||||
await expect(service.create('v1', dto, user)).rejects.toMatchObject({ response: { code: 'device_revoked' } });
|
||||
});
|
||||
|
||||
it('rejects a second command while one is pending', async () => {
|
||||
const { service, tx } = createService({ pending: { id: 'c0' } });
|
||||
|
||||
await expect(service.create('v1', dto, user)).rejects.toMatchObject({ response: { code: 'command_already_pending' } });
|
||||
expect(tx.remoteCommand.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a lost race between two simultaneous requests as already pending', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.$transaction.mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError('serialization failure', { code: 'P2034', clientVersion: 'test' }),
|
||||
);
|
||||
|
||||
const error = await service.create('v1', dto, user).catch((e) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(ConflictException);
|
||||
expect(error.response.code).toBe('command_already_pending');
|
||||
});
|
||||
|
||||
it('warns when the terminal has not been seen recently', async () => {
|
||||
const { service } = createService({ device: { id: 'd1', revokedAt: null, lastSeenAt: new Date(Date.now() - 60 * 60 * 1000) } });
|
||||
|
||||
const result = await service.create('v1', dto, user);
|
||||
|
||||
expect(result).toHaveProperty('warning');
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
@@ -35,7 +36,7 @@ export class CommandsService {
|
||||
});
|
||||
}
|
||||
|
||||
// Use transaction for consistency
|
||||
// Serializable so two simultaneous requests for the same tab can't both pass the pending check
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
// Resolve tab ownership
|
||||
const ownership = await tx.tabOwnership.findUnique({
|
||||
@@ -93,6 +94,8 @@ export class CommandsService {
|
||||
venueId,
|
||||
deviceId: device.id,
|
||||
targetTabId: dto.tabId,
|
||||
customerName: snapshot?.customerName ?? null,
|
||||
amountInCents: snapshot?.totalInCents ?? null,
|
||||
type: dto.type || 'close_tab',
|
||||
expectedVersion: dto.expectedVersion,
|
||||
paymentMethod: dto.paymentMethod,
|
||||
@@ -130,6 +133,14 @@ export class CommandsService {
|
||||
status: command.status,
|
||||
...warning,
|
||||
};
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }).catch((error) => {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2034') {
|
||||
throw new ConflictException({
|
||||
code: 'command_already_pending',
|
||||
message: 'A command for this tab is already pending',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
this.realtime.notify(venueId, 'commands');
|
||||
@@ -152,6 +163,8 @@ export class CommandsService {
|
||||
select: {
|
||||
id: true,
|
||||
targetTabId: true,
|
||||
customerName: true,
|
||||
amountInCents: true,
|
||||
type: true,
|
||||
status: true,
|
||||
paymentMethod: true,
|
||||
@@ -173,6 +186,8 @@ export class CommandsService {
|
||||
commands: commands.map((c) => ({
|
||||
id: c.id,
|
||||
tabId: c.targetTabId,
|
||||
customerName: c.customerName,
|
||||
amountInCents: c.amountInCents,
|
||||
type: c.type,
|
||||
status: c.status,
|
||||
paymentMethod: c.paymentMethod,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { DevicesService } from './devices.service';
|
||||
|
||||
type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number };
|
||||
type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number; items?: unknown };
|
||||
|
||||
function createService({ previous = [] as Snapshot[], delivered = 0, expired = 0 } = {}) {
|
||||
const tx = {
|
||||
tabOwnership: { deleteMany: jest.fn(), upsert: jest.fn() },
|
||||
openTabSnapshot: { deleteMany: jest.fn(), upsert: jest.fn() },
|
||||
tabOwnership: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() },
|
||||
openTabSnapshot: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() },
|
||||
plannedTab: { updateMany: jest.fn().mockResolvedValue({ count: delivered }) },
|
||||
device: { update: jest.fn() },
|
||||
};
|
||||
@@ -27,13 +27,13 @@ const device = { id: 'd1', venueId: 'v1', revokedAt: null, lastSeenAt: new Date(
|
||||
const tab = (id: string, customerName: string) => ({ id, customerName, version: 1, totalInCents: 0, itemCount: 0 });
|
||||
|
||||
describe('DevicesService.sync planned customers', () => {
|
||||
it('marks planned customers delivered once the device reports a tab with their name', async () => {
|
||||
it('marks planned customers delivered once the device reports a tab with their name, ignoring case', async () => {
|
||||
const { service, tx } = createService();
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [tab('t1', 'Jan'), tab('t2', 'Piet')] });
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [tab('t1', 'Jan'), tab('t2', ' piet Peeters')] });
|
||||
|
||||
expect(tx.plannedTab.updateMany).toHaveBeenCalledWith({
|
||||
where: { venueId: 'v1', deliveredAt: null, customerName: { in: ['Jan', 'Piet'] } },
|
||||
where: { venueId: 'v1', deliveredAt: null, customerNameKey: { in: ['jan', 'piet peeters'] } },
|
||||
data: { deliveredAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
@@ -125,3 +125,32 @@ describe('DevicesService.sync tab lines', () => {
|
||||
expect(tx.openTabSnapshot.upsert.mock.calls[0][0].update.items).toBe(Prisma.JsonNull);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DevicesService.sync writes', () => {
|
||||
const items = [{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 }];
|
||||
const jan = { id: 't1', customerName: 'Jan', version: 2, totalInCents: 2500, itemCount: 10, items };
|
||||
// JSONB doesn't keep key order
|
||||
const janSnapshot = { tabId: 't1', customerName: 'Jan', version: 2, totalInCents: 2500, itemCount: 10, items: [{ quantity: 10, productName: 'Cola', unitPriceInCents: 250 }] };
|
||||
|
||||
it('only bumps the report time of unchanged tabs', async () => {
|
||||
const { service, tx } = createService({ previous: [janSnapshot] });
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [jan] });
|
||||
|
||||
expect(tx.openTabSnapshot.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.tabOwnership.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.openTabSnapshot.updateMany).toHaveBeenCalledWith({
|
||||
where: { deviceId: 'd1', tabId: { in: ['t1'] } },
|
||||
data: { reportedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it('writes a tab whose lines changed', async () => {
|
||||
const { service, tx } = createService({ previous: [janSnapshot] });
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [{ ...jan, items: [{ ...items[0], quantity: 11 }] }] });
|
||||
|
||||
expect(tx.openTabSnapshot.upsert).toHaveBeenCalledTimes(1);
|
||||
expect(tx.openTabSnapshot.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,8 @@ import { AuditService } from '../audit/audit.service';
|
||||
import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service';
|
||||
import { DEVICE_ONLINE_THRESHOLD_MS } from '@kooltap/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { OpenTabDto, SyncDto } from './dto/sync.dto';
|
||||
import { OpenTabDto, OpenTabItemDto, SyncDto } from './dto/sync.dto';
|
||||
import { customerNameKey } from '../planned-tabs/customer-name';
|
||||
import { AckCommandDto } from './dto/ack-command.dto';
|
||||
|
||||
@Injectable()
|
||||
@@ -36,22 +37,28 @@ export class DevicesService {
|
||||
// Devices sync often, so dashboards are only told about what actually changed
|
||||
const previous = await this.prisma.openTabSnapshot.findMany({
|
||||
where: { deviceId: device.id },
|
||||
select: { tabId: true, customerName: true, version: true, totalInCents: true, itemCount: true },
|
||||
select: { tabId: true, customerName: true, version: true, totalInCents: true, itemCount: true, items: true },
|
||||
});
|
||||
const previousById = new Map(previous.map((s) => [s.tabId, s]));
|
||||
const tabsChanged =
|
||||
previous.length !== openTabs.length ||
|
||||
openTabs.some((t) => {
|
||||
const p = previousById.get(t.id);
|
||||
return !p || p.customerName !== t.customerName || p.version !== t.version
|
||||
|| p.totalInCents !== t.totalInCents || p.itemCount !== t.itemCount;
|
||||
});
|
||||
let plannedDelivered = 0;
|
||||
|
||||
// Ownership changes are detected up front so audit logging stays outside the transaction
|
||||
const takenOver = await this.prisma.tabOwnership.findMany({
|
||||
where: { venueId: device.venueId, tabId: { in: openTabIds }, deviceId: { not: device.id } },
|
||||
});
|
||||
const takenOverIds = new Set(takenOver.map((o) => o.tabId));
|
||||
|
||||
// Only new or changed tabs are written; unchanged ones just get their report time bumped
|
||||
const changedTabs = openTabs.filter((t) => {
|
||||
const p = previousById.get(t.id);
|
||||
return !p || takenOverIds.has(t.id) || p.customerName !== t.customerName || p.version !== t.version
|
||||
|| p.totalInCents !== t.totalInCents || p.itemCount !== t.itemCount
|
||||
|| !sameItems(p.items as StoredItem[] | null, t.items);
|
||||
});
|
||||
const changedIds = new Set(changedTabs.map((t) => t.id));
|
||||
const unchangedIds = openTabIds.filter((id) => !changedIds.has(id));
|
||||
const tabsChanged = previous.length !== openTabs.length || changedTabs.length > 0;
|
||||
let plannedDelivered = 0;
|
||||
|
||||
for (const ownership of takenOver) {
|
||||
await this.auditService.log(
|
||||
device.venueId,
|
||||
@@ -71,7 +78,18 @@ export class DevicesService {
|
||||
where: { deviceId: device.id, tabId: { notIn: openTabIds } },
|
||||
});
|
||||
|
||||
for (const tab of openTabs) {
|
||||
if (unchangedIds.length > 0) {
|
||||
await tx.openTabSnapshot.updateMany({
|
||||
where: { deviceId: device.id, tabId: { in: unchangedIds } },
|
||||
data: { reportedAt: now },
|
||||
});
|
||||
await tx.tabOwnership.updateMany({
|
||||
where: { venueId: device.venueId, deviceId: device.id, tabId: { in: unchangedIds } },
|
||||
data: { lastReportedAt: now },
|
||||
});
|
||||
}
|
||||
|
||||
for (const tab of changedTabs) {
|
||||
await tx.openTabSnapshot.upsert({
|
||||
where: {
|
||||
deviceId_tabId: { deviceId: device.id, tabId: tab.id },
|
||||
@@ -111,14 +129,12 @@ export class DevicesService {
|
||||
});
|
||||
}
|
||||
|
||||
// A planned customer counts as delivered once the device reports a tab with that exact name
|
||||
// (the terminal matches names exactly when creating tabs)
|
||||
if (openTabs.length > 0) {
|
||||
({ count: plannedDelivered } = await tx.plannedTab.updateMany({
|
||||
where: {
|
||||
venueId: device.venueId,
|
||||
deliveredAt: null,
|
||||
customerName: { in: openTabs.map((t) => t.customerName) },
|
||||
customerNameKey: { in: openTabs.map((t) => customerNameKey(t.customerName)) },
|
||||
},
|
||||
data: { deliveredAt: now },
|
||||
}));
|
||||
@@ -279,8 +295,21 @@ export class DevicesService {
|
||||
}
|
||||
}
|
||||
|
||||
type StoredItem = { productName: string; quantity: number; unitPriceInCents: number };
|
||||
|
||||
function sameItems(stored: StoredItem[] | null, reported: OpenTabItemDto[] | undefined): boolean {
|
||||
if (!stored || !reported) return !stored && !reported;
|
||||
return stored.length === reported.length && stored.every((s, i) =>
|
||||
s.productName === reported[i].productName
|
||||
&& s.quantity === reported[i].quantity
|
||||
&& s.unitPriceInCents === reported[i].unitPriceInCents);
|
||||
}
|
||||
|
||||
function storedItems(items: OpenTabItemDto[]): StoredItem[] {
|
||||
return items.map(({ productName, quantity, unitPriceInCents }) => ({ productName, quantity, unitPriceInCents }));
|
||||
}
|
||||
|
||||
/** The tab's lines in the stored shape, or JSON null when the terminal didn't report them. */
|
||||
function snapshotItems(tab: OpenTabDto) {
|
||||
if (!tab.items) return Prisma.JsonNull;
|
||||
return tab.items.map(({ productName, quantity, unitPriceInCents }) => ({ productName, quantity, unitPriceInCents }));
|
||||
return tab.items ? storedItems(tab.items) : Prisma.JsonNull;
|
||||
}
|
||||
|
||||
+12
-9
@@ -39,20 +39,23 @@ async function bootstrap() {
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new LoggingInterceptor());
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('KoolTap Remote Tab Control API')
|
||||
.setDescription('API for managing remote tab control in venues')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
const swagger = process.env.NODE_ENV !== 'production';
|
||||
if (swagger) {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('KoolTap Remote Tab Control API')
|
||||
.setDescription('API for managing remote tab control in venues')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
}
|
||||
|
||||
const port = process.env.PORT || 3001;
|
||||
await app.listen(port);
|
||||
console.log(`API running on http://localhost:${port}`);
|
||||
console.log(`Swagger docs at http://localhost:${port}/api/docs`);
|
||||
if (swagger) console.log(`Swagger docs at http://localhost:${port}/api/docs`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,8 @@
|
||||
export function normalizeCustomerName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/** The key customers are matched on: names differing only in case or spacing are the same customer. */
|
||||
export function customerNameKey(name: string): string {
|
||||
return normalizeCustomerName(name).toLowerCase();
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException, ConflictException, BadRequestException }
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { customerNameKey, normalizeCustomerName } from './customer-name';
|
||||
|
||||
@Injectable()
|
||||
export class PlannedTabsService {
|
||||
@@ -20,12 +21,12 @@ export class PlannedTabsService {
|
||||
}
|
||||
|
||||
async create(venueId: string, customerName: string, email?: string) {
|
||||
const name = customerName.trim();
|
||||
const name = normalizeCustomerName(customerName);
|
||||
if (!name) throw new BadRequestException({ code: 'name_required', message: 'Customer name is required' });
|
||||
const key = customerNameKey(name);
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await this.prisma.plannedTab.findUnique({
|
||||
where: { venueId_customerName: { venueId, customerName: name } },
|
||||
where: { venueId_customerNameKey: { venueId, customerNameKey: key } },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' });
|
||||
@@ -42,6 +43,7 @@ export class PlannedTabsService {
|
||||
id: uuidv4(),
|
||||
venueId,
|
||||
customerName: name,
|
||||
customerNameKey: key,
|
||||
email: email?.trim() || null,
|
||||
position: (last?.position ?? -1) + 1,
|
||||
},
|
||||
@@ -53,14 +55,15 @@ export class PlannedTabsService {
|
||||
}
|
||||
|
||||
async update(venueId: string, id: string, customerName: string, email?: string) {
|
||||
const name = customerName.trim();
|
||||
const name = normalizeCustomerName(customerName);
|
||||
if (!name) throw new BadRequestException({ code: 'name_required', message: 'Customer name is required' });
|
||||
const key = customerNameKey(name);
|
||||
|
||||
const tab = await this.prisma.plannedTab.findFirst({ where: { id, venueId } });
|
||||
if (!tab) throw new NotFoundException({ code: 'not_found', message: 'Planned tab not found' });
|
||||
|
||||
const duplicate = await this.prisma.plannedTab.findUnique({
|
||||
where: { venueId_customerName: { venueId, customerName: name } },
|
||||
where: { venueId_customerNameKey: { venueId, customerNameKey: key } },
|
||||
});
|
||||
if (duplicate && duplicate.id !== id) {
|
||||
throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' });
|
||||
@@ -68,7 +71,7 @@ export class PlannedTabsService {
|
||||
|
||||
const result = await this.prisma.plannedTab.update({
|
||||
where: { id },
|
||||
data: { customerName: name, email: email?.trim() || null },
|
||||
data: { customerName: name, customerNameKey: key, email: email?.trim() || null },
|
||||
select: { id: true, customerName: true, email: true, position: true, createdAt: true },
|
||||
});
|
||||
|
||||
@@ -85,16 +88,16 @@ export class PlannedTabsService {
|
||||
async import(venueId: string, entries: { customerName: string; email?: string }[], mode: 'merge' | 'replace') {
|
||||
const incoming = new Map<string, { customerName: string; email?: string }>();
|
||||
for (const entry of entries) {
|
||||
const customerName = entry.customerName.trim().replace(/\s+/g, ' ');
|
||||
const customerName = normalizeCustomerName(entry.customerName);
|
||||
if (!customerName) continue;
|
||||
const key = customerName.toLowerCase();
|
||||
const key = customerNameKey(customerName);
|
||||
const previous = incoming.get(key);
|
||||
incoming.set(key, { customerName, email: entry.email || previous?.email });
|
||||
}
|
||||
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.plannedTab.findMany({ where: { venueId }, orderBy: { position: 'asc' } });
|
||||
const existingByKey = new Map(existing.map((t) => [t.customerName.toLowerCase(), t]));
|
||||
const existingByKey = new Map(existing.map((t) => [customerNameKey(t.customerName), t]));
|
||||
let nextPosition = (existing[existing.length - 1]?.position ?? -1) + 1;
|
||||
|
||||
let created = 0;
|
||||
@@ -106,7 +109,7 @@ export class PlannedTabsService {
|
||||
const current = existingByKey.get(key);
|
||||
if (!current) {
|
||||
await tx.plannedTab.create({
|
||||
data: { id: uuidv4(), venueId, customerName: entry.customerName, email: entry.email ?? null, position: nextPosition++ },
|
||||
data: { id: uuidv4(), venueId, customerName: entry.customerName, customerNameKey: key, email: entry.email ?? null, position: nextPosition++ },
|
||||
});
|
||||
created++;
|
||||
} else if (entry.email && entry.email !== current.email) {
|
||||
@@ -118,7 +121,7 @@ export class PlannedTabsService {
|
||||
}
|
||||
|
||||
if (mode === 'replace') {
|
||||
const toRemove = existing.filter((t) => !incoming.has(t.customerName.toLowerCase())).map((t) => t.id);
|
||||
const toRemove = existing.filter((t) => !incoming.has(customerNameKey(t.customerName))).map((t) => t.id);
|
||||
if (toRemove.length > 0) {
|
||||
removed = (await tx.plannedTab.deleteMany({ where: { id: { in: toRemove } } })).count;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Post, Param, UseGuards, Req } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
@@ -21,11 +21,14 @@ export class RemindersController {
|
||||
}
|
||||
|
||||
@Post('send')
|
||||
@ApiOperation({ summary: 'Send payment reminders via Resend' })
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Send payment reminders via Resend (admin only)' })
|
||||
@ApiResponse({ status: 201, description: 'Reminders sent' })
|
||||
@ApiResponse({ status: 400, description: 'Resend not configured' })
|
||||
async send(@Param('venueId') venueId: string, @Req() req: any) {
|
||||
return this.service.sendReminders(venueId, req.user?.email || 'unknown');
|
||||
@ApiResponse({ status: 409, description: 'Reminders are already being sent' })
|
||||
async send(@Param('venueId') venueId: string) {
|
||||
return this.service.sendReminders(venueId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { EmailTemplatesService } from '../email/email-templates.service';
|
||||
import { RemindersService } from './reminders.service';
|
||||
|
||||
function createService(items: unknown) {
|
||||
function createService(items: unknown, { plannedName = 'Bram' } = {}) {
|
||||
const prisma = {
|
||||
tabOwnership: { findMany: jest.fn().mockResolvedValue([{ tabId: 't1', deviceId: 'd1' }]) },
|
||||
openTabSnapshot: {
|
||||
@@ -9,22 +10,23 @@ function createService(items: unknown) {
|
||||
{ tabId: 't1', deviceId: 'd1', customerName: 'Bram', totalInCents: 2900, itemCount: 12, items },
|
||||
]),
|
||||
},
|
||||
plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: 'Bram', email: 'bram@example.com' }]) },
|
||||
tabReminder: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn() },
|
||||
plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: plannedName, email: 'bram@example.com' }]) },
|
||||
tabReminder: { findMany: jest.fn().mockResolvedValue([]), createMany: jest.fn() },
|
||||
venue: { findUnique: jest.fn().mockResolvedValue({ name: 'De Kroon' }) },
|
||||
};
|
||||
return new RemindersService(prisma as any, { notify: jest.fn() } as any, new EmailTemplatesService());
|
||||
const service = new RemindersService(prisma as any, { notify: jest.fn() } as any, new EmailTemplatesService());
|
||||
return { service, prisma };
|
||||
}
|
||||
|
||||
describe('RemindersService.sendReminders', () => {
|
||||
const originalFetch = global.fetch;
|
||||
let sentBodies: any[];
|
||||
let requests: { url: string; headers: Record<string, string>; body: any }[];
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.RESEND_API_KEY = 'test-key';
|
||||
sentBodies = [];
|
||||
global.fetch = jest.fn(async (_url: any, init: any) => {
|
||||
sentBodies.push(JSON.parse(init.body));
|
||||
requests = [];
|
||||
global.fetch = jest.fn(async (url: any, init: any) => {
|
||||
requests.push({ url, headers: init.headers, body: JSON.parse(init.body) });
|
||||
return { ok: true, text: async () => '' } as Response;
|
||||
}) as any;
|
||||
});
|
||||
@@ -35,15 +37,15 @@ describe('RemindersService.sendReminders', () => {
|
||||
});
|
||||
|
||||
it('includes the tab summary with Belgian amounts', async () => {
|
||||
const service = createService([
|
||||
const { service } = createService([
|
||||
{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 },
|
||||
{ productName: 'Chips', quantity: 2, unitPriceInCents: 200 },
|
||||
]);
|
||||
|
||||
const result = await service.sendReminders('v1', 'admin@example.com');
|
||||
const result = await service.sendReminders('v1');
|
||||
|
||||
expect(result.sent).toBe(1);
|
||||
const [email] = sentBodies;
|
||||
const [email] = requests[0].body;
|
||||
expect(email.to).toBe('bram@example.com');
|
||||
expect(email.text).toMatch(/10x Cola\s+€\s25,00/);
|
||||
expect(email.text).toMatch(/2x Chips\s+€\s4,00/);
|
||||
@@ -52,12 +54,60 @@ describe('RemindersService.sendReminders', () => {
|
||||
});
|
||||
|
||||
it('leaves the summary out when the terminal did not report lines', async () => {
|
||||
const service = createService(null);
|
||||
const { service } = createService(null);
|
||||
|
||||
await service.sendReminders('v1', 'admin@example.com');
|
||||
await service.sendReminders('v1');
|
||||
|
||||
const [email] = sentBodies;
|
||||
const [email] = requests[0].body;
|
||||
expect(email.text).not.toContain('10x');
|
||||
expect(email.text).toMatch(/€\s29,00/);
|
||||
});
|
||||
|
||||
it('sends one batch request with an idempotency key and records the reminders', async () => {
|
||||
const { service, prisma } = createService(null);
|
||||
|
||||
await service.sendReminders('v1');
|
||||
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0].url).toBe('https://api.resend.com/emails/batch');
|
||||
expect(requests[0].headers['Idempotency-Key']).toMatch(/^reminders-[0-9a-f]{64}$/);
|
||||
expect(prisma.tabReminder.createMany).toHaveBeenCalledWith({
|
||||
data: [expect.objectContaining({ tabId: 't1', email: 'bram@example.com', amountInCents: 2900 })],
|
||||
});
|
||||
});
|
||||
|
||||
it('matches planned customers regardless of case and spacing', async () => {
|
||||
const { service } = createService(null, { plannedName: ' bRAM ' });
|
||||
|
||||
const { candidates } = await service.listCandidates('v1');
|
||||
|
||||
expect(candidates.map((c) => c.email)).toEqual(['bram@example.com']);
|
||||
});
|
||||
|
||||
it('refuses a second send for the venue while one is in progress', async () => {
|
||||
const { service } = createService(null);
|
||||
|
||||
const first = service.sendReminders('v1');
|
||||
await expect(service.sendReminders('v1')).rejects.toBeInstanceOf(ConflictException);
|
||||
await first;
|
||||
await expect(service.sendReminders('v1')).resolves.toMatchObject({ sent: 1 });
|
||||
});
|
||||
|
||||
it('does not record reminders when Resend rejects the batch', async () => {
|
||||
global.fetch = jest.fn(async () => ({ ok: false, text: async () => 'rate limited' }) as Response) as any;
|
||||
const { service, prisma } = createService(null);
|
||||
|
||||
const result = await service.sendReminders('v1');
|
||||
|
||||
expect(result).toMatchObject({ sent: 0, total: 1, errors: [{ customerName: 'Bram', error: 'rate limited' }] });
|
||||
expect(prisma.tabReminder.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets database errors surface instead of re-sending to everyone', async () => {
|
||||
const { service, prisma } = createService(null);
|
||||
prisma.tabReminder.findMany.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(service.sendReminders('v1')).rejects.toThrow('db down');
|
||||
expect(requests).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
import { Injectable, BadRequestException, NotFoundException, InternalServerErrorException } from '@nestjs/common';
|
||||
import { Injectable, BadRequestException, ConflictException, NotFoundException, InternalServerErrorException } from '@nestjs/common';
|
||||
import * as crypto from 'crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { EmailTemplateError, EmailTemplatesService, RenderedEmail } from '../email/email-templates.service';
|
||||
import { customerNameKey } from '../planned-tabs/customer-name';
|
||||
|
||||
// Tabs stay open per customer (closing only settles them), so a customer can be reminded again
|
||||
// once this many days have passed since their last reminder.
|
||||
const REMINDER_COOLDOWN_DAYS = 7;
|
||||
|
||||
// Resend accepts at most 100 emails per batch request
|
||||
const BATCH_SIZE = 100;
|
||||
|
||||
type Candidate = {
|
||||
tabId: string;
|
||||
customerName: string;
|
||||
email: string;
|
||||
amountInCents: number;
|
||||
itemCount: number;
|
||||
items: TabLine[] | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RemindersService {
|
||||
private resendApiKey: string;
|
||||
private fromEmail: string;
|
||||
private sendingVenues = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
@@ -21,7 +36,7 @@ export class RemindersService {
|
||||
this.fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTap <onboarding@resend.dev>';
|
||||
}
|
||||
|
||||
async listCandidates(venueId: string) {
|
||||
async listCandidates(venueId: string): Promise<{ candidates: Candidate[] }> {
|
||||
const ownerships = await this.prisma.tabOwnership.findMany({
|
||||
where: { venueId },
|
||||
});
|
||||
@@ -32,37 +47,24 @@ export class RemindersService {
|
||||
OR: ownerships.map((o) => ({ deviceId: o.deviceId, tabId: o.tabId })),
|
||||
},
|
||||
});
|
||||
const latestByTab = new Map(snapshots.map((s) => [s.tabId, s]));
|
||||
|
||||
const emailMap = new Map<string, string>();
|
||||
try {
|
||||
const plannedTabs = await this.prisma.plannedTab.findMany({
|
||||
where: { venueId, email: { not: null } },
|
||||
select: { customerName: true, email: true },
|
||||
});
|
||||
for (const pt of plannedTabs) {
|
||||
if (pt.email) emailMap.set(pt.customerName.toLowerCase(), pt.email);
|
||||
}
|
||||
} catch {
|
||||
// email column may not exist yet if migration hasn't been applied
|
||||
}
|
||||
const plannedTabs = await this.prisma.plannedTab.findMany({
|
||||
where: { venueId, email: { not: null } },
|
||||
select: { customerName: true, email: true },
|
||||
});
|
||||
const emailByKey = new Map(plannedTabs.map((pt) => [customerNameKey(pt.customerName), pt.email!]));
|
||||
|
||||
let sentTabIds = new Set<string>();
|
||||
try {
|
||||
const alreadySent = await this.prisma.tabReminder.findMany({
|
||||
where: { venueId, sentAt: { gt: new Date(Date.now() - REMINDER_COOLDOWN_DAYS * 24 * 60 * 60 * 1000) } },
|
||||
select: { tabId: true },
|
||||
});
|
||||
sentTabIds = new Set(alreadySent.map((r) => r.tabId));
|
||||
} catch {
|
||||
// Table may not exist yet if migration hasn't been applied
|
||||
}
|
||||
const alreadySent = await this.prisma.tabReminder.findMany({
|
||||
where: { venueId, sentAt: { gt: new Date(Date.now() - REMINDER_COOLDOWN_DAYS * 24 * 60 * 60 * 1000) } },
|
||||
select: { tabId: true },
|
||||
});
|
||||
const sentTabIds = new Set(alreadySent.map((r) => r.tabId));
|
||||
|
||||
const candidates = [];
|
||||
for (const snap of latestByTab.values()) {
|
||||
const candidates: Candidate[] = [];
|
||||
for (const snap of snapshots) {
|
||||
if (snap.totalInCents <= 0) continue;
|
||||
if (sentTabIds.has(snap.tabId)) continue;
|
||||
const email = emailMap.get(snap.customerName.toLowerCase());
|
||||
const email = emailByKey.get(customerNameKey(snap.customerName));
|
||||
if (!email) continue;
|
||||
candidates.push({
|
||||
tabId: snap.tabId,
|
||||
@@ -78,13 +80,7 @@ export class RemindersService {
|
||||
return { candidates };
|
||||
}
|
||||
|
||||
async sendReminders(venueId: string, requestorEmail: string) {
|
||||
const { candidates } = await this.listCandidates(venueId);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return { sent: 0, message: 'No candidates to remind' };
|
||||
}
|
||||
|
||||
async sendReminders(venueId: string) {
|
||||
if (!this.resendApiKey) {
|
||||
throw new BadRequestException({
|
||||
code: 'resend_not_configured',
|
||||
@@ -92,64 +88,71 @@ export class RemindersService {
|
||||
});
|
||||
}
|
||||
|
||||
// A double click or a second admin must not email everyone twice
|
||||
if (this.sendingVenues.has(venueId)) {
|
||||
throw new ConflictException({ code: 'reminders_in_progress', message: 'Reminders are already being sent' });
|
||||
}
|
||||
this.sendingVenues.add(venueId);
|
||||
|
||||
try {
|
||||
return await this.sendToCandidates(venueId);
|
||||
} finally {
|
||||
this.sendingVenues.delete(venueId);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendToCandidates(venueId: string) {
|
||||
const { candidates } = await this.listCandidates(venueId);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return { sent: 0, total: 0 };
|
||||
}
|
||||
|
||||
const venueName = await this.venueName(venueId);
|
||||
let sent = 0;
|
||||
const errors: { customerName: string; error: string }[] = [];
|
||||
|
||||
const rendered: { candidate: Candidate; email: RenderedEmail }[] = [];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const email = await this.emailTemplates.render('payment-reminder', {
|
||||
customerName: candidate.customerName,
|
||||
venueName,
|
||||
amount: formatEuro(candidate.amountInCents),
|
||||
itemCount: candidate.itemCount,
|
||||
hasItems: (candidate.items?.length ?? 0) > 0,
|
||||
items: (candidate.items ?? []).map((item) => ({
|
||||
quantity: item.quantity,
|
||||
productName: item.productName,
|
||||
unitPrice: formatEuro(item.unitPriceInCents),
|
||||
lineTotal: formatEuro(item.quantity * item.unitPriceInCents),
|
||||
})),
|
||||
});
|
||||
|
||||
const error = await this.sendEmail(candidate.email, email);
|
||||
if (error) {
|
||||
errors.push({ customerName: candidate.customerName, error });
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.prisma.tabReminder.create({
|
||||
data: {
|
||||
venueId,
|
||||
tabId: candidate.tabId,
|
||||
customerName: candidate.customerName,
|
||||
email: candidate.email,
|
||||
amountInCents: candidate.amountInCents,
|
||||
},
|
||||
});
|
||||
|
||||
sent++;
|
||||
rendered.push({ candidate, email: await this.renderReminder(candidate, venueName) });
|
||||
} catch (err: any) {
|
||||
errors.push({ customerName: candidate.customerName, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < rendered.length; i += BATCH_SIZE) {
|
||||
const batch = rendered.slice(i, i + BATCH_SIZE);
|
||||
const error = await this.sendBatch(batch.map(({ candidate, email }) => ({ to: candidate.email, email })));
|
||||
if (error) {
|
||||
for (const { candidate } of batch) errors.push({ customerName: candidate.customerName, error });
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.prisma.tabReminder.createMany({
|
||||
data: batch.map(({ candidate }) => ({
|
||||
venueId,
|
||||
tabId: candidate.tabId,
|
||||
customerName: candidate.customerName,
|
||||
email: candidate.email,
|
||||
amountInCents: candidate.amountInCents,
|
||||
})),
|
||||
});
|
||||
sent += batch.length;
|
||||
}
|
||||
|
||||
if (sent > 0) this.realtime.notify(venueId, 'reminders');
|
||||
|
||||
return { sent, total: candidates.length, errors: errors.length > 0 ? errors : undefined };
|
||||
}
|
||||
|
||||
async history(venueId: string) {
|
||||
try {
|
||||
const reminders = await this.prisma.tabReminder.findMany({
|
||||
where: { venueId },
|
||||
orderBy: { sentAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
return { reminders };
|
||||
} catch {
|
||||
return { reminders: [] };
|
||||
}
|
||||
const reminders = await this.prisma.tabReminder.findMany({
|
||||
where: { venueId },
|
||||
orderBy: { sentAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
return { reminders };
|
||||
}
|
||||
|
||||
async sendTestEmail(venueId: string, plannedTabId: string) {
|
||||
@@ -186,7 +189,7 @@ export class RemindersService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const error = await this.sendEmail(tab.email, email);
|
||||
const error = await this.resend('/emails', { from: this.fromEmail, to: tab.email, ...email });
|
||||
if (error) {
|
||||
throw new BadRequestException({
|
||||
code: 'email_failed',
|
||||
@@ -197,15 +200,39 @@ export class RemindersService {
|
||||
return { ok: true, email: tab.email, customerName: tab.customerName };
|
||||
}
|
||||
|
||||
/** Sends through Resend; returns Resend's error text, or null when sent. */
|
||||
private async sendEmail(to: string, email: RenderedEmail): Promise<string | null> {
|
||||
const res = await fetch('https://api.resend.com/emails', {
|
||||
private renderReminder(candidate: Candidate, venueName: string): Promise<RenderedEmail> {
|
||||
return this.emailTemplates.render('payment-reminder', {
|
||||
customerName: candidate.customerName,
|
||||
venueName,
|
||||
amount: formatEuro(candidate.amountInCents),
|
||||
itemCount: candidate.itemCount,
|
||||
hasItems: (candidate.items?.length ?? 0) > 0,
|
||||
items: (candidate.items ?? []).map((item) => ({
|
||||
quantity: item.quantity,
|
||||
productName: item.productName,
|
||||
unitPrice: formatEuro(item.unitPriceInCents),
|
||||
lineTotal: formatEuro(item.quantity * item.unitPriceInCents),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/** One Resend batch request; returns Resend's error text, or null when sent. */
|
||||
private sendBatch(messages: { to: string; email: RenderedEmail }[]): Promise<string | null> {
|
||||
const body = messages.map(({ to, email }) => ({ from: this.fromEmail, to, ...email }));
|
||||
// Resend ignores a retried request with the same key for 24 hours, so a retry never emails twice
|
||||
const idempotencyKey = 'reminders-' + crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex');
|
||||
return this.resend('/emails/batch', body, idempotencyKey);
|
||||
}
|
||||
|
||||
private async resend(path: string, body: unknown, idempotencyKey?: string): Promise<string | null> {
|
||||
const res = await fetch(`https://api.resend.com${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.resendApiKey}`,
|
||||
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
|
||||
},
|
||||
body: JSON.stringify({ from: this.fromEmail, to, ...email }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
return res.ok ? null : await res.text();
|
||||
@@ -224,4 +251,4 @@ const euro = new Intl.NumberFormat('nl-BE', { style: 'currency', currency: 'EUR'
|
||||
|
||||
function formatEuro(cents: number): string {
|
||||
return euro.format(cents / 100);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { customerNameKey } from '../planned-tabs/customer-name';
|
||||
|
||||
@Injectable()
|
||||
export class TabsService {
|
||||
@@ -15,7 +16,7 @@ export class TabsService {
|
||||
},
|
||||
});
|
||||
|
||||
const snapshots = await this.prisma.openTabSnapshot.findMany({
|
||||
const snapshots = ownerships.length === 0 ? [] : await this.prisma.openTabSnapshot.findMany({
|
||||
where: {
|
||||
OR: ownerships.map((o) => ({ deviceId: o.deviceId, tabId: o.tabId })),
|
||||
},
|
||||
@@ -25,15 +26,16 @@ export class TabsService {
|
||||
// Customer details (email for reminders) live on the planned customer with the same name
|
||||
const planned = await this.prisma.plannedTab.findMany({
|
||||
where: { venueId },
|
||||
select: { id: true, customerName: true, email: true },
|
||||
select: { id: true, customerNameKey: true, email: true },
|
||||
});
|
||||
const plannedByName = new Map(planned.map((p) => [p.customerName, p]));
|
||||
const plannedByKey = new Map(planned.map((p) => [p.customerNameKey, p]));
|
||||
|
||||
const tabs = [];
|
||||
for (const ownership of ownerships) {
|
||||
const snapshot = snapshotByKey.get(`${ownership.deviceId}:${ownership.tabId}`);
|
||||
|
||||
if (snapshot) {
|
||||
const plannedTab = plannedByKey.get(customerNameKey(snapshot.customerName));
|
||||
tabs.push({
|
||||
tabId: snapshot.tabId,
|
||||
customerName: snapshot.customerName,
|
||||
@@ -47,8 +49,8 @@ export class TabsService {
|
||||
deviceLastSeenAt: ownership.device.lastSeenAt,
|
||||
deviceRevokedAt: ownership.device.revokedAt,
|
||||
lastReportedAt: snapshot.reportedAt,
|
||||
plannedTabId: plannedByName.get(snapshot.customerName)?.id ?? null,
|
||||
email: plannedByName.get(snapshot.customerName)?.email ?? null,
|
||||
plannedTabId: plannedTab?.id ?? null,
|
||||
email: plannedTab?.email ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import * as request from 'supertest';
|
||||
import request = require('supertest');
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
|
||||
@@ -42,7 +42,7 @@ describe('KoolTap API (e2e)', () => {
|
||||
it('POST /api/v1/auth/login with invalid credentials should return 401', () => {
|
||||
return request(app.getHttpServer())
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: 'nonexistent@test.com', password: 'wrong' })
|
||||
.send({ email: 'nonexistent@test.com', password: 'wrong-password' })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
@@ -50,8 +50,9 @@ describe('KoolTap API (e2e)', () => {
|
||||
// First create a user via seed or directly
|
||||
const bcrypt = require('bcrypt');
|
||||
const hash = await bcrypt.hash('Test123!', 10);
|
||||
const uuid = require('uuid').v4();
|
||||
const venueId = uuid;
|
||||
const { v4: uuid } = require('uuid');
|
||||
const venueId = uuid();
|
||||
const email = `test-${venueId}@test.com`;
|
||||
|
||||
await prisma.venue.create({
|
||||
data: { id: venueId, name: 'Test Venue' },
|
||||
@@ -59,9 +60,9 @@ describe('KoolTap API (e2e)', () => {
|
||||
|
||||
await prisma.clientUser.create({
|
||||
data: {
|
||||
id: uuid,
|
||||
id: uuid(),
|
||||
venueId,
|
||||
email: 'test@test.com',
|
||||
email,
|
||||
passwordHash: hash,
|
||||
name: 'Test User',
|
||||
role: 'admin',
|
||||
@@ -70,12 +71,12 @@ describe('KoolTap API (e2e)', () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: 'test@test.com', password: 'Test123!' })
|
||||
.send({ email, password: 'Test123!' })
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.accessToken).toBeDefined();
|
||||
expect(res.body.refreshToken).toBeDefined();
|
||||
expect(res.body.user.email).toBe('test@test.com');
|
||||
expect(res.body.user.email).toBe(email);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -83,21 +84,20 @@ describe('KoolTap API (e2e)', () => {
|
||||
describe('Venue isolation', () => {
|
||||
it('should not allow cross-venue access', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const uuid = require('uuid').v4();
|
||||
const venueAId = uuid;
|
||||
const venueBId = uuid;
|
||||
const { v4: uuid } = require('uuid');
|
||||
const venueAId = uuid();
|
||||
const venueBId = uuid();
|
||||
const email = `venuea-${venueAId}@test.com`;
|
||||
|
||||
await prisma.venue.create({ data: { id: venueAId, name: 'Venue A' } });
|
||||
await prisma.venue.create({ data: { id: venueBId, name: 'Venue B' } });
|
||||
|
||||
const hash = await bcrypt.hash('Pass123!', 10);
|
||||
const userId = uuid;
|
||||
|
||||
await prisma.clientUser.create({
|
||||
data: {
|
||||
id: userId,
|
||||
id: uuid(),
|
||||
venueId: venueAId,
|
||||
email: 'venuea@test.com',
|
||||
email,
|
||||
passwordHash: hash,
|
||||
name: 'Venue A User',
|
||||
role: 'admin',
|
||||
@@ -107,7 +107,7 @@ describe('KoolTap API (e2e)', () => {
|
||||
// Login as Venue A user
|
||||
const loginRes = await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email: 'venuea@test.com', password: 'Pass123!' })
|
||||
.send({ email, password: 'Pass123!' })
|
||||
.expect(200);
|
||||
|
||||
const token = loginRes.body.accessToken;
|
||||
@@ -116,11 +116,9 @@ describe('KoolTap API (e2e)', () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/api/v1/venues/${venueBId}/tabs`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(200) // Should return empty - venue scoping is by user venueId, not by URL param
|
||||
.expect(403)
|
||||
.expect((res) => {
|
||||
// The endpoint uses the user's venueId from JWT
|
||||
// But for cross-venue tests we need the endpoint to verify
|
||||
expect(Array.isArray(res.body.tabs)).toBe(true);
|
||||
expect(res.body.error.code).toBe('venue_forbidden');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"moduleNameMapper": {
|
||||
"^@kooltap/shared$": "<rootDir>/../../../packages/shared/src"
|
||||
},
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
|
||||
Reference in New Issue
Block a user