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
+54 -105
View File
@@ -1,225 +1,174 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { AdminService } from './admin.service';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { z } from 'zod';
import {
CopyCatalogDto,
DryRunDto,
PurgeDto,
ResetPasswordDto,
ResetVenueDto,
TestEmailDto,
UpdateRoleDto,
VenueNameDto,
} from './dto/admin.dto';
auditQuerySchema, copyCatalogSchema, deleteVenueQuerySchema, dryRunSchema, purgeSchema, resetPasswordSchema, resetVenueSchema,
testEmailSchema, updateRoleSchema, venueNameSchema,
} from '@kooltap/shared';
import { zod } from '../common/zod.pipe';
import { Authorized, AuthUser, CurrentUser, Roles } from '../common/auth';
import { AdminService } from './admin.service';
type Body<S extends z.ZodType> = z.output<S>;
/** The superadmin page: every venue, terminal and user of KoolTap. Destructive actions take a dryRun to preview. */
@ApiTags('Admin')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('superadmin')
@Controller('admin')
@Authorized()
@Roles('superadmin')
export class AdminController {
constructor(private admin: AdminService) {}
// ---- Venues
@Get('venues')
@ApiOperation({ summary: 'Every venue with what it holds' })
listVenues() {
return this.admin.listVenues();
}
@Post('venues')
@ApiOperation({ summary: 'Create a venue' })
createVenue(@Body() dto: VenueNameDto, @Req() req: any) {
return this.admin.createVenue(dto.name, req.user);
createVenue(@Body(zod(venueNameSchema)) dto: Body<typeof venueNameSchema>, @CurrentUser() user: AuthUser) {
return this.admin.createVenue(dto.name, user);
}
@Patch('venues/:venueId')
@ApiOperation({ summary: 'Rename a venue' })
renameVenue(@Param('venueId') venueId: string, @Body() dto: VenueNameDto, @Req() req: any) {
return this.admin.renameVenue(venueId, dto.name, req.user);
renameVenue(@Param('venueId', ParseUUIDPipe) venueId: string, @Body(zod(venueNameSchema)) dto: Body<typeof venueNameSchema>, @CurrentUser() user: AuthUser) {
return this.admin.renameVenue(venueId, dto.name, user);
}
@Delete('venues/:venueId')
@ApiOperation({ summary: 'Delete a venue with everything in it, users and terminals included' })
@ApiQuery({ name: 'confirmName', description: "The venue's name" })
deleteVenue(@Param('venueId') venueId: string, @Query('confirmName') confirmName: string | undefined, @Req() req: any) {
return this.admin.deleteVenue(venueId, confirmName, req.user);
deleteVenue(
@Param('venueId', ParseUUIDPipe) venueId: string,
@Query(zod(deleteVenueQuerySchema)) query: Body<typeof deleteVenueQuerySchema>,
@CurrentUser() user: AuthUser,
) {
return this.admin.deleteVenue(venueId, query.confirmName, user);
}
@Post('venues/:venueId/view')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Start viewing the dashboard as the venue; recorded in the venue's audit log" })
viewVenue(@Param('venueId') venueId: string, @Req() req: any) {
return this.admin.viewVenue(venueId, req.user);
viewVenue(@Param('venueId', ParseUUIDPipe) venueId: string, @CurrentUser() user: AuthUser) {
return this.admin.viewVenue(venueId, user);
}
@Post('venues/:venueId/copy-catalog')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Copy another venue's products that this venue doesn't have yet" })
copyCatalog(@Param('venueId') venueId: string, @Body() dto: CopyCatalogDto, @Req() req: any) {
return this.admin.copyCatalog(venueId, dto.fromVenueId, req.user);
copyCatalog(@Param('venueId', ParseUUIDPipe) venueId: string, @Body(zod(copyCatalogSchema)) dto: Body<typeof copyCatalogSchema>, @CurrentUser() user: AuthUser) {
return this.admin.copyCatalog(venueId, dto.fromVenueId, user);
}
@Post('venues/:venueId/reset')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Wipe parts of a venue's data; users, terminals and products stay" })
resetVenue(@Param('venueId') venueId: string, @Body() dto: ResetVenueDto, @Req() req: any) {
return this.admin.resetVenue(venueId, dto.scopes, { stockValue: dto.stockValue, dryRun: dto.dryRun }, req.user);
resetVenue(@Param('venueId', ParseUUIDPipe) venueId: string, @Body(zod(resetVenueSchema)) dto: Body<typeof resetVenueSchema>, @CurrentUser() user: AuthUser) {
return this.admin.resetVenue(venueId, dto.scopes, dto, user);
}
@Get('venues/:venueId/consistency')
@ApiOperation({ summary: "Look for data in the venue that doesn't add up" })
consistency(@Param('venueId') venueId: string) {
consistency(@Param('venueId', ParseUUIDPipe) venueId: string) {
return this.admin.consistency(venueId);
}
@Get('venues/:venueId/export')
@ApiOperation({ summary: 'Everything of the venue as JSON, without passwords and pictures' })
exportVenue(@Param('venueId') venueId: string, @Req() req: any) {
return this.admin.exportVenue(venueId, req.user);
exportVenue(@Param('venueId', ParseUUIDPipe) venueId: string, @CurrentUser() user: AuthUser) {
return this.admin.exportVenue(venueId, user);
}
@Post('venues/:venueId/registration-codes')
@ApiOperation({ summary: 'A pairing code for a terminal of the venue' })
createRegistrationCode(@Param('venueId') venueId: string, @Req() req: any) {
return this.admin.createRegistrationCode(venueId, req.user);
createRegistrationCode(@Param('venueId', ParseUUIDPipe) venueId: string, @CurrentUser() user: AuthUser) {
return this.admin.createRegistrationCode(venueId, user);
}
// ---- Terminals
@Get('devices')
@ApiOperation({ summary: 'Every terminal, unpaired ones included' })
listDevices() {
return this.admin.listDevices();
}
@Post('devices/:deviceId/revoke')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Unpair a terminal' })
revokeDevice(@Param('deviceId') deviceId: string, @Req() req: any) {
return this.admin.revokeDevice(deviceId, req.user);
revokeDevice(@Param('deviceId', ParseUUIDPipe) deviceId: string, @CurrentUser() user: AuthUser) {
return this.admin.revokeDevice(deviceId, user);
}
@Get('registration-codes')
@ApiOperation({ summary: 'Pairing codes that can still be used' })
listRegistrationCodes() {
return this.admin.listRegistrationCodes();
}
@Post('registration-codes/:id/revoke')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Make a pairing code unusable' })
revokeRegistrationCode(@Param('id') id: string) {
revokeRegistrationCode(@Param('id', ParseUUIDPipe) id: string) {
return this.admin.revokeRegistrationCode(id);
}
@Post('registration-codes/cleanup')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Delete used and expired pairing codes' })
cleanupRegistrationCodes() {
return this.admin.cleanupRegistrationCodes();
}
@Post('commands/expire-stuck')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Expire every pending command' })
expireStuckCommands(@Body() dto: DryRunDto, @Req() req: any) {
return this.admin.expireStuckCommands(dto.dryRun ?? false, req.user);
}
// ---- Users
@Get('users')
@ApiOperation({ summary: 'Every dashboard user' })
listUsers() {
return this.admin.listUsers();
}
@Patch('users/:userId/role')
@ApiOperation({ summary: "Change a user's role (not your own)" })
updateRole(@Param('userId') userId: string, @Body() dto: UpdateRoleDto, @Req() req: any) {
return this.admin.updateRole(userId, dto.role, req.user);
updateRole(@Param('userId', ParseUUIDPipe) userId: string, @Body(zod(updateRoleSchema)) dto: Body<typeof updateRoleSchema>, @CurrentUser() user: AuthUser) {
return this.admin.updateRole(userId, dto.role, dto.venueId, user);
}
@Post('users/:userId/password')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Set a user's password; signs them out everywhere" })
resetPassword(@Param('userId') userId: string, @Body() dto: ResetPasswordDto, @Req() req: any) {
return this.admin.resetPassword(userId, dto.password, req.user);
resetPassword(@Param('userId', ParseUUIDPipe) userId: string, @Body(zod(resetPasswordSchema)) dto: Body<typeof resetPasswordSchema>, @CurrentUser() user: AuthUser) {
return this.admin.resetPassword(userId, dto.password, user);
}
@Post('users/:userId/logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Sign a user out everywhere, at once' })
logoutUser(@Param('userId') userId: string, @Req() req: any) {
return this.admin.logoutUser(userId, req.user);
logoutUser(@Param('userId', ParseUUIDPipe) userId: string, @CurrentUser() user: AuthUser) {
return this.admin.logoutUser(userId, user);
}
@Delete('users/:userId')
@ApiOperation({ summary: 'Delete a user (not yourself)' })
deleteUser(@Param('userId') userId: string, @Req() req: any) {
return this.admin.deleteUser(userId, req.user);
deleteUser(@Param('userId', ParseUUIDPipe) userId: string, @CurrentUser() user: AuthUser) {
return this.admin.deleteUser(userId, user);
}
@Post('sessions/revoke-all')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Sign out every user but yourself' })
logoutEveryone(@Req() req: any) {
return this.admin.logoutEveryone(req.user);
logoutEveryone(@CurrentUser() user: AuthUser) {
return this.admin.logoutEveryone(user);
}
// ---- Maintenance
@Post('purge')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Delete old terminal events, audit entries or finished commands' })
purge(@Body() dto: PurgeDto, @Req() req: any) {
return this.admin.purge(dto.targets, dto.olderThanDays, dto.venueId, dto.dryRun ?? false, req.user);
purge(@Body(zod(purgeSchema)) dto: Body<typeof purgeSchema>, @CurrentUser() user: AuthUser) {
return this.admin.purge(dto.targets, dto.olderThanDays, dto.venueId, dto.dryRun, user);
}
@Post('images/cleanup')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Delete pictures no product uses (older than a week)' })
cleanupImages(@Body() dto: DryRunDto, @Req() req: any) {
return this.admin.cleanupImages(dto.dryRun ?? false, req.user);
cleanupImages(@Body(zod(dryRunSchema)) dto: Body<typeof dryRunSchema>, @CurrentUser() user: AuthUser) {
return this.admin.cleanupImages(dto.dryRun, user);
}
@Post('email/test')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Send the test email to an address' })
sendTestEmail(@Body() dto: TestEmailDto, @Req() req: any) {
return this.admin.sendTestEmail(dto.to, req.user);
sendTestEmail(@Body(zod(testEmailSchema)) dto: Body<typeof testEmailSchema>, @CurrentUser() user: AuthUser) {
return this.admin.sendTestEmail(dto.to, user);
}
// ---- Insight
@Get('audit')
@ApiOperation({ summary: 'The audit log of every venue' })
@ApiQuery({ name: 'venueId', required: false })
@ApiQuery({ name: 'action', required: false, description: 'Part of the action name' })
@ApiQuery({ name: 'limit', required: false })
@ApiQuery({ name: 'offset', required: false })
auditLog(
@Query('venueId') venueId?: string,
@Query('action') action?: string,
@Query('limit') limit?: string,
@Query('offset') offset?: string,
) {
return this.admin.auditLog({
venueId: venueId || undefined,
action: action || undefined,
limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200),
offset: Math.max(parseInt(offset ?? '', 10) || 0, 0),
});
auditLog(@Query(zod(auditQuerySchema)) query: Body<typeof auditQuerySchema>) {
return this.admin.auditLog(query);
}
@Get('health')
@ApiOperation({ summary: 'Database, migrations, live connections and background jobs' })
health() {
return this.admin.health();
}
+3 -4
View File
@@ -1,13 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { DevicesModule } from '../devices/devices.module';
import { ImagesModule } from '../images/images.module';
import { RemindersModule } from '../reminders/reminders.module';
import { JobsModule } from '../jobs/jobs.module';
import { VenuesModule } from '../venues/venues.module';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
@Module({
imports: [AuthModule, DevicesModule, RemindersModule, JobsModule],
imports: [ImagesModule, RemindersModule, VenuesModule],
controllers: [AdminController],
providers: [AdminService],
})
+287 -369
View File
@@ -1,32 +1,35 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { v4 as uuidv4 } from 'uuid';
import { PrismaService } from '../prisma/prisma.service';
import { randomUUID } from 'crypto';
import { promises as fs } from 'fs';
import * as path from 'path';
import {
AdminDevice, AdminRegistrationCode, AdminUser, AdminVenue, AuditEntry, Finding, PurgeTarget, RealtimeTopic, ResetScope, Role,
SystemHealth,
} from '@kooltap/shared';
import { Prisma, PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service';
import { AuthService } from '../auth/auth.service';
import { DevicesService } from '../devices/devices.service';
import { RealtimeService } from '../realtime/realtime.service';
import { RemindersService } from '../reminders/reminders.service';
import { CommandExpiryJob } from '../jobs/command-expiry.job';
import { createdFieldTimes } from '../stock/product-fields';
import { VenuesService } from '../venues/venues.service';
import { ImagesService, imageDir } from '../images/images.service';
import { Resend } from '../email/resend';
import { hashPassword } from '../auth/auth.service';
import { AuthUser } from '../common/auth';
import { createdFieldTimes } from '../ledger/product-fields';
import { lockVenue } from '../ledger/venue-ledger';
import { ensureDefaultCustomers } from '../customers/default-customers';
import { checkConsistency } from './consistency';
import { countReset, deleteVenue, lockVenue, resetVenue, ResetCounts, ResetScope } from './venue-reset';
import { ensureDefaultCustomers } from '../planned-tabs/default-customers';
import { PurgeTarget } from './dto/admin.dto';
/** The superadmin doing it. */
export type Actor = { id: string; email: string; name: string; venueId: string };
import { countReset, resetVenue, ResetCounts } from './venue-reset';
// What each reset scope changes on the dashboard
const SCOPE_TOPICS: Record<ResetScope, RealtimeTopic[]> = {
// Plans the Free Drinks customer again
tabs: ['tabs', 'planned-tabs'],
// Adds the Free Drinks customer again
tabs: ['tabs', 'customers'],
settlements: ['tabs'],
tabEvents: [],
plannedTabs: ['planned-tabs', 'reminders'],
customers: ['customers', 'reminders'],
stock: ['stock'],
stockHistory: ['stock'],
commands: ['commands'],
auditLog: [],
};
@@ -35,115 +38,100 @@ const ORPHAN_IMAGE_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Everything the superadmin page does. Global actions (purging, signing everyone out) are written to the audit log
* of the superadmin's own venue, since the log is kept per venue.
*/
/** Everything the superadmin page does. */
@Injectable()
export class AdminService {
constructor(
private prisma: PrismaService,
private audit: AuditService,
private realtime: RealtimeService,
private authService: AuthService,
private devicesService: DevicesService,
private venues: VenuesService,
private reminders: RemindersService,
private commandExpiry: CommandExpiryJob,
private images: ImagesService,
private resend: Resend,
) {}
// ---- Venues
async listVenues() {
const venues = await this.prisma.venue.findMany({
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
createdAt: true,
_count: {
select: {
clientUsers: true,
devices: { where: { revokedAt: null } },
tabs: true,
products: { where: { archivedAt: null } },
plannedTabs: true,
settlements: true,
async listVenues(): Promise<AdminVenue[]> {
const [venues, lastSeen] = await Promise.all([
this.prisma.venue.findMany({
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
createdAt: true,
_count: {
select: {
users: true,
devices: { where: { revokedAt: null } },
tabs: true,
products: { where: { archivedAt: null } },
customers: true,
settlements: true,
},
},
},
},
});
const lastSeen = await this.prisma.device.groupBy({
by: ['venueId'],
where: { revokedAt: null },
_max: { lastSeenAt: true },
});
}),
this.prisma.device.groupBy({ by: ['venueId'], where: { revokedAt: null }, _max: { lastSeenAt: true } }),
]);
const lastSeenByVenue = new Map(lastSeen.map((row) => [row.venueId, row._max.lastSeenAt]));
return {
venues: venues.map(({ _count, ...venue }) => ({
...venue,
users: _count.clientUsers,
devices: _count.devices,
openTabs: _count.tabs,
products: _count.products,
plannedTabs: _count.plannedTabs,
settlements: _count.settlements,
lastSyncAt: lastSeenByVenue.get(venue.id) ?? null,
})),
};
return venues.map(({ _count, ...venue }) => ({
...venue,
createdAt: venue.createdAt.toISOString(),
users: _count.users,
devices: _count.devices,
openTabs: _count.tabs,
products: _count.products,
customers: _count.customers,
settlements: _count.settlements,
lastSyncAt: lastSeenByVenue.get(venue.id)?.toISOString() ?? null,
}));
}
async createVenue(name: string, actor: Actor) {
async createVenue(name: string, actor: AuthUser) {
const venue = await this.prisma.$transaction(async (tx) => {
const created = await tx.venue.create({ data: { name: name.trim() } });
const created = await tx.venue.create({ data: { name } });
await ensureDefaultCustomers(tx, created.id);
return created;
});
await this.audit.log(venue.id, 'client_user', actor.id, 'admin_venue_created', { name: venue.name, by: actor.email });
return venue;
await this.audit.log({ venueId: venue.id, actor: { type: 'user', id: actor.id }, action: 'admin_venue_created', details: { name } });
return { id: venue.id, name: venue.name };
}
async renameVenue(venueId: string, name: string, actor: Actor) {
async renameVenue(venueId: string, name: string, actor: AuthUser) {
const before = await this.findVenue(venueId);
const venue = await this.prisma.venue.update({ where: { id: venueId }, data: { name: name.trim() } });
await this.audit.log(venueId, 'client_user', actor.id, 'admin_venue_renamed', { from: before.name, to: venue.name, by: actor.email });
return venue;
await this.prisma.venue.update({ where: { id: venueId }, data: { name } });
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_venue_renamed', details: { from: before.name, to: name } });
return { ok: true };
}
/** [confirmName] must be the venue's name, so a wrong id can't delete a venue by accident. */
async deleteVenue(venueId: string, confirmName: string | undefined, actor: Actor) {
/** Deletes a venue with everything in it. [confirmName] must be its name, so a wrong id can't delete one by accident. */
async deleteVenue(venueId: string, confirmName: string, actor: AuthUser) {
const venue = await this.findVenue(venueId);
if (venueId === actor.venueId) {
throw new BadRequestException({ code: 'cannot_delete_own_venue', message: 'You cannot delete the venue your account belongs to' });
}
if (confirmName !== venue.name) {
throw new BadRequestException({ code: 'confirm_name_mismatch', message: 'Type the venue name to confirm' });
}
await this.prisma.$transaction(async (tx) => {
await lockVenue(tx, venueId);
await deleteVenue(tx, venueId);
}, { timeout: 60000 });
await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_venue_deleted', { venueId, name: venue.name, by: actor.email });
await tx.venue.delete({ where: { id: venueId } });
}, { timeout: 60_000 });
await this.audit.log({ venueId: null, actor: { type: 'user', id: actor.id }, action: 'admin_venue_deleted', details: { venueId, name: venue.name } });
return { ok: true };
}
/** Recorded in the venue's own audit log, so its owner can see that KoolTap looked. */
async viewVenue(venueId: string, actor: Actor) {
async viewVenue(venueId: string, actor: AuthUser) {
const venue = await this.findVenue(venueId);
await this.audit.log(venueId, 'client_user', actor.id, 'admin_viewed_venue', { by: actor.email });
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_viewed_venue', details: { by: actor.email } });
return { id: venue.id, name: venue.name };
}
/**
* Copies the products of [fromVenueId] that [venueId] doesn't have yet (by name), with their pictures but without
* stock: stock is counted per venue.
*/
async copyCatalog(venueId: string, fromVenueId: string, actor: Actor) {
if (venueId === fromVenueId) {
throw new BadRequestException({ code: 'same_venue', message: 'Pick another venue to copy from' });
}
/** Copies the products of [fromVenueId] that [venueId] doesn't have yet (by name), with their pictures but no stock. */
async copyCatalog(venueId: string, fromVenueId: string, actor: AuthUser) {
if (venueId === fromVenueId) throw new BadRequestException({ code: 'same_venue', message: 'Pick another venue to copy from' });
await this.findVenue(venueId);
await this.findVenue(fromVenueId);
@@ -156,184 +144,148 @@ export class AdminService {
]);
const taken = new Set(existing.map((p) => p.name.trim().toLowerCase()));
const copies = source.filter((p) => !taken.has(p.name.trim().toLowerCase()));
if (copies.length > 0) {
await tx.product.createMany({
data: copies.map((p) => {
const fields = {
name: p.name, category: p.category, priceInCents: p.priceInCents, buyPriceInCents: p.buyPriceInCents,
lowStockThreshold: p.lowStockThreshold, desiredStock: p.desiredStock,
};
return {
venueId,
productId: uuidv4(),
...fields,
stock: null,
countedAt: null,
reportedAt: now,
imageId: p.imageId,
fieldUpdatedAt: createdFieldTimes({ ...fields, archived: false, imageId: p.imageId }, now),
};
}),
});
}
await tx.product.createMany({
data: copies.map((p) => {
const fields = {
name: p.name, category: p.category, priceInCents: p.priceInCents, buyPriceInCents: p.buyPriceInCents,
lowStockThreshold: p.lowStockThreshold, desiredStock: p.desiredStock, imageId: p.imageId,
};
return { id: randomUUID(), venueId, ...fields, fieldUpdatedAt: createdFieldTimes({ ...fields, archived: false }, now), updatedAt: now };
}),
});
return copies.length;
});
await this.audit.log(venueId, 'client_user', actor.id, 'admin_catalog_copied', { fromVenueId, created, by: actor.email });
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_catalog_copied', details: { fromVenueId, created } });
if (created > 0) this.realtime.notify(venueId, 'stock');
return { created };
}
async resetVenue(venueId: string, scopes: ResetScope[], options: { stockValue?: number; dryRun?: boolean }, actor: Actor) {
async resetVenue(venueId: string, scopes: ResetScope[], options: { stockValue: number; dryRun: boolean }, actor: AuthUser) {
await this.findVenue(venueId);
const unique = [...new Set(scopes)];
if (options.dryRun) {
return { dryRun: true, counts: await this.prisma.$transaction((tx) => countReset(tx, venueId, unique)) };
}
const stockValue = options.stockValue ?? 0;
const counts: ResetCounts = await this.prisma.$transaction(async (tx) => {
await lockVenue(tx, venueId);
return resetVenue(tx, venueId, unique, { stockValue, actor: { id: actor.id, name: actor.name || actor.email }, now: new Date() });
}, { timeout: 60000 });
return resetVenue(tx, venueId, unique, { stockValue: options.stockValue, userId: actor.id, now: new Date() });
}, { timeout: 60_000 });
// After the reset, so wiping the audit log keeps the entry that says who wiped it
await this.audit.log(venueId, 'client_user', actor.id, 'admin_venue_reset', { scopes: unique, stockValue, counts, by: actor.email });
const topics = new Set(unique.flatMap((scope) => SCOPE_TOPICS[scope]));
if (topics.size > 0) this.realtime.notify(venueId, ...topics);
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_venue_reset', details: { scopes: unique, stockValue: options.stockValue, counts } });
this.realtime.notify(venueId, ...unique.flatMap((scope) => SCOPE_TOPICS[scope]));
return { dryRun: false, counts };
}
async consistency(venueId: string) {
async consistency(venueId: string): Promise<{ checkedAt: string; findings: Finding[] }> {
await this.findVenue(venueId);
const where = { venueId };
const [settlements, tabs, products, pendingCommands, devices] = await Promise.all([
this.prisma.settlement.findMany({
where,
select: { id: true, customerName: true, amountInCents: true, lines: true, commandId: true },
}),
this.prisma.tab.findMany({ where, select: { id: true, customerName: true, lines: true } }),
this.prisma.product.findMany({
where: { venueId, archivedAt: null },
select: { productId: true, name: true, stock: true, countedAt: true },
}),
this.prisma.remoteCommand.findMany({
where: { venueId, status: 'pending' },
select: { id: true, expiresAt: true, device: { select: { name: true, revokedAt: true } } },
}),
const [settlements, drinks, tabs, products, devices] = await Promise.all([
this.prisma.settlement.findMany({ where, select: { id: true, customerName: true, amountInCents: true } }),
this.prisma.$queryRaw<{ settlementId: string; cents: bigint }[]>`
SELECT "settlementId", SUM("unitPriceInCents")::bigint AS cents FROM "TabUnit"
WHERE "venueId" = ${venueId}::uuid AND "settlementId" IS NOT NULL AND "removedAt" IS NULL
GROUP BY "settlementId"`,
this.prisma.tab.findMany({ where, select: { id: true, customerName: true } }),
this.prisma.product.findMany({ where: { venueId, archivedAt: null }, select: { id: true, name: true, stock: true } }),
this.prisma.device.findMany({ where, select: { id: true, name: true, revokedAt: true, tokenExpiresAt: true } }),
]);
const drinksBySettlement = new Map(drinks.map((row) => [row.settlementId, Number(row.cents)]));
const findings = checkConsistency({ settlements, tabs, products, pendingCommands, devices }, new Date());
return { checkedAt: new Date(), findings };
const now = new Date();
const findings = checkConsistency({
settlements: settlements.map((s) => ({ ...s, drinksInCents: drinksBySettlement.get(s.id) ?? 0 })),
tabs,
products,
devices,
}, now);
return { checkedAt: now.toISOString(), findings };
}
/** Everything of a venue except password hashes and picture bytes, for a backup before a reset. */
async exportVenue(venueId: string, actor: Actor) {
/** Everything of a venue except password hashes and pictures, for a backup before a reset. */
async exportVenue(venueId: string, actor: AuthUser) {
const venue = await this.findVenue(venueId);
const where = { venueId };
const [users, devices, products, tabs, settlements, plannedTabs, reminders, stockAdjustments, commands, tabEvents, auditLog] =
await Promise.all([
this.prisma.clientUser.findMany({ where, select: { id: true, email: true, name: true, role: true, createdAt: true } }),
this.prisma.device.findMany({ where, select: { id: true, name: true, lastSeenAt: true, revokedAt: true, createdAt: true } }),
this.prisma.product.findMany({ where }),
this.prisma.tab.findMany({ where }),
this.prisma.settlement.findMany({ where, orderBy: { sequence: 'asc' } }),
this.prisma.plannedTab.findMany({ where, orderBy: { position: 'asc' } }),
this.prisma.tabReminder.findMany({ where, orderBy: { sentAt: 'asc' } }),
this.prisma.stockAdjustment.findMany({ where, orderBy: { createdAt: 'asc' } }),
this.prisma.remoteCommand.findMany({ where, orderBy: { createdAt: 'asc' } }),
this.prisma.tabEvent.findMany({ where, orderBy: { occurredAt: 'asc' } }),
this.prisma.auditLog.findMany({ where, orderBy: { createdAt: 'asc' } }),
]);
await this.audit.log(venueId, 'client_user', actor.id, 'admin_venue_exported', { by: actor.email });
const [users, devices, customers, products, tabs, settlements, units, stockMovements, reminders, tabEvents, auditLog] = await Promise.all([
this.prisma.user.findMany({ where, select: { id: true, email: true, name: true, role: true, createdAt: true } }),
this.prisma.device.findMany({ where, select: { id: true, name: true, lastSeenAt: true, revokedAt: true, createdAt: true } }),
this.prisma.customer.findMany({ where, orderBy: { position: 'asc' } }),
this.prisma.product.findMany({ where }),
this.prisma.tab.findMany({ where }),
this.prisma.settlement.findMany({ where, orderBy: { sequence: 'asc' } }),
this.prisma.tabUnit.findMany({ where, orderBy: { addedAt: 'asc' } }),
this.prisma.stockMovement.findMany({ where, orderBy: { createdAt: 'asc' } }),
this.prisma.reminder.findMany({ where, orderBy: { sentAt: 'asc' } }),
this.prisma.tabEvent.findMany({ where, orderBy: { occurredAt: 'asc' } }),
this.prisma.auditLog.findMany({ where, orderBy: { createdAt: 'asc' } }),
]);
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_venue_exported' });
return {
exportedAt: new Date(),
venue,
users,
devices,
products,
tabs,
settlements,
plannedTabs,
reminders,
stockAdjustments,
commands,
tabEvents,
auditLog,
exportedAt: new Date(), venue, users, devices, customers, products, tabs, settlements, units, stockMovements, reminders, tabEvents, auditLog,
};
}
async createRegistrationCode(venueId: string, actor: AuthUser) {
await this.findVenue(venueId);
const code = await this.venues.createRegistrationCode(venueId);
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_registration_code_created' });
return code;
}
// ---- Terminals
async listDevices() {
async listDevices(): Promise<AdminDevice[]> {
const devices = await this.prisma.device.findMany({
orderBy: [{ revokedAt: { sort: 'desc', nulls: 'first' } }, { lastSeenAt: 'desc' }],
select: {
id: true,
name: true,
venueId: true,
tokenPrefix: true,
tokenExpiresAt: true,
lastSeenAt: true,
revokedAt: true,
createdAt: true,
venue: { select: { name: true } },
_count: { select: { remoteCommands: { where: { status: 'pending' } } } },
},
include: { venue: { select: { name: true } } },
});
return devices.map((d) => ({
id: d.id,
name: d.name,
venueId: d.venueId,
venueName: d.venue.name,
tokenPrefix: d.tokenPrefix,
tokenExpiresAt: d.tokenExpiresAt.toISOString(),
lastSeenAt: d.lastSeenAt.toISOString(),
revokedAt: d.revokedAt?.toISOString() ?? null,
createdAt: d.createdAt.toISOString(),
}));
}
async revokeDevice(deviceId: string, actor: AuthUser) {
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
if (!device) throw new NotFoundException({ code: 'device_not_found', message: 'Terminal not found' });
return this.venues.unpairDevice(device.venueId, deviceId, { type: 'user', id: actor.id });
}
async listRegistrationCodes(): Promise<{ codes: AdminRegistrationCode[]; stale: number }> {
const now = new Date();
const [codes, stale] = await Promise.all([
this.prisma.registrationCode.findMany({
where: { usedAt: null, expiresAt: { gt: now } },
orderBy: { createdAt: 'desc' },
include: { venue: { select: { name: true } } },
}),
this.prisma.registrationCode.count({ where: { OR: [{ usedAt: { not: null } }, { expiresAt: { lte: now } }] } }),
]);
return {
devices: devices.map(({ venue, _count, ...device }) => ({
...device,
venueName: venue.name,
pendingCommands: _count.remoteCommands,
codes: codes.map((c) => ({
id: c.id, code: c.code, venueId: c.venueId, venueName: c.venue.name, expiresAt: c.expiresAt.toISOString(), createdAt: c.createdAt.toISOString(),
})),
stale,
};
}
async revokeDevice(deviceId: string, actor: Actor) {
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
if (!device) {
throw new NotFoundException({ code: 'device_not_found', message: 'Device not found' });
}
await this.devicesService.revoke(device.venueId, deviceId);
await this.audit.log(device.venueId, 'client_user', actor.id, 'admin_device_revoked', { deviceId, deviceName: device.name, by: actor.email });
return { ok: true };
}
async listRegistrationCodes() {
const codes = await this.prisma.registrationCode.findMany({
where: { usedAt: null, expiresAt: { gt: new Date() } },
orderBy: { createdAt: 'desc' },
select: { id: true, code: true, venueId: true, expiresAt: true, createdAt: true, venue: { select: { name: true } } },
});
const stale = await this.prisma.registrationCode.count({
where: { OR: [{ usedAt: { not: null } }, { expiresAt: { lte: new Date() } }] },
});
return { codes: codes.map(({ venue, ...code }) => ({ ...code, venueName: venue.name })), stale };
}
async createRegistrationCode(venueId: string, actor: Actor) {
await this.findVenue(venueId);
const code = await this.authService.generateRegistrationCode(venueId);
await this.audit.log(venueId, 'client_user', actor.id, 'admin_registration_code_created', { by: actor.email });
return code;
}
async revokeRegistrationCode(id: string) {
const { count } = await this.prisma.registrationCode.updateMany({
where: { id, usedAt: null, expiresAt: { gt: new Date() } },
data: { expiresAt: new Date() },
});
const now = new Date();
const { count } = await this.prisma.registrationCode.updateMany({ where: { id, usedAt: null, expiresAt: { gt: now } }, data: { expiresAt: now } });
return { ok: true, revoked: count > 0 };
}
/** Deletes used and expired codes; they are only kept so a code can't be used twice, which expiry already ensures. */
/** Deletes used and expired codes; expiry already keeps them from being used again. */
async cleanupRegistrationCodes() {
const { count } = await this.prisma.registrationCode.deleteMany({
where: { OR: [{ usedAt: { not: null } }, { expiresAt: { lte: new Date() } }] },
@@ -341,175 +293,139 @@ export class AdminService {
return { deleted: count };
}
/**
* Expires every pending command. The server settles tabs itself now, so a pending command is one from before that
* whose terminal never picked it up.
*/
async expireStuckCommands(dryRun: boolean, actor: Actor) {
const where = { status: 'pending' };
if (dryRun) {
return { dryRun: true, count: await this.prisma.remoteCommand.count({ where }) };
}
const stuck = await this.prisma.remoteCommand.findMany({ where, select: { id: true, venueId: true } });
if (stuck.length > 0) {
await this.prisma.remoteCommand.updateMany({
where: { id: { in: stuck.map((c) => c.id) }, status: 'pending' },
data: { status: 'expired', result: 'Expired by a KoolTap admin' },
});
}
const venues = new Set(stuck.map((c) => c.venueId));
for (const venueId of venues) {
const count = stuck.filter((c) => c.venueId === venueId).length;
await this.audit.log(venueId, 'client_user', actor.id, 'admin_commands_expired', { count, by: actor.email });
this.realtime.notify(venueId, 'commands');
}
return { dryRun: false, count: stuck.length };
}
// ---- Users
async listUsers() {
const now = new Date();
async listUsers(): Promise<AdminUser[]> {
const [users, sessions] = await Promise.all([
this.prisma.clientUser.findMany({
orderBy: [{ venue: { name: 'asc' } }, { email: 'asc' }],
select: { id: true, email: true, name: true, role: true, venueId: true, createdAt: true, venue: { select: { name: true } } },
}),
this.prisma.refreshToken.groupBy({
by: ['userId'],
where: { revokedAt: null, expiresAt: { gt: now } },
_count: { _all: true },
}),
this.prisma.user.findMany({ orderBy: [{ venue: { name: 'asc' } }, { email: 'asc' }], include: { venue: { select: { name: true } } } }),
this.prisma.refreshToken.groupBy({ by: ['userId'], where: { revokedAt: null, expiresAt: { gt: new Date() } }, _count: { _all: true } }),
]);
const sessionsByUser = new Map(sessions.map((row) => [row.userId, row._count._all]));
return {
users: users.map(({ venue, ...user }) => ({
...user,
role: user.role === 'viewer' ? 'manager' : user.role,
venueName: venue.name,
activeSessions: sessionsByUser.get(user.id) ?? 0,
})),
};
return users.map((u) => ({
id: u.id,
email: u.email,
name: u.name,
role: u.role,
venueId: u.venueId,
venueName: u.venue?.name ?? null,
createdAt: u.createdAt.toISOString(),
activeSessions: sessionsByUser.get(u.id) ?? 0,
}));
}
async updateRole(userId: string, role: string, actor: Actor) {
/**
* A superadmin leaves their venue: they are above the venues. Anyone else stays in theirs, so a superadmin becoming
* an admin or manager needs [venueId] to join.
*/
async updateRole(userId: string, role: Role, venueId: string | undefined, actor: AuthUser) {
// Demoting yourself could leave nobody who can open this page
if (userId === actor.id) {
throw new BadRequestException({ code: 'cannot_change_own_role', message: 'You cannot change your own role' });
}
if (userId === actor.id) throw new BadRequestException({ code: 'cannot_change_own_role', message: 'You cannot change your own role' });
const user = await this.findUser(userId);
await this.prisma.clientUser.update({ where: { id: userId }, data: { role } });
await this.audit.log(user.venueId, 'client_user', actor.id, 'admin_role_changed', { userId, email: user.email, from: user.role, to: role, by: actor.email });
this.realtime.notify(user.venueId, 'users');
const newVenueId = role === 'superadmin' ? null : user.venueId ?? venueId;
if (role !== 'superadmin' && !newVenueId) {
throw new BadRequestException({ code: 'venue_required', message: 'Pick the venue this user joins' });
}
if (newVenueId && newVenueId !== user.venueId) await this.findVenue(newVenueId);
await this.prisma.user.update({ where: { id: userId }, data: { role, venueId: newVenueId } });
await this.audit.log({
venueId: newVenueId ?? user.venueId,
actor: { type: 'user', id: actor.id },
action: 'admin_role_changed',
details: { userId, from: user.role, to: role },
});
for (const id of new Set([user.venueId, newVenueId])) if (id) this.realtime.notify(id, 'users');
return { ok: true };
}
/** Sets a new password and signs the user out everywhere. */
async resetPassword(userId: string, password: string, actor: Actor) {
async resetPassword(userId: string, password: string, actor: AuthUser) {
// That would sign you out too; My Account changes your own password and keeps you signed in
if (userId === actor.id) {
throw new BadRequestException({ code: 'use_account_page', message: 'Change your own password on My Account' });
}
if (userId === actor.id) throw new BadRequestException({ code: 'use_account_page', message: 'Change your own password on My Account' });
const user = await this.findUser(userId);
const passwordHash = await bcrypt.hash(password, 10);
const now = new Date();
await this.prisma.$transaction([
this.prisma.clientUser.update({ where: { id: userId }, data: { passwordHash, sessionsRevokedAt: now } }),
this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: now } }),
]);
await this.audit.log(user.venueId, 'client_user', actor.id, 'admin_password_reset', { userId, email: user.email, by: actor.email });
await this.signOut(userId, { passwordHash: await hashPassword(password) });
await this.audit.log({ venueId: user.venueId, actor: { type: 'user', id: actor.id }, action: 'admin_password_reset', details: { userId } });
return { ok: true };
}
/** Signs the user out everywhere, at once: their access tokens are refused too. */
async logoutUser(userId: string, actor: Actor) {
if (userId === actor.id) {
throw new BadRequestException({ code: 'cannot_logout_self', message: 'Use Logout to sign yourself out' });
}
async logoutUser(userId: string, actor: AuthUser) {
if (userId === actor.id) throw new BadRequestException({ code: 'cannot_logout_self', message: 'Use Logout to sign yourself out' });
const user = await this.findUser(userId);
const now = new Date();
await this.prisma.$transaction([
this.prisma.clientUser.update({ where: { id: userId }, data: { sessionsRevokedAt: now } }),
this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: now } }),
]);
await this.audit.log(user.venueId, 'client_user', actor.id, 'admin_user_logged_out', { userId, email: user.email, by: actor.email });
await this.signOut(userId);
await this.audit.log({ venueId: user.venueId, actor: { type: 'user', id: actor.id }, action: 'admin_user_logged_out', details: { userId } });
return { ok: true };
}
/** Signs out every user but the superadmin doing it. */
async logoutEveryone(actor: Actor) {
async logoutEveryone(actor: AuthUser) {
const now = new Date();
const others = { id: { not: actor.id } };
const [users] = await this.prisma.$transaction([
this.prisma.clientUser.updateMany({ where: others, data: { sessionsRevokedAt: now } }),
this.prisma.user.updateMany({ where: { id: { not: actor.id } }, data: { sessionsRevokedAt: now } }),
this.prisma.refreshToken.updateMany({ where: { userId: { not: actor.id }, revokedAt: null }, data: { revokedAt: now } }),
]);
await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_everyone_logged_out', { users: users.count, by: actor.email });
await this.audit.log({ venueId: null, actor: { type: 'user', id: actor.id }, action: 'admin_everyone_logged_out', details: { users: users.count } });
return { users: users.count };
}
async deleteUser(userId: string, actor: Actor) {
async deleteUser(userId: string, actor: AuthUser) {
const user = await this.findUser(userId);
return this.authService.deleteUser(user.venueId, userId, actor);
if (user.venueId) return this.venues.deleteUser(user.venueId, userId, actor);
// Another superadmin
if (userId === actor.id) throw new BadRequestException({ code: 'cannot_delete_self', message: 'You cannot delete your own account' });
await this.prisma.user.delete({ where: { id: userId } });
await this.audit.log({ venueId: null, actor: { type: 'user', id: actor.id }, action: 'user_deleted', details: { userId, email: user.email, role: user.role } });
return { ok: true };
}
// ---- Maintenance
/** Removes old history. Pending commands are never purged: a terminal may still pick them up. */
async purge(targets: PurgeTarget[], olderThanDays: number, venueId: string | undefined, dryRun: boolean, actor: Actor) {
async purge(targets: PurgeTarget[], olderThanDays: number, venueId: string | undefined, dryRun: boolean, actor: AuthUser) {
if (venueId) await this.findVenue(venueId);
const before = new Date(Date.now() - olderThanDays * DAY_MS);
const venue = venueId ? { venueId } : {};
const wheres = {
tabEvents: { ...venue, receivedAt: { lt: before } },
auditLog: { ...venue, createdAt: { lt: before } },
commands: { ...venue, createdAt: { lt: before }, status: { not: 'pending' } },
};
const run = {
tabEvents: () => (dryRun ? this.prisma.tabEvent.count({ where: wheres.tabEvents }) : this.prisma.tabEvent.deleteMany({ where: wheres.tabEvents }).then((r) => r.count)),
auditLog: () => (dryRun ? this.prisma.auditLog.count({ where: wheres.auditLog }) : this.prisma.auditLog.deleteMany({ where: wheres.auditLog }).then((r) => r.count)),
commands: () => (dryRun ? this.prisma.remoteCommand.count({ where: wheres.commands }) : this.prisma.remoteCommand.deleteMany({ where: wheres.commands }).then((r) => r.count)),
const tabEvents = { ...venue, receivedAt: { lt: before } };
const auditLog = { ...venue, createdAt: { lt: before } };
const run: Record<PurgeTarget, () => Promise<number>> = {
tabEvents: () => (dryRun ? this.prisma.tabEvent.count({ where: tabEvents }) : this.prisma.tabEvent.deleteMany({ where: tabEvents }).then((r) => r.count)),
auditLog: () => (dryRun ? this.prisma.auditLog.count({ where: auditLog }) : this.prisma.auditLog.deleteMany({ where: auditLog }).then((r) => r.count)),
};
const counts: Partial<Record<PurgeTarget, number>> = {};
for (const target of new Set(targets)) counts[target] = await run[target]();
if (!dryRun) {
await this.audit.log(venueId ?? actor.venueId, 'client_user', actor.id, 'admin_purged', {
targets, olderThanDays, venueId: venueId ?? 'all', counts, by: actor.email,
});
if (venueId && counts.commands) this.realtime.notify(venueId, 'commands');
await this.audit.log({ venueId: venueId ?? null, actor: { type: 'user', id: actor.id }, action: 'admin_purged', details: { targets, olderThanDays, counts } });
}
return { dryRun, counts };
}
/** Deletes pictures no product uses, after a grace period for terminals that upload before they link. */
async cleanupImages(dryRun: boolean, actor: Actor) {
const where = { products: { none: {} }, createdAt: { lt: new Date(Date.now() - ORPHAN_IMAGE_GRACE_MS) } };
if (dryRun) {
const images = await this.prisma.productImage.findMany({ where, select: { id: true } });
const [{ bytes }] = images.length === 0 ? [{ bytes: 0n }] : await this.prisma.$queryRaw<{ bytes: bigint }[]>`
SELECT COALESCE(SUM(OCTET_LENGTH("bytes")), 0)::bigint AS bytes FROM "ProductImage" WHERE "id" = ANY(${images.map((i) => i.id)})`;
return { dryRun: true, count: images.length, bytes: Number(bytes) };
async cleanupImages(dryRun: boolean, actor: AuthUser) {
const images = await this.prisma.productImage.findMany({
where: { products: { none: {} }, createdAt: { lt: new Date(Date.now() - ORPHAN_IMAGE_GRACE_MS) } },
select: { id: true, bytes: true },
});
const bytes = images.reduce((sum, image) => sum + image.bytes, 0);
if (!dryRun && images.length > 0) {
await this.images.remove(images.map((image) => image.id));
await this.audit.log({ venueId: null, actor: { type: 'user', id: actor.id }, action: 'admin_images_cleaned', details: { count: images.length } });
}
const { count } = await this.prisma.productImage.deleteMany({ where });
await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_images_cleaned', { count, by: actor.email });
return { dryRun: false, count };
return { dryRun, count: images.length, bytes };
}
async sendTestEmail(to: string, actor: Actor) {
const result = await this.reminders.sendTestEmailTo(to);
await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_test_email_sent', { to, by: actor.email });
async sendTestEmail(to: string, actor: AuthUser) {
const result = await this.reminders.sendTestEmail(to);
await this.audit.log({ venueId: null, actor: { type: 'user', id: actor.id }, action: 'admin_test_email_sent', details: { to } });
return result;
}
// ---- Insight
async auditLog(options: { venueId?: string; action?: string; limit: number; offset: number }) {
const where = {
async auditLog(options: { venueId?: string; action?: string; limit: number; offset: number }): Promise<{ entries: AuditEntry[]; total: number }> {
const where: Prisma.AuditLogWhereInput = {
...(options.venueId ? { venueId: options.venueId } : {}),
...(options.action ? { action: { contains: options.action, mode: 'insensitive' as const } } : {}),
...(options.action ? { action: { contains: options.action, mode: 'insensitive' } } : {}),
};
const [entries, total] = await Promise.all([
this.prisma.auditLog.findMany({
@@ -523,42 +439,45 @@ export class AdminService {
]);
// Actors are stored as ids; show who they are
const actorIds = [...new Set(entries.map((e) => e.actorId))];
const actorIds = [...new Set(entries.map((e) => e.actorId).filter((id): id is string => id !== null))];
const [users, devices] = await Promise.all([
this.prisma.clientUser.findMany({ where: { id: { in: actorIds } }, select: { id: true, email: true } }),
this.prisma.user.findMany({ where: { id: { in: actorIds } }, select: { id: true, email: true } }),
this.prisma.device.findMany({ where: { id: { in: actorIds } }, select: { id: true, name: true } }),
]);
const actorNames = new Map<string, string>([
...users.map((u): [string, string] => [u.id, u.email]),
...devices.map((d): [string, string] => [d.id, d.name]),
]);
const actorNames = new Map([...users.map((u) => [u.id, u.email] as const), ...devices.map((d) => [d.id, d.name] as const)]);
return {
total,
entries: entries.map(({ venue, details, ...entry }) => ({
...entry,
venueName: venue.name,
actorName: actorNames.get(entry.actorId) ?? null,
details: parseDetails(details),
entries: entries.map((e) => ({
id: e.id,
venueId: e.venueId,
venueName: e.venue?.name ?? null,
actorType: e.actorType,
actorId: e.actorId,
actorName: e.actorId ? actorNames.get(e.actorId) ?? null : null,
action: e.action,
details: e.details,
createdAt: e.createdAt.toISOString(),
})),
};
}
async health() {
const [[size], migrations, tables, stuckCommands] = await Promise.all([
async health(): Promise<SystemHealth> {
const [[size], migrations, tables, imageBytes] = await Promise.all([
this.prisma.$queryRaw<{ bytes: bigint }[]>`SELECT pg_database_size(current_database())::bigint AS bytes`,
this.prisma.$queryRaw<{ migration_name: string; finished_at: Date | null; rolled_back_at: Date | null }[]>`
SELECT "migration_name", "finished_at", "rolled_back_at" FROM "_prisma_migrations" ORDER BY "started_at" ASC`,
this.tableCounts(),
this.prisma.remoteCommand.count({ where: { status: 'pending' } }),
this.imageBytesOnDisk(),
]);
const applied = migrations.filter((m) => m.finished_at && !m.rolled_back_at);
return {
checkedAt: new Date(),
checkedAt: new Date().toISOString(),
uptimeSeconds: Math.round(process.uptime()),
nodeVersion: process.version,
databaseBytes: Number(size.bytes),
imageBytes,
tables,
migrations: {
applied: applied.length,
@@ -567,56 +486,55 @@ export class AdminService {
failed: migrations.filter((m) => !m.finished_at && !m.rolled_back_at).map((m) => m.migration_name),
},
realtimeConnections: this.realtime.connectionCount(),
commandExpiryLastRunAt: this.commandExpiry.lastRunAt,
stuckCommands,
emailConfigured: this.reminders.configured,
emailConfigured: this.resend.configured,
};
}
private async tableCounts() {
const p = this.prisma;
const entries = await Promise.all(Object.entries({
const counts: Record<string, () => Promise<number>> = {
Venue: () => p.venue.count(),
ClientUser: () => p.clientUser.count(),
Device: () => p.device.count(),
User: () => p.user.count(),
RefreshToken: () => p.refreshToken.count(),
Device: () => p.device.count(),
RegistrationCode: () => p.registrationCode.count(),
Customer: () => p.customer.count(),
Tab: () => p.tab.count(),
TabEvent: () => p.tabEvent.count(),
Settlement: () => p.settlement.count(),
PlannedTab: () => p.plannedTab.count(),
TabReminder: () => p.tabReminder.count(),
TabUnit: () => p.tabUnit.count(),
Product: () => p.product.count(),
ProductImage: () => p.productImage.count(),
StockAdjustment: () => p.stockAdjustment.count(),
RemoteCommand: () => p.remoteCommand.count(),
StockMovement: () => p.stockMovement.count(),
TabEvent: () => p.tabEvent.count(),
Reminder: () => p.reminder.count(),
AuditLog: () => p.auditLog.count(),
}).map(async ([table, count]) => ({ table, rows: await count() })));
return entries;
};
return Promise.all(Object.entries(counts).map(async ([table, count]) => ({ table, rows: await count() })));
}
private async imageBytesOnDisk(): Promise<number> {
const files = await fs.readdir(imageDir()).catch(() => [] as string[]);
const sizes = await Promise.all(files.map((file) => fs.stat(path.join(imageDir(), file)).then((s) => s.size, () => 0)));
return sizes.reduce((sum, size) => sum + size, 0);
}
private async findVenue(venueId: string) {
const venue = await this.prisma.venue.findUnique({ where: { id: venueId } });
if (!venue) {
throw new NotFoundException({ code: 'venue_not_found', message: 'Venue not found' });
}
if (!venue) throw new NotFoundException({ code: 'venue_not_found', message: 'Venue not found' });
return venue;
}
private async findUser(userId: string) {
const user = await this.prisma.clientUser.findUnique({ where: { id: userId } });
if (!user) {
throw new NotFoundException({ code: 'user_not_found', message: 'User not found' });
}
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new NotFoundException({ code: 'user_not_found', message: 'User not found' });
return user;
}
}
function parseDetails(details: string | null): unknown {
if (!details) return null;
try {
return JSON.parse(details);
} catch {
return details;
private async signOut(userId: string, data: { passwordHash?: string } = {}) {
const now = new Date();
await this.prisma.$transaction([
this.prisma.user.update({ where: { id: userId }, data: { ...data, sessionsRevokedAt: now } }),
this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: now } }),
]);
}
}
+24 -85
View File
@@ -1,97 +1,36 @@
import { checkConsistency, VenueSnapshot } from './consistency';
const now = new Date('2026-10-26T12:00:00Z');
const later = new Date('2026-10-27T12:00:00Z');
const earlier = new Date('2026-10-25T12:00:00Z');
const unit = (id: string, removedAt: string | null = null) => ({ id, addedAt: earlier.toISOString(), removedAt });
function snapshot(overrides: Partial<VenueSnapshot> = {}): VenueSnapshot {
return { settlements: [], tabs: [], products: [], pendingCommands: [], devices: [], ...overrides };
}
const codes = (venue: VenueSnapshot) => checkConsistency(venue, now).map((f) => f.code);
const now = new Date('2026-10-28T12:00:00Z');
const empty: VenueSnapshot = { settlements: [], tabs: [], products: [], devices: [] };
describe('checkConsistency', () => {
it('finds nothing in a venue whose data adds up', () => {
const venue = snapshot({
settlements: [{
id: 's1', customerName: 'Jan', amountInCents: 500, commandId: null,
lines: [{ id: 'l1', productId: 'p1', productName: 'Pils', unitPriceInCents: 250, quantity: 2, units: [] }],
}],
tabs: [{ id: 't1', customerName: 'Jan', lines: [{ id: 'l2', productId: 'p1', productName: 'Pils', unitPriceInCents: 250, units: [unit('u1')] }] }],
products: [{ productId: 'p1', name: 'Pils', stock: 10, countedAt: earlier }],
pendingCommands: [{ id: 'c1', expiresAt: later, device: { name: 'Bar', revokedAt: null } }],
devices: [{ id: 'd1', name: 'Bar', revokedAt: null, tokenExpiresAt: later }],
});
expect(checkConsistency(venue, now)).toEqual([]);
it('finds nothing in a venue that adds up', () => {
expect(checkConsistency({
settlements: [{ id: 's1', customerName: 'Jan', amountInCents: 500, drinksInCents: 500 }],
tabs: [{ id: 't1', customerName: 'Jan' }, { id: 't2', customerName: 'Marie' }],
products: [{ id: 'p1', name: 'Cola', stock: 3 }],
devices: [{ id: 'd1', name: 'Bar', revokedAt: null, tokenExpiresAt: new Date('2027-01-01') }],
}, now)).toEqual([]);
});
it('flags a settlement whose amount differs from its drinks', () => {
const venue = snapshot({
settlements: [{
id: 's1', customerName: 'Jan', amountInCents: 750, commandId: null,
lines: [{ id: 'l1', productId: 'p1', productName: 'Pils', unitPriceInCents: 250, quantity: 2, units: [] }],
}],
});
const [finding] = checkConsistency(venue, now);
expect(finding).toMatchObject({ severity: 'warning', code: 'settlement_amount_mismatch', subjectId: 's1' });
expect(finding.message).toContain('7.50');
expect(finding.message).toContain('5.00');
});
it('flags what does not add up, warnings before information', () => {
const findings = checkConsistency({
...empty,
settlements: [{ id: 's1', customerName: 'Jan', amountInCents: 500, drinksInCents: 250 }],
tabs: [{ id: 't1', customerName: 'Jan' }, { id: 't2', customerName: ' jan ' }],
products: [{ id: 'p1', name: 'Cola', stock: -2 }],
devices: [{ id: 'd1', name: 'Bar', revokedAt: null, tokenExpiresAt: new Date('2026-10-01') }],
}, now);
it('flags one close request paid twice as an error, before anything else', () => {
const settlement = { customerName: 'Jan', amountInCents: 0, lines: [], commandId: 'c1' };
const venue = snapshot({
settlements: [{ ...settlement, id: 's1' }, { ...settlement, id: 's2' }],
products: [{ productId: 'p1', name: 'Pils', stock: -2, countedAt: earlier }],
});
expect(codes(venue)).toEqual(['settlement_duplicate_command', 'stock_negative']);
});
it('flags two open tabs that only differ in case or spacing', () => {
const venue = snapshot({
tabs: [
{ id: 't1', customerName: 'Jan Peeters', lines: [] },
{ id: 't2', customerName: ' jan peeters ', lines: [] },
],
});
expect(codes(venue)).toEqual(['tab_duplicate_customer']);
});
it('notes drinks of a product that is gone, but not ones already taken off the tab', () => {
const line = { id: 'l1', productId: 'gone', productName: 'Cava', unitPriceInCents: 500 };
expect(codes(snapshot({ tabs: [{ id: 't1', customerName: 'Jan', lines: [{ ...line, units: [unit('u1')] }] }] })))
.toEqual(['tab_unknown_product']);
expect(codes(snapshot({ tabs: [{ id: 't1', customerName: 'Jan', lines: [{ ...line, units: [unit('u1', now.toISOString())] }] }] })))
.toEqual([]);
});
it('notes tracked stock that was never counted', () => {
expect(codes(snapshot({ products: [{ productId: 'p1', name: 'Pils', stock: 4, countedAt: null }] }))).toEqual(['stock_never_counted']);
expect(codes(snapshot({ products: [{ productId: 'p1', name: 'Pils', stock: null, countedAt: null }] }))).toEqual([]);
});
it('flags pending commands that expired or wait for an unpaired terminal', () => {
const venue = snapshot({
pendingCommands: [
{ id: 'c1', expiresAt: earlier, device: { name: 'Bar', revokedAt: null } },
{ id: 'c2', expiresAt: later, device: { name: 'Old', revokedAt: earlier } },
],
});
expect(checkConsistency(venue, now).map((f) => [f.code, f.subjectId])).toEqual([
['command_stuck', 'c1'],
['command_revoked_device', 'c2'],
expect(findings.map((f) => f.code)).toEqual([
'settlement_amount_mismatch', 'tab_duplicate_customer', 'stock_negative', 'device_token_expired',
]);
});
it('notes a paired terminal whose token expired, but not an unpaired one', () => {
const venue = snapshot({
devices: [
{ id: 'd1', name: 'Bar', revokedAt: null, tokenExpiresAt: earlier },
{ id: 'd2', name: 'Old', revokedAt: earlier, tokenExpiresAt: earlier },
],
});
expect(checkConsistency(venue, now).map((f) => f.subjectId)).toEqual(['d1']);
it('ignores the expired token of an unpaired terminal', () => {
expect(checkConsistency({
...empty,
devices: [{ id: 'd1', name: 'Old', revokedAt: new Date('2026-09-01'), tokenExpiresAt: new Date('2026-10-01') }],
}, now)).toEqual([]);
});
});
+17 -88
View File
@@ -1,23 +1,11 @@
import { customerNameKey } from '../planned-tabs/customer-name';
import { Line, SettledLine, isActive } from '../tabs/tab-state';
export type Severity = 'error' | 'warning' | 'info';
export type Finding = {
severity: Severity;
// settlement_amount_mismatch | settlement_duplicate_command | tab_duplicate_customer | tab_unknown_product |
// stock_negative | stock_never_counted | command_stuck | command_revoked_device | device_token_expired
code: string;
message: string;
// What it is about: a settlement, tab, product, command or terminal id
subjectId: string;
};
import { Finding, FindingSeverity } from '@kooltap/shared';
import { customerNameKey } from '../customers/customer-name';
export type VenueSnapshot = {
settlements: { id: string; customerName: string; amountInCents: number; lines: unknown; commandId: string | null }[];
tabs: { id: string; customerName: string; lines: unknown }[];
products: { productId: string; name: string; stock: number | null; countedAt: Date | null }[];
pendingCommands: { id: string; expiresAt: Date; device: { name: string; revokedAt: Date | null } | null }[];
/** Each settlement with what its drinks add up to. */
settlements: { id: string; customerName: string; amountInCents: number; drinksInCents: number }[];
tabs: { id: string; customerName: string }[];
products: { id: string; name: string; stock: number | null }[];
devices: { id: string; name: string; revokedAt: Date | null; tokenExpiresAt: Date }[];
};
@@ -30,102 +18,43 @@ const euro = (cents: number) => (cents / 100).toFixed(2);
export function checkConsistency(venue: VenueSnapshot, now: Date): Finding[] {
const findings: Finding[] = [];
// A settlement's amount is what the terminal charged; its lines are what the server had on the tab. A difference
// A settlement's amount is what the terminal charged; its drinks are what the server had on the tab. A difference
// means the terminal and the server disagreed about the tab when it was paid
for (const settlement of venue.settlements) {
const lines = (settlement.lines as SettledLine[] | null) ?? [];
const linesTotal = lines.reduce((sum, line) => sum + line.quantity * line.unitPriceInCents, 0);
if (linesTotal !== settlement.amountInCents) {
if (settlement.drinksInCents !== settlement.amountInCents) {
findings.push({
severity: 'warning',
code: 'settlement_amount_mismatch',
message: `${settlement.customerName} paid € ${euro(settlement.amountInCents)}, the drinks add up to € ${euro(linesTotal)}`,
message: `${settlement.customerName} paid € ${euro(settlement.amountInCents)}, the drinks add up to € ${euro(settlement.drinksInCents)}`,
subjectId: settlement.id,
});
}
}
const byCommand = new Map<string, string[]>();
for (const settlement of venue.settlements) {
if (!settlement.commandId) continue;
byCommand.set(settlement.commandId, [...(byCommand.get(settlement.commandId) ?? []), settlement.id]);
}
for (const [commandId, ids] of byCommand) {
if (ids.length > 1) {
findings.push({
severity: 'error',
code: 'settlement_duplicate_command',
message: `One close request was paid ${ids.length} times`,
subjectId: commandId,
});
}
}
// Customers are matched on their name everywhere, so two open tabs with one name get mixed up
const tabsByName = new Map<string, string[]>();
const tabsByName = new Map<string, typeof venue.tabs>();
for (const tab of venue.tabs) {
const key = customerNameKey(tab.customerName);
tabsByName.set(key, [...(tabsByName.get(key) ?? []), tab.id]);
tabsByName.set(key, [...(tabsByName.get(key) ?? []), tab]);
}
for (const ids of tabsByName.values()) {
if (ids.length > 1) {
const name = venue.tabs.find((tab) => tab.id === ids[0])!.customerName;
for (const tabs of tabsByName.values()) {
if (tabs.length > 1) {
findings.push({
severity: 'warning',
code: 'tab_duplicate_customer',
message: `${ids.length} open tabs are named ${name}`,
subjectId: ids[0],
message: `${tabs.length} open tabs are named ${tabs[0].customerName}`,
subjectId: tabs[0].id,
});
}
}
const productIds = new Set(venue.products.map((p) => p.productId));
for (const tab of venue.tabs) {
for (const line of (tab.lines as Line[] | null) ?? []) {
if (line.productId && !productIds.has(line.productId) && line.units.some(isActive)) {
findings.push({
severity: 'info',
code: 'tab_unknown_product',
message: `${tab.customerName}'s tab has ${line.productName}, which is not in the catalog`,
subjectId: tab.id,
});
}
}
}
for (const product of venue.products) {
if (product.stock !== null && product.stock < 0) {
findings.push({
severity: 'warning',
code: 'stock_negative',
message: `${product.name} has a stock of ${product.stock}: more was sold than counted`,
subjectId: product.productId,
});
}
if (product.stock !== null && product.countedAt === null) {
findings.push({
severity: 'info',
code: 'stock_never_counted',
message: `${product.name}'s stock is tracked but was never counted`,
subjectId: product.productId,
});
}
}
for (const command of venue.pendingCommands) {
if (command.device?.revokedAt) {
findings.push({
severity: 'warning',
code: 'command_revoked_device',
message: `A close request waits for ${command.device.name}, which is unpaired`,
subjectId: command.id,
});
} else if (command.expiresAt <= now) {
findings.push({
severity: 'warning',
code: 'command_stuck',
message: 'A close request expired but is still pending',
subjectId: command.id,
subjectId: product.id,
});
}
}
@@ -141,6 +70,6 @@ export function checkConsistency(venue: VenueSnapshot, now: Date): Finding[] {
}
}
const order: Record<Severity, number> = { error: 0, warning: 1, info: 2 };
const order: Record<FindingSeverity, number> = { error: 0, warning: 1, info: 2 };
return findings.sort((a, b) => order[a.severity] - order[b.severity]);
}
-95
View File
@@ -1,95 +0,0 @@
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsEmail,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { RESET_SCOPES, ResetScope } from '../venue-reset';
export const PURGE_TARGETS = ['tabEvents', 'auditLog', 'commands'] as const;
export type PurgeTarget = (typeof PURGE_TARGETS)[number];
export class VenueNameDto {
@ApiProperty({ example: 'De Kroeg' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
}
export class DryRunDto {
@ApiPropertyOptional({ description: 'Only count what would change' })
@IsOptional()
@IsBoolean()
dryRun?: boolean;
}
export class ResetVenueDto extends DryRunDto {
@ApiProperty({ enum: RESET_SCOPES, isArray: true })
@IsArray()
@ArrayMinSize(1)
@IsIn(RESET_SCOPES, { each: true })
scopes: ResetScope[];
@ApiPropertyOptional({ description: 'The count every tracked product gets with the stock scope', default: 0 })
@IsOptional()
@IsInt()
@Min(0)
@Max(100000)
stockValue?: number;
}
export class PurgeDto extends DryRunDto {
@ApiProperty({ example: 90 })
@IsInt()
@Min(1)
@Max(3650)
olderThanDays: number;
@ApiProperty({ enum: PURGE_TARGETS, isArray: true })
@IsArray()
@ArrayMinSize(1)
@IsIn(PURGE_TARGETS, { each: true })
targets: PurgeTarget[];
@ApiPropertyOptional({ description: 'Only this venue; every venue when left out' })
@IsOptional()
@IsString()
venueId?: string;
}
export class CopyCatalogDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
fromVenueId: string;
}
export class UpdateRoleDto {
@ApiProperty({ enum: ['admin', 'manager', 'superadmin'] })
@IsIn(['admin', 'manager', 'superadmin'])
role: string;
}
export class ResetPasswordDto {
@ApiProperty({ example: 'SecurePass123!' })
@IsString()
@MinLength(6)
password: string;
}
export class TestEmailDto {
@ApiProperty({ example: 'you@example.com' })
@IsEmail()
to: string;
}
+28 -93
View File
@@ -1,40 +1,9 @@
import { Prisma } from '@prisma/client';
import { ensureDefaultCustomers } from '../planned-tabs/default-customers';
type Tx = Prisma.TransactionClient;
/**
* What a superadmin can wipe from a venue. Users, terminals and the product catalog always stay: a reset is for
* starting a new event or season with the same bar.
*/
export const RESET_SCOPES = [
// The open tabs. Terminals drop them on their next sync; drinks a terminal still has to send bring a tab back
'tabs',
// Paid tabs: the sales history
'settlements',
// What the terminals reported. Also the record of which events were applied, so a terminal that re-sends an old
// event would apply it again: only wipe these when every terminal has synced
'tabEvents',
// Planned customers and the reminders sent to them
'plannedTabs',
// Every tracked product's stock is set to one count
'stock',
// Stock deliveries, breakage and counts made on the dashboard
'stockHistory',
// Command history
'commands',
'auditLog',
] as const;
export type ResetScope = (typeof RESET_SCOPES)[number];
import { ResetScope } from '@kooltap/shared';
import { Tx } from '../prisma/prisma.service';
import { ensureDefaultCustomers } from '../customers/default-customers';
export type ResetCounts = Partial<Record<ResetScope, number>>;
/** Locks the venue like a sync does, so no terminal changes it halfway through. */
export async function lockVenue(tx: Tx, venueId: string) {
await tx.$queryRaw`SELECT "id" FROM "Venue" WHERE "id" = ${venueId} FOR UPDATE`;
}
/** How many rows each scope would remove (or, for stock, how many products it would set). */
export async function countReset(tx: Tx, venueId: string, scopes: ResetScope[]): Promise<ResetCounts> {
const where = { venueId };
@@ -42,10 +11,9 @@ export async function countReset(tx: Tx, venueId: string, scopes: ResetScope[]):
tabs: () => tx.tab.count({ where }),
settlements: () => tx.settlement.count({ where }),
tabEvents: () => tx.tabEvent.count({ where }),
plannedTabs: () => tx.plannedTab.count({ where }),
customers: () => tx.customer.count({ where }),
stock: () => tx.product.count({ where: { venueId, stock: { not: null } } }),
stockHistory: () => tx.stockAdjustment.count({ where }),
commands: () => tx.remoteCommand.count({ where }),
stockHistory: () => tx.stockMovement.count({ where }),
auditLog: () => tx.auditLog.count({ where }),
};
const counts: ResetCounts = {};
@@ -54,14 +22,21 @@ export async function countReset(tx: Tx, venueId: string, scopes: ResetScope[]):
}
/**
* Wipes [scopes] of a venue inside [tx], which must have locked it. [stockValue] is the count every tracked product
* gets; it is recorded as an applied stock count, after the stock history is wiped, so the history explains it.
* Wipes [scopes] of a venue inside [tx], which must have locked it. Users, terminals and the catalog always stay: a
* reset is for starting a new event or season with the same bar.
*
* - tabs: terminals drop them on their next sync; drinks a terminal still has to send bring a tab back
* - tabEvents: also the record of which events were applied, so a terminal that sends an old event again would
* apply it again: only wipe these when every terminal has synced
* - customers: and the reminders sent to them
* - stock: every tracked product gets [stockValue] as a count, recorded after the stock history is wiped so the
* history explains it
*/
export async function resetVenue(
tx: Tx,
venueId: string,
scopes: ResetScope[],
options: { stockValue: number; actor: { id: string; name: string }; now: Date },
options: { stockValue: number; userId: string; now: Date },
): Promise<ResetCounts> {
const where = { venueId };
const counts: ResetCounts = {};
@@ -70,65 +45,25 @@ export async function resetVenue(
if (has('tabs')) counts.tabs = (await tx.tab.deleteMany({ where })).count;
if (has('settlements')) counts.settlements = (await tx.settlement.deleteMany({ where })).count;
if (has('tabEvents')) counts.tabEvents = (await tx.tabEvent.deleteMany({ where })).count;
if (has('plannedTabs')) {
await tx.tabReminder.deleteMany({ where });
counts.plannedTabs = (await tx.plannedTab.deleteMany({ where })).count;
if (has('customers')) {
await tx.reminder.deleteMany({ where });
counts.customers = (await tx.customer.deleteMany({ where })).count;
}
// The Free Drinks tab is always there: planned again, and opened on the next sync
if (has('tabs') || has('plannedTabs')) await ensureDefaultCustomers(tx, venueId);
if (has('stockHistory')) counts.stockHistory = (await tx.stockAdjustment.deleteMany({ where })).count;
// The Free Drinks tab is always there: added again, and opened on the next sync
if (has('tabs') || has('customers')) await ensureDefaultCustomers(tx, venueId);
if (has('stockHistory')) counts.stockHistory = (await tx.stockMovement.deleteMany({ where })).count;
if (has('stock')) {
const { now, actor, stockValue } = options;
const tracked = await tx.product.findMany({
where: { venueId, stock: { not: null } },
select: { productId: true, name: true },
const { now, userId, stockValue } = options;
const tracked = await tx.product.findMany({ where: { venueId, stock: { not: null } }, select: { id: true } });
await tx.product.updateMany({ where: { venueId, stock: { not: null } }, data: { stock: stockValue, countedAt: now } });
await tx.stockMovement.createMany({
data: tracked.map((product) => ({
venueId, productId: product.id, type: 'count' as const, quantity: stockValue, stockAfter: stockValue, note: 'Stock reset', userId, createdAt: now,
})),
});
await tx.product.updateMany({
where: { venueId, stock: { not: null } },
data: { stock: stockValue, countedAt: now },
});
if (tracked.length > 0) {
await tx.stockAdjustment.createMany({
data: tracked.map((product) => ({
venueId,
productId: product.productId,
productName: product.name,
type: 'set',
quantity: stockValue,
note: 'Stock reset',
requestedBy: actor.name,
requestedById: actor.id,
status: 'applied',
stockAfter: stockValue,
createdAt: now,
processedAt: now,
})),
});
}
counts.stock = tracked.length;
}
if (has('commands')) counts.commands = (await tx.remoteCommand.deleteMany({ where })).count;
if (has('auditLog')) counts.auditLog = (await tx.auditLog.deleteMany({ where })).count;
return counts;
}
/** Deletes a venue and everything in it, users and terminals included. */
export async function deleteVenue(tx: Tx, venueId: string) {
const where = { venueId };
const users = await tx.clientUser.findMany({ where, select: { id: true } });
await tx.auditLog.deleteMany({ where });
await tx.tabEvent.deleteMany({ where });
await tx.tabReminder.deleteMany({ where });
await tx.plannedTab.deleteMany({ where });
await tx.remoteCommand.deleteMany({ where });
await tx.settlement.deleteMany({ where });
await tx.tab.deleteMany({ where });
await tx.stockAdjustment.deleteMany({ where });
await tx.product.deleteMany({ where });
await tx.registrationCode.deleteMany({ where });
await tx.device.deleteMany({ where });
await tx.refreshToken.deleteMany({ where: { userId: { in: users.map((u) => u.id) } } });
await tx.clientUser.deleteMany({ where });
await tx.venue.delete({ where: { id: venueId } });
}
+14 -25
View File
@@ -1,50 +1,39 @@
import { Module } from '@nestjs/common';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { VenuesModule } from './venues/venues.module';
import { DevicesModule } from './devices/devices.module';
import { TabsModule } from './tabs/tabs.module';
import { CommandsModule } from './commands/commands.module';
import { AuditModule } from './audit/audit.module';
import { JobsModule } from './jobs/jobs.module';
import { PlannedTabsModule } from './planned-tabs/planned-tabs.module';
import { RealtimeModule } from './realtime/realtime.module';
import { EmailModule } from './email/email.module';
import { AuthModule } from './auth/auth.module';
import { TerminalModule } from './terminal/terminal.module';
import { VenuesModule } from './venues/venues.module';
import { TabsModule } from './tabs/tabs.module';
import { CustomersModule } from './customers/customers.module';
import { RemindersModule } from './reminders/reminders.module';
import { StockModule } from './stock/stock.module';
import { ImagesModule } from './images/images.module';
import { RealtimeModule } from './realtime/realtime.module';
import { EmailModule } from './email/email.module';
import { AdminModule } from './admin/admin.module';
import { HealthController } from './health.controller';
@Module({
imports: [
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
ScheduleModule.forRoot(),
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
PrismaModule,
AuditModule,
RealtimeModule,
EmailModule,
AuthModule,
TerminalModule,
VenuesModule,
DevicesModule,
TabsModule,
CommandsModule,
AuditModule,
JobsModule,
PlannedTabsModule,
CustomersModule,
RemindersModule,
StockModule,
ImagesModule,
AdminModule,
],
controllers: [HealthController],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}
export class AppModule {}
+25 -14
View File
@@ -1,6 +1,14 @@
import { Injectable, Logger } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { PrismaService } from '../prisma/prisma.service';
import { Prisma, PrismaService, Tx } from '../prisma/prisma.service';
import { ActorType } from '../generated/prisma/client';
export type AuditEntry = {
/** Null for actions that are not about one venue. */
venueId: string | null;
actor: { type: ActorType; id: string | null };
action: string;
details?: Prisma.InputJsonObject;
};
@Injectable()
export class AuditService {
@@ -8,20 +16,23 @@ export class AuditService {
constructor(private prisma: PrismaService) {}
async log(venueId: string, actorType: string, actorId: string, action: string, details?: Record<string, any>) {
/** Records [entry]; inside [tx] it is part of that change, otherwise a failure is only logged. */
async log(entry: AuditEntry, tx?: Tx) {
const data = {
venueId: entry.venueId,
actorType: entry.actor.type,
actorId: entry.actor.id,
action: entry.action,
details: entry.details,
};
if (tx) {
await tx.auditLog.create({ data });
return;
}
try {
await this.prisma.auditLog.create({
data: {
id: uuidv4(),
venueId,
actorType,
actorId,
action,
details: details ? JSON.stringify(details) : null,
},
});
await this.prisma.auditLog.create({ data });
} catch (error) {
this.logger.error(`Failed to write audit log: ${error}`);
}
}
}
}
+45 -27
View File
@@ -1,47 +1,65 @@
import { Controller, Post, Body, HttpCode, HttpStatus, Req, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { z } from 'zod';
import { changePasswordSchema, loginSchema, Me } from '@kooltap/shared';
import { zod } from '../common/zod.pipe';
import { Authorized, AuthUser, CurrentUser } from '../common/auth';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { clearAuthCookies, REFRESH_COOKIE, setAuthCookies } from './cookies';
@ApiTags('Authentication')
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
constructor(private auth: AuthService) {}
@Post('login')
@Throttle({ default: { limit: 10, ttl: 60000 } })
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login with email and password' })
async login(@Body() dto: LoginDto) {
return this.authService.login(dto.email, dto.password);
@Throttle({ default: { limit: 10, ttl: 60_000 } })
async login(@Body(zod(loginSchema)) dto: z.output<typeof loginSchema>, @Res({ passthrough: true }) res: Response): Promise<Me> {
const { me, tokens } = await this.auth.login(dto.email, dto.password);
setAuthCookies(res, tokens);
return me;
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Refresh access token' })
async refresh(@Body() dto: RefreshTokenDto) {
return this.authService.refresh(dto.refreshToken);
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise<Me> {
try {
const { me, tokens } = await this.auth.refresh(req.cookies?.[REFRESH_COOKIE]);
setAuthCookies(res, tokens);
return me;
} catch (error) {
clearAuthCookies(res);
throw error;
}
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Logout and revoke refresh token' })
async logout(@Body() dto: RefreshTokenDto) {
await this.authService.logout(dto.refreshToken);
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
await this.auth.logout(req.cookies?.[REFRESH_COOKIE]);
clearAuthCookies(res);
return { ok: true };
}
@Post('change-password')
@Throttle({ default: { limit: 10, ttl: 60000 } })
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: "Change the signed-in user's password; signs out their other sessions and returns new tokens" })
async changePassword(@Body() dto: ChangePasswordDto, @Req() req: any) {
return this.authService.changePassword(req.user.id, dto.currentPassword, dto.newPassword);
@Get('me')
@Authorized()
me(@CurrentUser() user: AuthUser): Promise<Me> {
return this.auth.me(user.id);
}
}
/** Signs out the user's other sessions; this one carries on with new tokens. */
@Post('change-password')
@HttpCode(HttpStatus.OK)
@Authorized()
@Throttle({ default: { limit: 10, ttl: 60_000 } })
async changePassword(
@CurrentUser() user: AuthUser,
@Body(zod(changePasswordSchema)) dto: z.output<typeof changePasswordSchema>,
@Res({ passthrough: true }) res: Response,
): Promise<Me> {
const { me, tokens } = await this.auth.changePassword(user.id, dto.currentPassword, dto.newPassword);
setAuthCookies(res, tokens);
return me;
}
}
+7 -14
View File
@@ -1,22 +1,15 @@
import { Module } from '@nestjs/common';
import { Global, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { DeviceStrategy } from './strategies/device.strategy';
import { UserAuthGuard } from './user-auth.guard';
import { getJwtSecret } from './jwt-secret';
@Global()
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({
secret: getJwtSecret(),
signOptions: { expiresIn: '15m' },
}),
],
imports: [JwtModule.register({ secret: getJwtSecret(), signOptions: { expiresIn: '15m' } })],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, DeviceStrategy],
exports: [AuthService, JwtModule],
providers: [AuthService, UserAuthGuard],
exports: [AuthService, UserAuthGuard],
})
export class AuthModule {}
export class AuthModule {}
-72
View File
@@ -1,72 +0,0 @@
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);
});
});
+78 -223
View File
@@ -1,262 +1,117 @@
import { Injectable, UnauthorizedException, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import { createHash, randomBytes } from 'crypto';
import { Me } from '@kooltap/shared';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { RealtimeService } from '../realtime/realtime.service';
import { TOKEN_EXPIRY_DAYS, TOKEN_BYTE_LENGTH, TOKEN_PREFIX_LENGTH } from '@kooltap/shared';
import { AuthUser } from '../common/auth';
import { REFRESH_TOKEN_TTL_MS } from './cookies';
export const sha256 = (value: string) => createHash('sha256').update(value).digest('hex');
export const hashPassword = (password: string) => bcrypt.hash(password, 12);
// Compared against when there is no such user, so the response time doesn't tell which emails exist
const NO_USER_HASH = bcrypt.hashSync('no user', 12);
type Tokens = { accessToken: string; refreshToken: string };
const userSelect = {
id: true, email: true, name: true, role: true, venueId: true, sessionsRevokedAt: true, venue: { select: { name: true } },
} as const;
/** Whether a token was issued before the user's sessions were revoked. iat is in whole seconds. */
export function issuedBefore(payload: { iat?: number }, revokedAt: Date | null | undefined): boolean {
return revokedAt != null && (payload.iat ?? 0) < Math.floor(revokedAt.getTime() / 1000);
}
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
private auditService: AuditService,
private realtime: RealtimeService,
private jwt: JwtService,
private audit: AuditService,
) {}
async login(email: string, password: string) {
const user = await this.prisma.clientUser.findUnique({ where: { email } });
if (!user) {
async login(email: string, password: string): Promise<{ me: Me; tokens: Tokens }> {
const user = await this.prisma.user.findUnique({ where: { email }, select: { ...userSelect, passwordHash: true } });
const valid = await bcrypt.compare(password, user?.passwordHash ?? NO_USER_HASH);
if (!user || !valid) {
throw new UnauthorizedException({ code: 'invalid_credentials', message: 'Invalid email or password' });
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
throw new UnauthorizedException({ code: 'invalid_credentials', message: 'Invalid email or password' });
}
return this.issueTokens(user);
return { me: toMe(user), tokens: await this.issueTokens(user.id) };
}
async refresh(refreshToken: string) {
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
const stored = await this.prisma.refreshToken.findUnique({ where: { tokenHash: hash } });
/** Rotates the refresh token: of two requests with the same token, only one gets new tokens. */
async refresh(refreshToken: string | undefined): Promise<{ me: Me; tokens: Tokens }> {
const invalid = new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' });
if (!refreshToken) throw invalid;
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
throw new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' });
}
const now = new Date();
const stored = await this.prisma.refreshToken.findUnique({ where: { tokenHash: sha256(refreshToken) } });
if (!stored || stored.revokedAt || stored.expiresAt < now) throw invalid;
const { count } = await this.prisma.refreshToken.updateMany({ where: { id: stored.id, revokedAt: null }, data: { revokedAt: now } });
if (count === 0) throw invalid;
// 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' });
}
return this.issueTokens(user);
const user = await this.prisma.user.findUnique({ where: { id: stored.userId }, select: userSelect });
if (!user) throw invalid;
return { me: toMe(user), tokens: await this.issueTokens(user.id) };
}
async logout(refreshToken: string) {
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
async logout(refreshToken: string | undefined) {
if (!refreshToken) return;
await this.prisma.refreshToken.updateMany({
where: { tokenHash: hash, revokedAt: null },
where: { tokenHash: sha256(refreshToken), revokedAt: null },
data: { revokedAt: new Date() },
});
}
/**
* Changes the signed-in user's password. Every session of the user is signed out, also this one, which gets new
* tokens instead.
*/
async changePassword(userId: string, currentPassword: string, newPassword: string) {
const user = await this.prisma.clientUser.findUnique({ where: { id: userId } });
if (!user) {
throw new UnauthorizedException({ code: 'user_not_found', message: 'User not found' });
}
async me(userId: string): Promise<Me> {
return toMe(await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, select: userSelect }));
}
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!valid) {
/** Changes the user's password and signs out every session; this one gets new tokens. */
async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<{ me: Me; tokens: Tokens }> {
const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, select: { ...userSelect, passwordHash: true } });
if (!(await bcrypt.compare(currentPassword, user.passwordHash))) {
throw new BadRequestException({ code: 'wrong_password', message: 'The current password is not correct' });
}
const passwordHash = await bcrypt.hash(newPassword, 10);
await this.prisma.$transaction([
this.prisma.clientUser.update({ where: { id: user.id }, data: { passwordHash } }),
this.prisma.refreshToken.updateMany({ where: { userId: user.id, revokedAt: null }, data: { revokedAt: new Date() } }),
this.prisma.user.update({ where: { id: userId }, data: { passwordHash: await hashPassword(newPassword) } }),
this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: new Date() } }),
]);
await this.auditService.log(user.venueId, 'client_user', user.id, 'password_changed');
return this.issueTokens(user);
}
private async issueTokens(user: { id: string; email: string; name: string; role: string; venueId: string }) {
const payload = { sub: user.id, email: user.email, role: user.role, venueId: user.venueId };
const accessToken = this.jwtService.sign(payload);
const refreshToken = crypto.randomBytes(32).toString('hex');
const refreshHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
await this.prisma.refreshToken.create({
data: {
userId: user.id,
tokenHash: refreshHash,
expiresAt,
},
});
return {
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
venueId: user.venueId,
},
};
}
async generateDeviceToken(): Promise<{ rawToken: string; hash: string; prefix: string; expiresAt: Date }> {
const rawBytes = crypto.randomBytes(TOKEN_BYTE_LENGTH);
const rawToken = 'dev_' + rawBytes.toString('base64url');
const hash = crypto.createHash('sha256').update(rawToken).digest('hex');
const prefix = rawToken.substring(0, TOKEN_PREFIX_LENGTH);
const expiresAt = new Date(Date.now() + TOKEN_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
return { rawToken, hash, prefix, expiresAt };
}
async registerDevice(venueId: string, name: string, registrationCode: string) {
const code = await this.prisma.registrationCode.findUnique({ where: { code: registrationCode } });
if (!code || code.usedAt || code.expiresAt < new Date()) {
throw new UnauthorizedException({ code: 'invalid_registration_code', message: 'Invalid or expired registration code' });
}
if (code.venueId !== venueId) {
throw new UnauthorizedException({ code: 'venue_mismatch', message: 'Registration code does not match venue' });
}
// Claim the code atomically so it can only ever be used once
const claimed = await this.prisma.registrationCode.updateMany({
where: { id: code.id, usedAt: null },
data: { usedAt: new Date() },
});
if (claimed.count === 0) {
throw new UnauthorizedException({ code: 'invalid_registration_code', message: 'Invalid or expired registration code' });
}
const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken();
// A venue can have several terminals: every pairing adds one. A terminal that is replaced is unpaired on the
// dashboard
const deviceId = uuidv4();
await this.prisma.device.create({
data: {
id: deviceId,
venueId,
name,
tokenHash: hash,
tokenPrefix: prefix,
tokenExpiresAt: expiresAt,
},
});
await this.auditService.log(venueId, 'device', deviceId, 'device_registered', { deviceName: name });
this.realtime.notify(venueId, 'device');
return { deviceId, token: rawToken, tokenExpiresAt: expiresAt };
}
async createUser(venueId: string, email: string, password: string, name: string, role: string) {
const venue = await this.prisma.venue.findUnique({ where: { id: venueId } });
if (!venue) {
throw new NotFoundException({ code: 'venue_not_found', message: 'Venue not found. Your session may be stale — try logging out and back in.' });
}
const existingUser = await this.prisma.clientUser.findUnique({ where: { email } });
if (existingUser) {
throw new ConflictException({ code: 'email_exists', message: 'A user with this email already exists' });
}
const passwordHash = await bcrypt.hash(password, 10);
const user = await this.prisma.clientUser.create({
data: {
id: uuidv4(),
venueId,
email,
passwordHash,
name,
role,
},
select: { id: true, email: true, name: true, role: true, venueId: true, createdAt: true },
});
this.realtime.notify(venueId, 'users');
return user;
await this.audit.log({ venueId: user.venueId, actor: { type: 'user', id: userId }, action: 'password_changed' });
return { me: toMe(user), tokens: await this.issueTokens(userId) };
}
/**
* Deletes a dashboard user and signs them out: their refresh tokens are revoked, so they are out once their access
* token runs out. An admin cannot delete themself, so a venue always keeps an admin.
* The user an access token belongs to, as the database has them now: a deleted or signed-out user loses access at
* once, and a changed role applies right away. Null for a missing, invalid or revoked token.
*/
async deleteUser(venueId: string, userId: string, actor: { id: string }) {
if (userId === actor.id) {
throw new BadRequestException({ code: 'cannot_delete_self', message: 'You cannot delete your own account' });
async userFromAccessToken(token: string | undefined): Promise<AuthUser | null> {
if (!token) return null;
let payload: { sub: string; iat?: number };
try {
payload = await this.jwt.verifyAsync(token);
} catch {
return null;
}
const user = await this.prisma.clientUser.findFirst({ where: { id: userId, venueId } });
if (!user) {
throw new NotFoundException({ code: 'user_not_found', message: 'User not found' });
}
await this.prisma.$transaction([
this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: new Date() } }),
this.prisma.clientUser.delete({ where: { id: userId } }),
]);
await this.auditService.log(venueId, 'client_user', actor.id, 'user_deleted', { userId, email: user.email, role: user.role });
this.realtime.notify(venueId, 'users');
return { ok: true };
const user = await this.prisma.user.findUnique({ where: { id: payload.sub }, select: userSelect });
if (!user || issuedBefore(payload, user.sessionsRevokedAt)) return null;
return { id: user.id, email: user.email, name: user.name, role: user.role, venueId: user.venueId };
}
async validateDevice(deviceId: string, token: string): Promise<any> {
const hash = crypto.createHash('sha256').update(token).digest('hex');
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
if (!device || device.tokenHash !== hash) {
throw new UnauthorizedException({ code: 'invalid_device_token', message: 'Invalid device token' });
}
if (device.revokedAt) {
throw new UnauthorizedException({ code: 'device_revoked', message: 'Device has been revoked' });
}
if (device.tokenExpiresAt < new Date()) {
throw new UnauthorizedException({ code: 'token_expired', message: 'Device token has expired' });
}
return device;
}
async generateRegistrationCode(venueId: string): Promise<{ code: string; expiresAt: Date }> {
const code = crypto.randomBytes(3).toString('hex').toUpperCase();
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
await this.prisma.registrationCode.create({
data: {
venueId,
code,
expiresAt,
},
private async issueTokens(userId: string): Promise<Tokens> {
const refreshToken = randomBytes(32).toString('base64url');
await this.prisma.refreshToken.create({
data: { userId, tokenHash: sha256(refreshToken), expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS) },
});
return { code, expiresAt };
return { accessToken: await this.jwt.signAsync({ sub: userId }), refreshToken };
}
}
}
function toMe(user: { id: string; email: string; name: string; role: Me['role']; venueId: string | null; venue: { name: string } | null }): Me {
return { id: user.id, email: user.email, name: user.name, role: user.role, venueId: user.venueId, venueName: user.venue?.name ?? null };
}
+37
View File
@@ -0,0 +1,37 @@
import { CookieOptions, Response } from 'express';
export const ACCESS_COOKIE = 'kooltap_access';
export const REFRESH_COOKIE = 'kooltap_refresh';
export const ACCESS_TOKEN_TTL_MS = 15 * 60 * 1000;
export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
/**
* Tokens live in httpOnly cookies on the API's host, so no script on the dashboard can read them. SameSite=Lax: the
* dashboard and the API must share a site (kooltap.com and api.kooltap.com, or localhost with two ports).
*/
function options(maxAge: number, path: string): CookieOptions {
return { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', path, maxAge };
}
// The refresh token is only sent to the endpoints that need it
const REFRESH_PATH = '/api/v1/auth';
export function setAuthCookies(res: Response, tokens: { accessToken: string; refreshToken: string }) {
res.cookie(ACCESS_COOKIE, tokens.accessToken, options(ACCESS_TOKEN_TTL_MS, '/'));
res.cookie(REFRESH_COOKIE, tokens.refreshToken, options(REFRESH_TOKEN_TTL_MS, REFRESH_PATH));
}
export function clearAuthCookies(res: Response) {
res.clearCookie(ACCESS_COOKIE, { ...options(0, '/'), maxAge: undefined });
res.clearCookie(REFRESH_COOKIE, { ...options(0, REFRESH_PATH), maxAge: undefined });
}
/** A cookie from a raw Cookie header, for the websocket handshake. */
export function cookieFromHeader(header: string | undefined, name: string): string | null {
for (const part of header?.split(';') ?? []) {
const at = part.indexOf('=');
if (at > 0 && part.slice(0, at).trim() === name) return decodeURIComponent(part.slice(at + 1).trim());
}
return null;
}
@@ -1,13 +0,0 @@
import { IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ChangePasswordDto {
@ApiProperty({ example: 'OldPass123!' })
@IsString()
currentPassword: string;
@ApiProperty({ example: 'NewPass123!' })
@IsString()
@MinLength(6)
newPassword: string;
}
-13
View File
@@ -1,13 +0,0 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty({ example: 'admin@example.com' })
@IsEmail()
email: string;
@ApiProperty({ example: 'Admin123!' })
@IsString()
@MinLength(6)
password: string;
}
@@ -1,8 +0,0 @@
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RefreshTokenDto {
@ApiProperty({ example: 'refresh-token-value' })
@IsString()
refreshToken: string;
}
@@ -1,12 +0,0 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class DeviceAuthGuard extends AuthGuard('device') {
handleRequest(err: any, device: any, info: any, context: ExecutionContext) {
if (err || !device) {
throw err || new (require('@nestjs/common').UnauthorizedException)({ code: 'unauthorized', message: 'Invalid device authentication' });
}
return device;
}
}
@@ -1,12 +0,0 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
handleRequest(err: any, user: any, info: any, context: ExecutionContext) {
if (err || !user) {
throw err || new (require('@nestjs/common').UnauthorizedException)({ code: 'unauthorized', message: 'Authentication required' });
}
return user;
}
}
@@ -1,29 +0,0 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-custom';
import { Request } from 'express';
import { AuthService } from '../auth.service';
@Injectable()
export class DeviceStrategy extends PassportStrategy(Strategy, 'device') {
constructor(private authService: AuthService) {
super();
}
async validate(request: Request): Promise<any> {
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException({ code: 'missing_token', message: 'Missing device token' });
}
const token = authHeader.substring(7);
const deviceId = request.params.deviceId || request.body?.deviceId;
if (!deviceId) {
throw new UnauthorizedException({ code: 'missing_device_id', message: 'Missing device ID' });
}
const device = await this.authService.validateDevice(deviceId, token);
return device;
}
}
@@ -1,41 +0,0 @@
import { UnauthorizedException } from '@nestjs/common';
import { issuedBefore, JwtStrategy } from './jwt.strategy';
const mona = { id: 'user-1', email: 'mona@example.com', name: 'Mona', role: 'manager', venueId: 'venue-1' };
function strategy(users: (typeof mona & { sessionsRevokedAt?: Date | null })[]) {
const prisma = { clientUser: { findUnique: async ({ where }: any) => users.find((u) => u.id === where.id) ?? null } };
return new JwtStrategy(prisma as any);
}
describe('JwtStrategy', () => {
it('takes the user as it is now, not as it was when the token was issued', async () => {
const user = await strategy([{ ...mona, role: 'admin' }]).validate({ sub: 'user-1', email: mona.email, role: 'manager', venueId: 'venue-1' });
expect(user).toEqual({ ...mona, role: 'admin' });
});
it('refuses the token of a deleted user', async () => {
await expect(strategy([]).validate({ sub: 'user-1', role: 'admin', venueId: 'venue-1' })).rejects.toBeInstanceOf(UnauthorizedException);
});
it('refuses a token issued before the user was signed out everywhere, and accepts a newer one', async () => {
const signedOutAt = new Date('2026-10-26T12:00:00Z');
const users = [{ ...mona, sessionsRevokedAt: signedOutAt }];
const before = Math.floor(signedOutAt.getTime() / 1000) - 60;
const after = Math.floor(signedOutAt.getTime() / 1000) + 60;
await expect(strategy(users).validate({ sub: 'user-1', iat: before })).rejects.toBeInstanceOf(UnauthorizedException);
expect(await strategy(users).validate({ sub: 'user-1', iat: after })).toEqual(mona);
});
});
describe('issuedBefore', () => {
it('only refuses tokens from before the sign-out', () => {
const at = new Date('2026-10-26T12:00:00.500Z');
const second = Math.floor(at.getTime() / 1000);
expect(issuedBefore({ iat: second - 1 }, at)).toBe(true);
expect(issuedBefore({ iat: second }, at)).toBe(false);
expect(issuedBefore({ iat: second - 1 }, null)).toBe(false);
expect(issuedBefore({ iat: second - 1 }, undefined)).toBe(false);
});
});
@@ -1,34 +0,0 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { getJwtSecret } from '../jwt-secret';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private prisma: PrismaService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: getJwtSecret(),
});
}
// Looked up on every request: a deleted or signed-out user loses access at once instead of when the token expires,
// and a changed role or name applies right away
async validate(payload: any) {
const user = await this.prisma.clientUser.findUnique({
where: { id: payload.sub },
select: { id: true, email: true, name: true, role: true, venueId: true, sessionsRevokedAt: true },
});
if (!user || issuedBefore(payload, user.sessionsRevokedAt)) {
throw new UnauthorizedException({ code: 'unauthorized', message: 'Authentication required' });
}
const { sessionsRevokedAt, ...rest } = user;
return rest;
}
}
/** Whether the token was issued before the user's sessions were revoked. iat is in whole seconds. */
export function issuedBefore(payload: { iat?: number }, revokedAt: Date | null | undefined): boolean {
return revokedAt != null && (payload.iat ?? 0) < Math.floor(revokedAt.getTime() / 1000);
}
+17
View File
@@ -0,0 +1,17 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { ACCESS_COOKIE } from './cookies';
import { AuthService } from './auth.service';
/** The dashboard user from the access token cookie; 401 when it is missing, expired or revoked. */
@Injectable()
export class UserAuthGuard implements CanActivate {
constructor(private auth: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const user = await this.auth.userFromAccessToken(request.cookies?.[ACCESS_COOKIE]);
if (!user) throw new UnauthorizedException({ code: 'unauthorized', message: 'Authentication required' });
request.user = user;
return true;
}
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Creates the first venue and its admin on a new database. Does nothing when there is a venue already.
*
* node dist/cli/setup.js "<venue name>" <admin email> <admin password>
* (or SETUP_VENUE_NAME, SETUP_ADMIN_EMAIL, SETUP_ADMIN_PASSWORD)
*/
import 'dotenv/config';
import { hashPassword } from '../auth/auth.service';
import { createPrismaClient } from '../prisma/prisma.service';
import { ensureDefaultCustomers } from '../customers/default-customers';
async function main() {
const [venueName = process.env.SETUP_VENUE_NAME, email = process.env.SETUP_ADMIN_EMAIL, password = process.env.SETUP_ADMIN_PASSWORD] = process.argv.slice(2);
if (!venueName || !email || !password) {
throw new Error('Usage: setup "<venue name>" <admin email> <admin password>');
}
if (password.length < 10) throw new Error('The admin password must be at least 10 characters');
const prisma = createPrismaClient();
try {
const venues = await prisma.venue.count();
if (venues > 0) {
console.log(`The database already has ${venues} venue(s); nothing to do.`);
return;
}
const venue = await prisma.$transaction(async (tx) => {
const created = await tx.venue.create({ data: { name: venueName } });
await ensureDefaultCustomers(tx, created.id);
await tx.user.create({
data: { venueId: created.id, email: email.trim().toLowerCase(), name: 'Admin', role: 'admin', passwordHash: await hashPassword(password) },
});
return created;
});
console.log(`Created ${venue.name} with admin ${email}. Sign in to the dashboard with those credentials.`);
} finally {
await prisma.$disconnect();
}
}
main().catch((error) => {
console.error(`Setup failed: ${error.message ?? error}`);
process.exitCode = 1;
});
+50
View File
@@ -0,0 +1,50 @@
/**
* Superadmins run KoolTap itself, above the venues. There is no other way to get the first one: the dashboard only lets a
* superadmin hand out the role.
*
* node dist/cli/superadmin.js <email> makes an existing user a superadmin
* node dist/cli/superadmin.js <email> --create "<name>" <password> adds a new superadmin
* node dist/cli/superadmin.js <email> --revoke <venue id> makes a superadmin an admin of that venue
*/
import 'dotenv/config';
import { hashPassword } from '../auth/auth.service';
import { createPrismaClient } from '../prisma/prisma.service';
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
async function main() {
const [rawEmail, flag, ...rest] = process.argv.slice(2);
if (!rawEmail) throw new Error('Usage: superadmin <email> [--create "<name>" <password> | --revoke <venue id>]');
const email = rawEmail.trim().toLowerCase();
const prisma = createPrismaClient();
try {
if (flag === '--create') {
const [name, password] = rest;
if (!name || !password || password.length < 10) throw new Error('Give a name and a password of at least 10 characters');
if (await prisma.user.findUnique({ where: { email } })) throw new Error(`${email} exists already: leave out --create to promote them`);
await prisma.user.create({ data: { email, name, role: 'superadmin', venueId: null, passwordHash: await hashPassword(password) } });
console.log(`Added superadmin ${email}`);
} else if (flag === '--revoke') {
const [venueId] = rest;
if (!venueId) throw new Error('Give the venue the user becomes an admin of');
const venue = UUID.test(venueId) ? await prisma.venue.findUnique({ where: { id: venueId } }) : null;
if (!venue) throw new Error(`No venue with id ${venueId}`);
const { count } = await prisma.user.updateMany({ where: { email, role: 'superadmin' }, data: { role: 'admin', venueId } });
if (count === 0) throw new Error(`No superadmin with email ${email}`);
console.log(`${email} is now an admin of ${venue.name}`);
} else {
// Leaves their venue: a superadmin is above the venues
const { count } = await prisma.user.updateMany({ where: { email }, data: { role: 'superadmin', venueId: null } });
if (count === 0) throw new Error(`No user with email ${email}`);
console.log(`${email} is now a superadmin`);
}
} finally {
await prisma.$disconnect();
}
}
main().catch((error) => {
console.error(error.message ?? error);
process.exitCode = 1;
});
@@ -1,71 +0,0 @@
import {
Controller,
Post,
Get,
Param,
Query,
Body,
UseGuards,
HttpCode,
HttpStatus,
Req,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { CommandsService } from './commands.service';
import { DevicesService } from '../devices/devices.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
import { CreateCommandDto } from './dto/create-command.dto';
@ApiTags('Commands')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, VenueAccessGuard)
@Controller('venues/:venueId/commands')
export class CommandsController {
constructor(
private commandsService: CommandsService,
private devicesService: DevicesService,
) {}
@Post()
@ApiOperation({ summary: 'Create a remote command' })
async create(
@Param('venueId') venueId: string,
@Body() dto: CreateCommandDto,
@Req() req: any,
) {
return this.commandsService.create(venueId, dto, req.user);
}
@Get()
@ApiOperation({ summary: 'List command history' })
@ApiQuery({ name: 'status', required: false })
@ApiQuery({ name: 'limit', required: false })
@ApiQuery({ name: 'offset', required: false })
async findAll(
@Param('venueId') venueId: string,
@Query('status') status?: string,
@Query('limit') limit?: string,
@Query('offset') offset?: string,
) {
return this.commandsService.findAll(venueId, {
status,
limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200),
offset: Math.max(parseInt(offset ?? '', 10) || 0, 0),
});
}
@Post('devices/:deviceId/revoke')
@UseGuards(RolesGuard)
@Roles('admin')
@ApiOperation({ summary: 'Revoke a device (admin only)' })
@ApiResponse({ status: 200, description: 'Device revoked' })
async revoke(
@Param('venueId') venueId: string,
@Param('deviceId') deviceId: string,
) {
return this.devicesService.revoke(venueId, deviceId);
}
}
-12
View File
@@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { DevicesModule } from '../devices/devices.module';
import { CommandsController } from './commands.controller';
import { CommandsService } from './commands.service';
@Module({
imports: [DevicesModule],
controllers: [CommandsController],
providers: [CommandsService],
exports: [CommandsService],
})
export class CommandsModule {}
@@ -1,78 +0,0 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { CommandsService } from './commands.service';
import { createFakePrisma } from '../testing/fake-prisma';
const user = { id: 'u1', name: 'Staff', email: 'staff@example.com' };
const dto = { tabId: 't1', type: 'close_tab', expectedVersion: 3, paymentMethod: 'cash' };
const unit = (id: string, removed = false) => (
{ id, addedAt: '2026-09-24T20:00:00.000Z', removedAt: removed ? '2026-09-24T20:01:00.000Z' : null });
function createService({ lines = [] as unknown[] } = {}) {
const prisma = createFakePrisma();
prisma.venue.rows.push({ id: 'v1', tabsSeededAt: new Date('2026-10-01') });
prisma.tab.rows.push({
venueId: 'v1', id: 't1', customerName: 'Jan', openedAt: new Date('2026-09-24T19:00:00Z'), version: 3, lines,
updatedAt: new Date(),
});
const realtime = { notify: jest.fn() };
const cashPayments = { notify: jest.fn() };
const service = new CommandsService(prisma as any, { log: jest.fn() } as any, realtime as any, cashPayments as any);
return { service, prisma, realtime, cashPayments };
}
const colaLine = {
id: 'line-1', productId: 'cola', productName: 'Cola', unitPriceInCents: 250,
units: [unit('u1'), unit('u2'), unit('u3', true)],
};
describe('CommandsService.create', () => {
it('settles everything on the tab at once and records it as completed', async () => {
const { service, prisma, realtime, cashPayments } = createService({ lines: [colaLine] });
const result = await service.create('v1', dto, user);
expect(result).toMatchObject({ status: 'completed', amountInCents: 500 });
expect(prisma.tab.rows[0]).toMatchObject({ version: 4, lines: [] });
expect(prisma.settlement.rows).toHaveLength(1);
expect(prisma.settlement.rows[0]).toMatchObject({
tabId: 't1', customerName: 'Jan', paymentMethod: 'cash', amountInCents: 500, requestedBy: 'Staff', deviceId: null,
});
// The struck-through drink goes along, so the history shows it
expect(prisma.settlement.rows[0].lines).toMatchObject([{ quantity: 2, units: [{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }] }]);
expect(prisma.remoteCommand.rows[0]).toMatchObject({
id: result.id, deviceId: null, status: 'completed', customerName: 'Jan', amountInCents: 500,
});
expect(realtime.notify).toHaveBeenCalledWith('v1', 'commands', 'tabs');
expect(cashPayments.notify).toHaveBeenCalledWith('v1', [expect.objectContaining({ paymentMethod: 'cash', amountInCents: 500 })], 'Staff');
});
it('rejects a tab that is not open', async () => {
const { service } = createService({ lines: [colaLine] });
await expect(service.create('v1', { ...dto, tabId: 't9' }, user)).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects a tab that changed since it was loaded, changing nothing', async () => {
const { service, prisma } = createService({ lines: [colaLine] });
await expect(service.create('v1', { ...dto, expectedVersion: 2 }, user))
.rejects.toMatchObject({ response: { code: 'version_mismatch' } });
expect(prisma.settlement.rows).toEqual([]);
});
it('rejects a tab with nothing on it', async () => {
const { service } = createService({ lines: [] });
await expect(service.create('v1', dto, user)).rejects.toBeInstanceOf(ConflictException);
});
it('rejects an unknown payment method or command type', async () => {
const { service } = createService({ lines: [colaLine] });
await expect(service.create('v1', { ...dto, paymentMethod: 'bitcoin' }, user))
.rejects.toMatchObject({ response: { code: 'invalid_payment_method' } });
await expect(service.create('v1', { ...dto, type: 'open_tab' }, user))
.rejects.toMatchObject({ response: { code: 'invalid_command_type' } });
});
});
-173
View File
@@ -1,173 +0,0 @@
import {
Injectable,
NotFoundException,
BadRequestException,
ConflictException,
} from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { RealtimeService } from '../realtime/realtime.service';
import { CreateCommandDto } from './dto/create-command.dto';
import { VenueLedger } from '../tabs/venue-ledger';
import { CashPaymentNotifier } from '../email/cash-payment-notifier';
@Injectable()
export class CommandsService {
constructor(
private prisma: PrismaService,
private auditService: AuditService,
private realtime: RealtimeService,
private cashPayments: CashPaymentNotifier,
) {}
/**
* Settles a tab on the server, at once, also when every terminal is offline: everything still on the tab is
* settled, and the terminals see the lines disappear on their next sync. Recorded as a completed command for the
* history.
*/
async create(venueId: string, dto: CreateCommandDto, user: any) {
if (!dto.paymentMethod) {
throw new BadRequestException({
code: 'payment_method_required',
message: 'Payment method must be explicitly selected',
});
}
const validMethods = ['cash', 'payconiq', 'payment_pending'];
if (!validMethods.includes(dto.paymentMethod)) {
throw new BadRequestException({
code: 'invalid_payment_method',
message: `Payment method must be one of: ${validMethods.join(', ')}`,
});
}
const type = dto.type || 'close_tab';
if (type !== 'close_tab') {
throw new BadRequestException({ code: 'invalid_command_type', message: `Unknown command type: ${type}` });
}
const requestedBy = user.name || user.email;
const { result, settlements } = await this.prisma.$transaction(async (tx) => {
const now = new Date();
const ledger = await VenueLedger.open(tx, venueId);
const tab = ledger.tabs.get(dto.tabId);
if (!tab) {
throw new NotFoundException({ code: 'tab_not_found', message: 'This tab is no longer open' });
}
if (tab.version !== dto.expectedVersion) {
throw new ConflictException({
code: 'version_mismatch',
message: 'The tab has changed since it was loaded. Refresh and try again.',
});
}
const id = uuidv4();
const settlement = ledger.tabs.settleAll(dto.tabId, { id, settledAt: now, paymentMethod: dto.paymentMethod });
if (!settlement) {
throw new ConflictException({ code: 'tab_empty', message: 'There is nothing on this tab to settle' });
}
ledger.settlements.push({ ...settlement, commandId: id, requestedBy });
await ledger.save(tx, now);
const command = await tx.remoteCommand.create({
data: {
id,
venueId,
deviceId: null,
targetTabId: dto.tabId,
customerName: settlement.customerName,
amountInCents: settlement.amountInCents,
type,
expectedVersion: dto.expectedVersion,
paymentMethod: dto.paymentMethod,
requestedBy,
status: 'completed',
acknowledgedAt: now,
expiresAt: now,
},
});
await tx.auditLog.create({
data: {
id: uuidv4(),
venueId,
actorType: 'client_user',
actorId: user.id,
action: 'tab_settled',
details: JSON.stringify({
commandId: command.id,
tabId: dto.tabId,
paymentMethod: dto.paymentMethod,
amountInCents: settlement.amountInCents,
}),
},
});
return {
result: { id: command.id, status: command.status, amountInCents: settlement.amountInCents },
settlements: ledger.settlements,
};
});
this.realtime.notify(venueId, 'commands', 'tabs');
this.cashPayments.notify(venueId, settlements, requestedBy);
return result;
}
async findAll(
venueId: string,
options: { status?: string; limit: number; offset: number },
) {
const where: any = { venueId };
if (options.status) {
where.status = options.status;
}
const [commands, total] = await Promise.all([
this.prisma.remoteCommand.findMany({
where,
select: {
id: true,
targetTabId: true,
customerName: true,
amountInCents: true,
type: true,
status: true,
paymentMethod: true,
requestedBy: true,
expectedVersion: true,
createdAt: true,
acknowledgedAt: true,
result: true,
deviceId: true,
},
orderBy: { createdAt: 'desc' },
take: options.limit,
skip: options.offset,
}),
this.prisma.remoteCommand.count({ where }),
]);
return {
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,
requestedBy: c.requestedBy,
expectedVersion: c.expectedVersion,
createdAt: c.createdAt,
processedAt: c.acknowledgedAt,
result: c.result,
deviceId: c.deviceId,
})),
total,
};
}
}
@@ -1,22 +0,0 @@
import { IsString, IsIn, IsInt, Min, IsUUID } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateCommandDto {
@ApiProperty({ example: 'local-tab-uuid' })
@IsString()
tabId: string;
@ApiProperty({ example: 'close_tab', default: 'close_tab' })
@IsString()
type: string = 'close_tab';
@ApiProperty({ example: 5 })
@IsInt()
@Min(0)
expectedVersion: number;
@ApiProperty({ example: 'cash', enum: ['cash', 'payconiq', 'payment_pending'] })
@IsString()
@IsIn(['cash', 'payconiq', 'payment_pending'])
paymentMethod: string;
}
+51
View File
@@ -0,0 +1,51 @@
import {
CanActivate, createParamDecorator, ExecutionContext, ForbiddenException, Injectable, SetMetadata,
UseGuards,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '@kooltap/shared';
import { UserAuthGuard } from '../auth/user-auth.guard';
/** The signed-in dashboard user, as the database has them now. A superadmin has no venue. */
export type AuthUser = { id: string; email: string; name: string; role: Role; venueId: string | null };
/** The terminal a request comes from. */
export type AuthDevice = { id: string; venueId: string; name: string; lastSeenAt: Date };
export const CurrentUser = createParamDecorator((_: unknown, context: ExecutionContext): AuthUser => (
context.switchToHttp().getRequest().user));
export const CurrentDevice = createParamDecorator((_: unknown, context: ExecutionContext): AuthDevice => (
context.switchToHttp().getRequest().device));
const ROLES_KEY = 'roles';
/** A superadmin may do everything an admin or manager may, in every venue. */
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const roles = this.reflector.getAllAndOverride<Role[] | undefined>(ROLES_KEY, [context.getHandler(), context.getClass()]);
const user: AuthUser | undefined = context.switchToHttp().getRequest().user;
if (!roles || user?.role === 'superadmin' || (user && roles.includes(user.role))) return true;
throw new ForbiddenException({ code: 'forbidden', message: 'Your role does not allow this' });
}
}
/** A user only reaches their own venue's routes (`:venueId`); a superadmin reaches every venue's. */
@Injectable()
export class VenueAccessGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const venueId: string | undefined = request.params?.venueId;
const user: AuthUser | undefined = request.user;
if (!venueId || user?.role === 'superadmin' || user?.venueId === venueId) return true;
throw new ForbiddenException({ code: 'venue_forbidden', message: 'You do not have access to this venue' });
}
}
/** Signed in, with access to the route's venue, and one of the @Roles when the route has them. */
export const Authorized = () => UseGuards(UserAuthGuard, VenueAccessGuard, RolesGuard);
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
-1
View File
@@ -1 +0,0 @@
export { Roles, ROLES_KEY } from './roles.decorator';
@@ -1,4 +0,0 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
@@ -1,38 +0,0 @@
import { RolesGuard } from './roles.guard';
function context(role: string | undefined) {
return {
getHandler: () => null,
getClass: () => null,
switchToHttp: () => ({ getRequest: () => ({ user: role ? { role } : undefined }) }),
} as any;
}
function guard(required: string[] | undefined) {
return new RolesGuard({ getAllAndOverride: () => required } as any);
}
describe('RolesGuard', () => {
it('lets managers do what managers may, and keeps admin-only actions for the admin', () => {
expect(guard(['admin', 'manager']).canActivate(context('manager'))).toBe(true);
expect(guard(['admin']).canActivate(context('manager'))).toBe(false);
expect(guard(['admin']).canActivate(context('admin'))).toBe(true);
});
it('treats a token from before the rename ("viewer") as a manager', () => {
expect(guard(['admin', 'manager']).canActivate(context('viewer'))).toBe(true);
expect(guard(['admin']).canActivate(context('viewer'))).toBe(false);
});
it('lets a superadmin do everything, and keeps the admin page for superadmins', () => {
expect(guard(['admin']).canActivate(context('superadmin'))).toBe(true);
expect(guard(['admin', 'manager']).canActivate(context('superadmin'))).toBe(true);
expect(guard(['superadmin']).canActivate(context('superadmin'))).toBe(true);
expect(guard(['superadmin']).canActivate(context('admin'))).toBe(false);
});
it('refuses a request without a user when a role is required', () => {
expect(guard(['manager']).canActivate(context(undefined))).toBe(false);
expect(guard(undefined).canActivate(context(undefined))).toBe(true);
});
});
-26
View File
@@ -1,26 +0,0 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user) {
return false;
}
// "viewer" was the old name of a manager; tokens issued before the rename still carry it
const role = user.role === 'viewer' ? 'manager' : user.role;
// A superadmin may do everything an admin or manager may, in every venue
return role === 'superadmin' || requiredRoles.includes(role);
}
}
@@ -1,33 +0,0 @@
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { VenueAccessGuard } from './venue-access.guard';
function contextFor(params: Record<string, string>, user?: { venueId: string; role?: string }): ExecutionContext {
return {
switchToHttp: () => ({ getRequest: () => ({ params, user }) }),
} as unknown as ExecutionContext;
}
describe('VenueAccessGuard', () => {
const guard = new VenueAccessGuard();
it('allows routes without a venueId param', () => {
expect(guard.canActivate(contextFor({}, { venueId: 'a' }))).toBe(true);
});
it('allows access to the user\'s own venue', () => {
expect(guard.canActivate(contextFor({ venueId: 'a' }, { venueId: 'a' }))).toBe(true);
});
it('rejects access to another venue', () => {
expect(() => guard.canActivate(contextFor({ venueId: 'b' }, { venueId: 'a' }))).toThrow(ForbiddenException);
});
it('lets a superadmin into every venue', () => {
expect(guard.canActivate(contextFor({ venueId: 'b' }, { venueId: 'a', role: 'superadmin' }))).toBe(true);
expect(() => guard.canActivate(contextFor({ venueId: 'b' }, { venueId: 'a', role: 'admin' }))).toThrow(ForbiddenException);
});
it('rejects when no user is attached', () => {
expect(() => guard.canActivate(contextFor({ venueId: 'a' }))).toThrow(ForbiddenException);
});
});
@@ -1,27 +0,0 @@
import { Injectable, ExecutionContext, CanActivate, ForbiddenException } from '@nestjs/common';
/**
* Ensures the authenticated user can only access the venue they belong to. A superadmin can access every venue: that
* is how they view the dashboard as a venue. Must run after JwtAuthGuard so that `request.user` is populated.
*/
@Injectable()
export class VenueAccessGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const venueId = request.params?.venueId;
if (!venueId) {
return true;
}
if (request.user?.role === 'superadmin') {
return true;
}
if (!request.user || request.user.venueId !== venueId) {
throw new ForbiddenException({ code: 'venue_forbidden', message: 'You do not have access to this venue' });
}
return true;
}
}
@@ -1,20 +0,0 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable, tap } from 'rxjs';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { method, url } = request;
const start = Date.now();
return next.handle().pipe(
tap(() => {
const duration = Date.now() - start;
const userId = request.user?.id || request.device?.id || 'anonymous';
const venueId = request.user?.venueId || request.device?.venueId || '-';
console.log(`[${method}] ${url} | user=${userId} venue=${venueId} | ${duration}ms`);
}),
);
}
}
@@ -0,0 +1,19 @@
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
import { Observable, tap } from 'rxjs';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest();
const start = Date.now();
return next.handle().pipe(
tap(() => {
const actor = request.user?.id ?? request.device?.id ?? 'anonymous';
const venueId = request.user?.venueId ?? request.device?.venueId ?? '-';
this.logger.log(`${request.method} ${request.url} | actor=${actor} venue=${venueId} | ${Date.now() - start}ms`);
}),
);
}
}
+17
View File
@@ -0,0 +1,17 @@
import { BadRequestException, PipeTransform } from '@nestjs/common';
import { z } from 'zod';
/** Validates and converts a body or query with a schema from @kooltap/shared: `@Body(zod(schema))`. */
export class ZodPipe<S extends z.ZodType> implements PipeTransform<unknown, z.output<S>> {
constructor(private readonly schema: S) {}
transform(value: unknown): z.output<S> {
const result = this.schema.safeParse(value);
if (!result.success) {
throw new BadRequestException({ code: 'validation_error', message: z.prettifyError(result.error) });
}
return result.data;
}
}
export const zod = <S extends z.ZodType>(schema: S) => new ZodPipe(schema);
+4
View File
@@ -0,0 +1,4 @@
/** The dashboard's origin(s), comma separated in CORS_ORIGIN. */
export function corsOrigin(): string[] {
return (process.env.CORS_ORIGIN || 'http://localhost:3000').split(',').map((origin) => origin.trim()).filter(Boolean);
}
@@ -0,0 +1,36 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { z } from 'zod';
import { Customer, customerSchema, ImportResult, importCustomersSchema } from '@kooltap/shared';
import { zod } from '../common/zod.pipe';
import { Authorized, Roles } from '../common/auth';
import { CustomersService } from './customers.service';
@Controller('venues/:venueId/customers')
@Authorized()
export class CustomersController {
constructor(private customers: CustomersService) {}
@Get()
findAll(@Param('venueId') venueId: string): Promise<Customer[]> {
return this.customers.findAll(venueId);
}
@Post()
@Roles('admin', 'manager')
create(@Param('venueId') venueId: string, @Body(zod(customerSchema)) dto: z.output<typeof customerSchema>): Promise<Customer> {
return this.customers.create(venueId, dto);
}
@Post('import')
@HttpCode(HttpStatus.OK)
@Roles('admin', 'manager')
import(@Param('venueId') venueId: string, @Body(zod(importCustomersSchema)) dto: z.output<typeof importCustomersSchema>): Promise<ImportResult> {
return this.customers.import(venueId, dto.entries, dto.mode);
}
@Delete(':id')
@Roles('admin', 'manager')
remove(@Param('venueId') venueId: string, @Param('id', ParseUUIDPipe) id: string) {
return this.customers.remove(venueId, id);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { CustomersController } from './customers.controller';
import { CustomersService } from './customers.service';
@Module({
controllers: [CustomersController],
providers: [CustomersService],
})
export class CustomersModule {}
+101
View File
@@ -0,0 +1,101 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Customer, ImportResult } from '@kooltap/shared';
import { Prisma, PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service';
import { customerNameKey, normalizeCustomerName } from './customer-name';
const customerSelect = { id: true, name: true, email: true, position: true, createdAt: true, tabOpenedAt: true } as const;
function toCustomer(row: { id: string; name: string; email: string | null; position: number; createdAt: Date; tabOpenedAt: Date | null }): Customer {
return { ...row, createdAt: row.createdAt.toISOString(), tabOpenedAt: row.tabOpenedAt?.toISOString() ?? null };
}
@Injectable()
export class CustomersService {
constructor(
private prisma: PrismaService,
private realtime: RealtimeService,
) {}
async findAll(venueId: string): Promise<Customer[]> {
const customers = await this.prisma.customer.findMany({ where: { venueId }, orderBy: { position: 'asc' }, select: customerSelect });
return customers.map(toCustomer);
}
/** Adds a customer at the end of the list; the next sync opens their tab. */
async create(venueId: string, dto: { name: string; email?: string | null }): Promise<Customer> {
const name = normalizeCustomerName(dto.name);
try {
const customer = await this.prisma.$transaction(async (tx) => {
const last = await tx.customer.aggregate({ where: { venueId }, _max: { position: true } });
return tx.customer.create({
data: { venueId, name, nameKey: customerNameKey(name), email: dto.email ?? null, position: (last._max.position ?? -1) + 1 },
select: customerSelect,
});
});
this.realtime.notify(venueId, 'customers');
return toCustomer(customer);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' });
}
throw error;
}
}
/**
* Bulk import of the customer list (e.g. from the venue's Excel file). Names match case-insensitively and the
* existing spelling is kept: tabs are matched by name and a respelled name would open a second tab. A missing email
* never clears an existing one. Customers whose tab was opened before get one again on the next sync when they have
* none (it may have been deleted since).
*/
async import(venueId: string, entries: { name: string; email?: string | null }[], mode: 'merge' | 'replace'): Promise<ImportResult> {
const incoming = new Map<string, { name: string; email: string | null }>();
for (const entry of entries) {
const name = normalizeCustomerName(entry.name);
const key = customerNameKey(name);
incoming.set(key, { name, email: entry.email || incoming.get(key)?.email || null });
}
const result = await this.prisma.$transaction(async (tx) => {
const existing = await tx.customer.findMany({ where: { venueId }, orderBy: { position: 'asc' } });
const existingByKey = new Map(existing.map((c) => [c.nameKey, c]));
let position = (existing.at(-1)?.position ?? -1) + 1;
const counts: ImportResult = { created: 0, updated: 0, unchanged: 0, removed: 0 };
const created = [...incoming].filter(([key]) => !existingByKey.has(key));
await tx.customer.createMany({
data: created.map(([nameKey, entry]) => ({ venueId, name: entry.name, nameKey, email: entry.email, position: position++ })),
});
counts.created = created.length;
for (const [key, entry] of incoming) {
const current = existingByKey.get(key);
if (!current) continue;
const newEmail = entry.email && entry.email !== current.email ? entry.email : undefined;
if (current.tabOpenedAt || newEmail) {
await tx.customer.update({ where: { id: current.id }, data: { tabOpenedAt: null, ...(newEmail ? { email: newEmail } : {}) } });
counts.updated++;
} else {
counts.unchanged++;
}
}
if (mode === 'replace') {
const removed = existing.filter((c) => !incoming.has(c.nameKey)).map((c) => c.id);
counts.removed = (await tx.customer.deleteMany({ where: { id: { in: removed } } })).count;
}
return counts;
}, { timeout: 30_000 });
this.realtime.notify(venueId, 'customers', 'tabs');
return result;
}
async remove(venueId: string, id: string) {
const { count } = await this.prisma.customer.deleteMany({ where: { id, venueId } });
if (count === 0) throw new NotFoundException({ code: 'customer_not_found', message: 'Customer not found' });
this.realtime.notify(venueId, 'customers', 'tabs');
return { ok: true };
}
}
@@ -0,0 +1,19 @@
import { Tx } from '../prisma/prisma.service';
import { customerNameKey } from './customer-name';
/** The tab free drinks are put on, so they still count in the stock and history. A normal customer without email. */
export const FREE_DRINKS_CUSTOMER = 'Free Drinks';
/**
* Adds the customers every venue starts with; the next sync opens their tabs. A customer that was removed, or whose
* tab was wiped, comes back: run when a venue is created or reset.
*/
export async function ensureDefaultCustomers(tx: Tx, venueId: string) {
const nameKey = customerNameKey(FREE_DRINKS_CUSTOMER);
await tx.customer.upsert({
where: { venueId_nameKey: { venueId, nameKey } },
create: { venueId, name: FREE_DRINKS_CUSTOMER, nameKey },
// Opened again when its tab is gone; an open tab with this name is kept
update: { tabOpenedAt: null },
});
}
@@ -1,97 +0,0 @@
import {
Controller,
Post,
Get,
Param,
Body,
UseGuards,
HttpCode,
HttpStatus,
Req,
Query,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { SkipThrottle, Throttle } from '@nestjs/throttler';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiConsumes } from '@nestjs/swagger';
import { ImagesService } from '../images/images.service';
import { MAX_UPLOAD_BYTES } from '../images/image-processing';
import { DevicesService } from './devices.service';
import { AuthService } from '../auth/auth.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { DeviceAuthGuard } from '../auth/guards/device-auth.guard';
import { AppVersionGuard } from './app-version.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { RegisterDeviceDto } from './dto/register-device.dto';
import { SyncDto } from './dto/sync.dto';
import { AckCommandDto } from './dto/ack-command.dto';
@ApiTags('Devices')
@Controller('devices')
export class DevicesController {
constructor(
private devicesService: DevicesService,
private authService: AuthService,
private images: ImagesService,
) {}
@Post('register')
@Throttle({ default: { limit: 10, ttl: 60000 } })
@UseGuards(AppVersionGuard)
@ApiOperation({ summary: 'Register a new device' })
@ApiResponse({ status: 201, description: 'Device registered successfully' })
async register(@Body() dto: RegisterDeviceDto) {
return this.authService.registerDevice(dto.venueId, dto.name, dto.registrationCode);
}
// Authenticated by the device token; the till must never be rate-limited out by other
// traffic from the venue's shared IP
@Post(':deviceId/sync')
@SkipThrottle()
@UseGuards(AppVersionGuard, DeviceAuthGuard)
@ApiOperation({ summary: 'Sync device state with server' })
@ApiResponse({ status: 200, description: 'Sync successful, returns pending commands' })
async sync(@Param('deviceId') deviceId: string, @Body() dto: SyncDto, @Req() req: any) {
return this.devicesService.sync(req.user, dto);
}
// A picture taken on a terminal. Only stored: the terminal links it to its product with a product_updated event,
// so an offline edit and its picture follow the same rules as every other change
@Post(':deviceId/images')
@SkipThrottle()
@UseGuards(AppVersionGuard, DeviceAuthGuard)
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_UPLOAD_BYTES } }))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload a product picture from a terminal' })
async uploadImage(@UploadedFile() file: Express.Multer.File | undefined) {
return { imageId: await this.images.store(file?.buffer) };
}
@Post(':deviceId/commands/:commandId/ack')
@SkipThrottle()
@UseGuards(AppVersionGuard, DeviceAuthGuard)
@ApiOperation({ summary: 'Acknowledge a command result' })
@ApiResponse({ status: 200, description: 'Command acknowledged' })
async ackCommand(
@Param('deviceId') deviceId: string,
@Param('commandId') commandId: string,
@Body() dto: AckCommandDto,
@Req() req: any,
) {
return this.devicesService.ackCommand(req.user, commandId, dto);
}
@Get('generation-code')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
@ApiBearerAuth()
@ApiOperation({ summary: 'Generate a registration code for pairing' })
@ApiResponse({ status: 200, description: 'Registration code generated' })
async generateCode(@Req() req: any, @Query('venueId') viewing?: string) {
// A superadmin pairs terminals for the venue they're viewing
const venueId = req.user.role === 'superadmin' && viewing ? viewing : req.user.venueId;
return this.authService.generateRegistrationCode(venueId);
}
}
-13
View File
@@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { DevicesController } from './devices.controller';
import { DevicesService } from './devices.service';
import { ImagesModule } from '../images/images.module';
@Module({
imports: [AuthModule, ImagesModule],
controllers: [DevicesController],
providers: [DevicesService],
exports: [DevicesService],
})
export class DevicesModule {}
@@ -1,694 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
import { DevicesService } from './devices.service';
import { SyncDto } from './dto/sync.dto';
import { createFakePrisma, FakePrisma } from '../testing/fake-prisma';
import { StockService } from '../stock/stock.service';
type Device = { id: string; venueId: string; name?: string; revokedAt: null; lastSeenAt: Date };
type SyncResult = Awaited<ReturnType<DevicesService['sync']>>;
// Yesterday at 20:00 UTC. In the past, as a time ahead of the server clock would be capped, and recent, as a new
// tablet's first settlements feed only goes back a few days
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
const at = (minutes: number) => new Date(Date.UTC(
yesterday.getUTCFullYear(), yesterday.getUTCMonth(), yesterday.getUTCDate(), 20, minutes,
)).toISOString();
function setup({ seeded = true } = {}) {
const prisma = createFakePrisma();
prisma.venue.rows.push({ id: 'v1', tabsSeededAt: seeded ? new Date('2026-10-01') : null });
const tabletA: Device = { id: 'd1', venueId: 'v1', name: 'Bar tablet', revokedAt: null, lastSeenAt: new Date() };
const tabletB: Device = { id: 'd2', venueId: 'v1', revokedAt: null, lastSeenAt: new Date() };
prisma.device.rows.push({ ...tabletA }, { ...tabletB });
const realtime = { notify: jest.fn() };
const cashPayments = { notify: jest.fn() };
const service = new DevicesService(prisma as any, { log: jest.fn() } as any, realtime as any, cashPayments as any);
const sync = (device: Device, body: Partial<SyncDto> = {}) =>
service.sync(device, { deviceId: device.id, openTabs: [], ...body } as SyncDto);
return { prisma, service, realtime, cashPayments, sync, tabletA, tabletB };
}
/** Builds the events a terminal sends, with fresh ids. */
const events = {
opened: (tabId: string, customerName: string, minutes = 0) => (
{ id: uuidv4(), type: 'tab_opened', tabId, customerName, occurredAt: at(minutes), openedAt: at(minutes) }),
renamed: (tabId: string, customerName: string, minutes = 0) => (
{ id: uuidv4(), type: 'tab_renamed', tabId, customerName, occurredAt: at(minutes) }),
deleted: (tabId: string, customerName: string, minutes = 0) => (
{ id: uuidv4(), type: 'tab_deleted', tabId, customerName, occurredAt: at(minutes) }),
line: (type: 'line_added' | 'line_removed', tabId: string, quantity: number, {
minutes = 0, lineId = undefined as string | undefined, productId = 'cola', productName = 'Cola', price = 250,
} = {}) => ({
id: uuidv4(), type, tabId, customerName: 'Alice', occurredAt: at(minutes),
lineId, productId, productName, unitPriceInCents: price, quantity,
}),
settled: (tabId: string, quantity: number, minutes = 0) => ({
id: uuidv4(), type: 'settled', tabId, customerName: 'Alice', occurredAt: at(minutes),
amountInCents: quantity * 250, paymentMethod: 'payconiq',
items: [{ productId: 'cola', productName: 'Cola', quantity, unitPriceInCents: 250 }],
}),
product: (type: string, productId: string, product: Record<string, unknown> | undefined, minutes = 0) => (
{ id: uuidv4(), type, productId, ...(product ? { product } : {}), occurredAt: at(minutes) }),
stockSet: (productId: string, quantity: number | null, minutes = 0) => (
{ id: uuidv4(), type: 'stock_set', productId, quantity, occurredAt: at(minutes) }),
};
const catalog = (stock: number | null = 0) => [
{ id: 'cola', name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock },
];
function tab(result: SyncResult, tabId: string) {
return result.tabs.find((t) => t.id === tabId);
}
function colaStock(result: SyncResult) {
return result.stock.find((s) => s.productId === 'cola')?.stock;
}
describe('DevicesService.sync: the server holds the tabs', () => {
it('shows a newly paired tablet the open tabs of the venue', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2)] });
const result = await sync(tabletB);
expect(result.tabs).toHaveLength(1);
expect(result.tabs[0]).toMatchObject({ id: 't1', customerName: 'Alice', version: 2 });
expect(result.tabs[0].lines).toMatchObject([{ productId: 'cola', productName: 'Cola', quantity: 2 }]);
});
it('gives units the ids the tablets give them when replaying', async () => {
const { sync, tabletA } = setup();
const added = events.line('line_added', 't1', 2, { lineId: 'line-1' });
const result = await sync(tabletA, { events: [events.opened('t1', 'Alice'), added] });
expect(tab(result, 't1')!.lines[0]).toMatchObject({
id: 'line-1',
units: [
{ id: `${added.id}:0`, addedAt: at(0), removedAt: null },
{ id: `${added.id}:1`, addedAt: at(0), removedAt: null },
],
});
});
it('adds what two tablets put on one tab', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2, { lineId: 'line-1' })] });
// Tablet B replayed its drink onto the server's line, so it uses that line id
await sync(tabletB, { events: [events.line('line_added', 't1', 1, { lineId: 'line-1', minutes: 1 })] });
const result = await sync(tabletA);
expect(tab(result, 't1')!.lines).toMatchObject([{ id: 'line-1', quantity: 3 }]);
});
it('applies an event once, also when it arrives again in a later sync', async () => {
const { sync, tabletA, prisma } = setup();
const added = events.line('line_added', 't1', 1);
await sync(tabletA, { events: [events.opened('t1', 'Alice'), added] });
const result = await sync(tabletA, { events: [added, added] });
expect(result.acknowledgedEventIds).toEqual([added.id, added.id]);
expect(tab(result, 't1')!.lines[0].quantity).toBe(1);
expect(prisma.tabEvent.rows).toHaveLength(2);
});
it('catches up with everything a tablet did offline, in order', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletB, { events: [events.opened('bob', 'Bob')] });
await sync(tabletA, {
events: [
events.opened('t1', 'Alice', 1),
events.line('line_added', 't1', 3, { minutes: 2 }),
events.line('line_removed', 't1', 1, { minutes: 3 }),
events.renamed('t1', 'Alice V.', 4),
events.deleted('bob', 'Bob', 5),
],
});
const result = await sync(tabletB);
expect(result.tabs.map((t) => t.customerName)).toEqual(['Alice V.']);
const [line] = tab(result, 't1')!.lines;
expect(line.quantity).toBe(2);
// The latest drink was taken back
expect(line.units.map((u) => u.removedAt)).toEqual([null, null, at(3)]);
});
it('settles only what the tablet saw, leaving a drink added elsewhere on the tab', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2)] });
await sync(tabletB, { events: [events.line('line_added', 't1', 1, { minutes: 5 })] });
// Tablet A was offline and settled the 2 Cola it knew about
const result = await sync(tabletA, { events: [events.settled('t1', 2, 3)] });
expect(tab(result, 't1')!.lines).toMatchObject([{ quantity: 1, units: [{ addedAt: at(5) }] }]);
expect(result.settlements).toHaveLength(1);
expect(result.settlements[0]).toMatchObject({ tabId: 't1', paymentMethod: 'payconiq', amountInCents: 500 });
expect(result.settlements[0].lines).toMatchObject([{ productId: 'cola', quantity: 2 }]);
expect(result.settlements[0].lines[0].units.map((u) => u.addedAt)).toEqual([at(0), at(0)]);
});
it('brings a tab deleted elsewhere back with the drink that was put on it', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, { events: [events.opened('bob', 'Bob')] });
await sync(tabletB, { events: [events.deleted('bob', 'Bob', 1)] });
const result = await sync(tabletA, {
events: [{ ...events.line('line_added', 'bob', 1, { minutes: 2, productId: 'beer', productName: 'Beer', price: 400 }), customerName: 'Bob' }],
});
expect(tab(result, 'bob')).toMatchObject({ customerName: 'Bob', lines: [{ productName: 'Beer', quantity: 1 }] });
});
it('matches events from older app versions by product name and price', async () => {
const { sync, tabletA } = setup();
const { lineId, productId, ...old } = events.line('line_added', 't1', 2);
await sync(tabletA, { events: [events.opened('t1', 'Alice'), old] });
const { lineId: _, productId: __, ...removed } = events.line('line_removed', 't1', 1, { minutes: 1 });
const result = await sync(tabletA, { events: [removed] });
expect(tab(result, 't1')!.lines).toMatchObject([{ productId: null, quantity: 1 }]);
});
it('does not apply a rejected event, and still syncs the rest', async () => {
const { sync, tabletA } = setup();
const invalid = { ...events.line('line_added', 't1', 1), quantity: 0 };
const result = await sync(tabletA, { events: [events.opened('t1', 'Alice'), invalid] });
expect(result.rejectedEvents).toEqual([{ id: invalid.id, reason: expect.any(String) }]);
expect(tab(result, 't1')!.lines).toEqual([]);
});
it('opens planned customers as tabs once, instead of on every tablet', async () => {
const { sync, tabletA, tabletB, prisma } = setup();
prisma.plannedTab.rows.push(
{ id: 'p1', venueId: 'v1', customerName: 'Els', customerNameKey: 'els', deliveredAt: null, position: 0 },
{ id: 'p2', venueId: 'v1', customerName: 'alice', customerNameKey: 'alice', deliveredAt: null, position: 1 },
);
await sync(tabletA, { events: [events.opened('t1', 'Alice')] });
const result = await sync(tabletB);
expect(result.plannedTabNames).toEqual([]);
expect(result.tabs.map((t) => t.customerName).sort()).toEqual(['Alice', 'Els']);
expect(prisma.plannedTab.rows.every((p) => p.deliveredAt !== null)).toBe(true);
});
});
describe('DevicesService.sync: taking over a tablet once', () => {
it("takes over the tablet's tabs and stock without applying that request's events again", async () => {
const { sync, tabletA, prisma } = setup({ seeded: false });
const result = await sync(tabletA, {
openTabs: [{
id: 't1', customerName: 'Alice', version: 4, totalInCents: 750, itemCount: 3,
items: [{ productName: 'Cola', quantity: 3, unitPriceInCents: 250 }],
}],
// Already in the tabs and stock above
events: [events.line('line_added', 't1', 1)],
products: catalog(45),
});
expect(tab(result, 't1')).toMatchObject({ customerName: 'Alice', version: 4, lines: [{ productName: 'Cola', quantity: 3 }] });
expect(colaStock(result)).toBe(45);
expect(prisma.venue.rows[0].tabsSeededAt).not.toBeNull();
const next = await sync(tabletA, {
openTabs: [],
events: [events.line('line_added', 't1', 1, { productId: 'cola', minutes: 1 })],
products: catalog(44),
});
expect(tab(next, 't1')!.lines[0].quantity).toBe(4);
expect(colaStock(next)).toBe(44);
});
});
describe('DevicesService.sync: settlements feed', () => {
it("sends every tablet the venue's settlements once, after its cursor", async () => {
const { sync, tabletA, tabletB } = setup();
const settled = events.settled('t1', 1, 2);
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 1), settled] });
const first = await sync(tabletB);
expect(first.settlements.map((s) => s.id)).toEqual([settled.id]);
const second = await sync(tabletB, { settlementsCursor: first.settlementsCursor });
expect(second.settlements).toEqual([]);
expect(second.settlementsCursor).toBe(first.settlementsCursor);
});
it('hands a settlement to the cash payment emails once, with the tablet that settled it', async () => {
const { sync, tabletA, cashPayments } = setup();
const settled = { ...events.settled('t1', 1, 2), paymentMethod: 'cash' };
const batch = [events.opened('t1', 'Alice'), events.line('line_added', 't1', 1), settled];
await sync(tabletA, { events: batch });
expect(cashPayments.notify).toHaveBeenCalledWith('v1', [expect.objectContaining({ id: settled.id, paymentMethod: 'cash' })], 'Bar tablet');
// Resent because the acknowledgement was lost: already applied, so nothing new to email
await sync(tabletA, { events: batch });
expect(cashPayments.notify).toHaveBeenLastCalledWith('v1', [], 'Bar tablet');
});
it('starts an empty feed after the latest settlement', async () => {
const { sync, tabletA } = setup();
const result = await sync(tabletA);
expect(result.settlements).toEqual([]);
expect(result.settlementsCursor).toBe('0');
});
});
/** A product added on the dashboard, untracked. */
function addProduct(prisma: FakePrisma, productId = 'cola', fields: Record<string, unknown> = {}) {
prisma.product.rows.push({
venueId: 'v1', productId, name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock: null, countedAt: null,
lowStockThreshold: null, archivedAt: null, reportedAt: new Date(), ...fields,
});
}
describe('DevicesService.sync: the dashboard manages the catalog', () => {
it('sends every product, archived ones marked, and ignores what a tablet reports', async () => {
const { sync, tabletA, prisma } = setup();
addProduct(prisma, 'cola', { lowStockThreshold: 6 });
addProduct(prisma, 'old', { name: 'Old', category: null, archivedAt: new Date() });
const result = await sync(tabletA, {
products: [
{ id: 'cola', name: 'Renamed on the tablet', category: null, priceInCents: 999, stock: 3 },
{ id: 'local', name: 'Seeded on the tablet', category: null, priceInCents: 100, stock: 24 },
],
});
// By category, products without one last (as Postgres sorts)
expect(result.catalog).toEqual([
{ id: 'cola', name: 'Cola', category: 'Frisdrank', priceInCents: 250, lowStockThreshold: 6, archived: false },
{ id: 'old', name: 'Old', category: null, priceInCents: 250, lowStockThreshold: null, archived: true },
]);
expect(prisma.product.rows).toHaveLength(2);
});
it("takes over the tablet's catalog when the venue is taken over", async () => {
const { sync, tabletA } = setup({ seeded: false });
const result = await sync(tabletA, { products: catalog(12) });
expect(result.catalog).toMatchObject([{ id: 'cola', name: 'Cola', archived: false }]);
expect(colaStock(result)).toBe(12);
});
});
describe('DevicesService.sync: the server holds the stock', () => {
async function trackedCola(sync: ReturnType<typeof setup>['sync'], tablet: Device, stock = 48) {
return sync(tablet, { events: [events.stockSet('cola', stock, -10)] });
}
/** Every test here starts with Cola on the dashboard. */
function setupWithCola() {
const venue = setup();
addProduct(venue.prisma);
return venue;
}
it('ignores a count for a product the venue does not have', async () => {
const { sync, tabletA } = setupWithCola();
const result = await sync(tabletA, { events: [events.stockSet('local', 24)] });
expect(result.stock).toEqual([{ productId: 'cola', stock: null, countedAt: null }]);
});
it('counts the sales of every tablet once', async () => {
const { sync, tabletA, tabletB } = setupWithCola();
await trackedCola(sync, tabletA);
const sale = events.line('line_added', 't1', 2, { minutes: 1 });
await sync(tabletA, { events: [events.opened('t1', 'Alice'), sale] });
await sync(tabletA, { events: [sale] });
const result = await sync(tabletB, { events: [events.line('line_added', 't1', 2, { minutes: 2 })] });
expect(colaStock(result)).toBe(44);
});
it('gives stock back for drinks taken off, but not for a settlement', async () => {
const { sync, tabletA } = setupWithCola();
await trackedCola(sync, tabletA);
const result = await sync(tabletA, {
events: [
events.opened('t1', 'Alice'),
events.line('line_added', 't1', 3, { minutes: 1 }),
events.line('line_removed', 't1', 1, { minutes: 2 }),
events.settled('t1', 2, 3),
],
});
expect(colaStock(result)).toBe(46);
});
it('keeps a count when sales from before it arrive later', async () => {
const { sync, tabletA, tabletB } = setupWithCola();
await trackedCola(sync, tabletA);
await sync(tabletB, { events: [events.stockSet('cola', 30, 60)] });
const result = await sync(tabletA, {
events: [
events.opened('t1', 'Alice'),
// Rung up offline before the count: already in it
events.line('line_added', 't1', 2, { minutes: 50 }),
// An older count from an offline tablet doesn't overwrite the newer one
events.stockSet('cola', 70, 55),
events.line('line_added', 't1', 3, { minutes: 70 }),
],
});
expect(colaStock(result)).toBe(27);
expect(result.stock[0].countedAt).toBe(at(60));
});
it('applies a pending dashboard adjustment once and no longer sends adjustments', async () => {
const { sync, tabletA, tabletB, prisma } = setupWithCola();
await trackedCola(sync, tabletA);
prisma.stockAdjustment.rows.push({
id: 'adj-1', venueId: 'v1', productId: 'cola', productName: 'Cola', type: 'add', quantity: 24,
status: 'pending', createdAt: new Date(),
});
const first = await sync(tabletA);
const second = await sync(tabletB);
expect(first.stockAdjustments).toEqual([]);
expect(colaStock(first)).toBe(72);
expect(colaStock(second)).toBe(72);
expect(prisma.stockAdjustment.rows[0]).toMatchObject({ status: 'applied', stockAfter: 72 });
});
it('records results from older tablets that applied an adjustment themselves', async () => {
const { sync, tabletA, prisma } = setupWithCola();
await trackedCola(sync, tabletA);
const id = uuidv4();
prisma.stockAdjustment.rows.push({
id, venueId: 'v1', productId: 'cola', productName: 'Cola', type: 'add', quantity: 24, status: 'pending', createdAt: new Date(),
});
const result = await sync(tabletA, { stockAdjustmentResults: [{ id, status: 'applied', stockAfter: 72 }] });
expect(result.acknowledgedStockAdjustmentResultIds).toEqual([id]);
// Applied by the tablet, so not again by the server
expect(colaStock(result)).toBe(48);
});
});
describe('DevicesService.sync live update signals', () => {
it('sends nothing when the sync changed nothing', async () => {
const { sync, tabletA, realtime } = setup();
await sync(tabletA, { products: catalog(), events: [events.opened('t1', 'Alice')] });
realtime.notify.mockClear();
await sync(tabletA, { products: catalog() });
expect(realtime.notify).not.toHaveBeenCalled();
});
it('signals "tabs" and "stock" when they changed', async () => {
const { sync, tabletA, realtime, prisma } = setup();
addProduct(prisma);
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.stockSet('cola', 5)] });
expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs', 'stock');
});
it('signals "device" when a terminal comes back online', async () => {
const { sync, realtime } = setup();
await sync({ id: 'd1', venueId: 'v1', revokedAt: null, lastSeenAt: new Date(Date.now() - 3_600_000) });
expect(realtime.notify).toHaveBeenCalledWith('v1', 'device');
});
});
describe('DevicesService.sync: website product changes reach the tablets', () => {
const admin = { id: 'u1', email: 'admin@example.com', name: 'Admin' };
function setupWithWebsite() {
const venue = setup();
const stock = new StockService(venue.prisma as any, { log: jest.fn() } as any, { notify: jest.fn() } as any);
return { ...venue, stock };
}
it('sends a new price to every tablet in its next sync', async () => {
const { sync, tabletA, tabletB, stock, prisma } = setupWithWebsite();
addProduct(prisma);
await stock.updateProduct('v1', 'cola', { priceInCents: 270 }, admin);
const a = await sync(tabletA);
const b = await sync(tabletB);
for (const result of [a, b]) {
expect(result.catalog).toEqual([expect.objectContaining({ id: 'cola', priceInCents: 270 })]);
}
});
it('keeps old drinks at their price and puts new ones on a new line', async () => {
const { sync, tabletA, tabletB, stock, prisma } = setupWithWebsite();
addProduct(prisma);
await sync(tabletA, {
events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2, { lineId: 'old-line' })],
});
await stock.updateProduct('v1', 'cola', { priceInCents: 270 }, admin);
// The tablet charges the new price on a line of its own
const result = await sync(tabletB, {
events: [events.line('line_added', 't1', 1, { lineId: 'new-line', price: 270, minutes: 5 })],
});
expect(tab(result, 't1')!.lines.map((l) => [l.id, l.unitPriceInCents, l.quantity])).toEqual([
['old-line', 250, 2],
['new-line', 270, 1],
]);
});
it('settles every line at its own price', async () => {
const { sync, tabletA, prisma } = setupWithWebsite();
addProduct(prisma);
const settled = {
...events.settled('t1', 0, 10),
amountInCents: 2 * 250 + 270,
items: [
{ productId: 'cola', productName: 'Cola', quantity: 2, unitPriceInCents: 250 },
{ productId: 'cola', productName: 'Cola', quantity: 1, unitPriceInCents: 270 },
],
};
const result = await sync(tabletA, {
events: [
events.opened('t1', 'Alice'),
events.line('line_added', 't1', 2, { lineId: 'old-line' }),
events.line('line_added', 't1', 1, { lineId: 'new-line', price: 270, minutes: 5 }),
settled,
],
});
expect(tab(result, 't1')!.lines).toEqual([]);
expect(result.settlements[0].lines.map((l) => [l.unitPriceInCents, l.quantity])).toEqual([[250, 2], [270, 1]]);
});
it('counts both price lines down from the one product stock', async () => {
const { sync, tabletA, stock, prisma } = setupWithWebsite();
addProduct(prisma, 'cola', { stock: 20, countedAt: new Date('2026-01-01') });
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2)] });
await stock.updateProduct('v1', 'cola', { priceInCents: 270 }, admin);
const result = await sync(tabletA, {
events: [events.line('line_added', 't1', 1, { lineId: 'new-line', price: 270, minutes: 5 })],
});
expect(colaStock(result)).toBe(17);
});
it('sends a product added on the website with its starting stock', async () => {
const { sync, tabletA, stock } = setupWithWebsite();
const created = await stock.createProduct('v1', { name: 'Ice Tea', category: 'Frisdrank', priceInCents: 280, stock: 24 }, admin);
const result = await sync(tabletA);
expect(result.catalog).toEqual([expect.objectContaining({ id: created.productId, name: 'Ice Tea', priceInCents: 280 })]);
expect(result.stock).toEqual([expect.objectContaining({ productId: created.productId, stock: 24 })]);
});
it('never sends the buy price or desired stock to the tablets', async () => {
const { sync, tabletA, stock } = setupWithWebsite();
await stock.createProduct('v1', { name: 'Ice Tea', priceInCents: 280, buyPriceInCents: 110, stock: 4, desiredStock: 24 }, admin);
const result = await sync(tabletA);
expect(result.catalog![0]).not.toHaveProperty('buyPriceInCents');
expect(result.catalog![0]).not.toHaveProperty('desiredStock');
});
it('marks an archived product, and still counts a sale from a tablet that had not synced yet', async () => {
const { sync, tabletA, stock, prisma } = setupWithWebsite();
addProduct(prisma, 'cola', { stock: 20, countedAt: new Date('2026-01-01') });
await stock.updateProduct('v1', 'cola', { archived: true }, admin);
const result = await sync(tabletA, {
events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 1, { minutes: 1 })],
});
expect(result.catalog).toEqual([expect.objectContaining({ id: 'cola', archived: true })]);
expect(tab(result, 't1')!.lines).toMatchObject([{ productId: 'cola', quantity: 1 }]);
expect(colaStock(result)).toBe(19);
});
it('a renamed product keeps its id, so tablets update it instead of adding one', async () => {
const { sync, tabletA, stock, prisma } = setupWithWebsite();
addProduct(prisma);
await stock.updateProduct('v1', 'cola', { name: 'Coca-Cola' }, admin);
const result = await sync(tabletA);
expect(result.catalog).toEqual([expect.objectContaining({ id: 'cola', name: 'Coca-Cola' })]);
});
});
describe('DevicesService.sync: products created and changed on a tablet', () => {
const admin = { id: 'u1', email: 'admin@example.com', name: 'Admin' };
function setupWithWebsite() {
const venue = setup();
const stock = new StockService(venue.prisma as any, { log: jest.fn() } as any, { notify: jest.fn() } as any);
return { ...venue, stock };
}
const catalogEntry = (result: SyncResult, id: string) => result.catalog.find((p) => p.id === id);
it('creates a product made offline, and sells and counts it in the same sync', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, {
events: [
events.product('product_created', 'iced', { name: 'Ice Coffee', category: 'Koffie', priceInCents: 350, lowStockThreshold: 3 }, 1),
events.stockSet('iced', 12, 2),
events.opened('t1', 'Alice', 3),
events.line('line_added', 't1', 2, { minutes: 4, productId: 'iced', productName: 'Ice Coffee', price: 350 }),
],
});
const result = await sync(tabletB);
expect(catalogEntry(result, 'iced')).toEqual(
{ id: 'iced', name: 'Ice Coffee', category: 'Koffie', priceInCents: 350, lowStockThreshold: 3, archived: false, imageId: null });
expect(result.stock.find((s) => s.productId === 'iced')?.stock).toBe(10);
});
it('keeps both a website price change and an offline rename of the same product', async () => {
const { sync, tabletA, stock, prisma } = setupWithWebsite();
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
await stock.updateProduct('v1', 'cola', { priceInCents: 270 }, admin);
// Renamed offline before the price change, synced after it
const result = await sync(tabletA, { events: [events.product('product_updated', 'cola', { name: 'Coca-Cola' }, 5)] });
expect(catalogEntry(result, 'cola')).toMatchObject({ name: 'Coca-Cola', priceInCents: 270 });
expect(prisma.product.rows[0].fieldUpdatedAt).toMatchObject({ name: at(5) });
});
it('lets the latest change of a field win, also when the older one arrives last', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
await sync(tabletB, { events: [events.product('product_updated', 'cola', { priceInCents: 300 }, 20)] });
// Tablet A was offline: its older price change arrives later and loses
const result = await sync(tabletA, { events: [events.product('product_updated', 'cola', { priceInCents: 280 }, 10)] });
expect(catalogEntry(result, 'cola')!.priceInCents).toBe(300);
});
it('a website change made after a tablet change wins', async () => {
const { sync, tabletA, stock } = setupWithWebsite();
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
await sync(tabletA, { events: [events.product('product_updated', 'cola', { priceInCents: 280 }, 1)] });
await stock.updateProduct('v1', 'cola', { priceInCents: 260 }, admin);
const result = await sync(tabletA);
expect(catalogEntry(result, 'cola')!.priceInCents).toBe(260);
});
it('archives and restores a product from a tablet', async () => {
const { sync, tabletA } = setup();
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
const archived = await sync(tabletA, { events: [events.product('product_archived', 'cola', undefined, 1)] });
expect(catalogEntry(archived, 'cola')!.archived).toBe(true);
const restored = await sync(tabletA, { events: [events.product('product_restored', 'cola', undefined, 2)] });
expect(catalogEntry(restored, 'cola')!.archived).toBe(false);
});
it('only links a picture that was uploaded', async () => {
const { sync, tabletA, prisma } = setup();
prisma.productImage.rows.push({ id: 'a'.repeat(64), contentType: 'image/webp', width: 512, height: 512, bytes: Buffer.from('x') });
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
const unknown = await sync(tabletA, { events: [events.product('product_updated', 'cola', { imageId: 'b'.repeat(64) }, 1)] });
expect(catalogEntry(unknown, 'cola')!.imageId).toBeNull();
const known = await sync(tabletA, { events: [events.product('product_updated', 'cola', { imageId: 'a'.repeat(64) }, 2)] });
expect(catalogEntry(known, 'cola')!.imageId).toBe('a'.repeat(64));
const removed = await sync(tabletA, { events: [events.product('product_updated', 'cola', { imageId: null }, 3)] });
expect(catalogEntry(removed, 'cola')!.imageId).toBeNull();
});
it('accepts two products with the same name from two offline tablets', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, { events: [events.product('product_created', 'cola-a', { name: 'Cola', priceInCents: 250 }, 0)] });
const result = await sync(tabletB, { events: [events.product('product_created', 'cola-b', { name: 'cola', priceInCents: 250 }, 0)] });
expect(result.catalog.map((p) => p.id).sort()).toEqual(['cola-a', 'cola-b']);
});
it('acknowledges a change to a product the venue does not have, without creating it', async () => {
const { sync, tabletA } = setup();
const change = events.product('product_updated', 'gone', { name: 'Gone' }, 0);
const result = await sync(tabletA, { events: [change] });
expect(result.acknowledgedEventIds).toEqual([change.id]);
expect(result.catalog).toEqual([]);
});
it('rejects a new product without a price', async () => {
const { sync, tabletA } = setup();
const incomplete = events.product('product_created', 'x', { name: 'X' }, 0);
const result = await sync(tabletA, { events: [incomplete] });
expect(result.rejectedEvents).toEqual([{ id: incomplete.id, reason: 'a new product needs a name and a price' }]);
});
it('applies a product event once, also when it is sent again', async () => {
const { sync, tabletA, tabletB } = setup();
const created = events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0);
const renamed = events.product('product_updated', 'cola', { name: 'Coca-Cola' }, 1);
await sync(tabletA, { events: [created, renamed] });
await sync(tabletB, { events: [events.product('product_updated', 'cola', { name: 'Cola Classic' }, 2)] });
// The old rename again, with its id: already applied, so it must not undo the newer name
const result = await sync(tabletA, { events: [renamed] });
expect(catalogEntry(result, 'cola')!.name).toBe('Cola Classic');
});
});
-349
View File
@@ -1,349 +0,0 @@
import {
Injectable,
Logger,
NotFoundException,
ConflictException,
ForbiddenException,
} from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { PrismaService } from '../prisma/prisma.service';
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 { SyncDto } from './dto/sync.dto';
import { AckCommandDto } from './dto/ack-command.dto';
import { eventTime, parseTabEvents, tabEventRow } from './tab-events';
import { ParsedProduct, parseProducts, parseStockResults } from './stock-sync';
import { VenueLedger } from '../tabs/venue-ledger';
import { seedTabs, tabJson } from '../tabs/tab-state';
import { openPlannedTabs, settlementsAfter } from '../tabs/tab-sync';
import { applyPendingAdjustments } from '../stock/stock-adjustments';
import { applyProductEvent } from '../stock/product-events';
import { isProductEvent } from './dto/tab-event.dto';
import { CashPaymentNotifier } from '../email/cash-payment-notifier';
@Injectable()
export class DevicesService {
private readonly logger = new Logger(DevicesService.name);
constructor(
private prisma: PrismaService,
private auditService: AuditService,
private realtime: RealtimeService,
private cashPayments: CashPaymentNotifier,
) {}
async sync(device: any, dto: SyncDto) {
if (!device) {
throw new NotFoundException({ code: 'device_not_found', message: 'Device not found' });
}
if (device.revokedAt) {
throw new ForbiddenException({ code: 'device_revoked', message: 'Device has been revoked' });
}
const now = new Date();
const venueId: string = device.venueId;
const events = parseTabEvents(dto.events ?? []);
for (const { id, reason } of events.rejected) {
this.logger.warn(`Device ${device.id} sent an invalid tab event ${id ?? '(no id)'}: ${reason}`);
}
// Omitted by app versions without stock
const reported = dto.products ? parseProducts(dto.products) : null;
for (const { id, reason } of reported?.rejected ?? []) {
this.logger.warn(`Device ${device.id} sent an invalid product ${id ?? '(no id)'}: ${reason}`);
}
const stockResults = parseStockResults(dto.stockAdjustmentResults ?? []);
for (const { id, reason } of stockResults.rejected) {
this.logger.warn(`Device ${device.id} sent an invalid stock adjustment result ${id ?? '(no id)'}: ${reason}`);
}
const synced = await this.prisma.$transaction(async (tx) => {
// Locks the venue: other terminals' syncs wait, so no change is lost
const ledger = await VenueLedger.open(tx, venueId);
// 1. The dashboard manages the catalog: a terminal's own catalog is only taken over once, with the tabs
let productsChanged = reported && !ledger.seeded
? await this.saveProducts(tx, ledger, reported.products, now)
: false;
// 2. Events, each applied once: a terminal resends an event until its acknowledgement arrives
const unique = [...new Map(events.accepted.map((event) => [event.id, event])).values()];
const stored = unique.length === 0 ? [] : await tx.tabEvent.findMany({
where: { id: { in: unique.map((event) => event.id) } },
select: { id: true },
});
const storedIds = new Set(stored.map((event) => event.id));
const fresh = unique.filter((event) => !storedIds.has(event.id));
if (fresh.length > 0) {
await tx.tabEvent.createMany({ data: fresh.map((event) => tabEventRow(event, venueId, device.id, now)) });
}
if (!ledger.seeded) {
// The first sync since the server holds the tabs and stock: the terminal's own tabs and stock already
// include the events of this request, so they are stored but not applied again
ledger.seed(seedTabs(dto.openTabs ?? [], now));
for (const product of reported?.products ?? []) ledger.stock.seed(product.id, product.stock);
} else {
for (const event of fresh) {
const occurredAt = eventTime(event, now);
// Products first: a sale or count of a product created offline arrives in the same request
if (isProductEvent(event)) {
if (await applyProductEvent(tx, ledger, event, occurredAt)) productsChanged = true;
continue;
}
// Only drinks that were still on the tab go back into the stock: one settled or deleted meanwhile was sold
const stockEvent = event.type === 'line_removed' ? { ...event, quantity: ledger.tabs.removable(event) } : event;
const settlement = ledger.tabs.apply(event, occurredAt);
if (settlement) ledger.settlements.push({ ...settlement, deviceId: device.id });
ledger.stock.apply(stockEvent, occurredAt);
}
}
// 3. Outcomes from app versions that applied dashboard adjustments themselves. The terminal's outcome is the
// truth, even for an adjustment cancelled while it was on its way
let stockResultsRecorded = 0;
for (const result of stockResults.accepted) {
const { count } = await tx.stockAdjustment.updateMany({
where: { id: result.id, venueId, status: { in: ['pending', 'cancelled'] } },
data: {
status: result.status,
stockAfter: result.status === 'applied' ? result.stockAfter : null,
result: result.reason ?? null,
processedAt: now,
},
});
stockResultsRecorded += count;
}
// Requested before the server applied adjustments itself, and never applied by a terminal
const adjustmentsApplied = await applyPendingAdjustments(tx, ledger, now);
// 4. Planned customers become tabs here, once, instead of on every terminal
const plannedDelivered = await openPlannedTabs(tx, ledger, now);
await ledger.save(tx, now);
await tx.device.update({ where: { id: device.id }, data: { lastSeenAt: now } });
const feed = await settlementsAfter(tx, venueId, dto.settlementsCursor, now);
const catalog = await tx.product.findMany({
where: { venueId },
orderBy: [{ category: 'asc' }, { name: 'asc' }],
select: {
productId: true, name: true, category: true, priceInCents: true, lowStockThreshold: true, archivedAt: true,
imageId: true,
},
});
return {
ledger,
feed,
catalog,
topics: {
tabs: ledger.changedTabs,
plannedTabs: plannedDelivered > 0,
stock: productsChanged || ledger.stock.changed.size > 0 || stockResultsRecorded > 0 || adjustmentsApplied > 0,
},
};
}, { timeout: 15000 });
// Expire old commands
const expired = await this.prisma.remoteCommand.updateMany({
where: { deviceId: device.id, status: 'pending', expiresAt: { lte: now } },
data: { status: 'expired' },
});
// close_tab commands are no longer created (the server settles tabs itself); ones from before that are
// still delivered. Every pending command is returned on each sync; devices dedupe by command id
const cursor = Buffer.from(`${now.toISOString()}:${uuidv4().substring(0, 8)}`).toString('base64url');
const commands = await this.prisma.remoteCommand.findMany({
where: { deviceId: device.id, status: 'pending' },
select: {
id: true,
type: true,
targetTabId: true,
expectedVersion: true,
paymentMethod: true,
requestedBy: true,
createdAt: true,
},
orderBy: { createdAt: 'asc' },
});
const topics: RealtimeTopic[] = [];
if (synced.topics.tabs) topics.push('tabs');
if (synced.topics.plannedTabs) topics.push('planned-tabs');
if (expired.count > 0) topics.push('commands');
if (synced.topics.stock) topics.push('stock');
// Only when the device comes back online; dashboards poll the device status themselves
if (now.getTime() - new Date(device.lastSeenAt).getTime() > DEVICE_ONLINE_THRESHOLD_MS) topics.push('device');
if (topics.length > 0) this.realtime.notify(venueId, ...topics);
const { ledger, feed, catalog } = synced;
this.cashPayments.notify(venueId, ledger.settlements, device.name);
return {
cursor,
commands: commands.map((c) => ({
id: c.id,
type: c.type,
tabId: c.targetTabId,
expectedVersion: c.expectedVersion,
paymentMethod: c.paymentMethod,
requestedBy: c.requestedBy,
createdAt: c.createdAt,
})),
// The server opens planned tabs itself; terminals must not open them as well
plannedTabNames: [],
// Applied (or already applied earlier): the terminal can drop these from its outbox
acknowledgedEventIds: events.accepted.map((e) => e.id),
// Invalid and never applied: the terminal should log and drop these too, re-sending won't help
rejectedEvents: events.rejected,
// The server applies dashboard adjustments itself
stockAdjustments: [],
// Recorded (or already recorded earlier): the terminal can drop these results from its outbox
acknowledgedStockAdjustmentResultIds: stockResults.accepted.map((r) => r.id),
// Invalid and never recorded: drop these too, re-sending won't help
rejectedStockAdjustmentResults: stockResults.rejected,
// The venue's complete state after this request's events: the terminal replaces its own with it
tabs: ledger.tabs.all().map(tabJson),
// Every product of the venue; an archived one is hidden on the terminal (tabs and history refer to it)
catalog: catalog.map((p) => ({
id: p.productId,
name: p.name,
category: p.category,
priceInCents: p.priceInCents,
lowStockThreshold: p.lowStockThreshold,
archived: p.archivedAt !== null,
// The picture is at /images/<imageId>; null: none
imageId: p.imageId,
})),
stock: ledger.stock.entries().map(([productId, { stock, countedAt }]) => (
{ productId, stock, countedAt: countedAt?.toISOString() ?? null })),
settlements: feed.settlements,
settlementsCursor: feed.settlementsCursor,
};
}
/**
* Takes over the terminal's catalog on the first sync since the server holds the tabs; the dashboard manages it
* from then on. Never removes a product. Returns whether anything the dashboard shows changed.
*/
private async saveProducts(tx: Prisma.TransactionClient, ledger: VenueLedger, products: ParsedProduct[], now: Date) {
const stored = await tx.product.findMany({
where: { venueId: ledger.venueId },
select: { productId: true, name: true, category: true, priceInCents: true },
});
const storedById = new Map(stored.map((p) => [p.productId, p]));
let changed = false;
const unchanged: string[] = [];
for (const product of products) {
const previous = storedById.get(product.id);
if (previous && previous.name === product.name && previous.category === product.category
&& previous.priceInCents === product.priceInCents) {
unchanged.push(product.id);
continue;
}
const data = { name: product.name, category: product.category, priceInCents: product.priceInCents, reportedAt: now };
await tx.product.upsert({
where: { venueId_productId: { venueId: ledger.venueId, productId: product.id } },
// Not tracked until counted: its starting stock arrives as a stock_set event
create: { venueId: ledger.venueId, productId: product.id, ...data, stock: null },
update: data,
});
ledger.stock.addProduct(product.id);
changed = true;
}
if (unchanged.length > 0) {
await tx.product.updateMany({
where: { venueId: ledger.venueId, productId: { in: unchanged } },
data: { reportedAt: now },
});
}
return changed;
}
async ackCommand(device: any, commandId: string, dto: AckCommandDto) {
if (!device) {
throw new NotFoundException({ code: 'device_not_found', message: 'Device not found' });
}
const command = await this.prisma.remoteCommand.findUnique({ where: { id: commandId } });
if (!command) {
throw new NotFoundException({ code: 'command_not_found', message: 'Command not found' });
}
if (command.deviceId !== device.id) {
throw new ForbiddenException({ code: 'command_not_for_device', message: 'Command does not belong to this device' });
}
if (command.venueId !== device.venueId) {
throw new ForbiddenException({ code: 'venue_mismatch', message: 'Command belongs to a different venue' });
}
// Idempotent: the first acknowledgement wins, retries and late duplicates are no-ops
if (command.acknowledgedAt) {
return { ok: true, status: command.status };
}
const updated = await this.prisma.remoteCommand.updateMany({
where: { id: commandId, acknowledgedAt: null },
data: {
status: dto.status,
result: dto.result || null,
acknowledgedAt: new Date(),
},
});
if (updated.count === 0) {
// A concurrent ack got there first
return { ok: true };
}
await this.auditService.log(
device.venueId,
'device',
device.id,
'command_acknowledged',
{ commandId, status: dto.status, result: dto.result },
);
this.realtime.notify(device.venueId, 'commands', 'tabs');
return { ok: true };
}
async revoke(venueId: string, deviceId: string) {
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
if (!device) {
throw new NotFoundException({ code: 'device_not_found', message: 'Device not found' });
}
if (device.venueId !== venueId) {
throw new ForbiddenException({ code: 'venue_mismatch', message: 'Device belongs to a different venue' });
}
if (device.revokedAt) {
return { ok: true }; // Idempotent
}
await this.prisma.device.update({
where: { id: deviceId },
data: { revokedAt: new Date() },
});
// Expire pending commands
await this.prisma.remoteCommand.updateMany({
where: { deviceId, status: 'pending' },
data: { status: 'expired' },
});
await this.auditService.log(venueId, 'system', deviceId, 'device_revoked', { deviceName: device.name });
this.realtime.notify(venueId, 'device', 'commands');
return { ok: true };
}
}
@@ -1,18 +0,0 @@
import { IsString, IsIn, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class AckCommandDto {
@ApiProperty({ example: 'cmd-uuid' })
@IsString()
commandId: string;
@ApiProperty({ example: 'completed', enum: ['completed', 'failed', 'conflict', 'expired'] })
@IsString()
@IsIn(['completed', 'failed', 'conflict', 'expired'])
status: string;
@ApiPropertyOptional({ example: 'Tab closed successfully' })
@IsOptional()
@IsString()
result?: string;
}
@@ -1,18 +0,0 @@
import { IsString, IsUUID, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RegisterDeviceDto {
@ApiProperty({ example: 'uuid' })
@IsUUID()
venueId: string;
@ApiProperty({ example: 'Bar terminal 1' })
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({ example: 'ABC123' })
@IsString()
@IsNotEmpty()
registrationCode: string;
}
@@ -1,55 +0,0 @@
import { IsString, IsIn, IsInt, Min, IsNotEmpty, MaxLength, IsOptional, IsUUID, ValidateIf } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ReportedProductDto {
@ApiProperty({ example: 'local-product-id', description: "The terminal's own id for the product" })
@IsString()
@IsNotEmpty()
@MaxLength(100)
id: string;
@ApiProperty({ example: 'Cola' })
@IsString()
@IsNotEmpty()
@MaxLength(200)
name: string;
@ApiPropertyOptional({ example: 'Frisdrank' })
@IsOptional()
@IsString()
@MaxLength(100)
category?: string | null;
@ApiProperty({ example: 250 })
@IsInt()
@Min(0)
priceInCents: number;
@ApiProperty({ example: 48, nullable: true, description: 'Null when stock is not tracked; may be negative' })
@ValidateIf((product: ReportedProductDto) => product.stock !== null)
@IsInt()
stock: number | null;
}
export const STOCK_RESULT_STATUSES = ['applied', 'rejected'] as const;
export class StockAdjustmentResultDto {
@ApiProperty({ description: 'The stock adjustment id from the sync response' })
@IsUUID()
id: string;
@ApiProperty({ enum: STOCK_RESULT_STATUSES })
@IsIn(STOCK_RESULT_STATUSES)
status: (typeof STOCK_RESULT_STATUSES)[number];
@ApiPropertyOptional({ example: 72, description: 'applied: the stock right after applying it' })
@ValidateIf((result: StockAdjustmentResultDto) => result.status === 'applied')
@IsInt()
stockAfter?: number;
@ApiPropertyOptional({ example: 'unknown_product', description: 'rejected: why' })
@IsOptional()
@IsString()
@MaxLength(500)
reason?: string;
}
-102
View File
@@ -1,102 +0,0 @@
import { IsString, IsOptional, IsArray, IsInt, Min, IsUUID, ValidateNested, ArrayMaxSize, IsNotEmpty, MaxLength } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class OpenTabItemDto {
@ApiProperty({ example: 'Cola' })
@IsString()
@IsNotEmpty()
@MaxLength(200)
productName: string;
@ApiProperty({ example: 10 })
@IsInt()
@Min(0)
quantity: number;
@ApiProperty({ example: 250 })
@IsInt()
@Min(0)
unitPriceInCents: number;
}
export class OpenTabDto {
@ApiProperty({ example: 'local-tab-uuid' })
@IsString()
@IsNotEmpty()
id: string;
@ApiProperty({ example: 'Jan' })
@IsString()
customerName: string;
@ApiProperty({ example: 5 })
@IsInt()
@Min(0)
version: number;
@ApiProperty({ example: 2450 })
@IsInt()
@Min(0)
totalInCents: number;
@ApiProperty({ example: 3 })
@IsInt()
@Min(0)
itemCount: number;
@ApiPropertyOptional({ type: [OpenTabItemDto], description: 'Lines on the tab; omitted by older app versions' })
@IsOptional()
@IsArray()
@ArrayMaxSize(500)
@ValidateNested({ each: true })
@Type(() => OpenTabItemDto)
items?: OpenTabItemDto[];
}
export class SyncDto {
@ApiProperty({ example: 'uuid' })
@IsUUID()
deviceId: string;
@ApiPropertyOptional({ example: 'cursor-value' })
@IsOptional()
@IsString()
cursor?: string;
@ApiPropertyOptional({ description: 'Where the settlements feed left off; absent on the first sync after pairing' })
@IsOptional()
@IsString()
@MaxLength(100)
settlementsCursor?: string;
// The server holds the tabs: only used once, to take over the terminal's tabs on its first sync
@ApiProperty({ type: [OpenTabDto] })
@IsArray()
@ArrayMaxSize(1000)
@ValidateNested({ each: true })
@Type(() => OpenTabDto)
openTabs: OpenTabDto[];
// Validated one by one in the service: a bad event is rejected on its own instead of failing the sync
@ApiPropertyOptional({ description: 'TabEventDto[]: what happened on tabs since the last acknowledged sync' })
@IsOptional()
@IsArray()
@ArrayMaxSize(1000)
events?: unknown[];
// Validated one by one in the service, like events
@ApiPropertyOptional({
description: "ReportedProductDto[]: the terminal's complete product catalog with stock. Omitted by app versions without stock",
})
@IsOptional()
@IsArray()
@ArrayMaxSize(2000)
products?: unknown[];
@ApiPropertyOptional({ description: 'StockAdjustmentResultDto[]: outcomes of stock adjustments the terminal received' })
@IsOptional()
@IsArray()
@ArrayMaxSize(1000)
stockAdjustmentResults?: unknown[];
}
-177
View File
@@ -1,177 +0,0 @@
import {
IsString, IsIn, IsInt, Min, Max, IsUUID, IsNotEmpty, MaxLength, IsISO8601, ValidateIf, IsOptional, IsArray,
ArrayMaxSize, ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { OpenTabItemDto } from './sync.dto';
export const TAB_EVENT_TYPES = [
'tab_opened', 'tab_renamed', 'tab_deleted', 'line_added', 'line_removed', 'settled', 'stock_set',
'product_created', 'product_updated', 'product_archived', 'product_restored',
] as const;
export const PRODUCT_EVENT_TYPES = ['product_created', 'product_updated', 'product_archived', 'product_restored'] as const;
export type TabEventType = (typeof TAB_EVENT_TYPES)[number];
export const SETTLEMENT_PAYMENT_METHODS = ['cash', 'payconiq', 'payment_pending', 'other'] as const;
const isLine = (event: TabEventDto) => event.type === 'line_added' || event.type === 'line_removed';
const isSettlement = (event: TabEventDto) => event.type === 'settled';
const isStockSet = (event: TabEventDto) => event.type === 'stock_set';
export const isProductEvent = (event: TabEventDto) => (PRODUCT_EVENT_TYPES as readonly string[]).includes(event.type);
const isAboutTab = (event: TabEventDto) => !isStockSet(event) && !isProductEvent(event);
const carriesProduct = (event: TabEventDto) => event.type === 'product_created' || event.type === 'product_updated';
/** A product as created or changed on a terminal: only the fields that changed are sent. */
export class ProductEventFieldsDto {
@ApiPropertyOptional({ example: 'Cola' })
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(200)
name?: string;
@ApiPropertyOptional({ example: 'Frisdrank', nullable: true })
@IsOptional()
@IsString()
@MaxLength(100)
category?: string | null;
@ApiPropertyOptional({ example: 250 })
@IsOptional()
@IsInt()
@Min(0)
@Max(1_000_000)
priceInCents?: number;
@ApiPropertyOptional({ example: 12, nullable: true })
@IsOptional()
@IsInt()
@Min(0)
@Max(100000)
lowStockThreshold?: number | null;
@ApiPropertyOptional({ description: 'A picture uploaded to /devices/:deviceId/images first; null removes it', nullable: true })
@IsOptional()
@IsString()
@MaxLength(64)
imageId?: string | null;
}
export class SettledItemDto extends OpenTabItemDto {
@ApiPropertyOptional({ example: '12', description: 'Missing in events from older app versions' })
@IsOptional()
@IsString()
@MaxLength(100)
productId?: string;
}
export class TabEventDto {
@ApiProperty({ description: 'Generated by the terminal; re-sending the same id stores the event once' })
@IsUUID()
id: string;
@ApiProperty({ enum: TAB_EVENT_TYPES })
@IsIn(TAB_EVENT_TYPES)
type: TabEventType;
@ApiPropertyOptional({ example: 'local-tab-uuid', description: 'Every type except stock_set' })
@ValidateIf(isAboutTab)
@IsString()
@IsNotEmpty()
@MaxLength(100)
tabId?: string;
@ApiPropertyOptional({ example: 'Jan', description: 'Every type except stock_set; tab_renamed: the new name' })
@ValidateIf(isAboutTab)
@IsString()
@MaxLength(200)
customerName?: string;
@ApiProperty({ example: '2026-10-24T21:41:03.120Z', description: 'When it happened on the terminal' })
@IsISO8601({ strict: true })
occurredAt: string;
@ApiPropertyOptional({ example: '2026-10-24T20:00:00.000Z', description: 'tab_opened' })
@IsOptional()
@IsISO8601({ strict: true })
openedAt?: string;
@ApiPropertyOptional({ description: "line_added / line_removed: the terminal's line; missing from older app versions" })
@IsOptional()
@IsString()
@MaxLength(100)
lineId?: string;
@ApiPropertyOptional({ example: '12', description: 'line_added / line_removed (missing from older app versions), stock_set' })
@ValidateIf((event: TabEventDto) => isStockSet(event) || isProductEvent(event) || event.productId !== undefined)
@IsString()
@IsNotEmpty()
@MaxLength(100)
productId?: string;
@ApiPropertyOptional({ example: 'Cola', description: 'line_added / line_removed' })
@ValidateIf(isLine)
@IsString()
@IsNotEmpty()
@MaxLength(200)
productName?: string;
@ApiPropertyOptional({
example: 'order-uuid',
nullable: true,
description: 'line_added: the order the drinks are part of. line_removed: the order they are taken off, null for '
+ 'drinks without one. Missing from older app versions: line_removed then takes the latest drinks of the line',
})
@IsOptional()
@IsString()
@MaxLength(100)
orderId?: string | null;
@ApiPropertyOptional({ example: 250, description: 'line_added / line_removed' })
@ValidateIf(isLine)
@IsInt()
@Min(0)
unitPriceInCents?: number;
@ApiPropertyOptional({
example: 2,
description: 'line_added / line_removed: how many were added or removed, at least 1. '
+ 'stock_set: the counted stock (≥ 0), null to stop tracking',
})
@ValidateIf((event: TabEventDto) => isLine(event) || (isStockSet(event) && event.quantity !== null))
@IsInt()
@Min(0)
quantity?: number | null;
@ApiPropertyOptional({ example: 2450, description: 'settled: the amount that was settled' })
@ValidateIf(isSettlement)
@IsInt()
@Min(0)
amountInCents?: number;
@ApiPropertyOptional({ enum: SETTLEMENT_PAYMENT_METHODS, description: 'settled' })
@ValidateIf(isSettlement)
@IsIn(SETTLEMENT_PAYMENT_METHODS)
paymentMethod?: string;
@ApiPropertyOptional({ description: 'settled: the remote close command that caused it, if any' })
@IsOptional()
@IsUUID()
commandId?: string;
@ApiPropertyOptional({ type: ProductEventFieldsDto, description: 'product_created / product_updated' })
@ValidateIf(carriesProduct)
@ValidateNested()
@Type(() => ProductEventFieldsDto)
product?: ProductEventFieldsDto;
@ApiPropertyOptional({ type: [SettledItemDto], description: 'settled: the lines that were settled' })
@IsOptional()
@IsArray()
@ArrayMaxSize(500)
@ValidateNested({ each: true })
@Type(() => SettledItemDto)
items?: SettledItemDto[];
}
-54
View File
@@ -1,54 +0,0 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { ReportedProductDto, StockAdjustmentResultDto } from './dto/stock-sync.dto';
import { describe } from './tab-events';
export type Rejected = { id: string | null; reason: string };
/**
* Validates entries one by one, like tab events: one bad product or result is skipped on its own
* instead of failing the sync (and with it the open tabs and sales).
*/
function parseEach<T extends object>(cls: new () => T, raw: unknown[]): { accepted: T[]; rejected: Rejected[] } {
const accepted: T[] = [];
const rejected: Rejected[] = [];
for (const value of raw) {
const id = typeof (value as { id?: unknown })?.id === 'string' ? (value as { id: string }).id : null;
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
rejected.push({ id, reason: 'must be an object' });
continue;
}
const entry = plainToInstance(cls, value);
const errors = validateSync(entry, { whitelist: true });
if (errors.length > 0) {
rejected.push({ id, reason: describe(errors) });
continue;
}
accepted.push(entry);
}
return { accepted, rejected };
}
export function parseProducts(raw: unknown[]) {
const { accepted, rejected } = parseEach(ReportedProductDto, raw);
// A duplicated id would make the upserts fight; the last one reported wins
const byId = new Map(accepted.map((product) => [product.id, product]));
return {
products: [...byId.values()].map((p) => ({
id: p.id,
name: p.name.trim(),
category: p.category?.trim() || null,
priceInCents: p.priceInCents,
stock: p.stock ?? null,
})),
rejected,
};
}
export type ParsedProduct = ReturnType<typeof parseProducts>['products'][number];
export function parseStockResults(raw: unknown[]) {
return parseEach(StockAdjustmentResultDto, raw);
}
-139
View File
@@ -1,139 +0,0 @@
import { Prisma } from '@prisma/client';
import { parseTabEvents, tabEventRow } from './tab-events';
const now = new Date('2026-10-24T22:00:00.000Z');
const lineAdded = {
id: '6f1c2c0e-6a2b-4c1e-9a4c-1f2a3b4c5d6e',
type: 'line_added',
tabId: 'tab-1',
customerName: 'Jan',
occurredAt: '2026-10-24T21:41:03.120Z',
productName: 'Cola',
unitPriceInCents: 250,
quantity: 2,
};
const settled = {
id: '7a2d3e4f-5b6c-4d7e-8f9a-0b1c2d3e4f5a',
type: 'settled',
tabId: 'tab-1',
customerName: 'Jan',
occurredAt: '2026-10-24T21:55:00.000Z',
amountInCents: 500,
paymentMethod: 'payconiq',
items: [{ productName: 'Cola', quantity: 2, unitPriceInCents: 250 }],
};
describe('parseTabEvents', () => {
it('accepts valid line and settlement events', () => {
const { accepted, rejected } = parseTabEvents([lineAdded, settled]);
expect(accepted.map((e) => e.id)).toEqual([lineAdded.id, settled.id]);
expect(rejected).toEqual([]);
});
it('accepts tab and stock events, and a stock count of zero', () => {
const base = { occurredAt: '2026-10-24T21:00:00.000Z' };
const { accepted, rejected } = parseTabEvents([
{ ...base, id: '1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e', type: 'tab_opened', tabId: 't1', customerName: 'Jan', openedAt: base.occurredAt },
{ ...base, id: '2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f', type: 'tab_renamed', tabId: 't1', customerName: 'Jan P.' },
{ ...base, id: '3d4e5f6a-7b8c-4d9e-8f1a-2b3c4d5e6f7a', type: 'stock_set', productId: '12', quantity: 0 },
{ ...base, id: '4e5f6a7b-8c9d-4e0f-9a2b-3c4d5e6f7a8b', type: 'stock_set', productId: '12', quantity: null },
]);
expect(rejected).toEqual([]);
expect(accepted.map((e) => e.type)).toEqual(['tab_opened', 'tab_renamed', 'stock_set', 'stock_set']);
});
it('rejects a stock count without a product or below zero, and a tab event without a tab', () => {
const base = { occurredAt: '2026-10-24T21:00:00.000Z' };
const { accepted, rejected } = parseTabEvents([
{ ...base, id: '5f6a7b8c-9d0e-4f1a-8b3c-4d5e6f7a8b9c', type: 'stock_set', quantity: 3 },
{ ...base, id: '6a7b8c9d-0e1f-4a2b-9c4d-5e6f7a8b9c0d', type: 'stock_set', productId: '12', quantity: -1 },
{ ...base, id: '7b8c9d0e-1f2a-4b3c-8d5e-6f7a8b9c0d1e', type: 'tab_opened', customerName: 'Jan' },
]);
expect(accepted).toEqual([]);
expect(rejected).toHaveLength(3);
});
it('stores a stock count without a tab', () => {
const [event] = parseTabEvents([
{ id: '8c9d0e1f-2a3b-4c4d-9e6f-7a8b9c0d1e2f', type: 'stock_set', productId: '12', quantity: 30, occurredAt: '2026-10-24T21:00:00.000Z' },
]).accepted;
expect(tabEventRow(event, 'v1', 'd1', now)).toMatchObject({ type: 'stock_set', tabId: null, productId: '12', stockSetTo: 30 });
});
it('rejects invalid events one by one, keeping their id', () => {
const { accepted, rejected } = parseTabEvents([
lineAdded,
{ ...lineAdded, id: '8b3e4f5a-6c7d-4e8f-9a0b-1c2d3e4f5a6b', quantity: 0 },
{ ...settled, id: '9c4f5a6b-7d8e-4f9a-0b1c-2d3e4f5a6b7c', paymentMethod: 'bitcoin' },
{ ...lineAdded, id: 'not-a-uuid' },
'nonsense',
]);
expect(accepted.map((e) => e.id)).toEqual([lineAdded.id]);
expect(rejected.map((r) => r.id)).toEqual([
'8b3e4f5a-6c7d-4e8f-9a0b-1c2d3e4f5a6b',
'9c4f5a6b-7d8e-4f9a-0b1c-2d3e4f5a6b7c',
'not-a-uuid',
null,
]);
expect(rejected[0].reason).toMatch(/quantity/);
});
it('ignores fields it does not know, so newer app versions keep working', () => {
const { accepted } = parseTabEvents([{ ...lineAdded, discountInCents: 50 }]);
expect(accepted).toHaveLength(1);
expect(accepted[0]).not.toHaveProperty('discountInCents');
});
it('rejects line events without a product', () => {
const { productName, ...withoutProduct } = lineAdded;
expect(parseTabEvents([withoutProduct]).rejected).toHaveLength(1);
});
});
describe('tabEventRow', () => {
it('stores removed lines as a negative quantity', () => {
const [event] = parseTabEvents([{ ...lineAdded, type: 'line_removed', quantity: 3 }]).accepted;
expect(tabEventRow(event, 'v1', 'd1', now)).toMatchObject({
venueId: 'v1',
deviceId: 'd1',
type: 'line_removed',
productName: 'Cola',
unitPriceInCents: 250,
quantityDelta: -3,
occurredAt: new Date(lineAdded.occurredAt),
});
});
it('stores a settlement with its amount, payment method and lines', () => {
const [event] = parseTabEvents([settled]).accepted;
const row = tabEventRow(event, 'v1', 'd1', now);
expect(row).toMatchObject({ type: 'settled', amountInCents: 500, paymentMethod: 'payconiq', commandId: null });
expect(row.items).toEqual([{ productId: null, productName: 'Cola', quantity: 2, unitPriceInCents: 250 }]);
expect(row.quantityDelta).toBeUndefined();
});
it('stores JSON null for a settlement without lines', () => {
const { items, ...withoutItems } = settled;
const [event] = parseTabEvents([withoutItems]).accepted;
expect(tabEventRow(event, 'v1', 'd1', now).items).toBe(Prisma.JsonNull);
});
it('uses the receive time when the terminal clock runs ahead', () => {
const [event] = parseTabEvents([{ ...lineAdded, occurredAt: '2026-10-25T03:00:00.000Z' }]).accepted;
expect(tabEventRow(event, 'v1', 'd1', now).occurredAt).toEqual(now);
});
});
-106
View File
@@ -1,106 +0,0 @@
import { plainToInstance } from 'class-transformer';
import { validateSync, ValidationError } from 'class-validator';
import { Prisma } from '@prisma/client';
import { isProductEvent, TabEventDto } from './dto/tab-event.dto';
// A terminal clock that runs ahead must not put sales in the future
const MAX_CLOCK_AHEAD_MS = 5 * 60 * 1000;
export type RejectedTabEvent = { id: string | null; reason: string };
export function parseTabEvents(raw: unknown[]): { accepted: TabEventDto[]; rejected: RejectedTabEvent[] } {
const accepted: TabEventDto[] = [];
const rejected: RejectedTabEvent[] = [];
for (const value of raw) {
const id = typeof (value as { id?: unknown })?.id === 'string' ? (value as { id: string }).id : null;
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
rejected.push({ id, reason: 'event must be an object' });
continue;
}
const event = plainToInstance(TabEventDto, value);
// Unknown fields are ignored so a newer app version adding one never loses sales
const errors = validateSync(event, { whitelist: true });
if (errors.length > 0) {
rejected.push({ id, reason: describe(errors) });
continue;
}
// Validated here: 0 is a valid stock count, but not a valid number of drinks
if ((event.type === 'line_added' || event.type === 'line_removed') && event.quantity! < 1) {
rejected.push({ id, reason: 'quantity must not be less than 1' });
continue;
}
if (event.type === 'product_created' && (!event.product?.name || event.product.priceInCents === undefined)) {
rejected.push({ id, reason: 'a new product needs a name and a price' });
continue;
}
accepted.push(event);
}
return { accepted, rejected };
}
/** When the event happened, as stored and applied: a terminal clock running ahead is capped at [now]. */
export function eventTime(event: TabEventDto, now: Date): Date {
const occurredAt = new Date(event.occurredAt);
return occurredAt.getTime() > now.getTime() + MAX_CLOCK_AHEAD_MS ? now : occurredAt;
}
export function tabEventRow(event: TabEventDto, venueId: string, deviceId: string, now: Date): Prisma.TabEventCreateManyInput {
const base = {
id: event.id,
venueId,
deviceId,
tabId: event.tabId ?? null,
customerName: event.customerName ?? null,
type: event.type,
occurredAt: eventTime(event, now),
receivedAt: now,
};
if (event.type === 'stock_set') {
return { ...base, productId: event.productId, stockSetTo: event.quantity ?? null };
}
if (isProductEvent(event)) {
return {
...base,
productId: event.productId,
productData: event.product ? ({ ...event.product } as Prisma.InputJsonValue) : Prisma.JsonNull,
};
}
if (event.type === 'tab_opened' || event.type === 'tab_renamed' || event.type === 'tab_deleted') {
return base;
}
if (event.type === 'settled') {
return {
...base,
amountInCents: event.amountInCents,
paymentMethod: event.paymentMethod,
commandId: event.commandId ?? null,
items: event.items
? event.items.map(({ productId, productName, quantity, unitPriceInCents }) => (
{ productId: productId ?? null, productName, quantity, unitPriceInCents }))
: Prisma.JsonNull,
};
}
return {
...base,
lineId: event.lineId ?? null,
productId: event.productId ?? null,
productName: event.productName,
unitPriceInCents: event.unitPriceInCents,
quantityDelta: event.type === 'line_removed' ? -event.quantity! : event.quantity,
orderId: event.orderId ?? null,
};
}
export function describe(errors: ValidationError[]): string {
return errors
.flatMap((error) => [
...Object.values(error.constraints ?? {}),
...(error.children?.length ? [`${error.property}: ${describe(error.children)}`] : []),
])
.join('; ');
}
@@ -1,51 +0,0 @@
import { CashPaymentNotifier } from './cash-payment-notifier';
import { createFakePrisma } from '../testing/fake-prisma';
const settlement = (paymentMethod: string, id = 's1') => ({
id, tabId: 't1', customerName: 'Jan', settledAt: new Date('2026-10-27T20:15:00Z'), paymentMethod, amountInCents: 500,
commandId: null,
lines: [{ id: 'l1', productId: 'cola', productName: 'Cola', unitPriceInCents: 250, quantity: 2, units: [] }],
});
function setup(emails: string[]) {
process.env.RESEND_API_KEY = 'test-key';
const prisma = createFakePrisma();
prisma.venue.rows.push({ id: 'v1', name: 'Café De Kroon', cashPaymentEmails: emails });
const render = jest.fn().mockResolvedValue({ subject: 'Cash', html: '<p>Cash</p>' });
const notifier = new CashPaymentNotifier(prisma as any, { render } as any);
const fetchMock = jest.fn().mockResolvedValue({ ok: true, text: async () => '' });
global.fetch = fetchMock as any;
return { notifier, render, fetchMock };
}
afterEach(() => {
delete process.env.RESEND_API_KEY;
});
describe('CashPaymentNotifier', () => {
it('emails every address about a cash payment, once per settlement', async () => {
const { notifier, render, fetchMock } = setup(['a@bar.be', 'b@bar.be']);
await notifier.send('v1', [settlement('cash')], 'Bar tablet');
expect(render).toHaveBeenCalledWith('cash-payment', expect.objectContaining({
customerName: 'Jan', venueName: 'Café De Kroon', settledBy: 'Bar tablet', hasItems: true,
items: [expect.objectContaining({ quantity: 2, productName: 'Cola' })],
}));
const [url, request] = fetchMock.mock.calls[0];
expect(url).toBe('https://api.resend.com/emails/batch');
expect(request.headers['Idempotency-Key']).toBe('cash-payment-s1');
expect(JSON.parse(request.body).map((m: { to: string }) => m.to)).toEqual(['a@bar.be', 'b@bar.be']);
});
it('sends nothing for other payment methods or without addresses', async () => {
const withAddress = setup(['a@bar.be']);
withAddress.notifier.notify('v1', [settlement('payconiq')], 'Bar tablet');
await new Promise((resolve) => setImmediate(resolve));
expect(withAddress.fetchMock).not.toHaveBeenCalled();
const withoutAddress = setup([]);
await withoutAddress.notifier.send('v1', [settlement('cash')], 'Bar tablet');
expect(withoutAddress.fetchMock).not.toHaveBeenCalled();
});
});
+14 -33
View File
@@ -1,11 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { EmailTemplatesService } from './email-templates.service';
import { RecordedSettlement } from '../tabs/venue-ledger';
// Belgian notation, as on the dashboard: € 25,00
const euro = new Intl.NumberFormat('nl-BE', { style: 'currency', currency: 'EUR' });
const formatEuro = (cents: number) => euro.format(cents / 100);
import { formatEuro, Resend } from './resend';
import { SettlementDraft } from '../ledger/tab-state';
const dateTime = new Intl.DateTimeFormat('nl-BE', {
timeZone: 'Europe/Brussels', day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit',
@@ -18,16 +15,15 @@ const dateTime = new Intl.DateTimeFormat('nl-BE', {
@Injectable()
export class CashPaymentNotifier {
private readonly logger = new Logger(CashPaymentNotifier.name);
private readonly resendApiKey = process.env.RESEND_API_KEY || '';
private readonly fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTap <onboarding@resend.dev>';
constructor(
private prisma: PrismaService,
private emailTemplates: EmailTemplatesService,
private templates: EmailTemplatesService,
private resend: Resend,
) {}
/** Sends in the background; [settlements] may hold other payment methods, which are skipped. */
notify(venueId: string, settlements: RecordedSettlement[], settledBy: string) {
notify(venueId: string, settlements: SettlementDraft[], settledBy: string) {
const cash = settlements.filter((s) => s.paymentMethod === 'cash');
if (cash.length === 0) return;
this.send(venueId, cash, settledBy).catch((error) => {
@@ -35,22 +31,18 @@ export class CashPaymentNotifier {
});
}
async send(venueId: string, settlements: RecordedSettlement[], settledBy: string) {
const venue = await this.prisma.venue.findUnique({
where: { id: venueId },
select: { name: true, cashPaymentEmails: true },
});
const recipients = venue?.cashPaymentEmails ?? [];
if (recipients.length === 0) return;
if (!this.resendApiKey) {
async send(venueId: string, settlements: SettlementDraft[], settledBy: string) {
const venue = await this.prisma.venue.findUnique({ where: { id: venueId }, select: { name: true, cashPaymentEmails: true } });
if (!venue || venue.cashPaymentEmails.length === 0) return;
if (!this.resend.configured) {
this.logger.warn(`Not emailing ${settlements.length} cash payment(s) of venue ${venueId}: RESEND_API_KEY is not set`);
return;
}
for (const settlement of settlements) {
const items = settlement.lines.filter((line) => line.quantity > 0);
const email = await this.emailTemplates.render('cash-payment', {
venueName: venue!.name,
const email = await this.templates.render('cash-payment', {
venueName: venue.name,
customerName: settlement.customerName,
amount: formatEuro(settlement.amountInCents),
settledAt: dateTime.format(settlement.settledAt),
@@ -62,20 +54,9 @@ export class CashPaymentNotifier {
lineTotal: formatEuro(line.quantity * line.unitPriceInCents),
})),
});
const res = await fetch('https://api.resend.com/emails/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.resendApiKey}`,
// One settlement is emailed once, even when this request is retried
'Idempotency-Key': `cash-payment-${settlement.id}`,
},
body: JSON.stringify(recipients.map((to) => ({ from: this.fromEmail, to, ...email }))),
});
if (!res.ok) {
this.logger.error(`Cash payment email for settlement ${settlement.id} failed: ${await res.text()}`);
}
// One settlement is emailed once, even when this is retried
const error = await this.resend.sendBatch(venue.cashPaymentEmails.map((to) => ({ to, ...email })), `cash-payment-${settlement.id}`);
if (error) this.logger.error(`Cash payment email for settlement ${settlement.id} failed: ${error}`);
}
}
}
+3 -2
View File
@@ -1,10 +1,11 @@
import { Global, Module } from '@nestjs/common';
import { EmailTemplatesService } from './email-templates.service';
import { CashPaymentNotifier } from './cash-payment-notifier';
import { Resend } from './resend';
@Global()
@Module({
providers: [EmailTemplatesService, CashPaymentNotifier],
exports: [EmailTemplatesService, CashPaymentNotifier],
providers: [EmailTemplatesService, CashPaymentNotifier, Resend],
exports: [EmailTemplatesService, CashPaymentNotifier, Resend],
})
export class EmailModule {}
+45
View File
@@ -0,0 +1,45 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { RenderedEmail } from './email-templates.service';
export type OutgoingEmail = RenderedEmail & { to: string };
/** Sends email through Resend (RESEND_API_KEY, RESEND_FROM_EMAIL). */
@Injectable()
export class Resend {
private readonly apiKey = process.env.RESEND_API_KEY || '';
private readonly from = process.env.RESEND_FROM_EMAIL || 'KoolTap <onboarding@resend.dev>';
/** Whether sending is set up, without sending anything. */
get configured(): boolean {
return this.apiKey !== '';
}
/** Throws the error the dashboard shows when sending is not set up. */
ensureConfigured() {
if (!this.configured) {
throw new BadRequestException({ code: 'resend_not_configured', message: 'Email is not set up: set RESEND_API_KEY' });
}
}
/**
* Sends up to 100 emails in one request. Resend ignores a request with the same [idempotencyKey] for 24 hours, so a
* retry never emails twice. Returns Resend's error text, or null when sent.
*/
async sendBatch(emails: OutgoingEmail[], idempotencyKey?: string): Promise<string | null> {
const res = await fetch('https://api.resend.com/emails/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
},
body: JSON.stringify(emails.map((email) => ({ from: this.from, ...email }))),
});
return res.ok ? null : await res.text();
}
}
// Belgian notation, as on the dashboard: € 25,00
const euro = new Intl.NumberFormat('nl-BE', { style: 'currency', currency: 'EUR' });
export const formatEuro = (cents: number) => euro.format(cents / 100);
+5 -19
View File
@@ -1,32 +1,18 @@
import { Controller, Get, HttpStatus, Res } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { SkipThrottle } from '@nestjs/throttler';
import { Response } from 'express';
import { PrismaService } from './prisma/prisma.service';
@ApiTags('Health')
@SkipThrottle()
@Controller('health')
@SkipThrottle()
export class HealthController {
constructor(private prisma: PrismaService) {}
/** 503 when the database is unreachable, so the container is marked unhealthy. */
@Get()
@ApiOperation({ summary: 'Health check' })
@ApiResponse({ status: 200, description: 'Server is healthy' })
@ApiResponse({ status: 503, description: 'Database is unreachable' })
async check(@Res({ passthrough: true }) res: Response) {
let dbStatus = 'ok';
try {
await this.prisma.$queryRaw`SELECT 1`;
} catch {
dbStatus = 'error';
res.status(HttpStatus.SERVICE_UNAVAILABLE);
}
return {
status: dbStatus === 'ok' ? 'ok' : 'degraded',
database: dbStatus,
timestamp: new Date().toISOString(),
};
const database = await this.prisma.$queryRaw`SELECT 1`.then(() => 'ok', () => 'error');
if (database !== 'ok') res.status(HttpStatus.SERVICE_UNAVAILABLE);
return { status: database === 'ok' ? 'ok' : 'degraded', database, timestamp: new Date().toISOString() };
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { createHash } from 'crypto';
import sharp from 'sharp';
/** Product pictures are stored as squares of this size: sharp on a tablet tile, small enough to store in the database. */
/** Product pictures are stored as squares of this size: sharp on a tablet tile, a few tens of kB on disk. */
export const IMAGE_SIZE = 512;
/** The largest upload accepted; phone photos are a few MB. */
+10 -14
View File
@@ -1,31 +1,27 @@
import { Controller, Get, Param, Res } from '@nestjs/common';
import { SkipThrottle } from '@nestjs/throttler';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { ImagesService } from './images.service';
@ApiTags('Images')
@Controller('images')
export class ImagesController {
constructor(private images: ImagesService) {}
/**
* Public: product pictures are not secret, and the dashboard's <img> and the terminals load them without a
* token. The id is the hash of the content, so the response never changes and is cached for good.
* Public: product pictures are not secret, and the dashboard's <img> and the terminals load them without a token.
* The id is the hash of the content, so the response never changes and is cached for good.
*/
@Get(':id')
@SkipThrottle()
@ApiOperation({ summary: 'A product picture' })
async get(@Param('id') id: string, @Res() res: Response) {
const image = await this.images.get(id.replace(/\.webp$/, ''));
res.set({
'Content-Type': image.contentType,
'Content-Length': String(image.bytes.length),
'Cache-Control': 'public, max-age=31536000, immutable',
ETag: `"${image.id}"`,
// Helmet's default (same-origin) would keep the dashboard on another domain from showing it
'Cross-Origin-Resource-Policy': 'cross-origin',
const image = await this.images.find(id.replace(/\.webp$/, ''));
res.sendFile(image.file, {
headers: {
'Content-Type': image.contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
// Helmet's default (same-origin) would keep the dashboard on another domain from showing it
'Cross-Origin-Resource-Policy': 'cross-origin',
},
});
res.end(Buffer.from(image.bytes));
}
}
+33 -9
View File
@@ -1,17 +1,25 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { promises as fs } from 'fs';
import * as path from 'path';
import { PrismaService } from '../prisma/prisma.service';
import { InvalidImageError, processProductImage } from './image-processing';
/** Where pictures are stored; a volume in production, so they survive a redeploy. */
export const imageDir = () => path.resolve(process.env.IMAGE_DIR || 'data/images');
/**
* Product pictures: a file per picture on disk, named by the hash of its bytes, and a row with its size. The same
* picture is stored once, and a picture's address never changes.
*/
@Injectable()
export class ImagesService {
constructor(private prisma: PrismaService) {}
/** Processes and stores an upload; storing the same picture twice keeps one copy. Returns its id. */
/** Processes and stores an upload. Returns its id. */
async store(upload: Buffer | undefined): Promise<string> {
if (!upload || upload.length === 0) {
throw new BadRequestException({ code: 'image_required', message: 'Send the picture as the "file" field' });
}
let image;
try {
image = await processProductImage(upload);
@@ -22,19 +30,35 @@ export class ImagesService {
throw error;
}
const file = this.file(image.id);
await fs.mkdir(path.dirname(file), { recursive: true });
// Written next to it and renamed, so a picture is never read half-written
const temp = `${file}.${process.pid}.tmp`;
await fs.writeFile(temp, image.bytes);
await fs.rename(temp, file);
await this.prisma.productImage.upsert({
where: { id: image.id },
create: image,
create: { id: image.id, contentType: image.contentType, width: image.width, height: image.height, bytes: image.bytes.length },
update: {},
});
return image.id;
}
async get(id: string) {
const image = await this.prisma.productImage.findUnique({ where: { id } });
if (!image) {
throw new NotFoundException({ code: 'image_not_found', message: 'Image not found' });
}
return image;
/** The picture's file and type; 404 when there is no such picture. */
async find(id: string): Promise<{ file: string; contentType: string }> {
const image = /^[0-9a-f]{64}$/.test(id) ? await this.prisma.productImage.findUnique({ where: { id } }) : null;
if (!image) throw new NotFoundException({ code: 'image_not_found', message: 'Image not found' });
return { file: this.file(id), contentType: image.contentType };
}
/** Deletes pictures' files and rows. */
async remove(ids: string[]) {
await this.prisma.productImage.deleteMany({ where: { id: { in: ids } } });
await Promise.all(ids.map((id) => fs.rm(this.file(id), { force: true })));
}
private file(id: string) {
return path.join(imageDir(), `${id}.webp`);
}
}
-64
View File
@@ -1,64 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class CommandExpiryJob {
private readonly logger = new Logger(CommandExpiryJob.name);
/** Null until it ran since the server started; shown on the admin page. */
lastRunAt: Date | null = null;
constructor(
private prisma: PrismaService,
private realtime: RealtimeService,
) {}
@Cron(CronExpression.EVERY_HOUR)
async handleExpiredCommands() {
this.logger.log('Running command expiry job...');
const now = new Date();
this.lastRunAt = now;
const expiredCommands = await this.prisma.remoteCommand.findMany({
where: {
status: 'pending',
expiresAt: { lte: now },
},
});
if (expiredCommands.length === 0) {
this.logger.log('No expired commands found');
return;
}
await this.prisma.remoteCommand.updateMany({
where: {
id: { in: expiredCommands.map((c) => c.id) },
},
data: { status: 'expired' },
});
// Write audit entries
for (const cmd of expiredCommands) {
await this.prisma.auditLog.create({
data: {
id: uuidv4(),
venueId: cmd.venueId,
actorType: 'system',
actorId: 'command-expiry-job',
action: 'command_expired',
details: JSON.stringify({ commandId: cmd.id, tabId: cmd.targetTabId }),
},
});
}
for (const venueId of new Set(expiredCommands.map((c) => c.venueId))) {
this.realtime.notify(venueId, 'commands');
}
this.logger.log(`Expired ${expiredCommands.length} commands`);
}
}
-8
View File
@@ -1,8 +0,0 @@
import { Module } from '@nestjs/common';
import { CommandExpiryJob } from './command-expiry.job';
@Module({
providers: [CommandExpiryJob],
exports: [CommandExpiryJob],
})
export class JobsModule {}
+64
View File
@@ -0,0 +1,64 @@
import { ProductEvent } from '@kooltap/shared';
import { Tx } from '../prisma/prisma.service';
import { VenueLedger } from './venue-ledger';
import { createdFieldTimes, mergeProductChanges, ProductChanges } from './product-fields';
/**
* Applies a product created or changed on a terminal (also offline: [occurredAt] is when it happened there).
* A new product keeps the terminal's id, so it can be sold before the terminal synced. Duplicate names are
* accepted, a sale must never be lost over one; the dashboard shows them so one can be archived.
* Returns whether anything changed.
*/
export async function applyProductEvent(tx: Tx, ledger: VenueLedger, event: ProductEvent, occurredAt: Date): Promise<boolean> {
const { venueId } = ledger;
const changes = await productChanges(tx, event);
const existing = await tx.product.findUnique({ where: { id: event.productId } });
// Another venue's product: the id came from a terminal, it can't take it over
if (existing && existing.venueId !== venueId) return false;
if (!existing) {
// Changes to a product the server doesn't have (deleted) are acknowledged and dropped
if (event.type !== 'product_created') return false;
await tx.product.create({
data: {
id: event.productId,
venueId,
name: changes.name!,
category: changes.category ?? null,
priceInCents: changes.priceInCents!,
lowStockThreshold: changes.lowStockThreshold ?? null,
imageId: changes.imageId ?? null,
// Its starting stock arrives as a stock_set event
stock: null,
fieldUpdatedAt: createdFieldTimes({ ...changes, archived: false }, occurredAt),
updatedAt: new Date(),
},
});
ledger.stock.addProduct(event.productId);
return true;
}
const merged = mergeProductChanges(existing.fieldUpdatedAt, changes, occurredAt, existing);
if (!merged.changed) return false;
await tx.product.update({
where: { id: event.productId },
// The catalog version must move even when the change happened before the last one the terminals got
data: { ...merged.data, fieldUpdatedAt: merged.fieldUpdatedAt, updatedAt: new Date() },
});
return true;
}
async function productChanges(tx: Tx, event: ProductEvent): Promise<ProductChanges> {
if (event.type === 'product_archived') return { archived: true };
if (event.type === 'product_restored') return { archived: false };
const { imageId, ...fields } = event.product;
const changes: ProductChanges = { ...fields };
if (imageId !== undefined) {
// Only a picture that was uploaded: the terminal uploads before it sends the change
const known = imageId === null || await tx.productImage.findUnique({ where: { id: imageId }, select: { id: true } });
if (known) changes.imageId = imageId;
}
return changes;
}
@@ -1,7 +1,9 @@
import { Prisma } from '@prisma/client';
import { Prisma } from '../prisma/prisma.service';
/** The fields of a product that the dashboard and the terminals change; stock changes through its own rules. */
export const PRODUCT_FIELDS = ['name', 'category', 'priceInCents', 'buyPriceInCents', 'lowStockThreshold', 'desiredStock', 'archived', 'imageId'] as const;
export const PRODUCT_FIELDS = [
'name', 'category', 'priceInCents', 'buyPriceInCents', 'lowStockThreshold', 'desiredStock', 'archived', 'imageId',
] as const;
export type ProductField = (typeof PRODUCT_FIELDS)[number];
export type ProductChanges = {
@@ -44,10 +46,21 @@ export function mergeProductChanges(times: unknown, changes: ProductChanges, at:
}
}
return { data, fieldUpdatedAt: next as Prisma.InputJsonValue, changed: Object.keys(data).length > 0 };
return { data, fieldUpdatedAt: next as Prisma.InputJsonObject, changed: Object.keys(data).length > 0 };
}
/** Field times for a product created at [at]: every field it was created with. */
export function createdFieldTimes(changes: ProductChanges, at: Date): Prisma.InputJsonValue {
export function createdFieldTimes(changes: ProductChanges, at: Date): Prisma.InputJsonObject {
return Object.fromEntries(PRODUCT_FIELDS.filter((f) => changes[f] !== undefined).map((f) => [f, at.toISOString()]));
}
/**
* When a change made now on the dashboard happened: now, or just after the latest field time when a terminal whose
* clock runs a little ahead stamped one later. A change on the dashboard is made last, so it wins.
*/
export function dashboardChangeTime(now: Date, times: unknown): Date {
const stamps = Object.values((times && typeof times === 'object' ? times : {}) as Record<string, string>)
.map((t) => new Date(t).getTime())
.filter((t) => !Number.isNaN(t));
return new Date(Math.max(now.getTime(), ...stamps));
}
+53
View File
@@ -0,0 +1,53 @@
import { VenueStock } from './stock-rules';
const at = (minutes: number) => new Date(Date.UTC(2026, 8, 24, 20, minutes));
function stockOf(stock: number | null, countedAt: Date | null = null) {
return new VenueStock(new Map([['cola', { stock, countedAt }]]));
}
describe('VenueStock', () => {
it('counts sales down and drinks taken off back up', () => {
const stock = stockOf(10);
stock.sold('cola', 3, at(1));
stock.sold('cola', -1, at(2));
expect(stock.get('cola')!.stock).toBe(8);
});
it('may go negative: a sale is never blocked', () => {
const stock = stockOf(1);
stock.sold('cola', 3, at(1));
expect(stock.get('cola')!.stock).toBe(-2);
});
it('leaves sales from before the latest count out: the count has them', () => {
const stock = stockOf(10, at(5));
stock.sold('cola', 3, at(4));
expect(stock.get('cola')!.stock).toBe(10);
expect(stock.changed.size).toBe(0);
});
it('keeps the latest count when an older one arrives later, unless forced', () => {
const stock = stockOf(10, at(5));
expect(stock.count('cola', 3, at(4))).toBe(false);
expect(stock.get('cola')).toEqual({ stock: 10, countedAt: at(5) });
expect(stock.count('cola', 3, at(4), { force: true })).toBe(true);
expect(stock.get('cola')).toEqual({ stock: 3, countedAt: at(5) });
});
it('does not add to a product whose stock is not tracked', () => {
const stock = stockOf(null);
expect(stock.add('cola', 5)).toBeNull();
stock.sold('cola', 1, at(1));
expect(stock.get('cola')!.stock).toBeNull();
});
});
@@ -1,5 +1,3 @@
import { TabEventDto } from '../devices/dto/tab-event.dto';
/** One product's stock: null when not tracked, may be negative. */
export type ProductStock = { stock: number | null; countedAt: Date | null };
@@ -8,12 +6,13 @@ export type ProductStock = { stock: number | null; countedAt: Date | null };
* with the same rules on top of the stock the sync returns.
*/
export class VenueStock {
private readonly products: Map<string, ProductStock>;
/** Products to write back. */
readonly changed = new Set<string>();
constructor(products: Map<string, ProductStock>) {
this.products = products;
constructor(private readonly products: Map<string, ProductStock>) {}
has(productId: string): boolean {
return this.products.has(productId);
}
get(productId: string): ProductStock | undefined {
@@ -24,42 +23,27 @@ export class VenueStock {
return [...this.products.entries()];
}
/** A product a terminal reported for the first time: not tracked until it is counted. */
addProduct(productId: string) {
if (!this.products.has(productId)) this.products.set(productId, { stock: null, countedAt: null });
/** A product created during this sync or request: not tracked until it is counted. */
addProduct(productId: string, stock: ProductStock = { stock: null, countedAt: null }) {
if (!this.products.has(productId)) this.products.set(productId, stock);
}
/** Takes over a terminal's own stock, once, when the server starts owning the stock. */
seed(productId: string, stock: number | null) {
this.products.set(productId, { stock, countedAt: null });
/**
* Drinks rung up ([quantity] > 0) or taken off again (< 0). A sale before the latest count is already in that
* count.
*/
sold(productId: string, quantity: number, occurredAt: Date) {
const product = this.products.get(productId);
if (!product || product.stock === null) return;
if (product.countedAt && occurredAt <= product.countedAt) return;
product.stock -= quantity;
this.changed.add(productId);
}
/**
* Applies a tab event that happened at [occurredAt]. Only line events and stock counts change stock; a settlement
* doesn't, the drinks were counted down when they were put on the tab.
*/
apply(event: TabEventDto, occurredAt: Date) {
if (!event.productId) return;
switch (event.type) {
case 'line_added':
this.sold(event.productId, event.quantity!, occurredAt);
return;
case 'line_removed':
this.sold(event.productId, -event.quantity!, occurredAt);
return;
case 'stock_set':
this.count(event.productId, event.quantity ?? null, occurredAt);
return;
default:
return;
}
}
/**
* A count: the latest one wins, also when an older one arrives later (a terminal that was offline). [force] applies
* it regardless and makes it the latest.
* it regardless and makes it the latest. Null stops tracking. Returns whether it was applied.
*/
count(productId: string, stock: number | null, countedAt: Date, { force = false } = {}): boolean {
const product = this.products.get(productId);
@@ -74,7 +58,7 @@ export class VenueStock {
return true;
}
/** Adds [quantity] (signed) to a tracked product: a delivery or breakage. Returns the new stock. */
/** Adds [quantity] (signed) to a tracked product: a delivery or breakage. Returns the new stock, null when untracked. */
add(productId: string, quantity: number): number | null {
const product = this.products.get(productId);
if (!product || product.stock === null) return null;
@@ -83,14 +67,4 @@ export class VenueStock {
this.changed.add(productId);
return product.stock;
}
/** A sale before the latest count is already in that count. */
private sold(productId: string, quantity: number, occurredAt: Date) {
const product = this.products.get(productId);
if (!product || product.stock === null) return;
if (product.countedAt && occurredAt <= product.countedAt) return;
product.stock -= quantity;
this.changed.add(productId);
}
}
+49
View File
@@ -0,0 +1,49 @@
import { Line } from './tab-state';
import { tabOrders } from './tab-orders';
const at = (minutes: number) => new Date(Date.UTC(2026, 9, 28, 12, 30 + minutes)).toISOString();
function line(id: string, productName: string, unitPriceInCents: number, units: [orderId: string, minutes: number, removed?: boolean][]): Line {
return {
id,
productId: null,
productName,
unitPriceInCents,
units: units.map(([orderId, minutes, removed], index) => ({
id: `${id}-${orderId}:${index}`, addedAt: at(minutes), removedAt: removed ? at(minutes + 1) : null, orderId,
})),
};
}
describe('tabOrders', () => {
it('shows the drinks of each order under the time it started', () => {
const lines = [
line('duvel', 'Duvel', 400, [['o1', 0], ['o1', 0], ['o2', 38]]),
line('cola', 'Cola', 250, [['o1', 1]]),
line('beer', 'Beer', 300, [['o2', 39]]),
];
expect(tabOrders(lines)).toEqual([
{
orderId: 'o1', startedAt: at(0),
items: [
{ productName: 'Duvel', quantity: 2, unitPriceInCents: 400, removed: 0 },
{ productName: 'Cola', quantity: 1, unitPriceInCents: 250, removed: 0 },
],
},
{
orderId: 'o2', startedAt: at(38),
items: [
{ productName: 'Duvel', quantity: 1, unitPriceInCents: 400, removed: 0 },
{ productName: 'Beer', quantity: 1, unitPriceInCents: 300, removed: 0 },
],
},
]);
});
it('counts drinks taken off again apart', () => {
expect(tabOrders([line('cola', 'Cola', 250, [['o1', 0], ['o1', 0, true]])])[0].items).toEqual([
{ productName: 'Cola', quantity: 1, unitPriceInCents: 250, removed: 1 },
]);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { TabOrder } from '@kooltap/shared';
import { compareUnits, isActive, Line, Unit } from './tab-state';
/**
* Splits a tab's (or settlement's) lines into the orders they were rung up in, oldest first, as the terminal shows
* them under one "12:32 today" divider. Within an order every line shows how many of its drinks are still on it and
* how many were taken off again.
*/
export function tabOrders(lines: Line[]): TabOrder[] {
const orders = new Map<string | null, { startedAt: string; units: Map<Line, Unit[]> }>();
const drinks = lines.flatMap((line) => line.units.map((unit) => ({ line, unit })))
.sort((a, b) => compareUnits(a.unit, b.unit));
for (const { line, unit } of drinks) {
let order = orders.get(unit.orderId);
if (!order) {
order = { startedAt: unit.addedAt, units: new Map() };
orders.set(unit.orderId, order);
}
order.units.set(line, [...(order.units.get(line) ?? []), unit]);
}
return [...orders]
.map(([orderId, { startedAt, units }]) => ({
orderId,
startedAt,
items: [...units].map(([line, lineUnits]) => ({
productName: line.productName,
quantity: lineUnits.filter(isActive).length,
unitPriceInCents: line.unitPriceInCents,
removed: lineUnits.filter((unit) => !isActive(unit)).length,
})),
}))
.sort((a, b) => a.startedAt.localeCompare(b.startedAt));
}
+150
View File
@@ -0,0 +1,150 @@
import { TabEvent } from '@kooltap/shared';
import { compareUnits, tabJson, VenueTabs } from './tab-state';
const time = new Date('2026-09-24T20:00:00.000Z');
const TAB = '00000000-0000-4000-8000-000000000001';
const COLA = '00000000-0000-4000-8000-0000000000c0';
let counter = 0;
type Fields<T extends TabEvent['type']> = Omit<Extract<TabEvent, { type: T }>, 'id' | 'occurredAt' | 'type'>;
function event<T extends TabEvent['type']>(type: T, fields: Partial<Fields<T>> = {}): Extract<TabEvent, { type: T }> {
return {
id: `e${String(++counter).padStart(3, '0')}`, type, occurredAt: time.toISOString(), tabId: TAB, customerName: 'Jan', ...fields,
} as unknown as Extract<TabEvent, { type: T }>;
}
const cola = { lineId: 'line-1', productId: COLA, productName: 'Cola', unitPriceInCents: 250 };
function withCola(quantity: number, orderId = 'o1') {
const tabs = new VenueTabs([]);
tabs.apply(event('tab_opened'), time);
tabs.apply(event('line_added', { ...cola, orderId, quantity }), time);
return tabs;
}
describe('VenueTabs.apply', () => {
it('ignores a second tab_opened for a tab that exists', () => {
const tabs = withCola(1);
tabs.apply(event('tab_opened', { customerName: 'Other' }), time);
expect(tabs.get(TAB)).toMatchObject({ customerName: 'Jan', version: 2 });
});
it('opens a deleted tab again when drinks are added to it, so no sale is lost', () => {
const tabs = withCola(1);
tabs.apply(event('tab_deleted'), time);
tabs.apply(event('line_added', { ...cola, orderId: 'o2', quantity: 2 }), time);
expect(tabJson(tabs.get(TAB)!).lines).toMatchObject([{ id: 'line-1', quantity: 2 }]);
expect(tabs.deleted.size).toBe(0);
});
it('keeps only products the venue has', () => {
const tabs = new VenueTabs([], (id) => id === COLA);
tabs.apply(event('line_added', { ...cola, lineId: 'a', orderId: 'o1', quantity: 1 }), time);
tabs.apply(event('line_added', { ...cola, lineId: 'b', productId: '00000000-0000-4000-8000-0000000000ff', orderId: 'o1', quantity: 1 }), time);
expect(tabs.get(TAB)!.lines.map((line) => line.productId)).toEqual([COLA, null]);
});
it('takes off the latest drinks of the order, never more than are on it', () => {
const tabs = withCola(2, 'o1');
tabs.apply(event('line_added', { ...cola, orderId: 'o2', quantity: 2 }), time);
tabs.apply(event('line_removed', { lineId: 'line-1', orderId: 'o1', quantity: 5 }), time);
const units = tabs.get(TAB)!.lines[0].units;
expect(units.filter((unit) => unit.removedAt !== null).map((unit) => unit.orderId)).toEqual(['o1', 'o1']);
expect(tabJson(tabs.get(TAB)!).lines[0].quantity).toBe(2);
});
it('settles the oldest drinks of each line and takes the removed ones along', () => {
const tabs = withCola(3);
tabs.apply(event('line_removed', { lineId: 'line-1', orderId: 'o1', quantity: 1 }), time);
const settlement = tabs.apply(event('settled', { amountInCents: 250, paymentMethod: 'cash', items: [{ ...cola, quantity: 1 }] }), time)!;
expect(settlement).toMatchObject({ amountInCents: 250, lines: [{ id: 'line-1', quantity: 1 }] });
expect(settlement.lines[0].units).toHaveLength(2);
expect(tabJson(tabs.get(TAB)!).lines).toMatchObject([{ quantity: 1 }]);
});
it('keeps settled lines the server never saw in the settlement, with made-up drinks', () => {
const tabs = withCola(1);
const settled = event('settled', {
amountInCents: 650,
paymentMethod: 'cash',
items: [{ ...cola, quantity: 1 }, { lineId: 'chips', productId: null, productName: 'Chips', unitPriceInCents: 200, quantity: 2 }],
});
const settlement = tabs.apply(settled, time)!;
expect(settlement.lines).toMatchObject([
{ id: 'line-1', quantity: 1 },
{ id: 'chips', productId: null, quantity: 2, units: [{ id: `${settled.id}:item:1:0`, orderId: null }, { id: `${settled.id}:item:1:1` }] },
]);
expect(tabs.get(TAB)!.lines).toEqual([]);
});
it('records a settlement of a tab that is gone', () => {
const tabs = new VenueTabs([]);
const settlement = tabs.apply(event('settled', { amountInCents: 250, paymentMethod: 'cash', items: [{ ...cola, quantity: 1 }] }), time);
expect(settlement).toMatchObject({ tabId: TAB, customerName: 'Jan', lines: [{ quantity: 1 }] });
expect(tabs.all()).toEqual([]);
});
it('changes nothing for events about tabs that are gone', () => {
const tabs = new VenueTabs([]);
tabs.apply(event('tab_renamed'), time);
tabs.apply(event('tab_deleted'), time);
tabs.apply(event('line_removed', { lineId: 'line-1', orderId: 'o1', quantity: 1 }), time);
expect(tabs.changed.size + tabs.deleted.size).toBe(0);
});
});
describe('VenueTabs.removableUnits', () => {
it('counts only drinks still on the line, so a removal after a settlement returns nothing to the stock', () => {
const tabs = withCola(3);
const removal = event('line_removed', { lineId: 'line-1', orderId: 'o1', quantity: 5 });
expect(tabs.removableUnits(removal)).toHaveLength(3);
tabs.apply(event('settled', { amountInCents: 750, paymentMethod: 'cash', items: [{ ...cola, quantity: 3 }] }), time);
expect(tabs.removableUnits(removal)).toHaveLength(0);
});
});
describe('VenueTabs.settleAll', () => {
it('settles every drink, keeping the tab open and empty', () => {
const tabs = withCola(3);
tabs.apply(event('line_removed', { lineId: 'line-1', orderId: 'o1', quantity: 1 }), time);
const settlement = tabs.settleAll(TAB, { id: 's1', settledAt: time, paymentMethod: 'cash' })!;
expect(settlement).toMatchObject({ amountInCents: 500, lines: [{ quantity: 2 }] });
expect(settlement.lines[0].units).toHaveLength(3);
expect(tabJson(tabs.get(TAB)!)).toMatchObject({ version: 4, lines: [] });
});
it('returns null for an empty tab', () => {
const tabs = new VenueTabs([]);
tabs.apply(event('tab_opened'), time);
expect(tabs.settleAll(TAB, { id: 's1', settledAt: time, paymentMethod: 'cash' })).toBeNull();
});
});
describe('compareUnits', () => {
it('orders the drinks of one event by their index as a number', () => {
const unit = (id: string) => ({ id, addedAt: time.toISOString(), removedAt: null, orderId: null });
expect(['e:10', 'e:2', 'e:1'].map(unit).sort(compareUnits).map((u) => u.id)).toEqual(['e:1', 'e:2', 'e:10']);
});
});
+260
View File
@@ -0,0 +1,260 @@
import { PaymentMethod, TabEvent, TerminalLine, TerminalTab } from '@kooltap/shared';
/**
* The venue's open tabs and the rules that change them. Pure: the ledger loads the state, applies events here and
* stores what changed. The terminals apply the same rules to replay their unsent events, so a unit's id must be
* derived the same way on both sides (`<event id>:<index>`).
*/
/** One drink ever put on a line. [orderId] is the order it was rung up in on the terminal. */
export type Unit = { id: string; addedAt: string; removedAt: string | null; orderId: string | null };
export type Line = {
id: string;
productId: string | null;
productName: string;
unitPriceInCents: number;
units: Unit[];
};
export type TabState = {
id: string;
customerName: string;
openedAt: Date;
version: number;
lines: Line[];
};
export type SettledLine = Line & { quantity: number };
export type SettlementDraft = {
id: string;
tabId: string;
customerName: string;
settledAt: Date;
paymentMethod: PaymentMethod;
amountInCents: number;
lines: SettledLine[];
};
export const isActive = (unit: Unit) => unit.removedAt === null;
export const lineQuantity = (line: Line) => line.units.filter(isActive).length;
/**
* Oldest first, as the terminal added them: by time, then by the event that added them and their index in it (as a
* number, so `:10` comes after `:9`).
*/
export function compareUnits(a: Unit, b: Unit): number {
const [aEvent, aIndex] = splitUnitId(a.id);
const [bEvent, bIndex] = splitUnitId(b.id);
return a.addedAt.localeCompare(b.addedAt) || aEvent.localeCompare(bEvent) || aIndex - bIndex;
}
function splitUnitId(id: string): [string, number] {
const at = id.lastIndexOf(':');
return at < 0 ? [id, 0] : [id.slice(0, at), Number(id.slice(at + 1)) || 0];
}
export class VenueTabs {
private readonly tabs: Map<string, TabState>;
/** Tabs to write back, and tabs to delete. */
readonly changed = new Set<string>();
readonly deleted = new Set<string>();
/** [isProduct] says whether a product id is in the venue's catalog: lines keep only known products. */
constructor(tabs: TabState[], private readonly isProduct: (productId: string) => boolean = () => true) {
this.tabs = new Map(tabs.map((tab) => [tab.id, tab]));
}
get(tabId: string): TabState | undefined {
return this.tabs.get(tabId);
}
all(): TabState[] {
return [...this.tabs.values()];
}
/**
* Applies one event that happened at [occurredAt]. Returns the settlement a `settled` event made. Events about a
* tab or line that is gone change nothing (they are acknowledged all the same).
*/
apply(event: TabEvent, occurredAt: Date): SettlementDraft | null {
const at = occurredAt.toISOString();
switch (event.type) {
case 'tab_opened':
if (!this.tabs.has(event.tabId)) {
this.open(event.tabId, event.customerName, occurredAt);
this.touch(event.tabId);
}
return null;
case 'tab_renamed': {
const tab = this.tabs.get(event.tabId);
if (tab) {
tab.customerName = event.customerName;
this.touch(event.tabId);
}
return null;
}
case 'tab_deleted':
this.remove(event.tabId);
return null;
case 'line_added': {
// Deleted elsewhere: bring it back, so no sale is lost
const tab = this.tabs.get(event.tabId) ?? this.open(event.tabId, event.customerName, occurredAt);
let line = tab.lines.find((l) => l.id === event.lineId);
if (!line) {
line = {
id: event.lineId,
productId: this.isProduct(event.productId) ? event.productId : null,
productName: event.productName,
unitPriceInCents: event.unitPriceInCents,
units: [],
};
tab.lines.push(line);
}
for (let index = 0; index < event.quantity; index++) {
line.units.push({ id: `${event.id}:${index}`, addedAt: at, removedAt: null, orderId: event.orderId });
}
this.touch(event.tabId);
return null;
}
case 'line_removed': {
// The latest drinks of the order: the ones that were just rung up by mistake
const removable = this.removableUnits(event);
for (const unit of removable.slice(Math.max(0, removable.length - event.quantity))) unit.removedAt = at;
if (removable.length > 0) this.touch(event.tabId);
return null;
}
case 'settled': {
const tab = this.tabs.get(event.tabId);
const lines: SettledLine[] = [];
event.items.forEach((item, index) => {
const line = tab?.lines.find((l) => l.id === item.lineId);
if (tab && line) {
const settled = settleLine(tab, line, item.quantity);
if (settled.units.length > 0) lines.push(settled);
} else if (item.quantity > 0) {
// Settled on the terminal but unknown here: keep it in the history all the same
lines.push({
id: item.lineId,
productId: item.productId && this.isProduct(item.productId) ? item.productId : null,
productName: item.productName,
unitPriceInCents: item.unitPriceInCents,
quantity: item.quantity,
units: Array.from({ length: item.quantity }, (_, n) => (
{ id: `${event.id}:item:${index}:${n}`, addedAt: at, removedAt: null, orderId: null })),
});
}
});
if (tab) this.touch(event.tabId);
return {
id: event.id,
tabId: event.tabId,
customerName: tab?.customerName ?? event.customerName,
settledAt: occurredAt,
paymentMethod: event.paymentMethod,
amountInCents: event.amountInCents,
lines,
};
}
}
}
/**
* The drinks a line_removed event can take off: the active ones of its order. None when the tab or line is gone
* (settled or deleted elsewhere while the terminal was offline).
*/
removableUnits(event: Extract<TabEvent, { type: 'line_removed' }>): Unit[] {
const line = this.tabs.get(event.tabId)?.lines.find((l) => l.id === event.lineId);
return line?.units.filter((unit) => isActive(unit) && unit.orderId === event.orderId) ?? [];
}
/** Settles everything still on the tab, as the dashboard does. Null when there is nothing to settle. */
settleAll(tabId: string, settlement: { id: string; settledAt: Date; paymentMethod: PaymentMethod }): SettlementDraft | null {
const tab = this.tabs.get(tabId);
if (!tab) return null;
const lines = tab.lines
.filter((line) => lineQuantity(line) > 0)
.map((line) => settleLine(tab, line, lineQuantity(line)));
if (lines.length === 0) return null;
this.touch(tabId);
return {
...settlement,
tabId,
customerName: tab.customerName,
amountInCents: lines.reduce((sum, line) => sum + line.quantity * line.unitPriceInCents, 0),
lines,
};
}
/** Deletes a tab, from a terminal or the dashboard. False when it is not open. */
remove(tabId: string): boolean {
if (!this.tabs.delete(tabId)) return false;
this.changed.delete(tabId);
this.deleted.add(tabId);
return true;
}
/** Opens a tab without an event: a customer's tab, or a line added to a tab that is gone. */
open(tabId: string, customerName: string, openedAt: Date): TabState {
const tab: TabState = { id: tabId, customerName, openedAt, version: 0, lines: [] };
this.tabs.set(tabId, tab);
this.deleted.delete(tabId);
this.changed.add(tabId);
return tab;
}
private touch(tabId: string) {
this.tabs.get(tabId)!.version++;
this.changed.add(tabId);
}
}
/**
* Takes the [quantity] oldest active units off [line], together with its removed units, and drops the line when
* nothing is left. Drinks added after the settlement (by another terminal) stay on the tab.
*/
function settleLine(tab: TabState, line: Line, quantity: number): SettledLine {
const settled = line.units.filter(isActive).slice(0, quantity);
const removed = line.units.filter((unit) => !isActive(unit));
const taken = new Set([...settled, ...removed]);
line.units = line.units.filter((unit) => !taken.has(unit));
if (line.units.length === 0) tab.lines = tab.lines.filter((l) => l !== line);
return {
id: line.id,
productId: line.productId,
productName: line.productName,
unitPriceInCents: line.unitPriceInCents,
quantity: settled.length,
units: [...settled, ...removed].sort(compareUnits),
};
}
/** A line as the terminals get it: with how many drinks are still on it. */
export function lineJson(line: Line): TerminalLine {
return { ...line, quantity: lineQuantity(line) };
}
/** [email]: the email of the customer with the tab's name. */
export function tabJson(tab: TabState, email: string | null = null): TerminalTab {
return {
id: tab.id,
customerName: tab.customerName,
email,
openedAt: tab.openedAt.toISOString(),
version: tab.version,
lines: tab.lines.map(lineJson),
};
}
+187
View File
@@ -0,0 +1,187 @@
import { Tx } from '../prisma/prisma.service';
import { compareUnits, Line, SettlementDraft, TabState, Unit, VenueTabs } from './tab-state';
import { ProductStock, VenueStock } from './stock-rules';
export type RecordedSettlement = SettlementDraft & { deviceId?: string | null; settledByUserId?: string | null };
type UnitRow = {
id: string;
tabId: string | null;
settlementId: string | null;
lineId: string;
productId: string | null;
productName: string;
unitPriceInCents: number;
orderId: string | null;
addedAt: Date;
removedAt: Date | null;
};
/** Locks the venue: syncs, dashboard changes and resets of one venue run one after the other. */
export async function lockVenue(tx: Tx, venueId: string) {
await tx.$queryRaw`SELECT "id" FROM "Venue" WHERE "id" = ${venueId}::uuid FOR UPDATE`;
}
/**
* A venue's open tabs, stock and new settlements inside one transaction. Opening it locks the venue, so terminals
* syncing at the same time and the dashboard apply their changes one after the other instead of overwriting each
* other. Everything is loaded at once: a venue has tens of open tabs and products, not thousands.
*/
export class VenueLedger {
readonly settlements: RecordedSettlement[] = [];
private constructor(
readonly venueId: string,
readonly tabs: VenueTabs,
readonly stock: VenueStock,
/** The units on open tabs as loaded, to write only what changed. */
private readonly loaded: Map<string, UnitRow>,
) {}
static async open(tx: Tx, venueId: string): Promise<VenueLedger> {
await lockVenue(tx, venueId);
const [tabs, units, products] = await Promise.all([
tx.tab.findMany({ where: { venueId }, orderBy: { openedAt: 'asc' } }),
tx.tabUnit.findMany({ where: { venueId, tabId: { not: null } } }),
tx.product.findMany({ where: { venueId }, select: { id: true, stock: true, countedAt: true } }),
]);
const unitsByTab = groupBy(units, (unit) => unit.tabId!);
const stock = new VenueStock(new Map(products.map((p): [string, ProductStock] => (
[p.id, { stock: p.stock, countedAt: p.countedAt }]))));
return new VenueLedger(
venueId,
new VenueTabs(
tabs.map((tab) => ({
id: tab.id,
customerName: tab.customerName,
openedAt: tab.openedAt,
version: tab.version,
lines: linesOf(unitsByTab.get(tab.id) ?? []),
})),
(productId) => stock.has(productId),
),
stock,
new Map(units.map((unit) => [unit.id, unit])),
);
}
/** Writes what changed. Call once, at the end of the transaction. */
async save(tx: Tx, now: Date) {
const { venueId } = this;
for (const tabId of this.tabs.changed) {
const tab = this.tabs.get(tabId)!;
const data = { customerName: tab.customerName, openedAt: tab.openedAt, version: tab.version, updatedAt: now };
await tx.tab.upsert({ where: { id: tabId }, create: { id: tabId, venueId, ...data }, update: data });
}
if (this.settlements.length > 0) {
await tx.settlement.createMany({
data: this.settlements.map((s) => ({
id: s.id,
venueId,
tabId: s.tabId,
customerName: s.customerName,
settledAt: s.settledAt,
paymentMethod: s.paymentMethod,
amountInCents: s.amountInCents,
deviceId: s.deviceId ?? null,
settledByUserId: s.settledByUserId ?? null,
})),
});
}
// Where every unit of a changed tab or a new settlement belongs now
const wanted: UnitRow[] = [
...[...this.tabs.changed].flatMap((tabId) => this.tabs.get(tabId)!.lines.flatMap((line) => (
line.units.map((unit) => unitRow(unit, line, { tabId, settlementId: null }))))),
...this.settlements.flatMap((s) => s.lines.flatMap((line) => (
line.units.map((unit) => unitRow(unit, line, { tabId: null, settlementId: s.id }))))),
];
// A tab deleted and opened again in one go lost the drinks it had
const wantedIds = new Set(wanted.map((unit) => unit.id));
const dropped = [...this.loaded.values()]
.filter((unit) => this.tabs.changed.has(unit.tabId!) && !wantedIds.has(unit.id))
.map((unit) => unit.id);
if (dropped.length > 0) await tx.tabUnit.deleteMany({ where: { id: { in: dropped } } });
const inserts = wanted.filter((unit) => !this.loaded.has(unit.id));
if (inserts.length > 0) await tx.tabUnit.createMany({ data: inserts.map((unit) => ({ ...unit, venueId })) });
// Moved to a settlement or taken off: grouped, since one event changes many units the same way
const updates = groupBy(
wanted.filter((unit) => {
const before = this.loaded.get(unit.id);
return before && (before.tabId !== unit.tabId || before.settlementId !== unit.settlementId
|| before.removedAt?.getTime() !== unit.removedAt?.getTime());
}),
(unit) => JSON.stringify([unit.tabId, unit.settlementId, unit.removedAt]),
);
for (const units of updates.values()) {
const [{ tabId, settlementId, removedAt }] = units;
await tx.tabUnit.updateMany({ where: { id: { in: units.map((u) => u.id) } }, data: { tabId, settlementId, removedAt } });
}
// Deleting a tab deletes the drinks still on it
if (this.tabs.deleted.size > 0) {
await tx.tab.deleteMany({ where: { venueId, id: { in: [...this.tabs.deleted] } } });
}
for (const productId of this.stock.changed) {
const { stock, countedAt } = this.stock.get(productId)!;
await tx.product.update({ where: { id: productId }, data: { stock, countedAt } });
}
}
get changedTabs(): boolean {
return this.tabs.changed.size > 0 || this.tabs.deleted.size > 0;
}
}
/** Units grouped into lines, each line's units and the lines oldest first. */
export function linesOf(units: Omit<UnitRow, 'tabId' | 'settlementId'>[]): Line[] {
const lines = [...groupBy(units, (unit) => unit.lineId).values()].map((rows): Line => ({
id: rows[0].lineId,
productId: rows[0].productId,
productName: rows[0].productName,
unitPriceInCents: rows[0].unitPriceInCents,
units: rows
.map((row): Unit => ({
id: row.id,
addedAt: row.addedAt.toISOString(),
removedAt: row.removedAt?.toISOString() ?? null,
orderId: row.orderId,
}))
.sort(compareUnits),
}));
return lines.sort((a, b) => compareUnits(a.units[0], b.units[0]));
}
function unitRow(unit: Unit, line: Line, where: { tabId: string | null; settlementId: string | null }): UnitRow {
return {
id: unit.id,
...where,
lineId: line.id,
productId: line.productId,
productName: line.productName,
unitPriceInCents: line.unitPriceInCents,
orderId: unit.orderId,
addedAt: new Date(unit.addedAt),
removedAt: unit.removedAt ? new Date(unit.removedAt) : null,
};
}
function groupBy<T, K>(items: T[], key: (item: T) => K): Map<K, T[]> {
const groups = new Map<K, T[]>();
for (const item of items) {
const k = key(item);
const group = groups.get(k);
if (group) group.push(item);
else groups.set(k, [item]);
}
return groups;
}
+25 -50
View File
@@ -1,61 +1,36 @@
import 'dotenv/config';
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
import { NestExpressApplication } from '@nestjs/platform-express';
import cookieParser from 'cookie-parser';
import helmet from 'helmet';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
import { corsOrigin } from './cors';
import { HttpExceptionFilter } from './common/http-exception.filter';
import { LoggingInterceptor } from './common/logging.interceptor';
/** The app as it serves requests; also used by the end-to-end tests. */
export function configure(app: NestExpressApplication) {
// Bulk customer imports can exceed the 100kb default
app.useBodyParser('json', { limit: '1mb' });
app.setGlobalPrefix('api/v1');
// Behind a reverse proxy, trust X-Forwarded-For so rate limiting is per client instead of per proxy.
if (process.env.TRUST_PROXY) {
const value = process.env.TRUST_PROXY;
app.set('trust proxy', /^\d+$/.test(value) ? Number(value) : value === 'true' ? true : value);
}
// Behind a reverse proxy, trust X-Forwarded-For so rate limiting is per client instead of per proxy
const trustProxy = process.env.TRUST_PROXY;
if (trustProxy) app.set('trust proxy', /^\d+$/.test(trustProxy) ? Number(trustProxy) : trustProxy === 'true' || trustProxy);
app.use(helmet());
app.enableCors({
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
credentials: true,
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
app.use(cookieParser());
app.enableCors({ origin: corsOrigin(), credentials: true });
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new LoggingInterceptor());
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 port = process.env.PORT || 3001;
await app.listen(port);
console.log(`API running on http://localhost:${port}`);
if (swagger) console.log(`Swagger docs at http://localhost:${port}/api/docs`);
app.enableShutdownHooks();
return app;
}
bootstrap();
async function bootstrap() {
const app = configure(await NestFactory.create<NestExpressApplication>(AppModule));
app.useGlobalInterceptors(new LoggingInterceptor());
const port = Number(process.env.PORT) || 3001;
await app.listen(port);
new Logger('Bootstrap').log(`API running on http://localhost:${port}/api/v1`);
}
if (require.main === module) bootstrap();
@@ -1,46 +0,0 @@
import { DevicesService } from '../devices/devices.service';
import { SyncDto } from '../devices/dto/sync.dto';
import { resetVenue } from '../admin/venue-reset';
import { createFakePrisma } from '../testing/fake-prisma';
import { ensureDefaultCustomers, FREE_DRINKS_CUSTOMER } from './default-customers';
function setup() {
const prisma = createFakePrisma();
prisma.venue.rows.push({ id: 'v1', tabsSeededAt: new Date('2026-10-01') });
const tablet = { id: 'd1', venueId: 'v1', name: 'Bar tablet', revokedAt: null, lastSeenAt: new Date() };
prisma.device.rows.push({ ...tablet });
const service = new DevicesService(prisma as any, { log: jest.fn() } as any, { notify: jest.fn() } as any, { notify: jest.fn() } as any);
const sync = () => service.sync(tablet, { deviceId: tablet.id, openTabs: [] } as SyncDto);
const reset = (scopes: ('tabs' | 'plannedTabs')[]) =>
resetVenue(prisma as any, 'v1', scopes, { stockValue: 0, actor: { id: 'u1', name: 'Admin' }, now: new Date() });
return { prisma, sync, reset };
}
const names = (result: { tabs: { customerName: string }[] }) => result.tabs.map((tab) => tab.customerName);
describe('ensureDefaultCustomers', () => {
it('gives a venue a Free Drinks tab, without email, on the next sync', async () => {
const { prisma, sync } = setup();
await ensureDefaultCustomers(prisma as any, 'v1');
expect(prisma.plannedTab.rows).toEqual([expect.objectContaining({ customerName: FREE_DRINKS_CUSTOMER, email: null })]);
expect(names(await sync())).toEqual([FREE_DRINKS_CUSTOMER]);
// Opened once: a second sync keeps the one tab
expect(names(await sync())).toEqual([FREE_DRINKS_CUSTOMER]);
});
it('brings the tab back after a reset of the tabs or the planned customers', async () => {
const { prisma, sync, reset } = setup();
await ensureDefaultCustomers(prisma as any, 'v1');
await sync();
await reset(['tabs']);
expect(names(await sync())).toEqual([FREE_DRINKS_CUSTOMER]);
await reset(['plannedTabs']);
expect(prisma.plannedTab.rows).toHaveLength(1);
// Its tab was still open: kept, not opened a second time
expect(names(await sync())).toEqual([FREE_DRINKS_CUSTOMER]);
});
});
@@ -1,21 +0,0 @@
import { Prisma } from '@prisma/client';
import { customerNameKey } from './customer-name';
type Tx = Prisma.TransactionClient;
/** The tab free drinks are put on, so they still count in the stock and history. A normal customer without email. */
export const FREE_DRINKS_CUSTOMER = 'Free Drinks';
/**
* Plans the customers every venue starts with; the next sync opens their tabs. A customer that was removed, or whose
* tab was wiped, is planned again: run when a venue is created or reset.
*/
export async function ensureDefaultCustomers(tx: Tx, venueId: string) {
const key = customerNameKey(FREE_DRINKS_CUSTOMER);
await tx.plannedTab.upsert({
where: { venueId_customerNameKey: { venueId, customerNameKey: key } },
create: { venueId, customerName: FREE_DRINKS_CUSTOMER, customerNameKey: key },
// Opened again when its tab is gone; an open tab with this name is kept
update: { deliveredAt: null },
});
}
@@ -1,17 +0,0 @@
import { IsString, IsOptional, IsNotEmpty, IsEmail, MaxLength } from 'class-validator';
import { Transform } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreatePlannedTabDto {
@ApiProperty({ example: 'Jan' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
customerName: string;
@ApiPropertyOptional({ example: 'jan@example.com' })
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value))
@IsEmail()
email?: string;
}
@@ -1,33 +0,0 @@
import { IsString, IsOptional, IsNotEmpty, IsEmail, MaxLength, IsArray, ArrayMaxSize, ValidateNested, IsIn } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportPlannedTabEntryDto {
@ApiProperty({ example: 'Jan Peeters' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
customerName: string;
@ApiPropertyOptional({ example: 'jan@example.com' })
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value))
@IsEmail()
email?: string;
}
export class ImportPlannedTabsDto {
@ApiProperty({ type: [ImportPlannedTabEntryDto] })
@IsArray()
@ArrayMaxSize(5000)
@ValidateNested({ each: true })
@Type(() => ImportPlannedTabEntryDto)
entries: ImportPlannedTabEntryDto[];
@ApiProperty({
enum: ['merge', 'replace'],
description: 'merge adds new names and updates emails; replace also removes names that are not in the import',
})
@IsIn(['merge', 'replace'])
mode: 'merge' | 'replace';
}
@@ -1,17 +0,0 @@
import { IsString, IsOptional, IsNotEmpty, IsEmail, MaxLength } from 'class-validator';
import { Transform } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class UpdatePlannedTabDto {
@ApiProperty({ example: 'Jan' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
customerName: string;
@ApiPropertyOptional({ example: 'jan@example.com' })
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value))
@IsEmail()
email?: string;
}
@@ -1,53 +0,0 @@
import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { PlannedTabsService } from './planned-tabs.service';
import { CreatePlannedTabDto } from './dto/create-planned-tab.dto';
import { UpdatePlannedTabDto } from './dto/update-planned-tab.dto';
import { ImportPlannedTabsDto } from './dto/import-planned-tabs.dto';
@ApiTags('Planned Tabs')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
@Controller('venues/:venueId/planned-tabs')
export class PlannedTabsController {
constructor(private service: PlannedTabsService) {}
@Get()
@ApiOperation({ summary: 'List planned tab names' })
async findAll(@Param('venueId') venueId: string) {
return this.service.findAll(venueId);
}
@Post()
@Roles('admin', 'manager')
@ApiOperation({ summary: 'Add a planned tab name' })
async create(@Param('venueId') venueId: string, @Body() dto: CreatePlannedTabDto) {
return this.service.create(venueId, dto.customerName, dto.email);
}
@Post('import')
@Roles('admin', 'manager')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Bulk import planned tab names (e.g. from an Excel file)' })
async import(@Param('venueId') venueId: string, @Body() dto: ImportPlannedTabsDto) {
return this.service.import(venueId, dto.entries, dto.mode);
}
@Put(':id')
@Roles('admin', 'manager')
@ApiOperation({ summary: 'Rename a planned tab' })
async update(@Param('venueId') venueId: string, @Param('id') id: string, @Body() dto: UpdatePlannedTabDto) {
return this.service.update(venueId, id, dto.customerName, dto.email);
}
@Delete(':id')
@Roles('admin', 'manager')
@ApiOperation({ summary: 'Remove a planned tab' })
async remove(@Param('venueId') venueId: string, @Param('id') id: string) {
return this.service.remove(venueId, id);
}
}
@@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { PlannedTabsController } from './planned-tabs.controller';
import { PlannedTabsService } from './planned-tabs.service';
@Module({
controllers: [PlannedTabsController],
providers: [PlannedTabsService],
exports: [PlannedTabsService],
})
export class PlannedTabsModule {}
@@ -1,88 +0,0 @@
import { PlannedTabsService } from './planned-tabs.service';
type Row = { id: string; venueId: string; customerName: string; email: string | null; position: number; deliveredAt?: Date | null };
function createService(initial: Omit<Row, 'venueId'>[]) {
let rows: Row[] = initial.map((r) => ({ deliveredAt: null, ...r, venueId: 'v1' }));
const tx = {
plannedTab: {
findMany: jest.fn(async () => [...rows].sort((a, b) => a.position - b.position)),
create: jest.fn(async ({ data }: { data: Row }) => {
rows.push(data);
return data;
}),
update: jest.fn(async ({ where, data }: { where: { id: string }; data: Partial<Row> }) => {
const row = rows.find((r) => r.id === where.id)!;
Object.assign(row, data);
return row;
}),
deleteMany: jest.fn(async ({ where }: { where: { id: { in: string[] } } }) => {
const before = rows.length;
rows = rows.filter((r) => !where.id.in.includes(r.id));
return { count: before - rows.length };
}),
},
};
const prisma = { $transaction: (fn: (t: typeof tx) => unknown) => fn(tx) };
const service = new PlannedTabsService(prisma as any, { notify: jest.fn() } as any);
return { service, rows: () => rows };
}
describe('PlannedTabsService.import', () => {
it('adds new names, keeps existing spelling and only fills in emails', async () => {
const { service, rows } = createService([
{ id: 'a', customerName: 'Jan Peeters', email: null, position: 0 },
{ id: 'b', customerName: 'Els', email: 'els@example.com', position: 1 },
]);
const result = await service.import('v1', [
{ customerName: ' jan peeters ', email: 'jan@example.com' },
{ customerName: 'Els' },
{ customerName: 'Piet' },
], 'merge');
expect(result).toEqual({ created: 1, updated: 1, unchanged: 1, removed: 0 });
expect(rows().find((r) => r.id === 'a')).toMatchObject({ customerName: 'Jan Peeters', email: 'jan@example.com' });
expect(rows().find((r) => r.id === 'b')?.email).toBe('els@example.com');
expect(rows().find((r) => r.customerName === 'Piet')?.position).toBe(2);
});
it('collapses duplicate names within the import', async () => {
const { service, rows } = createService([]);
const result = await service.import('v1', [
{ customerName: 'Piet', email: 'piet@example.com' },
{ customerName: 'PIET' },
], 'merge');
expect(result.created).toBe(1);
expect(rows()).toHaveLength(1);
expect(rows()[0].email).toBe('piet@example.com');
});
it('queues delivered customers again so a deleted tab is reopened', async () => {
const { service, rows } = createService([
{ id: 'a', customerName: 'Jan', email: 'jan@example.com', position: 0, deliveredAt: new Date() },
]);
const result = await service.import('v1', [{ customerName: 'jan' }], 'merge');
expect(result).toEqual({ created: 0, updated: 1, unchanged: 0, removed: 0 });
expect(rows()[0]).toMatchObject({ deliveredAt: null, email: 'jan@example.com' });
});
it('removes names missing from the import only in replace mode', async () => {
const initial = [
{ id: 'a', customerName: 'Jan', email: null, position: 0 },
{ id: 'b', customerName: 'Els', email: null, position: 1 },
];
const merge = createService(initial);
expect((await merge.service.import('v1', [{ customerName: 'Jan' }], 'merge')).removed).toBe(0);
expect(merge.rows()).toHaveLength(2);
const replace = createService(initial);
expect((await replace.service.import('v1', [{ customerName: 'jan' }], 'replace')).removed).toBe(1);
expect(replace.rows().map((r) => r.customerName)).toEqual(['Jan']);
});
});
@@ -1,151 +0,0 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
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 {
constructor(
private prisma: PrismaService,
private realtime: RealtimeService,
) {}
async findAll(venueId: string) {
const tabs = await this.prisma.plannedTab.findMany({
where: { venueId },
orderBy: { position: 'asc' },
select: { id: true, customerName: true, email: true, position: true, createdAt: true, deliveredAt: true },
});
return { plannedTabs: tabs };
}
async create(venueId: string, customerName: string, email?: string) {
const name = normalizeCustomerName(customerName);
if (!name) throw new BadRequestException({ code: 'name_required', message: 'Customer name is required' });
const key = customerNameKey(name);
const existing = await this.prisma.plannedTab.findUnique({
where: { venueId_customerNameKey: { venueId, customerNameKey: key } },
});
if (existing) {
throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' });
}
// Get next position
const last = await this.prisma.plannedTab.findFirst({
where: { venueId },
orderBy: { position: 'desc' },
});
const tab = await this.prisma.plannedTab.create({
data: {
id: uuidv4(),
venueId,
customerName: name,
customerNameKey: key,
email: email?.trim() || null,
position: (last?.position ?? -1) + 1,
},
select: { id: true, customerName: true, email: true, position: true, createdAt: true },
});
this.realtime.notify(venueId, 'planned-tabs');
return tab;
}
async update(venueId: string, id: string, customerName: string, email?: string) {
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_customerNameKey: { venueId, customerNameKey: key } },
});
if (duplicate && duplicate.id !== id) {
throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' });
}
const result = await this.prisma.plannedTab.update({
where: { id },
data: { customerName: name, customerNameKey: key, email: email?.trim() || null },
select: { id: true, customerName: true, email: true, position: true, createdAt: true },
});
this.realtime.notify(venueId, 'planned-tabs', 'tabs');
return result;
}
/**
* Bulk import of the customer list (e.g. from the client's Excel file).
* Names match case-insensitively and existing spelling is kept, because the terminal matches
* planned names to tabs exactly and a respelled name would open a second tab.
* A missing email in the import never clears an existing one.
* Customers already delivered are queued again, so a tab deleted since is reopened on the next sync.
*/
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 = normalizeCustomerName(entry.customerName);
if (!customerName) continue;
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) => [customerNameKey(t.customerName), t]));
let nextPosition = (existing[existing.length - 1]?.position ?? -1) + 1;
let created = 0;
let updated = 0;
let unchanged = 0;
let removed = 0;
for (const [key, entry] of incoming) {
const current = existingByKey.get(key);
if (!current) {
await tx.plannedTab.create({
data: { id: uuidv4(), venueId, customerName: entry.customerName, customerNameKey: key, email: entry.email ?? null, position: nextPosition++ },
});
created++;
} else if (current.deliveredAt || (entry.email && entry.email !== current.email)) {
// Re-importing a delivered customer queues them again: their tab may have been deleted since, and the
// next sync skips anyone who still has an open tab
await tx.plannedTab.update({
where: { id: current.id },
data: { deliveredAt: null, ...(entry.email && entry.email !== current.email ? { email: entry.email } : {}) },
});
updated++;
} else {
unchanged++;
}
}
if (mode === 'replace') {
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;
}
}
return { created, updated, unchanged, removed };
}, { timeout: 30000 });
this.realtime.notify(venueId, 'planned-tabs', 'tabs');
return result;
}
async remove(venueId: string, id: string) {
const tab = await this.prisma.plannedTab.findFirst({ where: { id, venueId } });
if (!tab) throw new NotFoundException({ code: 'not_found', message: 'Planned tab not found' });
await this.prisma.plannedTab.delete({ where: { id } });
this.realtime.notify(venueId, 'planned-tabs', 'tabs');
return { ok: true };
}
}
+20 -3
View File
@@ -1,8 +1,25 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaPg } from '@prisma/adapter-pg';
import { Prisma, PrismaClient } from '../generated/prisma/client';
export { Prisma };
/** A transaction, or the client itself where a transaction isn't needed. */
export type Tx = Prisma.TransactionClient;
export function createPrismaClient(connectionString = process.env.DATABASE_URL): PrismaClient {
if (!connectionString) throw new Error('DATABASE_URL must be set');
return new PrismaClient({ adapter: new PrismaPg({ connectionString }) });
}
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error('DATABASE_URL must be set');
super({ adapter: new PrismaPg({ connectionString }) });
}
async onModuleInit() {
await this.$connect();
}
@@ -10,4 +27,4 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
async onModuleDestroy() {
await this.$disconnect();
}
}
}
+16 -25
View File
@@ -1,41 +1,32 @@
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../prisma/prisma.service';
import { OnGatewayConnection, OnGatewayInit, WebSocketGateway } from '@nestjs/websockets';
import { Namespace, Socket } from 'socket.io';
import { AuthService } from '../auth/auth.service';
import { ACCESS_COOKIE, cookieFromHeader } from '../auth/cookies';
import { corsOrigin } from '../cors';
import { RealtimeService, venueRoom } from './realtime.service';
@WebSocketGateway({
namespace: '/realtime',
cors: { origin: process.env.CORS_ORIGIN || 'http://localhost:3000', credentials: true },
})
@WebSocketGateway({ namespace: '/realtime', cors: { origin: corsOrigin(), credentials: true } })
export class RealtimeGateway implements OnGatewayInit, OnGatewayConnection {
constructor(
private jwtService: JwtService,
private auth: AuthService,
private realtime: RealtimeService,
private prisma: PrismaService,
) {}
afterInit(namespace: Namespace) {
this.realtime.attach(namespace);
// Authenticate during the handshake so rejected clients never count as connected;
// the dashboard refreshes its access token on `unauthorized` and reconnects
// Authenticated during the handshake with the access token cookie, so rejected clients never count as connected;
// the dashboard refreshes its token on `unauthorized` and reconnects
namespace.use(async (socket, next) => {
try {
const payload = await this.jwtService.verifyAsync(String(socket.handshake.auth?.token ?? ''));
socket.data.venueId = payload.venueId;
// A superadmin viewing another venue follows that venue. The role is checked in the database, not the token,
// so a demoted superadmin can't keep following other venues
const viewing = socket.handshake.auth?.venueId;
if (typeof viewing === 'string' && viewing && viewing !== payload.venueId) {
const user = await this.prisma.clientUser.findUnique({ where: { id: payload.sub }, select: { role: true } });
if (user?.role !== 'superadmin') return next(new Error('unauthorized'));
socket.data.venueId = viewing;
}
next();
} catch {
next(new Error('unauthorized'));
const user = await this.auth.userFromAccessToken(cookieFromHeader(socket.handshake.headers.cookie, ACCESS_COOKIE) ?? undefined);
if (!user) return next(new Error('unauthorized'));
// A superadmin follows the venue they are viewing; everyone else their own
const viewing = socket.handshake.auth?.venueId;
const venueId = user.role === 'superadmin' ? viewing : user.venueId;
if (typeof venueId !== 'string' || !venueId || (user.role !== 'superadmin' && viewing && viewing !== venueId)) {
return next(new Error('unauthorized'));
}
socket.data.venueId = venueId;
next();
});
}
-3
View File
@@ -1,12 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { getJwtSecret } from '../auth/jwt-secret';
import { RealtimeGateway } from './realtime.gateway';
import { RealtimeService } from './realtime.service';
@Global()
@Module({
imports: [JwtModule.register({ secret: getJwtSecret() })],
providers: [RealtimeGateway, RealtimeService],
exports: [RealtimeService],
})
+4 -6
View File
@@ -1,14 +1,12 @@
import { Injectable } from '@nestjs/common';
import { Namespace } from 'socket.io';
/** What changed; the dashboard refetches the matching data over the normal API. */
export type RealtimeTopic = 'tabs' | 'commands' | 'planned-tabs' | 'device' | 'reminders' | 'users' | 'stock';
import { RealtimeTopic } from '@kooltap/shared';
export const venueRoom = (venueId: string) => `venue:${venueId}`;
/**
* Tells connected dashboards of a venue that data changed. Events only carry topic names,
* never data, so a socket can't see anything its owner couldn't fetch through the API.
* Tells connected dashboards of a venue that data changed. Events only carry topic names, never data, so a socket
* can't see anything its owner couldn't fetch through the API.
*/
@Injectable()
export class RealtimeService {
@@ -19,7 +17,7 @@ export class RealtimeService {
}
notify(venueId: string, ...topics: RealtimeTopic[]) {
this.namespace?.to(venueRoom(venueId)).emit('invalidate', { topics });
if (topics.length > 0) this.namespace?.to(venueRoom(venueId)).emit('invalidate', { topics: [...new Set(topics)] });
}
/** Dashboards connected right now. */
@@ -1,14 +0,0 @@
import { IsArray, IsOptional, IsString, ArrayMaxSize } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class SendRemindersDto {
@ApiPropertyOptional({
type: [String],
description: 'Tabs to remind, including ones still in their cooldown. Omit to remind every eligible customer.',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(1000)
@IsString({ each: true })
tabIds?: string[];
}
+14 -42
View File
@@ -1,56 +1,28 @@
import { Controller, Get, Post, Param, Body, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { z } from 'zod';
import { Reminder, ReminderCandidate, SendRemindersResult, sendRemindersSchema } from '@kooltap/shared';
import { zod } from '../common/zod.pipe';
import { Authorized, Roles } from '../common/auth';
import { RemindersService } from './reminders.service';
import { SendRemindersDto } from './dto/send-reminders.dto';
@ApiTags('Reminders')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, VenueAccessGuard)
@Controller('venues/:venueId/reminders')
@Authorized()
export class RemindersController {
constructor(private service: RemindersService) {}
constructor(private reminders: RemindersService) {}
@Get('candidates')
@ApiOperation({ summary: 'List customers with an unpaid tab and their reminder history' })
@ApiResponse({ status: 200, description: 'Unpaid tabs with email, amount, reminder count and eligibility' })
async listCandidates(@Param('venueId') venueId: string) {
return this.service.listCandidates(venueId);
candidates(@Param('venueId') venueId: string): Promise<{ candidates: ReminderCandidate[]; cooldownDays: number }> {
return this.reminders.candidates(venueId);
}
@Post('send')
@UseGuards(RolesGuard)
@Roles('admin', 'manager')
@ApiOperation({ summary: 'Send payment reminders via Resend (admin only)' })
@ApiResponse({ status: 201, description: 'Reminders sent' })
@ApiResponse({ status: 400, description: 'Resend not configured' })
@ApiResponse({ status: 409, description: 'Reminders are already being sent' })
async send(@Param('venueId') venueId: string, @Body() dto: SendRemindersDto) {
return this.service.sendReminders(venueId, dto.tabIds);
send(@Param('venueId') venueId: string, @Body(zod(sendRemindersSchema)) dto: z.output<typeof sendRemindersSchema>): Promise<SendRemindersResult> {
return this.reminders.send(venueId, dto.tabIds);
}
@Get()
@ApiOperation({ summary: 'Get reminder history' })
@ApiQuery({ name: 'customerName', required: false, description: 'Only reminders sent to this customer' })
@ApiResponse({ status: 200, description: 'List of previously sent reminders' })
async history(@Param('venueId') venueId: string, @Query('customerName') customerName?: string) {
return this.service.history(venueId, customerName);
history(@Param('venueId') venueId: string, @Query('customerName') customerName?: string): Promise<Reminder[]> {
return this.reminders.history(venueId, customerName);
}
@Post('test/:plannedTabId')
@UseGuards(RolesGuard)
@Roles('admin', 'manager')
@ApiOperation({ summary: 'Send a test email to a planned customer (admin only)' })
@ApiResponse({ status: 201, description: 'Test email sent' })
@ApiResponse({ status: 400, description: 'Resend not configured or customer has no email' })
@ApiResponse({ status: 404, description: 'Planned customer not found' })
async sendTest(
@Param('venueId') venueId: string,
@Param('plannedTabId') plannedTabId: string,
) {
return this.service.sendTestEmail(venueId, plannedTabId);
}
}
}
@@ -1,162 +0,0 @@
import { ConflictException } from '@nestjs/common';
import { EmailTemplatesService } from '../email/email-templates.service';
import { RemindersService } from './reminders.service';
type SentRow = { customerName: string; _count: { _all: number }; _max: { sentAt: Date | null } };
type Item = { productName: string; quantity: number; unitPriceInCents: number };
/** € 29,00 for 12 drinks. */
const defaultItems: Item[] = [
{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 },
{ productName: 'Chips', quantity: 2, unitPriceInCents: 200 },
];
/** The tab's lines as the server holds them: one unit per drink. */
function lines(items: Item[]) {
return items.map((item, i) => ({
id: `line-${i}`,
productId: null,
productName: item.productName,
unitPriceInCents: item.unitPriceInCents,
units: Array.from({ length: item.quantity }, (_, n) => ({ id: `u${i}:${n}`, addedAt: '2026-09-24T20:00:00.000Z', removedAt: null })),
}));
}
function createService(items: Item[] | null, { plannedName = 'Bram', sent = [] as SentRow[] } = {}) {
const prisma = {
tab: {
findMany: jest.fn().mockResolvedValue([
{ id: 't1', customerName: 'Bram', version: 3, lines: lines(items ?? defaultItems), updatedAt: new Date() },
]),
},
plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: plannedName, email: 'bram@example.com' }]) },
tabReminder: { groupBy: jest.fn().mockResolvedValue(sent), createMany: jest.fn() },
venue: { findUnique: jest.fn().mockResolvedValue({ name: 'De Kroon' }) },
};
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 requests: { url: string; headers: Record<string, string>; body: any }[];
beforeEach(() => {
process.env.RESEND_API_KEY = 'test-key';
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;
});
afterEach(() => {
global.fetch = originalFetch;
delete process.env.RESEND_API_KEY;
});
it('includes the tab summary with Belgian amounts', async () => {
const { service } = createService(defaultItems);
const result = await service.sendReminders('v1');
expect(result.sent).toBe(1);
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/);
expect(email.html).toContain('10x Cola');
expect(email.subject).toContain('De Kroon');
});
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.groupBy.mockRejectedValue(new Error('db down'));
await expect(service.sendReminders('v1')).rejects.toThrow('db down');
expect(requests).toHaveLength(0);
});
it('skips customers in their cooldown unless they are picked by hand', async () => {
const daysAgo = (d: number) => new Date(Date.now() - d * 24 * 60 * 60 * 1000);
const { service } = createService(null, {
sent: [{ customerName: 'bram', _count: { _all: 2 }, _max: { sentAt: daysAgo(1) } }],
});
const [candidate] = (await service.listCandidates('v1')).candidates;
expect(candidate).toMatchObject({ eligible: false, reminderCount: 2 });
await expect(service.sendReminders('v1')).resolves.toMatchObject({ sent: 0 });
await expect(service.sendReminders('v1', ['t1'])).resolves.toMatchObject({ sent: 1 });
});
it('only sends to the picked tabs', async () => {
const { service } = createService(null);
await expect(service.sendReminders('v1', ['other'])).resolves.toMatchObject({ sent: 0, total: 0 });
expect(requests).toHaveLength(0);
});
it('lists customers without an email as not eligible', async () => {
const { service, prisma } = createService(null);
prisma.plannedTab.findMany.mockResolvedValue([]);
const { candidates } = await service.listCandidates('v1');
expect(candidates).toEqual([expect.objectContaining({ email: null, eligible: false, reminderCount: 0 })]);
await expect(service.sendReminders('v1', ['t1'])).resolves.toMatchObject({ sent: 0 });
});
it('uses a new idempotency key for a deliberate repeat', async () => {
const { service, prisma } = createService(null);
await service.sendReminders('v1', ['t1']);
prisma.tabReminder.groupBy.mockResolvedValue([
{ customerName: 'Bram', _count: { _all: 1 }, _max: { sentAt: new Date() } },
]);
await service.sendReminders('v1', ['t1']);
expect(requests[0].headers['Idempotency-Key']).not.toBe(requests[1].headers['Idempotency-Key']);
});
});
+105 -225
View File
@@ -1,123 +1,92 @@
import { Injectable, BadRequestException, ConflictException, NotFoundException, InternalServerErrorException } from '@nestjs/common';
import * as crypto from 'crypto';
import { ConflictException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { createHash } from 'crypto';
import { Reminder, ReminderCandidate, SendRemindersResult } from '@kooltap/shared';
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';
import { tabSummary } from '../tabs/tab-state';
import { EmailTemplateError, EmailTemplatesService } from '../email/email-templates.service';
import { formatEuro, OutgoingEmail, Resend } from '../email/resend';
import { customerNameKey } from '../customers/customer-name';
import { linesOf } from '../ledger/venue-ledger';
import { tabContents } from '../tabs/tabs.service';
// Tabs stay open per customer (closing only settles them), so a customer is eligible again
// once this many days have passed since their last reminder. Admins can still pick them by hand.
// Tabs stay open per customer (settling only empties them), so a customer is eligible again once this many days have
// passed since their last reminder. Admins can still pick them by hand
export const REMINDER_COOLDOWN_DAYS = 7;
const COOLDOWN_MS = REMINDER_COOLDOWN_DAYS * 24 * 60 * 60 * 1000;
// Resend accepts at most 100 emails per batch request
const BATCH_SIZE = 100;
type Candidate = {
tabId: string;
customerName: string;
// Null when the planned customer has no email address: shown, but can't be reminded
email: string | null;
amountInCents: number;
itemCount: number;
items: TabLine[] | null;
reminderCount: number;
lastReminderAt: Date | null;
// When the cooldown ends; null when they have never been reminded
nextEligibleAt: Date | null;
// Has an email and is not within the cooldown: selected by default
eligible: boolean;
};
type Candidate = ReminderCandidate & { customerId: string | null };
type Recipient = Candidate & { email: string };
@Injectable()
export class RemindersService {
private resendApiKey: string;
private fromEmail: string;
private sendingVenues = new Set<string>();
constructor(
private prisma: PrismaService,
private realtime: RealtimeService,
private emailTemplates: EmailTemplatesService,
) {
this.resendApiKey = process.env.RESEND_API_KEY || '';
this.fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTap <onboarding@resend.dev>';
}
private templates: EmailTemplatesService,
private resend: Resend,
) {}
/** Every customer with an unpaid open tab, with their reminder track record. */
async listCandidates(venueId: string): Promise<{ candidates: Candidate[]; cooldownDays: number }> {
const tabs = (await this.prisma.tab.findMany({ where: { venueId }, orderBy: { openedAt: 'asc' } })).map(tabSummary);
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!]));
// Reminders are tracked per customer: names differing only in case or spacing are one person
const sentPerName = await this.prisma.tabReminder.groupBy({
by: ['customerName'],
where: { venueId },
_count: { _all: true },
_max: { sentAt: true },
});
const sentByKey = new Map<string, { count: number; last: Date | null }>();
for (const row of sentPerName) {
const key = customerNameKey(row.customerName);
const prev = sentByKey.get(key) ?? { count: 0, last: null };
const last = row._max.sentAt;
sentByKey.set(key, {
count: prev.count + row._count._all,
last: !prev.last || (last && last > prev.last) ? last : prev.last,
});
}
const now = Date.now();
const candidates: Candidate[] = [];
for (const snap of tabs) {
if (snap.totalInCents <= 0) continue;
const key = customerNameKey(snap.customerName);
const email = emailByKey.get(key) ?? null;
const sent = sentByKey.get(key);
const nextEligibleAt = sent?.last ? new Date(sent.last.getTime() + COOLDOWN_MS) : null;
candidates.push({
tabId: snap.tabId,
customerName: snap.customerName,
email,
amountInCents: snap.totalInCents,
itemCount: snap.itemCount,
items: snap.items,
reminderCount: sent?.count ?? 0,
lastReminderAt: sent?.last ?? null,
nextEligibleAt,
eligible: email !== null && (!nextEligibleAt || nextEligibleAt.getTime() <= now),
});
}
async candidates(venueId: string): Promise<{ candidates: ReminderCandidate[]; cooldownDays: number }> {
const candidates = (await this.listCandidates(venueId)).map(({ customerId, ...candidate }) => candidate);
return { candidates, cooldownDays: REMINDER_COOLDOWN_DAYS };
}
/**
* Sends to the given tabs, eligible or not (an admin's explicit choice overrides the cooldown),
* or to every eligible customer when no tabs are given.
*/
async sendReminders(venueId: string, tabIds?: string[]) {
if (!this.resendApiKey) {
throw new BadRequestException({
code: 'resend_not_configured',
message: 'Resend API key is not configured. Set RESEND_API_KEY environment variable.',
});
}
private async listCandidates(venueId: string): Promise<Candidate[]> {
const [tabs, units, customers, sent] = await Promise.all([
this.prisma.tab.findMany({ where: { venueId }, orderBy: { openedAt: 'asc' } }),
this.prisma.tabUnit.findMany({ where: { venueId, tabId: { not: null } } }),
this.prisma.customer.findMany({ where: { venueId }, select: { id: true, nameKey: true, email: true } }),
this.prisma.reminder.groupBy({
by: ['customerId'],
where: { venueId, customerId: { not: null } },
_count: { _all: true },
_max: { sentAt: true },
}),
]);
const customersByKey = new Map(customers.map((c) => [c.nameKey, c]));
const sentByCustomer = new Map(sent.map((row) => [row.customerId!, row]));
const now = Date.now();
return tabs.flatMap((tab): Candidate[] => {
const { items, totalInCents, itemCount } = tabContents(linesOf(units.filter((unit) => unit.tabId === tab.id)));
if (totalInCents <= 0) return [];
const customer = customersByKey.get(customerNameKey(tab.customerName));
const reminded = customer ? sentByCustomer.get(customer.id) : undefined;
const last = reminded?._max.sentAt ?? null;
const nextEligibleAt = last ? new Date(last.getTime() + COOLDOWN_MS) : null;
return [{
tabId: tab.id,
customerId: customer?.id ?? null,
customerName: tab.customerName,
email: customer?.email ?? null,
amountInCents: totalInCents,
itemCount,
items,
reminderCount: reminded?._count._all ?? 0,
lastReminderAt: last?.toISOString() ?? null,
nextEligibleAt: nextEligibleAt?.toISOString() ?? null,
eligible: customer?.email != null && (!nextEligibleAt || nextEligibleAt.getTime() <= now),
}];
});
}
/**
* Sends to the given tabs, eligible or not (an admin's explicit choice overrides the cooldown), or to every
* eligible customer when no tabs are given.
*/
async send(venueId: string, tabIds?: string[]): Promise<SendRemindersResult> {
this.resend.ensureConfigured();
// 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, tabIds);
} finally {
@@ -125,116 +94,70 @@ export class RemindersService {
}
}
private async sendToCandidates(venueId: string, tabIds?: string[]) {
const { candidates: all } = await this.listCandidates(venueId);
private async sendToCandidates(venueId: string, tabIds?: string[]): Promise<SendRemindersResult> {
const selected = tabIds ? new Set(tabIds) : null;
const candidates = all.filter((c): c is Recipient =>
c.email !== null && (selected ? selected.has(c.tabId) : c.eligible),
);
const recipients = (await this.listCandidates(venueId)).filter((c): c is Recipient => (
c.email !== null && (selected ? selected.has(c.tabId) : c.eligible)));
if (recipients.length === 0) return { sent: 0, total: 0 };
if (candidates.length === 0) {
return { sent: 0, total: 0 };
}
const venueName = await this.venueName(venueId);
let sent = 0;
const venue = await this.prisma.venue.findUniqueOrThrow({ where: { id: venueId }, select: { name: true } });
const errors: { customerName: string; error: string }[] = [];
const rendered: { candidate: Recipient; email: RenderedEmail }[] = [];
for (const candidate of candidates) {
const rendered: { recipient: Recipient; email: OutgoingEmail }[] = [];
for (const recipient of recipients) {
try {
rendered.push({ candidate, email: await this.renderReminder(candidate, venueName) });
} catch (err: any) {
errors.push({ customerName: candidate.customerName, error: err.message });
rendered.push({ recipient, email: { to: recipient.email, ...(await this.renderReminder(recipient, venue.name)) } });
} catch (error) {
errors.push({ customerName: recipient.customerName, error: (error as Error).message });
}
}
let sent = 0;
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,
reminderCount: candidate.reminderCount,
})));
// The reminder counts are part of the key: a deliberate repeat after a successful send is a new request
const key = createHash('sha256')
.update(JSON.stringify([batch.map((b) => b.email), batch.map((b) => b.recipient.reminderCount)]))
.digest('hex');
const error = await this.resend.sendBatch(batch.map((b) => b.email), `reminders-${key}`);
if (error) {
for (const { candidate } of batch) errors.push({ customerName: candidate.customerName, error });
for (const { recipient } of batch) errors.push({ customerName: recipient.customerName, error });
continue;
}
await this.prisma.tabReminder.createMany({
data: batch.map(({ candidate }) => ({
await this.prisma.reminder.createMany({
data: batch.map(({ recipient }) => ({
venueId,
tabId: candidate.tabId,
customerName: candidate.customerName,
email: candidate.email,
amountInCents: candidate.amountInCents,
customerId: recipient.customerId,
customerName: recipient.customerName,
email: recipient.email,
tabId: recipient.tabId,
amountInCents: recipient.amountInCents,
})),
});
sent += batch.length;
}
if (sent > 0) this.realtime.notify(venueId, 'reminders');
return { sent, total: candidates.length, errors: errors.length > 0 ? errors : undefined };
return { sent, total: recipients.length, ...(errors.length > 0 ? { errors } : {}) };
}
async history(venueId: string, customerName?: string) {
const reminders = await this.prisma.tabReminder.findMany({
where: {
venueId,
...(customerName ? { customerName: { equals: customerName.trim(), mode: 'insensitive' as const } } : {}),
},
async history(venueId: string, customerName?: string): Promise<Reminder[]> {
const reminders = await this.prisma.reminder.findMany({
where: { venueId, ...(customerName ? { customerName: { equals: customerName.trim(), mode: 'insensitive' } } : {}) },
orderBy: { sentAt: 'desc' },
take: 100,
});
return { reminders };
}
async sendTestEmail(venueId: string, plannedTabId: string) {
this.ensureConfigured();
const tab = await this.prisma.plannedTab.findFirst({
where: { id: plannedTabId, venueId },
});
if (!tab) {
throw new NotFoundException({ code: 'not_found', message: 'Planned customer not found' });
}
if (!tab.email) {
throw new BadRequestException({ code: 'no_email', message: 'This customer has no email address set' });
}
await this.sendTest(tab.email, tab.customerName, await this.venueName(venueId));
return { ok: true, email: tab.email, customerName: tab.customerName };
return reminders.map((r) => ({
id: r.id, tabId: r.tabId, customerName: r.customerName, email: r.email, amountInCents: r.amountInCents, sentAt: r.sentAt.toISOString(),
}));
}
/** The test email to any address: for a superadmin checking that sending works. */
async sendTestEmailTo(to: string) {
this.ensureConfigured();
await this.sendTest(to, to, 'KoolTap');
return { ok: true, email: to };
}
/** Whether sending is set up, without sending anything. */
get configured(): boolean {
return this.resendApiKey !== '';
}
private ensureConfigured() {
if (!this.resendApiKey) {
throw new BadRequestException({
code: 'resend_not_configured',
message: 'Resend API key is not configured. Set RESEND_API_KEY environment variable.',
});
}
}
private async sendTest(to: string, customerName: string, venueName: string) {
let email: RenderedEmail;
async sendTestEmail(to: string) {
this.resend.ensureConfigured();
let email;
try {
email = await this.emailTemplates.render('test-email', { customerName, venueName });
email = await this.templates.render('test-email', { customerName: to, venueName: 'KoolTap' });
} catch (error) {
// Template mistakes are shown to the admin instead of a generic server error
if (error instanceof EmailTemplateError) {
@@ -242,24 +165,19 @@ export class RemindersService {
}
throw error;
}
const error = await this.resend('/emails', { from: this.fromEmail, to, ...email });
if (error) {
throw new BadRequestException({
code: 'email_failed',
message: `Failed to send test email: ${error}`,
});
}
const error = await this.resend.sendBatch([{ to, ...email }]);
if (error) throw new InternalServerErrorException({ code: 'email_failed', message: `Failed to send the test email: ${error}` });
return { ok: true, email: to };
}
private renderReminder(candidate: Recipient, venueName: string): Promise<RenderedEmail> {
return this.emailTemplates.render('payment-reminder', {
customerName: candidate.customerName,
private renderReminder(recipient: Recipient, venueName: string) {
return this.templates.render('payment-reminder', {
customerName: recipient.customerName,
venueName,
amount: formatEuro(candidate.amountInCents),
itemCount: candidate.itemCount,
hasItems: (candidate.items?.length ?? 0) > 0,
items: (candidate.items ?? []).map((item) => ({
amount: formatEuro(recipient.amountInCents),
itemCount: recipient.itemCount,
hasItems: recipient.items.length > 0,
items: recipient.items.map((item) => ({
quantity: item.quantity,
productName: item.productName,
unitPrice: formatEuro(item.unitPriceInCents),
@@ -267,42 +185,4 @@ export class RemindersService {
})),
});
}
/** One Resend batch request; returns Resend's error text, or null when sent. */
private sendBatch(messages: { to: string; email: RenderedEmail; reminderCount: number }[]): 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.
// The reminder counts are part of the key: a deliberate repeat after a successful send is a new request.
const counts = messages.map((m) => m.reminderCount);
const idempotencyKey = 'reminders-' + crypto.createHash('sha256').update(JSON.stringify([body, counts])).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(body),
});
return res.ok ? null : await res.text();
}
private async venueName(venueId: string): Promise<string> {
const venue = await this.prisma.venue.findUnique({ where: { id: venueId }, select: { name: true } });
return venue?.name ?? 'KoolTap';
}
}
type TabLine = { productName: string; quantity: number; unitPriceInCents: number };
// Belgian notation, as on the dashboard: € 25,00
const euro = new Intl.NumberFormat('nl-BE', { style: 'currency', currency: 'EUR' });
function formatEuro(cents: number): string {
return euro.format(cents / 100);
}
@@ -1,43 +0,0 @@
import {
IsString, IsIn, IsInt, Min, Max, IsNotEmpty, MaxLength, IsOptional, IsArray, ArrayMinSize, ArrayMaxSize,
ValidateNested,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export const STOCK_ADJUSTMENT_TYPES = ['add', 'set'] as const;
export type StockAdjustmentType = (typeof STOCK_ADJUSTMENT_TYPES)[number];
export class StockAdjustmentEntryDto {
@ApiProperty({ example: 'local-product-id' })
@IsString()
@IsNotEmpty()
productId: string;
@ApiProperty({ enum: STOCK_ADJUSTMENT_TYPES, description: 'add: change the stock by quantity; set: the stock is now quantity' })
@IsIn(STOCK_ADJUSTMENT_TYPES)
type: StockAdjustmentType;
@ApiProperty({ example: 24, description: 'add: non-zero, negative to take stock out; set: zero or more' })
@IsInt()
@Min(-100000)
@Max(100000)
quantity: number;
@ApiPropertyOptional({ example: 'Delivery from the brewery' })
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value))
@IsString()
@MaxLength(200)
note?: string;
}
export class CreateStockAdjustmentsDto {
@ApiProperty({ type: [StockAdjustmentEntryDto] })
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(500)
@ValidateNested({ each: true })
@Type(() => StockAdjustmentEntryDto)
adjustments: StockAdjustmentEntryDto[];
}
@@ -1,116 +0,0 @@
import { IsInt, Min, Max, ValidateIf, IsString, IsNotEmpty, MaxLength, IsOptional, IsBoolean } from 'class-validator';
import { Transform } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
const trim = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value);
// An empty category is no category
const trimToNull = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() || null : value);
export class CreateProductDto {
@ApiProperty({ example: 'Cola' })
@Transform(trim)
@IsString()
@IsNotEmpty()
@MaxLength(200)
name: string;
@ApiPropertyOptional({ example: 'Frisdrank', nullable: true })
@IsOptional()
@Transform(trimToNull)
@IsString()
@MaxLength(100)
category?: string | null;
@ApiProperty({ example: 250, description: 'The sell price, charged on the terminals' })
@IsInt()
@Min(0)
@Max(1_000_000)
priceInCents: number;
@ApiPropertyOptional({ example: 120, nullable: true, description: 'What the bar pays for one; null for not entered. Not sent to the terminals' })
@IsOptional()
@ValidateIf((dto: CreateProductDto) => dto.buyPriceInCents !== null)
@IsInt()
@Min(0)
@Max(1_000_000)
buyPriceInCents?: number | null;
@ApiPropertyOptional({ example: 24, nullable: true, description: 'The starting count; null or left out: stock is not tracked' })
@IsOptional()
@ValidateIf((dto: CreateProductDto) => dto.stock !== null)
@IsInt()
@Min(0)
@Max(100000)
stock?: number | null;
@ApiPropertyOptional({ example: 12, nullable: true, description: 'Warn when stock drops to this level; null for no warning' })
@IsOptional()
@ValidateIf((dto: CreateProductDto) => dto.lowStockThreshold !== null)
@IsInt()
@Min(0)
@Max(100000)
lowStockThreshold?: number | null;
@ApiPropertyOptional({ example: 100, nullable: true, description: 'How many to have in stock; null for none. Not sent to the terminals' })
@IsOptional()
@ValidateIf((dto: CreateProductDto) => dto.desiredStock !== null)
@IsInt()
@Min(0)
@Max(100000)
desiredStock?: number | null;
}
/** Only the fields that are sent change. The stock itself changes through stock adjustments. */
export class UpdateProductDto {
@ApiPropertyOptional({ example: 'Cola Zero' })
@IsOptional()
@Transform(trim)
@IsString()
@IsNotEmpty()
@MaxLength(200)
name?: string;
@ApiPropertyOptional({ example: 'Frisdrank', nullable: true })
@IsOptional()
@Transform(trimToNull)
@ValidateIf((dto: UpdateProductDto) => dto.category !== null)
@IsString()
@MaxLength(100)
category?: string | null;
@ApiPropertyOptional({ example: 250 })
@IsOptional()
@IsInt()
@Min(0)
@Max(1_000_000)
priceInCents?: number;
@ApiPropertyOptional({ example: 120, nullable: true, description: 'What the bar pays for one; null for not entered. Not sent to the terminals' })
@IsOptional()
@ValidateIf((dto: UpdateProductDto) => dto.buyPriceInCents !== null)
@IsInt()
@Min(0)
@Max(1_000_000)
buyPriceInCents?: number | null;
@ApiPropertyOptional({ example: 12, nullable: true, description: 'Warn when stock drops to this level; null for no warning' })
@IsOptional()
@ValidateIf((dto: UpdateProductDto) => dto.lowStockThreshold !== null)
@IsInt()
@Min(0)
@Max(100000)
lowStockThreshold?: number | null;
@ApiPropertyOptional({ example: 100, nullable: true, description: 'How many to have in stock; null for none. Not sent to the terminals' })
@IsOptional()
@ValidateIf((dto: UpdateProductDto) => dto.desiredStock !== null)
@IsInt()
@Min(0)
@Max(100000)
desiredStock?: number | null;
@ApiPropertyOptional({ description: 'true hides the product on the terminals (deleting it); false brings it back' })
@IsOptional()
@IsBoolean()
archived?: boolean;
}
-70
View File
@@ -1,70 +0,0 @@
import { Prisma } from '@prisma/client';
import { TabEventDto } from '../devices/dto/tab-event.dto';
import { VenueLedger } from '../tabs/venue-ledger';
import { createdFieldTimes, mergeProductChanges, ProductChanges } from './product-fields';
type Tx = Prisma.TransactionClient;
/**
* Applies a product created or changed on a terminal (also offline: [occurredAt] is when it happened there).
* A new product gets the terminal's id, so it can be sold before the terminal synced. Duplicate names are
* accepted, a sale must never be lost over one; the dashboard shows them so one can be archived.
* Returns whether anything changed.
*/
export async function applyProductEvent(tx: Tx, ledger: VenueLedger, event: TabEventDto, occurredAt: Date): Promise<boolean> {
const { venueId } = ledger;
const productId = event.productId!;
const changes = await productChanges(tx, event);
const existing = await tx.product.findUnique({ where: { venueId_productId: { venueId, productId } } });
if (!existing) {
// Changes to a product the server doesn't have (deleted, or another venue's) are acknowledged and dropped
if (event.type !== 'product_created') return false;
await tx.product.create({
data: {
venueId,
productId,
name: changes.name!,
category: changes.category ?? null,
priceInCents: changes.priceInCents!,
lowStockThreshold: changes.lowStockThreshold ?? null,
imageId: changes.imageId ?? null,
// Its starting stock arrives as a stock_set event
stock: null,
reportedAt: occurredAt,
fieldUpdatedAt: createdFieldTimes({ ...changes, archived: false }, occurredAt),
},
});
ledger.stock.addProduct(productId);
return true;
}
const merged = mergeProductChanges(existing.fieldUpdatedAt, changes, occurredAt, existing);
if (!merged.changed) return false;
await tx.product.update({
where: { venueId_productId: { venueId, productId } },
data: { ...merged.data, fieldUpdatedAt: merged.fieldUpdatedAt, reportedAt: occurredAt },
});
return true;
}
async function productChanges(tx: Tx, event: TabEventDto): Promise<ProductChanges> {
const fields = event.product ?? {};
const changes: ProductChanges = {};
if (fields.name !== undefined) changes.name = fields.name.trim();
// An empty category is no category
if (fields.category !== undefined) changes.category = fields.category?.trim() || null;
if (fields.priceInCents !== undefined) changes.priceInCents = fields.priceInCents;
if (fields.lowStockThreshold !== undefined) changes.lowStockThreshold = fields.lowStockThreshold;
if (event.type === 'product_archived') changes.archived = true;
if (event.type === 'product_restored') changes.archived = false;
if (fields.imageId !== undefined) {
// Only a picture that was uploaded: the terminal uploads before it sends the change
const known = fields.imageId === null
|| await tx.productImage.findUnique({ where: { id: fields.imageId }, select: { id: true } });
if (known) changes.imageId = fields.imageId;
}
return changes;
}
-38
View File
@@ -1,38 +0,0 @@
import { Prisma } from '@prisma/client';
import { VenueLedger } from '../tabs/venue-ledger';
type Tx = Prisma.TransactionClient;
/**
* Applies the venue's pending dashboard adjustments (or only [ids]) to the stock, oldest first, and records the
* outcome. Returns how many were handled.
*/
export async function applyPendingAdjustments(tx: Tx, ledger: VenueLedger, now: Date, ids?: string[]): Promise<number> {
const pending = await tx.stockAdjustment.findMany({
where: { venueId: ledger.venueId, status: 'pending', ...(ids ? { id: { in: ids } } : {}) },
orderBy: { createdAt: 'asc' },
});
// Created in one go share their time: keep the order they were entered in
if (ids) pending.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
for (const adjustment of pending) {
let stockAfter: number | null;
if (adjustment.type === 'set') {
// A count on the dashboard is the latest count, also when a terminal's clock runs a little ahead
ledger.stock.count(adjustment.productId, adjustment.quantity, now, { force: true });
stockAfter = ledger.stock.get(adjustment.productId) ? adjustment.quantity : null;
} else {
stockAfter = ledger.stock.add(adjustment.productId, adjustment.quantity);
}
const rejected = stockAfter === null
? (ledger.stock.get(adjustment.productId) ? 'stock_not_tracked' : 'unknown_product')
: null;
await tx.stockAdjustment.update({
where: { id: adjustment.id },
data: { status: rejected ? 'rejected' : 'applied', stockAfter, result: rejected, processedAt: now },
});
}
return pending.length;
}

Some files were not shown because too many files have changed in this diff Show More