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