@@ -1,225 +1,174 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { AdminService } from './admin.service';
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
CopyCatalogDto,
|
||||
DryRunDto,
|
||||
PurgeDto,
|
||||
ResetPasswordDto,
|
||||
ResetVenueDto,
|
||||
TestEmailDto,
|
||||
UpdateRoleDto,
|
||||
VenueNameDto,
|
||||
} from './dto/admin.dto';
|
||||
auditQuerySchema, copyCatalogSchema, deleteVenueQuerySchema, dryRunSchema, purgeSchema, resetPasswordSchema, resetVenueSchema,
|
||||
testEmailSchema, updateRoleSchema, venueNameSchema,
|
||||
} from '@kooltap/shared';
|
||||
import { zod } from '../common/zod.pipe';
|
||||
import { Authorized, AuthUser, CurrentUser, Roles } from '../common/auth';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
type Body<S extends z.ZodType> = z.output<S>;
|
||||
|
||||
/** The superadmin page: every venue, terminal and user of KoolTap. Destructive actions take a dryRun to preview. */
|
||||
@ApiTags('Admin')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('superadmin')
|
||||
@Controller('admin')
|
||||
@Authorized()
|
||||
@Roles('superadmin')
|
||||
export class AdminController {
|
||||
constructor(private admin: AdminService) {}
|
||||
|
||||
// ---- Venues
|
||||
|
||||
@Get('venues')
|
||||
@ApiOperation({ summary: 'Every venue with what it holds' })
|
||||
listVenues() {
|
||||
return this.admin.listVenues();
|
||||
}
|
||||
|
||||
@Post('venues')
|
||||
@ApiOperation({ summary: 'Create a venue' })
|
||||
createVenue(@Body() dto: VenueNameDto, @Req() req: any) {
|
||||
return this.admin.createVenue(dto.name, req.user);
|
||||
createVenue(@Body(zod(venueNameSchema)) dto: Body<typeof venueNameSchema>, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.createVenue(dto.name, user);
|
||||
}
|
||||
|
||||
@Patch('venues/:venueId')
|
||||
@ApiOperation({ summary: 'Rename a venue' })
|
||||
renameVenue(@Param('venueId') venueId: string, @Body() dto: VenueNameDto, @Req() req: any) {
|
||||
return this.admin.renameVenue(venueId, dto.name, req.user);
|
||||
renameVenue(@Param('venueId', ParseUUIDPipe) venueId: string, @Body(zod(venueNameSchema)) dto: Body<typeof venueNameSchema>, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.renameVenue(venueId, dto.name, user);
|
||||
}
|
||||
|
||||
@Delete('venues/:venueId')
|
||||
@ApiOperation({ summary: 'Delete a venue with everything in it, users and terminals included' })
|
||||
@ApiQuery({ name: 'confirmName', description: "The venue's name" })
|
||||
deleteVenue(@Param('venueId') venueId: string, @Query('confirmName') confirmName: string | undefined, @Req() req: any) {
|
||||
return this.admin.deleteVenue(venueId, confirmName, req.user);
|
||||
deleteVenue(
|
||||
@Param('venueId', ParseUUIDPipe) venueId: string,
|
||||
@Query(zod(deleteVenueQuerySchema)) query: Body<typeof deleteVenueQuerySchema>,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.admin.deleteVenue(venueId, query.confirmName, user);
|
||||
}
|
||||
|
||||
@Post('venues/:venueId/view')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Start viewing the dashboard as the venue; recorded in the venue's audit log" })
|
||||
viewVenue(@Param('venueId') venueId: string, @Req() req: any) {
|
||||
return this.admin.viewVenue(venueId, req.user);
|
||||
viewVenue(@Param('venueId', ParseUUIDPipe) venueId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.viewVenue(venueId, user);
|
||||
}
|
||||
|
||||
@Post('venues/:venueId/copy-catalog')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Copy another venue's products that this venue doesn't have yet" })
|
||||
copyCatalog(@Param('venueId') venueId: string, @Body() dto: CopyCatalogDto, @Req() req: any) {
|
||||
return this.admin.copyCatalog(venueId, dto.fromVenueId, req.user);
|
||||
copyCatalog(@Param('venueId', ParseUUIDPipe) venueId: string, @Body(zod(copyCatalogSchema)) dto: Body<typeof copyCatalogSchema>, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.copyCatalog(venueId, dto.fromVenueId, user);
|
||||
}
|
||||
|
||||
@Post('venues/:venueId/reset')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Wipe parts of a venue's data; users, terminals and products stay" })
|
||||
resetVenue(@Param('venueId') venueId: string, @Body() dto: ResetVenueDto, @Req() req: any) {
|
||||
return this.admin.resetVenue(venueId, dto.scopes, { stockValue: dto.stockValue, dryRun: dto.dryRun }, req.user);
|
||||
resetVenue(@Param('venueId', ParseUUIDPipe) venueId: string, @Body(zod(resetVenueSchema)) dto: Body<typeof resetVenueSchema>, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.resetVenue(venueId, dto.scopes, dto, user);
|
||||
}
|
||||
|
||||
@Get('venues/:venueId/consistency')
|
||||
@ApiOperation({ summary: "Look for data in the venue that doesn't add up" })
|
||||
consistency(@Param('venueId') venueId: string) {
|
||||
consistency(@Param('venueId', ParseUUIDPipe) venueId: string) {
|
||||
return this.admin.consistency(venueId);
|
||||
}
|
||||
|
||||
@Get('venues/:venueId/export')
|
||||
@ApiOperation({ summary: 'Everything of the venue as JSON, without passwords and pictures' })
|
||||
exportVenue(@Param('venueId') venueId: string, @Req() req: any) {
|
||||
return this.admin.exportVenue(venueId, req.user);
|
||||
exportVenue(@Param('venueId', ParseUUIDPipe) venueId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.exportVenue(venueId, user);
|
||||
}
|
||||
|
||||
@Post('venues/:venueId/registration-codes')
|
||||
@ApiOperation({ summary: 'A pairing code for a terminal of the venue' })
|
||||
createRegistrationCode(@Param('venueId') venueId: string, @Req() req: any) {
|
||||
return this.admin.createRegistrationCode(venueId, req.user);
|
||||
createRegistrationCode(@Param('venueId', ParseUUIDPipe) venueId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.createRegistrationCode(venueId, user);
|
||||
}
|
||||
|
||||
// ---- Terminals
|
||||
|
||||
@Get('devices')
|
||||
@ApiOperation({ summary: 'Every terminal, unpaired ones included' })
|
||||
listDevices() {
|
||||
return this.admin.listDevices();
|
||||
}
|
||||
|
||||
@Post('devices/:deviceId/revoke')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Unpair a terminal' })
|
||||
revokeDevice(@Param('deviceId') deviceId: string, @Req() req: any) {
|
||||
return this.admin.revokeDevice(deviceId, req.user);
|
||||
revokeDevice(@Param('deviceId', ParseUUIDPipe) deviceId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.revokeDevice(deviceId, user);
|
||||
}
|
||||
|
||||
@Get('registration-codes')
|
||||
@ApiOperation({ summary: 'Pairing codes that can still be used' })
|
||||
listRegistrationCodes() {
|
||||
return this.admin.listRegistrationCodes();
|
||||
}
|
||||
|
||||
@Post('registration-codes/:id/revoke')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Make a pairing code unusable' })
|
||||
revokeRegistrationCode(@Param('id') id: string) {
|
||||
revokeRegistrationCode(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.admin.revokeRegistrationCode(id);
|
||||
}
|
||||
|
||||
@Post('registration-codes/cleanup')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Delete used and expired pairing codes' })
|
||||
cleanupRegistrationCodes() {
|
||||
return this.admin.cleanupRegistrationCodes();
|
||||
}
|
||||
|
||||
@Post('commands/expire-stuck')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Expire every pending command' })
|
||||
expireStuckCommands(@Body() dto: DryRunDto, @Req() req: any) {
|
||||
return this.admin.expireStuckCommands(dto.dryRun ?? false, req.user);
|
||||
}
|
||||
|
||||
// ---- Users
|
||||
|
||||
@Get('users')
|
||||
@ApiOperation({ summary: 'Every dashboard user' })
|
||||
listUsers() {
|
||||
return this.admin.listUsers();
|
||||
}
|
||||
|
||||
@Patch('users/:userId/role')
|
||||
@ApiOperation({ summary: "Change a user's role (not your own)" })
|
||||
updateRole(@Param('userId') userId: string, @Body() dto: UpdateRoleDto, @Req() req: any) {
|
||||
return this.admin.updateRole(userId, dto.role, req.user);
|
||||
updateRole(@Param('userId', ParseUUIDPipe) userId: string, @Body(zod(updateRoleSchema)) dto: Body<typeof updateRoleSchema>, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.updateRole(userId, dto.role, dto.venueId, user);
|
||||
}
|
||||
|
||||
@Post('users/:userId/password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Set a user's password; signs them out everywhere" })
|
||||
resetPassword(@Param('userId') userId: string, @Body() dto: ResetPasswordDto, @Req() req: any) {
|
||||
return this.admin.resetPassword(userId, dto.password, req.user);
|
||||
resetPassword(@Param('userId', ParseUUIDPipe) userId: string, @Body(zod(resetPasswordSchema)) dto: Body<typeof resetPasswordSchema>, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.resetPassword(userId, dto.password, user);
|
||||
}
|
||||
|
||||
@Post('users/:userId/logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Sign a user out everywhere, at once' })
|
||||
logoutUser(@Param('userId') userId: string, @Req() req: any) {
|
||||
return this.admin.logoutUser(userId, req.user);
|
||||
logoutUser(@Param('userId', ParseUUIDPipe) userId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.logoutUser(userId, user);
|
||||
}
|
||||
|
||||
@Delete('users/:userId')
|
||||
@ApiOperation({ summary: 'Delete a user (not yourself)' })
|
||||
deleteUser(@Param('userId') userId: string, @Req() req: any) {
|
||||
return this.admin.deleteUser(userId, req.user);
|
||||
deleteUser(@Param('userId', ParseUUIDPipe) userId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.deleteUser(userId, user);
|
||||
}
|
||||
|
||||
@Post('sessions/revoke-all')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Sign out every user but yourself' })
|
||||
logoutEveryone(@Req() req: any) {
|
||||
return this.admin.logoutEveryone(req.user);
|
||||
logoutEveryone(@CurrentUser() user: AuthUser) {
|
||||
return this.admin.logoutEveryone(user);
|
||||
}
|
||||
|
||||
// ---- Maintenance
|
||||
|
||||
@Post('purge')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Delete old terminal events, audit entries or finished commands' })
|
||||
purge(@Body() dto: PurgeDto, @Req() req: any) {
|
||||
return this.admin.purge(dto.targets, dto.olderThanDays, dto.venueId, dto.dryRun ?? false, req.user);
|
||||
purge(@Body(zod(purgeSchema)) dto: Body<typeof purgeSchema>, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.purge(dto.targets, dto.olderThanDays, dto.venueId, dto.dryRun, user);
|
||||
}
|
||||
|
||||
@Post('images/cleanup')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Delete pictures no product uses (older than a week)' })
|
||||
cleanupImages(@Body() dto: DryRunDto, @Req() req: any) {
|
||||
return this.admin.cleanupImages(dto.dryRun ?? false, req.user);
|
||||
cleanupImages(@Body(zod(dryRunSchema)) dto: Body<typeof dryRunSchema>, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.cleanupImages(dto.dryRun, user);
|
||||
}
|
||||
|
||||
@Post('email/test')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Send the test email to an address' })
|
||||
sendTestEmail(@Body() dto: TestEmailDto, @Req() req: any) {
|
||||
return this.admin.sendTestEmail(dto.to, req.user);
|
||||
sendTestEmail(@Body(zod(testEmailSchema)) dto: Body<typeof testEmailSchema>, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.sendTestEmail(dto.to, user);
|
||||
}
|
||||
|
||||
// ---- Insight
|
||||
|
||||
@Get('audit')
|
||||
@ApiOperation({ summary: 'The audit log of every venue' })
|
||||
@ApiQuery({ name: 'venueId', required: false })
|
||||
@ApiQuery({ name: 'action', required: false, description: 'Part of the action name' })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
@ApiQuery({ name: 'offset', required: false })
|
||||
auditLog(
|
||||
@Query('venueId') venueId?: string,
|
||||
@Query('action') action?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('offset') offset?: string,
|
||||
) {
|
||||
return this.admin.auditLog({
|
||||
venueId: venueId || undefined,
|
||||
action: action || undefined,
|
||||
limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200),
|
||||
offset: Math.max(parseInt(offset ?? '', 10) || 0, 0),
|
||||
});
|
||||
auditLog(@Query(zod(auditQuerySchema)) query: Body<typeof auditQuerySchema>) {
|
||||
return this.admin.auditLog(query);
|
||||
}
|
||||
|
||||
@Get('health')
|
||||
@ApiOperation({ summary: 'Database, migrations, live connections and background jobs' })
|
||||
health() {
|
||||
return this.admin.health();
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { DevicesModule } from '../devices/devices.module';
|
||||
import { ImagesModule } from '../images/images.module';
|
||||
import { RemindersModule } from '../reminders/reminders.module';
|
||||
import { JobsModule } from '../jobs/jobs.module';
|
||||
import { VenuesModule } from '../venues/venues.module';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, DevicesModule, RemindersModule, JobsModule],
|
||||
imports: [ImagesModule, RemindersModule, VenuesModule],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
})
|
||||
|
||||
+287
-369
@@ -1,32 +1,35 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
AdminDevice, AdminRegistrationCode, AdminUser, AdminVenue, AuditEntry, Finding, PurgeTarget, RealtimeTopic, ResetScope, Role,
|
||||
SystemHealth,
|
||||
} from '@kooltap/shared';
|
||||
import { Prisma, PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { DevicesService } from '../devices/devices.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { RemindersService } from '../reminders/reminders.service';
|
||||
import { CommandExpiryJob } from '../jobs/command-expiry.job';
|
||||
import { createdFieldTimes } from '../stock/product-fields';
|
||||
import { VenuesService } from '../venues/venues.service';
|
||||
import { ImagesService, imageDir } from '../images/images.service';
|
||||
import { Resend } from '../email/resend';
|
||||
import { hashPassword } from '../auth/auth.service';
|
||||
import { AuthUser } from '../common/auth';
|
||||
import { createdFieldTimes } from '../ledger/product-fields';
|
||||
import { lockVenue } from '../ledger/venue-ledger';
|
||||
import { ensureDefaultCustomers } from '../customers/default-customers';
|
||||
import { checkConsistency } from './consistency';
|
||||
import { countReset, deleteVenue, lockVenue, resetVenue, ResetCounts, ResetScope } from './venue-reset';
|
||||
import { ensureDefaultCustomers } from '../planned-tabs/default-customers';
|
||||
import { PurgeTarget } from './dto/admin.dto';
|
||||
|
||||
/** The superadmin doing it. */
|
||||
export type Actor = { id: string; email: string; name: string; venueId: string };
|
||||
import { countReset, resetVenue, ResetCounts } from './venue-reset';
|
||||
|
||||
// What each reset scope changes on the dashboard
|
||||
const SCOPE_TOPICS: Record<ResetScope, RealtimeTopic[]> = {
|
||||
// Plans the Free Drinks customer again
|
||||
tabs: ['tabs', 'planned-tabs'],
|
||||
// Adds the Free Drinks customer again
|
||||
tabs: ['tabs', 'customers'],
|
||||
settlements: ['tabs'],
|
||||
tabEvents: [],
|
||||
plannedTabs: ['planned-tabs', 'reminders'],
|
||||
customers: ['customers', 'reminders'],
|
||||
stock: ['stock'],
|
||||
stockHistory: ['stock'],
|
||||
commands: ['commands'],
|
||||
auditLog: [],
|
||||
};
|
||||
|
||||
@@ -35,115 +38,100 @@ const ORPHAN_IMAGE_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Everything the superadmin page does. Global actions (purging, signing everyone out) are written to the audit log
|
||||
* of the superadmin's own venue, since the log is kept per venue.
|
||||
*/
|
||||
/** Everything the superadmin page does. */
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private audit: AuditService,
|
||||
private realtime: RealtimeService,
|
||||
private authService: AuthService,
|
||||
private devicesService: DevicesService,
|
||||
private venues: VenuesService,
|
||||
private reminders: RemindersService,
|
||||
private commandExpiry: CommandExpiryJob,
|
||||
private images: ImagesService,
|
||||
private resend: Resend,
|
||||
) {}
|
||||
|
||||
// ---- Venues
|
||||
|
||||
async listVenues() {
|
||||
const venues = await this.prisma.venue.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
clientUsers: true,
|
||||
devices: { where: { revokedAt: null } },
|
||||
tabs: true,
|
||||
products: { where: { archivedAt: null } },
|
||||
plannedTabs: true,
|
||||
settlements: true,
|
||||
async listVenues(): Promise<AdminVenue[]> {
|
||||
const [venues, lastSeen] = await Promise.all([
|
||||
this.prisma.venue.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
users: true,
|
||||
devices: { where: { revokedAt: null } },
|
||||
tabs: true,
|
||||
products: { where: { archivedAt: null } },
|
||||
customers: true,
|
||||
settlements: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const lastSeen = await this.prisma.device.groupBy({
|
||||
by: ['venueId'],
|
||||
where: { revokedAt: null },
|
||||
_max: { lastSeenAt: true },
|
||||
});
|
||||
}),
|
||||
this.prisma.device.groupBy({ by: ['venueId'], where: { revokedAt: null }, _max: { lastSeenAt: true } }),
|
||||
]);
|
||||
const lastSeenByVenue = new Map(lastSeen.map((row) => [row.venueId, row._max.lastSeenAt]));
|
||||
|
||||
return {
|
||||
venues: venues.map(({ _count, ...venue }) => ({
|
||||
...venue,
|
||||
users: _count.clientUsers,
|
||||
devices: _count.devices,
|
||||
openTabs: _count.tabs,
|
||||
products: _count.products,
|
||||
plannedTabs: _count.plannedTabs,
|
||||
settlements: _count.settlements,
|
||||
lastSyncAt: lastSeenByVenue.get(venue.id) ?? null,
|
||||
})),
|
||||
};
|
||||
return venues.map(({ _count, ...venue }) => ({
|
||||
...venue,
|
||||
createdAt: venue.createdAt.toISOString(),
|
||||
users: _count.users,
|
||||
devices: _count.devices,
|
||||
openTabs: _count.tabs,
|
||||
products: _count.products,
|
||||
customers: _count.customers,
|
||||
settlements: _count.settlements,
|
||||
lastSyncAt: lastSeenByVenue.get(venue.id)?.toISOString() ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async createVenue(name: string, actor: Actor) {
|
||||
async createVenue(name: string, actor: AuthUser) {
|
||||
const venue = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.venue.create({ data: { name: name.trim() } });
|
||||
const created = await tx.venue.create({ data: { name } });
|
||||
await ensureDefaultCustomers(tx, created.id);
|
||||
return created;
|
||||
});
|
||||
await this.audit.log(venue.id, 'client_user', actor.id, 'admin_venue_created', { name: venue.name, by: actor.email });
|
||||
return venue;
|
||||
await this.audit.log({ venueId: venue.id, actor: { type: 'user', id: actor.id }, action: 'admin_venue_created', details: { name } });
|
||||
return { id: venue.id, name: venue.name };
|
||||
}
|
||||
|
||||
async renameVenue(venueId: string, name: string, actor: Actor) {
|
||||
async renameVenue(venueId: string, name: string, actor: AuthUser) {
|
||||
const before = await this.findVenue(venueId);
|
||||
const venue = await this.prisma.venue.update({ where: { id: venueId }, data: { name: name.trim() } });
|
||||
await this.audit.log(venueId, 'client_user', actor.id, 'admin_venue_renamed', { from: before.name, to: venue.name, by: actor.email });
|
||||
return venue;
|
||||
await this.prisma.venue.update({ where: { id: venueId }, data: { name } });
|
||||
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_venue_renamed', details: { from: before.name, to: name } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** [confirmName] must be the venue's name, so a wrong id can't delete a venue by accident. */
|
||||
async deleteVenue(venueId: string, confirmName: string | undefined, actor: Actor) {
|
||||
/** Deletes a venue with everything in it. [confirmName] must be its name, so a wrong id can't delete one by accident. */
|
||||
async deleteVenue(venueId: string, confirmName: string, actor: AuthUser) {
|
||||
const venue = await this.findVenue(venueId);
|
||||
if (venueId === actor.venueId) {
|
||||
throw new BadRequestException({ code: 'cannot_delete_own_venue', message: 'You cannot delete the venue your account belongs to' });
|
||||
}
|
||||
if (confirmName !== venue.name) {
|
||||
throw new BadRequestException({ code: 'confirm_name_mismatch', message: 'Type the venue name to confirm' });
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await lockVenue(tx, venueId);
|
||||
await deleteVenue(tx, venueId);
|
||||
}, { timeout: 60000 });
|
||||
|
||||
await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_venue_deleted', { venueId, name: venue.name, by: actor.email });
|
||||
await tx.venue.delete({ where: { id: venueId } });
|
||||
}, { timeout: 60_000 });
|
||||
await this.audit.log({ venueId: null, actor: { type: 'user', id: actor.id }, action: 'admin_venue_deleted', details: { venueId, name: venue.name } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Recorded in the venue's own audit log, so its owner can see that KoolTap looked. */
|
||||
async viewVenue(venueId: string, actor: Actor) {
|
||||
async viewVenue(venueId: string, actor: AuthUser) {
|
||||
const venue = await this.findVenue(venueId);
|
||||
await this.audit.log(venueId, 'client_user', actor.id, 'admin_viewed_venue', { by: actor.email });
|
||||
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_viewed_venue', details: { by: actor.email } });
|
||||
return { id: venue.id, name: venue.name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the products of [fromVenueId] that [venueId] doesn't have yet (by name), with their pictures but without
|
||||
* stock: stock is counted per venue.
|
||||
*/
|
||||
async copyCatalog(venueId: string, fromVenueId: string, actor: Actor) {
|
||||
if (venueId === fromVenueId) {
|
||||
throw new BadRequestException({ code: 'same_venue', message: 'Pick another venue to copy from' });
|
||||
}
|
||||
/** Copies the products of [fromVenueId] that [venueId] doesn't have yet (by name), with their pictures but no stock. */
|
||||
async copyCatalog(venueId: string, fromVenueId: string, actor: AuthUser) {
|
||||
if (venueId === fromVenueId) throw new BadRequestException({ code: 'same_venue', message: 'Pick another venue to copy from' });
|
||||
await this.findVenue(venueId);
|
||||
await this.findVenue(fromVenueId);
|
||||
|
||||
@@ -156,184 +144,148 @@ export class AdminService {
|
||||
]);
|
||||
const taken = new Set(existing.map((p) => p.name.trim().toLowerCase()));
|
||||
const copies = source.filter((p) => !taken.has(p.name.trim().toLowerCase()));
|
||||
if (copies.length > 0) {
|
||||
await tx.product.createMany({
|
||||
data: copies.map((p) => {
|
||||
const fields = {
|
||||
name: p.name, category: p.category, priceInCents: p.priceInCents, buyPriceInCents: p.buyPriceInCents,
|
||||
lowStockThreshold: p.lowStockThreshold, desiredStock: p.desiredStock,
|
||||
};
|
||||
return {
|
||||
venueId,
|
||||
productId: uuidv4(),
|
||||
...fields,
|
||||
stock: null,
|
||||
countedAt: null,
|
||||
reportedAt: now,
|
||||
imageId: p.imageId,
|
||||
fieldUpdatedAt: createdFieldTimes({ ...fields, archived: false, imageId: p.imageId }, now),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
await tx.product.createMany({
|
||||
data: copies.map((p) => {
|
||||
const fields = {
|
||||
name: p.name, category: p.category, priceInCents: p.priceInCents, buyPriceInCents: p.buyPriceInCents,
|
||||
lowStockThreshold: p.lowStockThreshold, desiredStock: p.desiredStock, imageId: p.imageId,
|
||||
};
|
||||
return { id: randomUUID(), venueId, ...fields, fieldUpdatedAt: createdFieldTimes({ ...fields, archived: false }, now), updatedAt: now };
|
||||
}),
|
||||
});
|
||||
return copies.length;
|
||||
});
|
||||
|
||||
await this.audit.log(venueId, 'client_user', actor.id, 'admin_catalog_copied', { fromVenueId, created, by: actor.email });
|
||||
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_catalog_copied', details: { fromVenueId, created } });
|
||||
if (created > 0) this.realtime.notify(venueId, 'stock');
|
||||
return { created };
|
||||
}
|
||||
|
||||
async resetVenue(venueId: string, scopes: ResetScope[], options: { stockValue?: number; dryRun?: boolean }, actor: Actor) {
|
||||
async resetVenue(venueId: string, scopes: ResetScope[], options: { stockValue: number; dryRun: boolean }, actor: AuthUser) {
|
||||
await this.findVenue(venueId);
|
||||
const unique = [...new Set(scopes)];
|
||||
|
||||
if (options.dryRun) {
|
||||
return { dryRun: true, counts: await this.prisma.$transaction((tx) => countReset(tx, venueId, unique)) };
|
||||
}
|
||||
|
||||
const stockValue = options.stockValue ?? 0;
|
||||
const counts: ResetCounts = await this.prisma.$transaction(async (tx) => {
|
||||
await lockVenue(tx, venueId);
|
||||
return resetVenue(tx, venueId, unique, { stockValue, actor: { id: actor.id, name: actor.name || actor.email }, now: new Date() });
|
||||
}, { timeout: 60000 });
|
||||
return resetVenue(tx, venueId, unique, { stockValue: options.stockValue, userId: actor.id, now: new Date() });
|
||||
}, { timeout: 60_000 });
|
||||
|
||||
// After the reset, so wiping the audit log keeps the entry that says who wiped it
|
||||
await this.audit.log(venueId, 'client_user', actor.id, 'admin_venue_reset', { scopes: unique, stockValue, counts, by: actor.email });
|
||||
|
||||
const topics = new Set(unique.flatMap((scope) => SCOPE_TOPICS[scope]));
|
||||
if (topics.size > 0) this.realtime.notify(venueId, ...topics);
|
||||
|
||||
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_venue_reset', details: { scopes: unique, stockValue: options.stockValue, counts } });
|
||||
this.realtime.notify(venueId, ...unique.flatMap((scope) => SCOPE_TOPICS[scope]));
|
||||
return { dryRun: false, counts };
|
||||
}
|
||||
|
||||
async consistency(venueId: string) {
|
||||
async consistency(venueId: string): Promise<{ checkedAt: string; findings: Finding[] }> {
|
||||
await this.findVenue(venueId);
|
||||
const where = { venueId };
|
||||
const [settlements, tabs, products, pendingCommands, devices] = await Promise.all([
|
||||
this.prisma.settlement.findMany({
|
||||
where,
|
||||
select: { id: true, customerName: true, amountInCents: true, lines: true, commandId: true },
|
||||
}),
|
||||
this.prisma.tab.findMany({ where, select: { id: true, customerName: true, lines: true } }),
|
||||
this.prisma.product.findMany({
|
||||
where: { venueId, archivedAt: null },
|
||||
select: { productId: true, name: true, stock: true, countedAt: true },
|
||||
}),
|
||||
this.prisma.remoteCommand.findMany({
|
||||
where: { venueId, status: 'pending' },
|
||||
select: { id: true, expiresAt: true, device: { select: { name: true, revokedAt: true } } },
|
||||
}),
|
||||
const [settlements, drinks, tabs, products, devices] = await Promise.all([
|
||||
this.prisma.settlement.findMany({ where, select: { id: true, customerName: true, amountInCents: true } }),
|
||||
this.prisma.$queryRaw<{ settlementId: string; cents: bigint }[]>`
|
||||
SELECT "settlementId", SUM("unitPriceInCents")::bigint AS cents FROM "TabUnit"
|
||||
WHERE "venueId" = ${venueId}::uuid AND "settlementId" IS NOT NULL AND "removedAt" IS NULL
|
||||
GROUP BY "settlementId"`,
|
||||
this.prisma.tab.findMany({ where, select: { id: true, customerName: true } }),
|
||||
this.prisma.product.findMany({ where: { venueId, archivedAt: null }, select: { id: true, name: true, stock: true } }),
|
||||
this.prisma.device.findMany({ where, select: { id: true, name: true, revokedAt: true, tokenExpiresAt: true } }),
|
||||
]);
|
||||
const drinksBySettlement = new Map(drinks.map((row) => [row.settlementId, Number(row.cents)]));
|
||||
|
||||
const findings = checkConsistency({ settlements, tabs, products, pendingCommands, devices }, new Date());
|
||||
return { checkedAt: new Date(), findings };
|
||||
const now = new Date();
|
||||
const findings = checkConsistency({
|
||||
settlements: settlements.map((s) => ({ ...s, drinksInCents: drinksBySettlement.get(s.id) ?? 0 })),
|
||||
tabs,
|
||||
products,
|
||||
devices,
|
||||
}, now);
|
||||
return { checkedAt: now.toISOString(), findings };
|
||||
}
|
||||
|
||||
/** Everything of a venue except password hashes and picture bytes, for a backup before a reset. */
|
||||
async exportVenue(venueId: string, actor: Actor) {
|
||||
/** Everything of a venue except password hashes and pictures, for a backup before a reset. */
|
||||
async exportVenue(venueId: string, actor: AuthUser) {
|
||||
const venue = await this.findVenue(venueId);
|
||||
const where = { venueId };
|
||||
const [users, devices, products, tabs, settlements, plannedTabs, reminders, stockAdjustments, commands, tabEvents, auditLog] =
|
||||
await Promise.all([
|
||||
this.prisma.clientUser.findMany({ where, select: { id: true, email: true, name: true, role: true, createdAt: true } }),
|
||||
this.prisma.device.findMany({ where, select: { id: true, name: true, lastSeenAt: true, revokedAt: true, createdAt: true } }),
|
||||
this.prisma.product.findMany({ where }),
|
||||
this.prisma.tab.findMany({ where }),
|
||||
this.prisma.settlement.findMany({ where, orderBy: { sequence: 'asc' } }),
|
||||
this.prisma.plannedTab.findMany({ where, orderBy: { position: 'asc' } }),
|
||||
this.prisma.tabReminder.findMany({ where, orderBy: { sentAt: 'asc' } }),
|
||||
this.prisma.stockAdjustment.findMany({ where, orderBy: { createdAt: 'asc' } }),
|
||||
this.prisma.remoteCommand.findMany({ where, orderBy: { createdAt: 'asc' } }),
|
||||
this.prisma.tabEvent.findMany({ where, orderBy: { occurredAt: 'asc' } }),
|
||||
this.prisma.auditLog.findMany({ where, orderBy: { createdAt: 'asc' } }),
|
||||
]);
|
||||
|
||||
await this.audit.log(venueId, 'client_user', actor.id, 'admin_venue_exported', { by: actor.email });
|
||||
const [users, devices, customers, products, tabs, settlements, units, stockMovements, reminders, tabEvents, auditLog] = await Promise.all([
|
||||
this.prisma.user.findMany({ where, select: { id: true, email: true, name: true, role: true, createdAt: true } }),
|
||||
this.prisma.device.findMany({ where, select: { id: true, name: true, lastSeenAt: true, revokedAt: true, createdAt: true } }),
|
||||
this.prisma.customer.findMany({ where, orderBy: { position: 'asc' } }),
|
||||
this.prisma.product.findMany({ where }),
|
||||
this.prisma.tab.findMany({ where }),
|
||||
this.prisma.settlement.findMany({ where, orderBy: { sequence: 'asc' } }),
|
||||
this.prisma.tabUnit.findMany({ where, orderBy: { addedAt: 'asc' } }),
|
||||
this.prisma.stockMovement.findMany({ where, orderBy: { createdAt: 'asc' } }),
|
||||
this.prisma.reminder.findMany({ where, orderBy: { sentAt: 'asc' } }),
|
||||
this.prisma.tabEvent.findMany({ where, orderBy: { occurredAt: 'asc' } }),
|
||||
this.prisma.auditLog.findMany({ where, orderBy: { createdAt: 'asc' } }),
|
||||
]);
|
||||
|
||||
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_venue_exported' });
|
||||
return {
|
||||
exportedAt: new Date(),
|
||||
venue,
|
||||
users,
|
||||
devices,
|
||||
products,
|
||||
tabs,
|
||||
settlements,
|
||||
plannedTabs,
|
||||
reminders,
|
||||
stockAdjustments,
|
||||
commands,
|
||||
tabEvents,
|
||||
auditLog,
|
||||
exportedAt: new Date(), venue, users, devices, customers, products, tabs, settlements, units, stockMovements, reminders, tabEvents, auditLog,
|
||||
};
|
||||
}
|
||||
|
||||
async createRegistrationCode(venueId: string, actor: AuthUser) {
|
||||
await this.findVenue(venueId);
|
||||
const code = await this.venues.createRegistrationCode(venueId);
|
||||
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_registration_code_created' });
|
||||
return code;
|
||||
}
|
||||
|
||||
// ---- Terminals
|
||||
|
||||
async listDevices() {
|
||||
async listDevices(): Promise<AdminDevice[]> {
|
||||
const devices = await this.prisma.device.findMany({
|
||||
orderBy: [{ revokedAt: { sort: 'desc', nulls: 'first' } }, { lastSeenAt: 'desc' }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
venueId: true,
|
||||
tokenPrefix: true,
|
||||
tokenExpiresAt: true,
|
||||
lastSeenAt: true,
|
||||
revokedAt: true,
|
||||
createdAt: true,
|
||||
venue: { select: { name: true } },
|
||||
_count: { select: { remoteCommands: { where: { status: 'pending' } } } },
|
||||
},
|
||||
include: { venue: { select: { name: true } } },
|
||||
});
|
||||
return devices.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
venueId: d.venueId,
|
||||
venueName: d.venue.name,
|
||||
tokenPrefix: d.tokenPrefix,
|
||||
tokenExpiresAt: d.tokenExpiresAt.toISOString(),
|
||||
lastSeenAt: d.lastSeenAt.toISOString(),
|
||||
revokedAt: d.revokedAt?.toISOString() ?? null,
|
||||
createdAt: d.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async revokeDevice(deviceId: string, actor: AuthUser) {
|
||||
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
|
||||
if (!device) throw new NotFoundException({ code: 'device_not_found', message: 'Terminal not found' });
|
||||
return this.venues.unpairDevice(device.venueId, deviceId, { type: 'user', id: actor.id });
|
||||
}
|
||||
|
||||
async listRegistrationCodes(): Promise<{ codes: AdminRegistrationCode[]; stale: number }> {
|
||||
const now = new Date();
|
||||
const [codes, stale] = await Promise.all([
|
||||
this.prisma.registrationCode.findMany({
|
||||
where: { usedAt: null, expiresAt: { gt: now } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { venue: { select: { name: true } } },
|
||||
}),
|
||||
this.prisma.registrationCode.count({ where: { OR: [{ usedAt: { not: null } }, { expiresAt: { lte: now } }] } }),
|
||||
]);
|
||||
return {
|
||||
devices: devices.map(({ venue, _count, ...device }) => ({
|
||||
...device,
|
||||
venueName: venue.name,
|
||||
pendingCommands: _count.remoteCommands,
|
||||
codes: codes.map((c) => ({
|
||||
id: c.id, code: c.code, venueId: c.venueId, venueName: c.venue.name, expiresAt: c.expiresAt.toISOString(), createdAt: c.createdAt.toISOString(),
|
||||
})),
|
||||
stale,
|
||||
};
|
||||
}
|
||||
|
||||
async revokeDevice(deviceId: string, actor: Actor) {
|
||||
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
|
||||
if (!device) {
|
||||
throw new NotFoundException({ code: 'device_not_found', message: 'Device not found' });
|
||||
}
|
||||
await this.devicesService.revoke(device.venueId, deviceId);
|
||||
await this.audit.log(device.venueId, 'client_user', actor.id, 'admin_device_revoked', { deviceId, deviceName: device.name, by: actor.email });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async listRegistrationCodes() {
|
||||
const codes = await this.prisma.registrationCode.findMany({
|
||||
where: { usedAt: null, expiresAt: { gt: new Date() } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true, code: true, venueId: true, expiresAt: true, createdAt: true, venue: { select: { name: true } } },
|
||||
});
|
||||
const stale = await this.prisma.registrationCode.count({
|
||||
where: { OR: [{ usedAt: { not: null } }, { expiresAt: { lte: new Date() } }] },
|
||||
});
|
||||
return { codes: codes.map(({ venue, ...code }) => ({ ...code, venueName: venue.name })), stale };
|
||||
}
|
||||
|
||||
async createRegistrationCode(venueId: string, actor: Actor) {
|
||||
await this.findVenue(venueId);
|
||||
const code = await this.authService.generateRegistrationCode(venueId);
|
||||
await this.audit.log(venueId, 'client_user', actor.id, 'admin_registration_code_created', { by: actor.email });
|
||||
return code;
|
||||
}
|
||||
|
||||
async revokeRegistrationCode(id: string) {
|
||||
const { count } = await this.prisma.registrationCode.updateMany({
|
||||
where: { id, usedAt: null, expiresAt: { gt: new Date() } },
|
||||
data: { expiresAt: new Date() },
|
||||
});
|
||||
const now = new Date();
|
||||
const { count } = await this.prisma.registrationCode.updateMany({ where: { id, usedAt: null, expiresAt: { gt: now } }, data: { expiresAt: now } });
|
||||
return { ok: true, revoked: count > 0 };
|
||||
}
|
||||
|
||||
/** Deletes used and expired codes; they are only kept so a code can't be used twice, which expiry already ensures. */
|
||||
/** Deletes used and expired codes; expiry already keeps them from being used again. */
|
||||
async cleanupRegistrationCodes() {
|
||||
const { count } = await this.prisma.registrationCode.deleteMany({
|
||||
where: { OR: [{ usedAt: { not: null } }, { expiresAt: { lte: new Date() } }] },
|
||||
@@ -341,175 +293,139 @@ export class AdminService {
|
||||
return { deleted: count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Expires every pending command. The server settles tabs itself now, so a pending command is one from before that
|
||||
* whose terminal never picked it up.
|
||||
*/
|
||||
async expireStuckCommands(dryRun: boolean, actor: Actor) {
|
||||
const where = { status: 'pending' };
|
||||
if (dryRun) {
|
||||
return { dryRun: true, count: await this.prisma.remoteCommand.count({ where }) };
|
||||
}
|
||||
|
||||
const stuck = await this.prisma.remoteCommand.findMany({ where, select: { id: true, venueId: true } });
|
||||
if (stuck.length > 0) {
|
||||
await this.prisma.remoteCommand.updateMany({
|
||||
where: { id: { in: stuck.map((c) => c.id) }, status: 'pending' },
|
||||
data: { status: 'expired', result: 'Expired by a KoolTap admin' },
|
||||
});
|
||||
}
|
||||
const venues = new Set(stuck.map((c) => c.venueId));
|
||||
for (const venueId of venues) {
|
||||
const count = stuck.filter((c) => c.venueId === venueId).length;
|
||||
await this.audit.log(venueId, 'client_user', actor.id, 'admin_commands_expired', { count, by: actor.email });
|
||||
this.realtime.notify(venueId, 'commands');
|
||||
}
|
||||
return { dryRun: false, count: stuck.length };
|
||||
}
|
||||
|
||||
// ---- Users
|
||||
|
||||
async listUsers() {
|
||||
const now = new Date();
|
||||
async listUsers(): Promise<AdminUser[]> {
|
||||
const [users, sessions] = await Promise.all([
|
||||
this.prisma.clientUser.findMany({
|
||||
orderBy: [{ venue: { name: 'asc' } }, { email: 'asc' }],
|
||||
select: { id: true, email: true, name: true, role: true, venueId: true, createdAt: true, venue: { select: { name: true } } },
|
||||
}),
|
||||
this.prisma.refreshToken.groupBy({
|
||||
by: ['userId'],
|
||||
where: { revokedAt: null, expiresAt: { gt: now } },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.user.findMany({ orderBy: [{ venue: { name: 'asc' } }, { email: 'asc' }], include: { venue: { select: { name: true } } } }),
|
||||
this.prisma.refreshToken.groupBy({ by: ['userId'], where: { revokedAt: null, expiresAt: { gt: new Date() } }, _count: { _all: true } }),
|
||||
]);
|
||||
const sessionsByUser = new Map(sessions.map((row) => [row.userId, row._count._all]));
|
||||
return {
|
||||
users: users.map(({ venue, ...user }) => ({
|
||||
...user,
|
||||
role: user.role === 'viewer' ? 'manager' : user.role,
|
||||
venueName: venue.name,
|
||||
activeSessions: sessionsByUser.get(user.id) ?? 0,
|
||||
})),
|
||||
};
|
||||
return users.map((u) => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
role: u.role,
|
||||
venueId: u.venueId,
|
||||
venueName: u.venue?.name ?? null,
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
activeSessions: sessionsByUser.get(u.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async updateRole(userId: string, role: string, actor: Actor) {
|
||||
/**
|
||||
* A superadmin leaves their venue: they are above the venues. Anyone else stays in theirs, so a superadmin becoming
|
||||
* an admin or manager needs [venueId] to join.
|
||||
*/
|
||||
async updateRole(userId: string, role: Role, venueId: string | undefined, actor: AuthUser) {
|
||||
// Demoting yourself could leave nobody who can open this page
|
||||
if (userId === actor.id) {
|
||||
throw new BadRequestException({ code: 'cannot_change_own_role', message: 'You cannot change your own role' });
|
||||
}
|
||||
if (userId === actor.id) throw new BadRequestException({ code: 'cannot_change_own_role', message: 'You cannot change your own role' });
|
||||
const user = await this.findUser(userId);
|
||||
await this.prisma.clientUser.update({ where: { id: userId }, data: { role } });
|
||||
await this.audit.log(user.venueId, 'client_user', actor.id, 'admin_role_changed', { userId, email: user.email, from: user.role, to: role, by: actor.email });
|
||||
this.realtime.notify(user.venueId, 'users');
|
||||
const newVenueId = role === 'superadmin' ? null : user.venueId ?? venueId;
|
||||
if (role !== 'superadmin' && !newVenueId) {
|
||||
throw new BadRequestException({ code: 'venue_required', message: 'Pick the venue this user joins' });
|
||||
}
|
||||
if (newVenueId && newVenueId !== user.venueId) await this.findVenue(newVenueId);
|
||||
|
||||
await this.prisma.user.update({ where: { id: userId }, data: { role, venueId: newVenueId } });
|
||||
await this.audit.log({
|
||||
venueId: newVenueId ?? user.venueId,
|
||||
actor: { type: 'user', id: actor.id },
|
||||
action: 'admin_role_changed',
|
||||
details: { userId, from: user.role, to: role },
|
||||
});
|
||||
for (const id of new Set([user.venueId, newVenueId])) if (id) this.realtime.notify(id, 'users');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Sets a new password and signs the user out everywhere. */
|
||||
async resetPassword(userId: string, password: string, actor: Actor) {
|
||||
async resetPassword(userId: string, password: string, actor: AuthUser) {
|
||||
// That would sign you out too; My Account changes your own password and keeps you signed in
|
||||
if (userId === actor.id) {
|
||||
throw new BadRequestException({ code: 'use_account_page', message: 'Change your own password on My Account' });
|
||||
}
|
||||
if (userId === actor.id) throw new BadRequestException({ code: 'use_account_page', message: 'Change your own password on My Account' });
|
||||
const user = await this.findUser(userId);
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const now = new Date();
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.clientUser.update({ where: { id: userId }, data: { passwordHash, sessionsRevokedAt: now } }),
|
||||
this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: now } }),
|
||||
]);
|
||||
await this.audit.log(user.venueId, 'client_user', actor.id, 'admin_password_reset', { userId, email: user.email, by: actor.email });
|
||||
await this.signOut(userId, { passwordHash: await hashPassword(password) });
|
||||
await this.audit.log({ venueId: user.venueId, actor: { type: 'user', id: actor.id }, action: 'admin_password_reset', details: { userId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Signs the user out everywhere, at once: their access tokens are refused too. */
|
||||
async logoutUser(userId: string, actor: Actor) {
|
||||
if (userId === actor.id) {
|
||||
throw new BadRequestException({ code: 'cannot_logout_self', message: 'Use Logout to sign yourself out' });
|
||||
}
|
||||
async logoutUser(userId: string, actor: AuthUser) {
|
||||
if (userId === actor.id) throw new BadRequestException({ code: 'cannot_logout_self', message: 'Use Logout to sign yourself out' });
|
||||
const user = await this.findUser(userId);
|
||||
const now = new Date();
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.clientUser.update({ where: { id: userId }, data: { sessionsRevokedAt: now } }),
|
||||
this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: now } }),
|
||||
]);
|
||||
await this.audit.log(user.venueId, 'client_user', actor.id, 'admin_user_logged_out', { userId, email: user.email, by: actor.email });
|
||||
await this.signOut(userId);
|
||||
await this.audit.log({ venueId: user.venueId, actor: { type: 'user', id: actor.id }, action: 'admin_user_logged_out', details: { userId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Signs out every user but the superadmin doing it. */
|
||||
async logoutEveryone(actor: Actor) {
|
||||
async logoutEveryone(actor: AuthUser) {
|
||||
const now = new Date();
|
||||
const others = { id: { not: actor.id } };
|
||||
const [users] = await this.prisma.$transaction([
|
||||
this.prisma.clientUser.updateMany({ where: others, data: { sessionsRevokedAt: now } }),
|
||||
this.prisma.user.updateMany({ where: { id: { not: actor.id } }, data: { sessionsRevokedAt: now } }),
|
||||
this.prisma.refreshToken.updateMany({ where: { userId: { not: actor.id }, revokedAt: null }, data: { revokedAt: now } }),
|
||||
]);
|
||||
await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_everyone_logged_out', { users: users.count, by: actor.email });
|
||||
await this.audit.log({ venueId: null, actor: { type: 'user', id: actor.id }, action: 'admin_everyone_logged_out', details: { users: users.count } });
|
||||
return { users: users.count };
|
||||
}
|
||||
|
||||
async deleteUser(userId: string, actor: Actor) {
|
||||
async deleteUser(userId: string, actor: AuthUser) {
|
||||
const user = await this.findUser(userId);
|
||||
return this.authService.deleteUser(user.venueId, userId, actor);
|
||||
if (user.venueId) return this.venues.deleteUser(user.venueId, userId, actor);
|
||||
|
||||
// Another superadmin
|
||||
if (userId === actor.id) throw new BadRequestException({ code: 'cannot_delete_self', message: 'You cannot delete your own account' });
|
||||
await this.prisma.user.delete({ where: { id: userId } });
|
||||
await this.audit.log({ venueId: null, actor: { type: 'user', id: actor.id }, action: 'user_deleted', details: { userId, email: user.email, role: user.role } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ---- Maintenance
|
||||
|
||||
/** Removes old history. Pending commands are never purged: a terminal may still pick them up. */
|
||||
async purge(targets: PurgeTarget[], olderThanDays: number, venueId: string | undefined, dryRun: boolean, actor: Actor) {
|
||||
async purge(targets: PurgeTarget[], olderThanDays: number, venueId: string | undefined, dryRun: boolean, actor: AuthUser) {
|
||||
if (venueId) await this.findVenue(venueId);
|
||||
const before = new Date(Date.now() - olderThanDays * DAY_MS);
|
||||
const venue = venueId ? { venueId } : {};
|
||||
const wheres = {
|
||||
tabEvents: { ...venue, receivedAt: { lt: before } },
|
||||
auditLog: { ...venue, createdAt: { lt: before } },
|
||||
commands: { ...venue, createdAt: { lt: before }, status: { not: 'pending' } },
|
||||
};
|
||||
const run = {
|
||||
tabEvents: () => (dryRun ? this.prisma.tabEvent.count({ where: wheres.tabEvents }) : this.prisma.tabEvent.deleteMany({ where: wheres.tabEvents }).then((r) => r.count)),
|
||||
auditLog: () => (dryRun ? this.prisma.auditLog.count({ where: wheres.auditLog }) : this.prisma.auditLog.deleteMany({ where: wheres.auditLog }).then((r) => r.count)),
|
||||
commands: () => (dryRun ? this.prisma.remoteCommand.count({ where: wheres.commands }) : this.prisma.remoteCommand.deleteMany({ where: wheres.commands }).then((r) => r.count)),
|
||||
const tabEvents = { ...venue, receivedAt: { lt: before } };
|
||||
const auditLog = { ...venue, createdAt: { lt: before } };
|
||||
const run: Record<PurgeTarget, () => Promise<number>> = {
|
||||
tabEvents: () => (dryRun ? this.prisma.tabEvent.count({ where: tabEvents }) : this.prisma.tabEvent.deleteMany({ where: tabEvents }).then((r) => r.count)),
|
||||
auditLog: () => (dryRun ? this.prisma.auditLog.count({ where: auditLog }) : this.prisma.auditLog.deleteMany({ where: auditLog }).then((r) => r.count)),
|
||||
};
|
||||
|
||||
const counts: Partial<Record<PurgeTarget, number>> = {};
|
||||
for (const target of new Set(targets)) counts[target] = await run[target]();
|
||||
|
||||
if (!dryRun) {
|
||||
await this.audit.log(venueId ?? actor.venueId, 'client_user', actor.id, 'admin_purged', {
|
||||
targets, olderThanDays, venueId: venueId ?? 'all', counts, by: actor.email,
|
||||
});
|
||||
if (venueId && counts.commands) this.realtime.notify(venueId, 'commands');
|
||||
await this.audit.log({ venueId: venueId ?? null, actor: { type: 'user', id: actor.id }, action: 'admin_purged', details: { targets, olderThanDays, counts } });
|
||||
}
|
||||
return { dryRun, counts };
|
||||
}
|
||||
|
||||
/** Deletes pictures no product uses, after a grace period for terminals that upload before they link. */
|
||||
async cleanupImages(dryRun: boolean, actor: Actor) {
|
||||
const where = { products: { none: {} }, createdAt: { lt: new Date(Date.now() - ORPHAN_IMAGE_GRACE_MS) } };
|
||||
if (dryRun) {
|
||||
const images = await this.prisma.productImage.findMany({ where, select: { id: true } });
|
||||
const [{ bytes }] = images.length === 0 ? [{ bytes: 0n }] : await this.prisma.$queryRaw<{ bytes: bigint }[]>`
|
||||
SELECT COALESCE(SUM(OCTET_LENGTH("bytes")), 0)::bigint AS bytes FROM "ProductImage" WHERE "id" = ANY(${images.map((i) => i.id)})`;
|
||||
return { dryRun: true, count: images.length, bytes: Number(bytes) };
|
||||
async cleanupImages(dryRun: boolean, actor: AuthUser) {
|
||||
const images = await this.prisma.productImage.findMany({
|
||||
where: { products: { none: {} }, createdAt: { lt: new Date(Date.now() - ORPHAN_IMAGE_GRACE_MS) } },
|
||||
select: { id: true, bytes: true },
|
||||
});
|
||||
const bytes = images.reduce((sum, image) => sum + image.bytes, 0);
|
||||
if (!dryRun && images.length > 0) {
|
||||
await this.images.remove(images.map((image) => image.id));
|
||||
await this.audit.log({ venueId: null, actor: { type: 'user', id: actor.id }, action: 'admin_images_cleaned', details: { count: images.length } });
|
||||
}
|
||||
const { count } = await this.prisma.productImage.deleteMany({ where });
|
||||
await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_images_cleaned', { count, by: actor.email });
|
||||
return { dryRun: false, count };
|
||||
return { dryRun, count: images.length, bytes };
|
||||
}
|
||||
|
||||
async sendTestEmail(to: string, actor: Actor) {
|
||||
const result = await this.reminders.sendTestEmailTo(to);
|
||||
await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_test_email_sent', { to, by: actor.email });
|
||||
async sendTestEmail(to: string, actor: AuthUser) {
|
||||
const result = await this.reminders.sendTestEmail(to);
|
||||
await this.audit.log({ venueId: null, actor: { type: 'user', id: actor.id }, action: 'admin_test_email_sent', details: { to } });
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- Insight
|
||||
|
||||
async auditLog(options: { venueId?: string; action?: string; limit: number; offset: number }) {
|
||||
const where = {
|
||||
async auditLog(options: { venueId?: string; action?: string; limit: number; offset: number }): Promise<{ entries: AuditEntry[]; total: number }> {
|
||||
const where: Prisma.AuditLogWhereInput = {
|
||||
...(options.venueId ? { venueId: options.venueId } : {}),
|
||||
...(options.action ? { action: { contains: options.action, mode: 'insensitive' as const } } : {}),
|
||||
...(options.action ? { action: { contains: options.action, mode: 'insensitive' } } : {}),
|
||||
};
|
||||
const [entries, total] = await Promise.all([
|
||||
this.prisma.auditLog.findMany({
|
||||
@@ -523,42 +439,45 @@ export class AdminService {
|
||||
]);
|
||||
|
||||
// Actors are stored as ids; show who they are
|
||||
const actorIds = [...new Set(entries.map((e) => e.actorId))];
|
||||
const actorIds = [...new Set(entries.map((e) => e.actorId).filter((id): id is string => id !== null))];
|
||||
const [users, devices] = await Promise.all([
|
||||
this.prisma.clientUser.findMany({ where: { id: { in: actorIds } }, select: { id: true, email: true } }),
|
||||
this.prisma.user.findMany({ where: { id: { in: actorIds } }, select: { id: true, email: true } }),
|
||||
this.prisma.device.findMany({ where: { id: { in: actorIds } }, select: { id: true, name: true } }),
|
||||
]);
|
||||
const actorNames = new Map<string, string>([
|
||||
...users.map((u): [string, string] => [u.id, u.email]),
|
||||
...devices.map((d): [string, string] => [d.id, d.name]),
|
||||
]);
|
||||
const actorNames = new Map([...users.map((u) => [u.id, u.email] as const), ...devices.map((d) => [d.id, d.name] as const)]);
|
||||
|
||||
return {
|
||||
total,
|
||||
entries: entries.map(({ venue, details, ...entry }) => ({
|
||||
...entry,
|
||||
venueName: venue.name,
|
||||
actorName: actorNames.get(entry.actorId) ?? null,
|
||||
details: parseDetails(details),
|
||||
entries: entries.map((e) => ({
|
||||
id: e.id,
|
||||
venueId: e.venueId,
|
||||
venueName: e.venue?.name ?? null,
|
||||
actorType: e.actorType,
|
||||
actorId: e.actorId,
|
||||
actorName: e.actorId ? actorNames.get(e.actorId) ?? null : null,
|
||||
action: e.action,
|
||||
details: e.details,
|
||||
createdAt: e.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async health() {
|
||||
const [[size], migrations, tables, stuckCommands] = await Promise.all([
|
||||
async health(): Promise<SystemHealth> {
|
||||
const [[size], migrations, tables, imageBytes] = await Promise.all([
|
||||
this.prisma.$queryRaw<{ bytes: bigint }[]>`SELECT pg_database_size(current_database())::bigint AS bytes`,
|
||||
this.prisma.$queryRaw<{ migration_name: string; finished_at: Date | null; rolled_back_at: Date | null }[]>`
|
||||
SELECT "migration_name", "finished_at", "rolled_back_at" FROM "_prisma_migrations" ORDER BY "started_at" ASC`,
|
||||
this.tableCounts(),
|
||||
this.prisma.remoteCommand.count({ where: { status: 'pending' } }),
|
||||
this.imageBytesOnDisk(),
|
||||
]);
|
||||
const applied = migrations.filter((m) => m.finished_at && !m.rolled_back_at);
|
||||
|
||||
return {
|
||||
checkedAt: new Date(),
|
||||
checkedAt: new Date().toISOString(),
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
nodeVersion: process.version,
|
||||
databaseBytes: Number(size.bytes),
|
||||
imageBytes,
|
||||
tables,
|
||||
migrations: {
|
||||
applied: applied.length,
|
||||
@@ -567,56 +486,55 @@ export class AdminService {
|
||||
failed: migrations.filter((m) => !m.finished_at && !m.rolled_back_at).map((m) => m.migration_name),
|
||||
},
|
||||
realtimeConnections: this.realtime.connectionCount(),
|
||||
commandExpiryLastRunAt: this.commandExpiry.lastRunAt,
|
||||
stuckCommands,
|
||||
emailConfigured: this.reminders.configured,
|
||||
emailConfigured: this.resend.configured,
|
||||
};
|
||||
}
|
||||
|
||||
private async tableCounts() {
|
||||
const p = this.prisma;
|
||||
const entries = await Promise.all(Object.entries({
|
||||
const counts: Record<string, () => Promise<number>> = {
|
||||
Venue: () => p.venue.count(),
|
||||
ClientUser: () => p.clientUser.count(),
|
||||
Device: () => p.device.count(),
|
||||
User: () => p.user.count(),
|
||||
RefreshToken: () => p.refreshToken.count(),
|
||||
Device: () => p.device.count(),
|
||||
RegistrationCode: () => p.registrationCode.count(),
|
||||
Customer: () => p.customer.count(),
|
||||
Tab: () => p.tab.count(),
|
||||
TabEvent: () => p.tabEvent.count(),
|
||||
Settlement: () => p.settlement.count(),
|
||||
PlannedTab: () => p.plannedTab.count(),
|
||||
TabReminder: () => p.tabReminder.count(),
|
||||
TabUnit: () => p.tabUnit.count(),
|
||||
Product: () => p.product.count(),
|
||||
ProductImage: () => p.productImage.count(),
|
||||
StockAdjustment: () => p.stockAdjustment.count(),
|
||||
RemoteCommand: () => p.remoteCommand.count(),
|
||||
StockMovement: () => p.stockMovement.count(),
|
||||
TabEvent: () => p.tabEvent.count(),
|
||||
Reminder: () => p.reminder.count(),
|
||||
AuditLog: () => p.auditLog.count(),
|
||||
}).map(async ([table, count]) => ({ table, rows: await count() })));
|
||||
return entries;
|
||||
};
|
||||
return Promise.all(Object.entries(counts).map(async ([table, count]) => ({ table, rows: await count() })));
|
||||
}
|
||||
|
||||
private async imageBytesOnDisk(): Promise<number> {
|
||||
const files = await fs.readdir(imageDir()).catch(() => [] as string[]);
|
||||
const sizes = await Promise.all(files.map((file) => fs.stat(path.join(imageDir(), file)).then((s) => s.size, () => 0)));
|
||||
return sizes.reduce((sum, size) => sum + size, 0);
|
||||
}
|
||||
|
||||
private async findVenue(venueId: string) {
|
||||
const venue = await this.prisma.venue.findUnique({ where: { id: venueId } });
|
||||
if (!venue) {
|
||||
throw new NotFoundException({ code: 'venue_not_found', message: 'Venue not found' });
|
||||
}
|
||||
if (!venue) throw new NotFoundException({ code: 'venue_not_found', message: 'Venue not found' });
|
||||
return venue;
|
||||
}
|
||||
|
||||
private async findUser(userId: string) {
|
||||
const user = await this.prisma.clientUser.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException({ code: 'user_not_found', message: 'User not found' });
|
||||
}
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException({ code: 'user_not_found', message: 'User not found' });
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
function parseDetails(details: string | null): unknown {
|
||||
if (!details) return null;
|
||||
try {
|
||||
return JSON.parse(details);
|
||||
} catch {
|
||||
return details;
|
||||
private async signOut(userId: string, data: { passwordHash?: string } = {}) {
|
||||
const now = new Date();
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.user.update({ where: { id: userId }, data: { ...data, sessionsRevokedAt: now } }),
|
||||
this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: now } }),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,97 +1,36 @@
|
||||
import { checkConsistency, VenueSnapshot } from './consistency';
|
||||
|
||||
const now = new Date('2026-10-26T12:00:00Z');
|
||||
const later = new Date('2026-10-27T12:00:00Z');
|
||||
const earlier = new Date('2026-10-25T12:00:00Z');
|
||||
|
||||
const unit = (id: string, removedAt: string | null = null) => ({ id, addedAt: earlier.toISOString(), removedAt });
|
||||
|
||||
function snapshot(overrides: Partial<VenueSnapshot> = {}): VenueSnapshot {
|
||||
return { settlements: [], tabs: [], products: [], pendingCommands: [], devices: [], ...overrides };
|
||||
}
|
||||
|
||||
const codes = (venue: VenueSnapshot) => checkConsistency(venue, now).map((f) => f.code);
|
||||
const now = new Date('2026-10-28T12:00:00Z');
|
||||
const empty: VenueSnapshot = { settlements: [], tabs: [], products: [], devices: [] };
|
||||
|
||||
describe('checkConsistency', () => {
|
||||
it('finds nothing in a venue whose data adds up', () => {
|
||||
const venue = snapshot({
|
||||
settlements: [{
|
||||
id: 's1', customerName: 'Jan', amountInCents: 500, commandId: null,
|
||||
lines: [{ id: 'l1', productId: 'p1', productName: 'Pils', unitPriceInCents: 250, quantity: 2, units: [] }],
|
||||
}],
|
||||
tabs: [{ id: 't1', customerName: 'Jan', lines: [{ id: 'l2', productId: 'p1', productName: 'Pils', unitPriceInCents: 250, units: [unit('u1')] }] }],
|
||||
products: [{ productId: 'p1', name: 'Pils', stock: 10, countedAt: earlier }],
|
||||
pendingCommands: [{ id: 'c1', expiresAt: later, device: { name: 'Bar', revokedAt: null } }],
|
||||
devices: [{ id: 'd1', name: 'Bar', revokedAt: null, tokenExpiresAt: later }],
|
||||
});
|
||||
expect(checkConsistency(venue, now)).toEqual([]);
|
||||
it('finds nothing in a venue that adds up', () => {
|
||||
expect(checkConsistency({
|
||||
settlements: [{ id: 's1', customerName: 'Jan', amountInCents: 500, drinksInCents: 500 }],
|
||||
tabs: [{ id: 't1', customerName: 'Jan' }, { id: 't2', customerName: 'Marie' }],
|
||||
products: [{ id: 'p1', name: 'Cola', stock: 3 }],
|
||||
devices: [{ id: 'd1', name: 'Bar', revokedAt: null, tokenExpiresAt: new Date('2027-01-01') }],
|
||||
}, now)).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags a settlement whose amount differs from its drinks', () => {
|
||||
const venue = snapshot({
|
||||
settlements: [{
|
||||
id: 's1', customerName: 'Jan', amountInCents: 750, commandId: null,
|
||||
lines: [{ id: 'l1', productId: 'p1', productName: 'Pils', unitPriceInCents: 250, quantity: 2, units: [] }],
|
||||
}],
|
||||
});
|
||||
const [finding] = checkConsistency(venue, now);
|
||||
expect(finding).toMatchObject({ severity: 'warning', code: 'settlement_amount_mismatch', subjectId: 's1' });
|
||||
expect(finding.message).toContain('7.50');
|
||||
expect(finding.message).toContain('5.00');
|
||||
});
|
||||
it('flags what does not add up, warnings before information', () => {
|
||||
const findings = checkConsistency({
|
||||
...empty,
|
||||
settlements: [{ id: 's1', customerName: 'Jan', amountInCents: 500, drinksInCents: 250 }],
|
||||
tabs: [{ id: 't1', customerName: 'Jan' }, { id: 't2', customerName: ' jan ' }],
|
||||
products: [{ id: 'p1', name: 'Cola', stock: -2 }],
|
||||
devices: [{ id: 'd1', name: 'Bar', revokedAt: null, tokenExpiresAt: new Date('2026-10-01') }],
|
||||
}, now);
|
||||
|
||||
it('flags one close request paid twice as an error, before anything else', () => {
|
||||
const settlement = { customerName: 'Jan', amountInCents: 0, lines: [], commandId: 'c1' };
|
||||
const venue = snapshot({
|
||||
settlements: [{ ...settlement, id: 's1' }, { ...settlement, id: 's2' }],
|
||||
products: [{ productId: 'p1', name: 'Pils', stock: -2, countedAt: earlier }],
|
||||
});
|
||||
expect(codes(venue)).toEqual(['settlement_duplicate_command', 'stock_negative']);
|
||||
});
|
||||
|
||||
it('flags two open tabs that only differ in case or spacing', () => {
|
||||
const venue = snapshot({
|
||||
tabs: [
|
||||
{ id: 't1', customerName: 'Jan Peeters', lines: [] },
|
||||
{ id: 't2', customerName: ' jan peeters ', lines: [] },
|
||||
],
|
||||
});
|
||||
expect(codes(venue)).toEqual(['tab_duplicate_customer']);
|
||||
});
|
||||
|
||||
it('notes drinks of a product that is gone, but not ones already taken off the tab', () => {
|
||||
const line = { id: 'l1', productId: 'gone', productName: 'Cava', unitPriceInCents: 500 };
|
||||
expect(codes(snapshot({ tabs: [{ id: 't1', customerName: 'Jan', lines: [{ ...line, units: [unit('u1')] }] }] })))
|
||||
.toEqual(['tab_unknown_product']);
|
||||
expect(codes(snapshot({ tabs: [{ id: 't1', customerName: 'Jan', lines: [{ ...line, units: [unit('u1', now.toISOString())] }] }] })))
|
||||
.toEqual([]);
|
||||
});
|
||||
|
||||
it('notes tracked stock that was never counted', () => {
|
||||
expect(codes(snapshot({ products: [{ productId: 'p1', name: 'Pils', stock: 4, countedAt: null }] }))).toEqual(['stock_never_counted']);
|
||||
expect(codes(snapshot({ products: [{ productId: 'p1', name: 'Pils', stock: null, countedAt: null }] }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags pending commands that expired or wait for an unpaired terminal', () => {
|
||||
const venue = snapshot({
|
||||
pendingCommands: [
|
||||
{ id: 'c1', expiresAt: earlier, device: { name: 'Bar', revokedAt: null } },
|
||||
{ id: 'c2', expiresAt: later, device: { name: 'Old', revokedAt: earlier } },
|
||||
],
|
||||
});
|
||||
expect(checkConsistency(venue, now).map((f) => [f.code, f.subjectId])).toEqual([
|
||||
['command_stuck', 'c1'],
|
||||
['command_revoked_device', 'c2'],
|
||||
expect(findings.map((f) => f.code)).toEqual([
|
||||
'settlement_amount_mismatch', 'tab_duplicate_customer', 'stock_negative', 'device_token_expired',
|
||||
]);
|
||||
});
|
||||
|
||||
it('notes a paired terminal whose token expired, but not an unpaired one', () => {
|
||||
const venue = snapshot({
|
||||
devices: [
|
||||
{ id: 'd1', name: 'Bar', revokedAt: null, tokenExpiresAt: earlier },
|
||||
{ id: 'd2', name: 'Old', revokedAt: earlier, tokenExpiresAt: earlier },
|
||||
],
|
||||
});
|
||||
expect(checkConsistency(venue, now).map((f) => f.subjectId)).toEqual(['d1']);
|
||||
it('ignores the expired token of an unpaired terminal', () => {
|
||||
expect(checkConsistency({
|
||||
...empty,
|
||||
devices: [{ id: 'd1', name: 'Old', revokedAt: new Date('2026-09-01'), tokenExpiresAt: new Date('2026-10-01') }],
|
||||
}, now)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,11 @@
|
||||
import { customerNameKey } from '../planned-tabs/customer-name';
|
||||
import { Line, SettledLine, isActive } from '../tabs/tab-state';
|
||||
|
||||
export type Severity = 'error' | 'warning' | 'info';
|
||||
|
||||
export type Finding = {
|
||||
severity: Severity;
|
||||
// settlement_amount_mismatch | settlement_duplicate_command | tab_duplicate_customer | tab_unknown_product |
|
||||
// stock_negative | stock_never_counted | command_stuck | command_revoked_device | device_token_expired
|
||||
code: string;
|
||||
message: string;
|
||||
// What it is about: a settlement, tab, product, command or terminal id
|
||||
subjectId: string;
|
||||
};
|
||||
import { Finding, FindingSeverity } from '@kooltap/shared';
|
||||
import { customerNameKey } from '../customers/customer-name';
|
||||
|
||||
export type VenueSnapshot = {
|
||||
settlements: { id: string; customerName: string; amountInCents: number; lines: unknown; commandId: string | null }[];
|
||||
tabs: { id: string; customerName: string; lines: unknown }[];
|
||||
products: { productId: string; name: string; stock: number | null; countedAt: Date | null }[];
|
||||
pendingCommands: { id: string; expiresAt: Date; device: { name: string; revokedAt: Date | null } | null }[];
|
||||
/** Each settlement with what its drinks add up to. */
|
||||
settlements: { id: string; customerName: string; amountInCents: number; drinksInCents: number }[];
|
||||
tabs: { id: string; customerName: string }[];
|
||||
products: { id: string; name: string; stock: number | null }[];
|
||||
devices: { id: string; name: string; revokedAt: Date | null; tokenExpiresAt: Date }[];
|
||||
};
|
||||
|
||||
@@ -30,102 +18,43 @@ const euro = (cents: number) => (cents / 100).toFixed(2);
|
||||
export function checkConsistency(venue: VenueSnapshot, now: Date): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
|
||||
// A settlement's amount is what the terminal charged; its lines are what the server had on the tab. A difference
|
||||
// A settlement's amount is what the terminal charged; its drinks are what the server had on the tab. A difference
|
||||
// means the terminal and the server disagreed about the tab when it was paid
|
||||
for (const settlement of venue.settlements) {
|
||||
const lines = (settlement.lines as SettledLine[] | null) ?? [];
|
||||
const linesTotal = lines.reduce((sum, line) => sum + line.quantity * line.unitPriceInCents, 0);
|
||||
if (linesTotal !== settlement.amountInCents) {
|
||||
if (settlement.drinksInCents !== settlement.amountInCents) {
|
||||
findings.push({
|
||||
severity: 'warning',
|
||||
code: 'settlement_amount_mismatch',
|
||||
message: `${settlement.customerName} paid € ${euro(settlement.amountInCents)}, the drinks add up to € ${euro(linesTotal)}`,
|
||||
message: `${settlement.customerName} paid € ${euro(settlement.amountInCents)}, the drinks add up to € ${euro(settlement.drinksInCents)}`,
|
||||
subjectId: settlement.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const byCommand = new Map<string, string[]>();
|
||||
for (const settlement of venue.settlements) {
|
||||
if (!settlement.commandId) continue;
|
||||
byCommand.set(settlement.commandId, [...(byCommand.get(settlement.commandId) ?? []), settlement.id]);
|
||||
}
|
||||
for (const [commandId, ids] of byCommand) {
|
||||
if (ids.length > 1) {
|
||||
findings.push({
|
||||
severity: 'error',
|
||||
code: 'settlement_duplicate_command',
|
||||
message: `One close request was paid ${ids.length} times`,
|
||||
subjectId: commandId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Customers are matched on their name everywhere, so two open tabs with one name get mixed up
|
||||
const tabsByName = new Map<string, string[]>();
|
||||
const tabsByName = new Map<string, typeof venue.tabs>();
|
||||
for (const tab of venue.tabs) {
|
||||
const key = customerNameKey(tab.customerName);
|
||||
tabsByName.set(key, [...(tabsByName.get(key) ?? []), tab.id]);
|
||||
tabsByName.set(key, [...(tabsByName.get(key) ?? []), tab]);
|
||||
}
|
||||
for (const ids of tabsByName.values()) {
|
||||
if (ids.length > 1) {
|
||||
const name = venue.tabs.find((tab) => tab.id === ids[0])!.customerName;
|
||||
for (const tabs of tabsByName.values()) {
|
||||
if (tabs.length > 1) {
|
||||
findings.push({
|
||||
severity: 'warning',
|
||||
code: 'tab_duplicate_customer',
|
||||
message: `${ids.length} open tabs are named ${name}`,
|
||||
subjectId: ids[0],
|
||||
message: `${tabs.length} open tabs are named ${tabs[0].customerName}`,
|
||||
subjectId: tabs[0].id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const productIds = new Set(venue.products.map((p) => p.productId));
|
||||
for (const tab of venue.tabs) {
|
||||
for (const line of (tab.lines as Line[] | null) ?? []) {
|
||||
if (line.productId && !productIds.has(line.productId) && line.units.some(isActive)) {
|
||||
findings.push({
|
||||
severity: 'info',
|
||||
code: 'tab_unknown_product',
|
||||
message: `${tab.customerName}'s tab has ${line.productName}, which is not in the catalog`,
|
||||
subjectId: tab.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const product of venue.products) {
|
||||
if (product.stock !== null && product.stock < 0) {
|
||||
findings.push({
|
||||
severity: 'warning',
|
||||
code: 'stock_negative',
|
||||
message: `${product.name} has a stock of ${product.stock}: more was sold than counted`,
|
||||
subjectId: product.productId,
|
||||
});
|
||||
}
|
||||
if (product.stock !== null && product.countedAt === null) {
|
||||
findings.push({
|
||||
severity: 'info',
|
||||
code: 'stock_never_counted',
|
||||
message: `${product.name}'s stock is tracked but was never counted`,
|
||||
subjectId: product.productId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const command of venue.pendingCommands) {
|
||||
if (command.device?.revokedAt) {
|
||||
findings.push({
|
||||
severity: 'warning',
|
||||
code: 'command_revoked_device',
|
||||
message: `A close request waits for ${command.device.name}, which is unpaired`,
|
||||
subjectId: command.id,
|
||||
});
|
||||
} else if (command.expiresAt <= now) {
|
||||
findings.push({
|
||||
severity: 'warning',
|
||||
code: 'command_stuck',
|
||||
message: 'A close request expired but is still pending',
|
||||
subjectId: command.id,
|
||||
subjectId: product.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -141,6 +70,6 @@ export function checkConsistency(venue: VenueSnapshot, now: Date): Finding[] {
|
||||
}
|
||||
}
|
||||
|
||||
const order: Record<Severity, number> = { error: 0, warning: 1, info: 2 };
|
||||
const order: Record<FindingSeverity, number> = { error: 0, warning: 1, info: 2 };
|
||||
return findings.sort((a, b) => order[a.severity] - order[b.severity]);
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { RESET_SCOPES, ResetScope } from '../venue-reset';
|
||||
|
||||
export const PURGE_TARGETS = ['tabEvents', 'auditLog', 'commands'] as const;
|
||||
export type PurgeTarget = (typeof PURGE_TARGETS)[number];
|
||||
|
||||
export class VenueNameDto {
|
||||
@ApiProperty({ example: 'De Kroeg' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class DryRunDto {
|
||||
@ApiPropertyOptional({ description: 'Only count what would change' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export class ResetVenueDto extends DryRunDto {
|
||||
@ApiProperty({ enum: RESET_SCOPES, isArray: true })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsIn(RESET_SCOPES, { each: true })
|
||||
scopes: ResetScope[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'The count every tracked product gets with the stock scope', default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100000)
|
||||
stockValue?: number;
|
||||
}
|
||||
|
||||
export class PurgeDto extends DryRunDto {
|
||||
@ApiProperty({ example: 90 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(3650)
|
||||
olderThanDays: number;
|
||||
|
||||
@ApiProperty({ enum: PURGE_TARGETS, isArray: true })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsIn(PURGE_TARGETS, { each: true })
|
||||
targets: PurgeTarget[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Only this venue; every venue when left out' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
venueId?: string;
|
||||
}
|
||||
|
||||
export class CopyCatalogDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
fromVenueId: string;
|
||||
}
|
||||
|
||||
export class UpdateRoleDto {
|
||||
@ApiProperty({ enum: ['admin', 'manager', 'superadmin'] })
|
||||
@IsIn(['admin', 'manager', 'superadmin'])
|
||||
role: string;
|
||||
}
|
||||
|
||||
export class ResetPasswordDto {
|
||||
@ApiProperty({ example: 'SecurePass123!' })
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class TestEmailDto {
|
||||
@ApiProperty({ example: 'you@example.com' })
|
||||
@IsEmail()
|
||||
to: string;
|
||||
}
|
||||
@@ -1,40 +1,9 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { ensureDefaultCustomers } from '../planned-tabs/default-customers';
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
|
||||
/**
|
||||
* What a superadmin can wipe from a venue. Users, terminals and the product catalog always stay: a reset is for
|
||||
* starting a new event or season with the same bar.
|
||||
*/
|
||||
export const RESET_SCOPES = [
|
||||
// The open tabs. Terminals drop them on their next sync; drinks a terminal still has to send bring a tab back
|
||||
'tabs',
|
||||
// Paid tabs: the sales history
|
||||
'settlements',
|
||||
// What the terminals reported. Also the record of which events were applied, so a terminal that re-sends an old
|
||||
// event would apply it again: only wipe these when every terminal has synced
|
||||
'tabEvents',
|
||||
// Planned customers and the reminders sent to them
|
||||
'plannedTabs',
|
||||
// Every tracked product's stock is set to one count
|
||||
'stock',
|
||||
// Stock deliveries, breakage and counts made on the dashboard
|
||||
'stockHistory',
|
||||
// Command history
|
||||
'commands',
|
||||
'auditLog',
|
||||
] as const;
|
||||
|
||||
export type ResetScope = (typeof RESET_SCOPES)[number];
|
||||
import { ResetScope } from '@kooltap/shared';
|
||||
import { Tx } from '../prisma/prisma.service';
|
||||
import { ensureDefaultCustomers } from '../customers/default-customers';
|
||||
|
||||
export type ResetCounts = Partial<Record<ResetScope, number>>;
|
||||
|
||||
/** Locks the venue like a sync does, so no terminal changes it halfway through. */
|
||||
export async function lockVenue(tx: Tx, venueId: string) {
|
||||
await tx.$queryRaw`SELECT "id" FROM "Venue" WHERE "id" = ${venueId} FOR UPDATE`;
|
||||
}
|
||||
|
||||
/** How many rows each scope would remove (or, for stock, how many products it would set). */
|
||||
export async function countReset(tx: Tx, venueId: string, scopes: ResetScope[]): Promise<ResetCounts> {
|
||||
const where = { venueId };
|
||||
@@ -42,10 +11,9 @@ export async function countReset(tx: Tx, venueId: string, scopes: ResetScope[]):
|
||||
tabs: () => tx.tab.count({ where }),
|
||||
settlements: () => tx.settlement.count({ where }),
|
||||
tabEvents: () => tx.tabEvent.count({ where }),
|
||||
plannedTabs: () => tx.plannedTab.count({ where }),
|
||||
customers: () => tx.customer.count({ where }),
|
||||
stock: () => tx.product.count({ where: { venueId, stock: { not: null } } }),
|
||||
stockHistory: () => tx.stockAdjustment.count({ where }),
|
||||
commands: () => tx.remoteCommand.count({ where }),
|
||||
stockHistory: () => tx.stockMovement.count({ where }),
|
||||
auditLog: () => tx.auditLog.count({ where }),
|
||||
};
|
||||
const counts: ResetCounts = {};
|
||||
@@ -54,14 +22,21 @@ export async function countReset(tx: Tx, venueId: string, scopes: ResetScope[]):
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipes [scopes] of a venue inside [tx], which must have locked it. [stockValue] is the count every tracked product
|
||||
* gets; it is recorded as an applied stock count, after the stock history is wiped, so the history explains it.
|
||||
* Wipes [scopes] of a venue inside [tx], which must have locked it. Users, terminals and the catalog always stay: a
|
||||
* reset is for starting a new event or season with the same bar.
|
||||
*
|
||||
* - tabs: terminals drop them on their next sync; drinks a terminal still has to send bring a tab back
|
||||
* - tabEvents: also the record of which events were applied, so a terminal that sends an old event again would
|
||||
* apply it again: only wipe these when every terminal has synced
|
||||
* - customers: and the reminders sent to them
|
||||
* - stock: every tracked product gets [stockValue] as a count, recorded after the stock history is wiped so the
|
||||
* history explains it
|
||||
*/
|
||||
export async function resetVenue(
|
||||
tx: Tx,
|
||||
venueId: string,
|
||||
scopes: ResetScope[],
|
||||
options: { stockValue: number; actor: { id: string; name: string }; now: Date },
|
||||
options: { stockValue: number; userId: string; now: Date },
|
||||
): Promise<ResetCounts> {
|
||||
const where = { venueId };
|
||||
const counts: ResetCounts = {};
|
||||
@@ -70,65 +45,25 @@ export async function resetVenue(
|
||||
if (has('tabs')) counts.tabs = (await tx.tab.deleteMany({ where })).count;
|
||||
if (has('settlements')) counts.settlements = (await tx.settlement.deleteMany({ where })).count;
|
||||
if (has('tabEvents')) counts.tabEvents = (await tx.tabEvent.deleteMany({ where })).count;
|
||||
if (has('plannedTabs')) {
|
||||
await tx.tabReminder.deleteMany({ where });
|
||||
counts.plannedTabs = (await tx.plannedTab.deleteMany({ where })).count;
|
||||
if (has('customers')) {
|
||||
await tx.reminder.deleteMany({ where });
|
||||
counts.customers = (await tx.customer.deleteMany({ where })).count;
|
||||
}
|
||||
// The Free Drinks tab is always there: planned again, and opened on the next sync
|
||||
if (has('tabs') || has('plannedTabs')) await ensureDefaultCustomers(tx, venueId);
|
||||
if (has('stockHistory')) counts.stockHistory = (await tx.stockAdjustment.deleteMany({ where })).count;
|
||||
// The Free Drinks tab is always there: added again, and opened on the next sync
|
||||
if (has('tabs') || has('customers')) await ensureDefaultCustomers(tx, venueId);
|
||||
if (has('stockHistory')) counts.stockHistory = (await tx.stockMovement.deleteMany({ where })).count;
|
||||
if (has('stock')) {
|
||||
const { now, actor, stockValue } = options;
|
||||
const tracked = await tx.product.findMany({
|
||||
where: { venueId, stock: { not: null } },
|
||||
select: { productId: true, name: true },
|
||||
const { now, userId, stockValue } = options;
|
||||
const tracked = await tx.product.findMany({ where: { venueId, stock: { not: null } }, select: { id: true } });
|
||||
await tx.product.updateMany({ where: { venueId, stock: { not: null } }, data: { stock: stockValue, countedAt: now } });
|
||||
await tx.stockMovement.createMany({
|
||||
data: tracked.map((product) => ({
|
||||
venueId, productId: product.id, type: 'count' as const, quantity: stockValue, stockAfter: stockValue, note: 'Stock reset', userId, createdAt: now,
|
||||
})),
|
||||
});
|
||||
await tx.product.updateMany({
|
||||
where: { venueId, stock: { not: null } },
|
||||
data: { stock: stockValue, countedAt: now },
|
||||
});
|
||||
if (tracked.length > 0) {
|
||||
await tx.stockAdjustment.createMany({
|
||||
data: tracked.map((product) => ({
|
||||
venueId,
|
||||
productId: product.productId,
|
||||
productName: product.name,
|
||||
type: 'set',
|
||||
quantity: stockValue,
|
||||
note: 'Stock reset',
|
||||
requestedBy: actor.name,
|
||||
requestedById: actor.id,
|
||||
status: 'applied',
|
||||
stockAfter: stockValue,
|
||||
createdAt: now,
|
||||
processedAt: now,
|
||||
})),
|
||||
});
|
||||
}
|
||||
counts.stock = tracked.length;
|
||||
}
|
||||
if (has('commands')) counts.commands = (await tx.remoteCommand.deleteMany({ where })).count;
|
||||
if (has('auditLog')) counts.auditLog = (await tx.auditLog.deleteMany({ where })).count;
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** Deletes a venue and everything in it, users and terminals included. */
|
||||
export async function deleteVenue(tx: Tx, venueId: string) {
|
||||
const where = { venueId };
|
||||
const users = await tx.clientUser.findMany({ where, select: { id: true } });
|
||||
await tx.auditLog.deleteMany({ where });
|
||||
await tx.tabEvent.deleteMany({ where });
|
||||
await tx.tabReminder.deleteMany({ where });
|
||||
await tx.plannedTab.deleteMany({ where });
|
||||
await tx.remoteCommand.deleteMany({ where });
|
||||
await tx.settlement.deleteMany({ where });
|
||||
await tx.tab.deleteMany({ where });
|
||||
await tx.stockAdjustment.deleteMany({ where });
|
||||
await tx.product.deleteMany({ where });
|
||||
await tx.registrationCode.deleteMany({ where });
|
||||
await tx.device.deleteMany({ where });
|
||||
await tx.refreshToken.deleteMany({ where: { userId: { in: users.map((u) => u.id) } } });
|
||||
await tx.clientUser.deleteMany({ where });
|
||||
await tx.venue.delete({ where: { id: venueId } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user