feat: add superadmin
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-26 11:58:10 +01:00
parent 072cda111c
commit 4e43521da7
45 changed files with 3531 additions and 65 deletions
+226
View File
@@ -0,0 +1,226 @@
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 {
CopyCatalogDto,
DryRunDto,
PurgeDto,
ResetPasswordDto,
ResetVenueDto,
TestEmailDto,
UpdateRoleDto,
VenueNameDto,
} from './dto/admin.dto';
/** 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')
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);
}
@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);
}
@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);
}
@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);
}
@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);
}
@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);
}
@Get('venues/:venueId/consistency')
@ApiOperation({ summary: "Look for data in the venue that doesn't add up" })
consistency(@Param('venueId') 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);
}
@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);
}
// ---- 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);
}
@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) {
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);
}
@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);
}
@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);
}
@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);
}
@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);
}
// ---- 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);
}
@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);
}
@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);
}
// ---- 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),
});
}
@Get('health')
@ApiOperation({ summary: 'Database, migrations, live connections and background jobs' })
health() {
return this.admin.health();
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { DevicesModule } from '../devices/devices.module';
import { RemindersModule } from '../reminders/reminders.module';
import { JobsModule } from '../jobs/jobs.module';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
@Module({
imports: [AuthModule, DevicesModule, RemindersModule, JobsModule],
controllers: [AdminController],
providers: [AdminService],
})
export class AdminModule {}
+613
View File
@@ -0,0 +1,613 @@
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 { 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 { RemindersService } from '../reminders/reminders.service';
import { CommandExpiryJob } from '../jobs/command-expiry.job';
import { createdFieldTimes } from '../stock/product-fields';
import { checkConsistency } from './consistency';
import { countReset, deleteVenue, lockVenue, resetVenue, ResetCounts, ResetScope } from './venue-reset';
import { PurgeTarget } from './dto/admin.dto';
/** The superadmin doing it. */
export type Actor = { id: string; email: string; name: string; venueId: string };
// What each reset scope changes on the dashboard
const SCOPE_TOPICS: Record<ResetScope, RealtimeTopic[]> = {
tabs: ['tabs'],
settlements: ['tabs'],
tabEvents: [],
plannedTabs: ['planned-tabs', 'reminders'],
stock: ['stock'],
stockHistory: ['stock'],
commands: ['commands'],
auditLog: [],
};
// A terminal uploads a picture before the event that links it to a product, possibly much later when it was offline
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.
*/
@Injectable()
export class AdminService {
constructor(
private prisma: PrismaService,
private audit: AuditService,
private realtime: RealtimeService,
private authService: AuthService,
private devicesService: DevicesService,
private reminders: RemindersService,
private commandExpiry: CommandExpiryJob,
) {}
// ---- 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,
},
},
},
});
const lastSeen = await 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,
})),
};
}
async createVenue(name: string, actor: Actor) {
const venue = await this.prisma.venue.create({ data: { name: name.trim() } });
await this.audit.log(venue.id, 'client_user', actor.id, 'admin_venue_created', { name: venue.name, by: actor.email });
return venue;
}
async renameVenue(venueId: string, name: string, actor: Actor) {
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;
}
/** [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) {
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 });
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) {
const venue = await this.findVenue(venueId);
await this.audit.log(venueId, 'client_user', actor.id, 'admin_viewed_venue', { 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' });
}
await this.findVenue(venueId);
await this.findVenue(fromVenueId);
const created = await this.prisma.$transaction(async (tx) => {
const now = new Date();
await lockVenue(tx, venueId);
const [source, existing] = await Promise.all([
tx.product.findMany({ where: { venueId: fromVenueId, archivedAt: null } }),
tx.product.findMany({ where: { venueId, archivedAt: null }, select: { name: true } }),
]);
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, lowStockThreshold: p.lowStockThreshold };
return {
venueId,
productId: uuidv4(),
...fields,
stock: null,
countedAt: null,
reportedAt: now,
imageId: p.imageId,
fieldUpdatedAt: createdFieldTimes({ ...fields, archived: false, imageId: p.imageId }, now),
};
}),
});
}
return copies.length;
});
await this.audit.log(venueId, 'client_user', actor.id, 'admin_catalog_copied', { fromVenueId, created, by: actor.email });
if (created > 0) this.realtime.notify(venueId, 'stock');
return { created };
}
async resetVenue(venueId: string, scopes: ResetScope[], options: { stockValue?: number; dryRun?: boolean }, actor: Actor) {
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 });
// 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);
return { dryRun: false, counts };
}
async consistency(venueId: string) {
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 } } },
}),
this.prisma.device.findMany({ where, select: { id: true, name: true, revokedAt: true, tokenExpiresAt: true } }),
]);
const findings = checkConsistency({ settlements, tabs, products, pendingCommands, devices }, new Date());
return { checkedAt: new Date(), findings };
}
/** Everything of a venue except password hashes and picture bytes, for a backup before a reset. */
async exportVenue(venueId: string, actor: Actor) {
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 });
return {
exportedAt: new Date(),
venue,
users,
devices,
products,
tabs,
settlements,
plannedTabs,
reminders,
stockAdjustments,
commands,
tabEvents,
auditLog,
};
}
// ---- Terminals
async listDevices() {
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' } } } },
},
});
return {
devices: devices.map(({ venue, _count, ...device }) => ({
...device,
venueName: venue.name,
pendingCommands: _count.remoteCommands,
})),
};
}
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() },
});
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. */
async cleanupRegistrationCodes() {
const { count } = await this.prisma.registrationCode.deleteMany({
where: { OR: [{ usedAt: { not: null } }, { expiresAt: { lte: new Date() } }] },
});
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();
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 },
}),
]);
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,
})),
};
}
async updateRole(userId: string, role: string, actor: Actor) {
// 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' });
}
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');
return { ok: true };
}
/** Sets a new password and signs the user out everywhere. */
async resetPassword(userId: string, password: string, actor: Actor) {
// 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' });
}
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 });
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' });
}
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 });
return { ok: true };
}
/** Signs out every user but the superadmin doing it. */
async logoutEveryone(actor: Actor) {
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.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 });
return { users: users.count };
}
async deleteUser(userId: string, actor: Actor) {
const user = await this.findUser(userId);
return this.authService.deleteUser(user.venueId, userId, actor);
}
// ---- 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) {
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 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');
}
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) };
}
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 };
}
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 });
return result;
}
// ---- Insight
async auditLog(options: { venueId?: string; action?: string; limit: number; offset: number }) {
const where = {
...(options.venueId ? { venueId: options.venueId } : {}),
...(options.action ? { action: { contains: options.action, mode: 'insensitive' as const } } : {}),
};
const [entries, total] = await Promise.all([
this.prisma.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
take: options.limit,
skip: options.offset,
include: { venue: { select: { name: true } } },
}),
this.prisma.auditLog.count({ where }),
]);
// Actors are stored as ids; show who they are
const actorIds = [...new Set(entries.map((e) => e.actorId))];
const [users, devices] = await Promise.all([
this.prisma.clientUser.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]),
]);
return {
total,
entries: entries.map(({ venue, details, ...entry }) => ({
...entry,
venueName: venue.name,
actorName: actorNames.get(entry.actorId) ?? null,
details: parseDetails(details),
})),
};
}
async health() {
const [[size], migrations, tables, stuckCommands] = 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' } }),
]);
const applied = migrations.filter((m) => m.finished_at && !m.rolled_back_at);
return {
checkedAt: new Date(),
uptimeSeconds: Math.round(process.uptime()),
nodeVersion: process.version,
databaseBytes: Number(size.bytes),
tables,
migrations: {
applied: applied.length,
latest: applied.at(-1)?.migration_name ?? null,
// Started but never finished: the database may be half migrated
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,
};
}
private async tableCounts() {
const p = this.prisma;
const entries = await Promise.all(Object.entries({
Venue: () => p.venue.count(),
ClientUser: () => p.clientUser.count(),
Device: () => p.device.count(),
RefreshToken: () => p.refreshToken.count(),
RegistrationCode: () => p.registrationCode.count(),
Tab: () => p.tab.count(),
TabEvent: () => p.tabEvent.count(),
Settlement: () => p.settlement.count(),
PlannedTab: () => p.plannedTab.count(),
TabReminder: () => p.tabReminder.count(),
Product: () => p.product.count(),
ProductImage: () => p.productImage.count(),
StockAdjustment: () => p.stockAdjustment.count(),
RemoteCommand: () => p.remoteCommand.count(),
AuditLog: () => p.auditLog.count(),
}).map(async ([table, count]) => ({ table, rows: await count() })));
return entries;
}
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' });
}
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' });
}
return user;
}
}
function parseDetails(details: string | null): unknown {
if (!details) return null;
try {
return JSON.parse(details);
} catch {
return details;
}
}
+97
View File
@@ -0,0 +1,97 @@
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);
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('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 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'],
]);
});
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']);
});
});
+146
View File
@@ -0,0 +1,146 @@
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;
};
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 }[];
devices: { id: string; name: string; revokedAt: Date | null; tokenExpiresAt: Date }[];
};
const euro = (cents: number) => (cents / 100).toFixed(2);
/**
* Looks for data that doesn't add up in one venue. Pure: the caller loads the snapshot. Stock is not recomputed from
* the events, because which events count depends on the order terminals synced in; only what is visibly off is shown.
*/
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
// 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) {
findings.push({
severity: 'warning',
code: 'settlement_amount_mismatch',
message: `${settlement.customerName} paid € ${euro(settlement.amountInCents)}, the drinks add up to € ${euro(linesTotal)}`,
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[]>();
for (const tab of venue.tabs) {
const key = customerNameKey(tab.customerName);
tabsByName.set(key, [...(tabsByName.get(key) ?? []), tab.id]);
}
for (const ids of tabsByName.values()) {
if (ids.length > 1) {
const name = venue.tabs.find((tab) => tab.id === ids[0])!.customerName;
findings.push({
severity: 'warning',
code: 'tab_duplicate_customer',
message: `${ids.length} open tabs are named ${name}`,
subjectId: ids[0],
});
}
}
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,
});
}
}
for (const device of venue.devices) {
if (!device.revokedAt && device.tokenExpiresAt <= now) {
findings.push({
severity: 'info',
code: 'device_token_expired',
message: `${device.name}'s token expired: it has to be paired again`,
subjectId: device.id,
});
}
}
const order: Record<Severity, number> = { error: 0, warning: 1, info: 2 };
return findings.sort((a, b) => order[a.severity] - order[b.severity]);
}
+95
View File
@@ -0,0 +1,95 @@
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;
}
+131
View File
@@ -0,0 +1,131 @@
import { Prisma } from '@prisma/client';
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];
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 };
const counters: Record<ResetScope, () => Promise<number>> = {
tabs: () => tx.tab.count({ where }),
settlements: () => tx.settlement.count({ where }),
tabEvents: () => tx.tabEvent.count({ where }),
plannedTabs: () => tx.plannedTab.count({ where }),
stock: () => tx.product.count({ where: { venueId, stock: { not: null } } }),
stockHistory: () => tx.stockAdjustment.count({ where }),
commands: () => tx.remoteCommand.count({ where }),
auditLog: () => tx.auditLog.count({ where }),
};
const counts: ResetCounts = {};
for (const scope of scopes) counts[scope] = await counters[scope]();
return counts;
}
/**
* 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.
*/
export async function resetVenue(
tx: Tx,
venueId: string,
scopes: ResetScope[],
options: { stockValue: number; actor: { id: string; name: string }; now: Date },
): Promise<ResetCounts> {
const where = { venueId };
const counts: ResetCounts = {};
const has = (scope: ResetScope) => scopes.includes(scope);
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('stockHistory')) counts.stockHistory = (await tx.stockAdjustment.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 },
});
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 } });
}
+2
View File
@@ -16,6 +16,7 @@ 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({
@@ -36,6 +37,7 @@ import { HealthController } from './health.controller';
RemindersModule,
StockModule,
ImagesModule,
AdminModule,
],
controllers: [HealthController],
providers: [
@@ -1,9 +1,9 @@
import { UnauthorizedException } from '@nestjs/common';
import { JwtStrategy } from './jwt.strategy';
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)[]) {
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);
}
@@ -17,4 +17,25 @@ describe('JwtStrategy', () => {
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);
});
});
+11 -6
View File
@@ -14,16 +14,21 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
});
}
// Looked up on every request: a deleted user loses access at once instead of when the token expires, and a
// changed role or name applies right away
// 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 },
select: { id: true, email: true, name: true, role: true, venueId: true, sessionsRevokedAt: true },
});
if (!user) {
if (!user || issuedBefore(payload, user.sessionsRevokedAt)) {
throw new UnauthorizedException({ code: 'unauthorized', message: 'Authentication required' });
}
return user;
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);
}
@@ -24,6 +24,13 @@ describe('RolesGuard', () => {
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);
+2 -1
View File
@@ -20,6 +20,7 @@ export class RolesGuard {
}
// "viewer" was the old name of a manager; tokens issued before the rename still carry it
const role = user.role === 'viewer' ? 'manager' : user.role;
return requiredRoles.includes(role);
// A superadmin may do everything an admin or manager may, in every venue
return role === 'superadmin' || requiredRoles.includes(role);
}
}
@@ -1,7 +1,7 @@
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { VenueAccessGuard } from './venue-access.guard';
function contextFor(params: Record<string, string>, user?: { venueId: string }): ExecutionContext {
function contextFor(params: Record<string, string>, user?: { venueId: string; role?: string }): ExecutionContext {
return {
switchToHttp: () => ({ getRequest: () => ({ params, user }) }),
} as unknown as ExecutionContext;
@@ -22,6 +22,11 @@ describe('VenueAccessGuard', () => {
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,8 +1,8 @@
import { Injectable, ExecutionContext, CanActivate, ForbiddenException } from '@nestjs/common';
/**
* Ensures the authenticated user can only access the venue they belong to.
* Must run after JwtAuthGuard so that `request.user` is populated.
* 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 {
@@ -14,6 +14,10 @@ export class VenueAccessGuard implements CanActivate {
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' });
}
+4 -2
View File
@@ -8,6 +8,7 @@ import {
HttpCode,
HttpStatus,
Req,
Query,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
@@ -86,8 +87,9 @@ export class DevicesController {
@ApiBearerAuth()
@ApiOperation({ summary: 'Generate a registration code for pairing' })
@ApiResponse({ status: 200, description: 'Registration code generated' })
async generateCode(@Req() req: any) {
const venueId = req.user.venueId;
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);
}
}
+3
View File
@@ -7,6 +7,8 @@ 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,
@@ -18,6 +20,7 @@ export class CommandExpiryJob {
this.logger.log('Running command expiry job...');
const now = new Date();
this.lastRunAt = now;
const expiredCommands = await this.prisma.remoteCommand.findMany({
where: {
+1
View File
@@ -3,5 +3,6 @@ import { CommandExpiryJob } from './command-expiry.job';
@Module({
providers: [CommandExpiryJob],
exports: [CommandExpiryJob],
})
export class JobsModule {}
+10
View File
@@ -1,4 +1,5 @@
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 { RealtimeService, venueRoom } from './realtime.service';
@@ -11,6 +12,7 @@ export class RealtimeGateway implements OnGatewayInit, OnGatewayConnection {
constructor(
private jwtService: JwtService,
private realtime: RealtimeService,
private prisma: PrismaService,
) {}
afterInit(namespace: Namespace) {
@@ -22,6 +24,14 @@ export class RealtimeGateway implements OnGatewayInit, OnGatewayConnection {
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'));
@@ -21,4 +21,9 @@ export class RealtimeService {
notify(venueId: string, ...topics: RealtimeTopic[]) {
this.namespace?.to(venueRoom(venueId)).emit('invalidate', { topics });
}
/** Dashboards connected right now. */
connectionCount(): number {
return this.namespace?.sockets.size ?? 0;
}
}
@@ -5,5 +5,6 @@ import { RemindersService } from './reminders.service';
@Module({
controllers: [RemindersController],
providers: [RemindersService],
exports: [RemindersService],
})
export class RemindersModule {}
+30 -13
View File
@@ -191,12 +191,7 @@ export class RemindersService {
}
async sendTestEmail(venueId: string, plannedTabId: string) {
if (!this.resendApiKey) {
throw new BadRequestException({
code: 'resend_not_configured',
message: 'Resend API key is not configured. Set RESEND_API_KEY environment variable.',
});
}
this.ensureConfigured();
const tab = await this.prisma.plannedTab.findFirst({
where: { id: plannedTabId, venueId },
@@ -210,12 +205,36 @@ export class RemindersService {
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 };
}
/** 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;
try {
email = await this.emailTemplates.render('test-email', {
customerName: tab.customerName,
venueName: await this.venueName(venueId),
});
email = await this.emailTemplates.render('test-email', { customerName, venueName });
} catch (error) {
// Template mistakes are shown to the admin instead of a generic server error
if (error instanceof EmailTemplateError) {
@@ -224,15 +243,13 @@ export class RemindersService {
throw error;
}
const error = await this.resend('/emails', { from: this.fromEmail, to: tab.email, ...email });
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}`,
});
}
return { ok: true, email: tab.email, customerName: tab.customerName };
}
private renderReminder(candidate: Recipient, venueName: string): Promise<RenderedEmail> {