feat: move to V2
CI / build-and-test (push) Canceled after 0s

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