@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 } }),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 {}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' } });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 +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);
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
@@ -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,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. */
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CommandExpiryJob } from './command-expiry.job';
|
||||
|
||||
@Module({
|
||||
providers: [CommandExpiryJob],
|
||||
exports: [CommandExpiryJob],
|
||||
})
|
||||
export class JobsModule {}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user