From 4e43521da7eb35842f44200fb132417167a7ef67 Mon Sep 17 00:00:00 2001
From: Bram Verhulst
Date: Mon, 26 Oct 2026 11:58:10 +0100
Subject: [PATCH] feat: add superadmin
---
README.md | 4 +
apps/api/package.json | 1 +
.../migration.sql | 2 +
apps/api/prisma/schema.prisma | 5 +-
apps/api/prisma/seed.ts | 30 +
apps/api/prisma/superadmin.ts | 31 +
apps/api/src/admin/admin.controller.ts | 226 +++++++
apps/api/src/admin/admin.module.ts | 14 +
apps/api/src/admin/admin.service.ts | 613 ++++++++++++++++++
apps/api/src/admin/consistency.spec.ts | 97 +++
apps/api/src/admin/consistency.ts | 146 +++++
apps/api/src/admin/dto/admin.dto.ts | 95 +++
apps/api/src/admin/venue-reset.ts | 131 ++++
apps/api/src/app.module.ts | 2 +
.../src/auth/strategies/jwt.strategy.spec.ts | 25 +-
apps/api/src/auth/strategies/jwt.strategy.ts | 17 +-
.../api/src/common/guards/roles.guard.spec.ts | 7 +
apps/api/src/common/guards/roles.guard.ts | 3 +-
.../common/guards/venue-access.guard.spec.ts | 7 +-
.../src/common/guards/venue-access.guard.ts | 8 +-
apps/api/src/devices/devices.controller.ts | 6 +-
apps/api/src/jobs/command-expiry.job.ts | 3 +
apps/api/src/jobs/jobs.module.ts | 1 +
apps/api/src/realtime/realtime.gateway.ts | 10 +
apps/api/src/realtime/realtime.service.ts | 5 +
apps/api/src/reminders/reminders.module.ts | 1 +
apps/api/src/reminders/reminders.service.ts | 43 +-
.../app/dashboard/admin/devices-section.tsx | 150 +++++
.../app/dashboard/admin/insight-sections.tsx | 184 ++++++
.../dashboard/admin/maintenance-section.tsx | 220 +++++++
.../src/app/dashboard/admin/page.tsx | 82 +++
.../src/app/dashboard/admin/shared.tsx | 124 ++++
.../src/app/dashboard/admin/users-section.tsx | 207 ++++++
.../app/dashboard/admin/venues-section.tsx | 463 +++++++++++++
apps/dashboard/src/app/dashboard/layout.tsx | 40 +-
.../src/app/dashboard/reminders/page.tsx | 4 +-
.../src/app/dashboard/settings/page.tsx | 11 +-
.../src/app/dashboard/stock/page.tsx | 4 +-
.../dashboard/src/app/dashboard/tabs/page.tsx | 4 +-
.../src/app/dashboard/users/page.tsx | 11 +-
apps/dashboard/src/lib/api/client.ts | 156 ++++-
apps/dashboard/src/lib/i18n.tsx | 338 ++++++++++
apps/dashboard/src/lib/realtime.tsx | 9 +-
apps/dashboard/src/lib/session.tsx | 49 +-
docs/security.md | 7 +-
45 files changed, 3531 insertions(+), 65 deletions(-)
create mode 100644 apps/api/prisma/migrations/20261026120000_sessions_revoked_at/migration.sql
create mode 100644 apps/api/prisma/superadmin.ts
create mode 100644 apps/api/src/admin/admin.controller.ts
create mode 100644 apps/api/src/admin/admin.module.ts
create mode 100644 apps/api/src/admin/admin.service.ts
create mode 100644 apps/api/src/admin/consistency.spec.ts
create mode 100644 apps/api/src/admin/consistency.ts
create mode 100644 apps/api/src/admin/dto/admin.dto.ts
create mode 100644 apps/api/src/admin/venue-reset.ts
create mode 100644 apps/dashboard/src/app/dashboard/admin/devices-section.tsx
create mode 100644 apps/dashboard/src/app/dashboard/admin/insight-sections.tsx
create mode 100644 apps/dashboard/src/app/dashboard/admin/maintenance-section.tsx
create mode 100644 apps/dashboard/src/app/dashboard/admin/page.tsx
create mode 100644 apps/dashboard/src/app/dashboard/admin/shared.tsx
create mode 100644 apps/dashboard/src/app/dashboard/admin/users-section.tsx
create mode 100644 apps/dashboard/src/app/dashboard/admin/venues-section.tsx
diff --git a/README.md b/README.md
index 8cd4659..87f1cf5 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,10 @@ Swagger docs at http://localhost:3001/api/docs
|--------|--------------------|------------|
| Admin | admin@example.com | Admin123! |
| Manager | viewer@example.com | Viewer123! |
+| Superadmin | superadmin@example.com | Super123! |
+
+The seed also adds a second venue, KoolTap Festival Bar (festival@example.com / Admin123!). To make an existing
+user a superadmin elsewhere, run `pnpm --filter @kooltap/api run superadmin ` (`--revoke` makes them an admin again).
## Device Simulator
diff --git a/apps/api/package.json b/apps/api/package.json
index da1d448..ab631bf 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -15,6 +15,7 @@
"db:migrate": "prisma migrate dev",
"db:seed": "ts-node prisma/seed.ts",
"db:reset": "prisma migrate reset --force && ts-node prisma/seed.ts",
+ "superadmin": "ts-node prisma/superadmin.ts",
"clean": "rimraf dist"
},
"dependencies": {
diff --git a/apps/api/prisma/migrations/20261026120000_sessions_revoked_at/migration.sql b/apps/api/prisma/migrations/20261026120000_sessions_revoked_at/migration.sql
new file mode 100644
index 0000000..cdb4f8b
--- /dev/null
+++ b/apps/api/prisma/migrations/20261026120000_sessions_revoked_at/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "ClientUser" ADD COLUMN "sessionsRevokedAt" TIMESTAMP(3);
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index d6f24b0..c84bed9 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -56,9 +56,12 @@ model ClientUser {
email String @unique
passwordHash String
name String
- // admin (the owner: users and terminals) | manager (runs the venue: products, stock, tabs, reminders)
+ // admin (the owner: users and terminals) | manager (runs the venue: products, stock, tabs, reminders) |
+ // superadmin (runs KoolTap itself: every venue, and the admin page)
role String @default("manager")
createdAt DateTime @default(now())
+ // Access tokens issued before this are refused, so a forced logout applies at once instead of when they expire
+ sessionsRevokedAt DateTime?
venue Venue @relation(fields: [venueId], references: [id])
diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts
index 021e860..4bc6566 100644
--- a/apps/api/prisma/seed.ts
+++ b/apps/api/prisma/seed.ts
@@ -61,6 +61,35 @@ async function main() {
});
console.log('Created viewer user: viewer@example.com / Viewer123!');
+ // Runs KoolTap itself: every venue, and the admin page
+ const superadminHash = await bcrypt.hash('Super123!', 10);
+ await prisma.clientUser.create({
+ data: {
+ id: uuidv4(),
+ venueId,
+ email: 'superadmin@example.com',
+ passwordHash: superadminHash,
+ name: 'KoolTap',
+ role: 'superadmin',
+ },
+ });
+ console.log('Created superadmin user: superadmin@example.com / Super123!');
+
+ // A second venue, so the admin page has more than one
+ const secondVenueId = uuidv4();
+ await prisma.venue.create({ data: { id: secondVenueId, name: 'KoolTap Festival Bar' } });
+ await prisma.clientUser.create({
+ data: {
+ id: uuidv4(),
+ venueId: secondVenueId,
+ email: 'festival@example.com',
+ passwordHash: adminHash,
+ name: 'Festival',
+ role: 'admin',
+ },
+ });
+ console.log('Created venue: KoolTap Festival Bar, admin festival@example.com / Admin123!');
+
// Create one device
const deviceId = uuidv4();
const token = 'dev_' + crypto.randomBytes(48).toString('base64url');
@@ -208,6 +237,7 @@ async function main() {
console.log('Demo credentials:');
console.log(' Admin: admin@example.com / Admin123!');
console.log(' Viewer: viewer@example.com / Viewer123!');
+ console.log(' Superadmin: superadmin@example.com / Super123!');
console.log(`\nDevice token (for simulator): ${token}`);
}
diff --git a/apps/api/prisma/superadmin.ts b/apps/api/prisma/superadmin.ts
new file mode 100644
index 0000000..5bdcdf9
--- /dev/null
+++ b/apps/api/prisma/superadmin.ts
@@ -0,0 +1,31 @@
+import { PrismaClient } from '@prisma/client';
+
+/**
+ * Makes an existing dashboard user a superadmin, or an admin again with --revoke. There is no other way to become
+ * the first superadmin: the dashboard only lets a superadmin hand out the role.
+ *
+ * pnpm --filter @kooltap/api run superadmin user@example.com [--revoke]
+ */
+async function main() {
+ const [email, flag] = process.argv.slice(2);
+ if (!email) {
+ console.error('Usage: superadmin [--revoke]');
+ process.exit(1);
+ }
+ const role = flag === '--revoke' ? 'admin' : 'superadmin';
+
+ const prisma = new PrismaClient();
+ try {
+ const user = await prisma.clientUser.findUnique({ where: { email } });
+ if (!user) {
+ console.error(`No user with email ${email}`);
+ process.exit(1);
+ }
+ await prisma.clientUser.update({ where: { id: user.id }, data: { role } });
+ console.log(`${email} is now ${role === 'superadmin' ? 'a superadmin' : 'an admin'}`);
+ } finally {
+ await prisma.$disconnect();
+ }
+}
+
+main();
diff --git a/apps/api/src/admin/admin.controller.ts b/apps/api/src/admin/admin.controller.ts
new file mode 100644
index 0000000..34edac7
--- /dev/null
+++ b/apps/api/src/admin/admin.controller.ts
@@ -0,0 +1,226 @@
+import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Query, Req, UseGuards } from '@nestjs/common';
+import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
+import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
+import { RolesGuard } from '../common/guards/roles.guard';
+import { Roles } from '../common/decorators/roles.decorator';
+import { AdminService } from './admin.service';
+import {
+ CopyCatalogDto,
+ DryRunDto,
+ PurgeDto,
+ ResetPasswordDto,
+ ResetVenueDto,
+ TestEmailDto,
+ UpdateRoleDto,
+ VenueNameDto,
+} from './dto/admin.dto';
+
+/** The superadmin page: every venue, terminal and user of KoolTap. Destructive actions take a dryRun to preview. */
+@ApiTags('Admin')
+@ApiBearerAuth()
+@UseGuards(JwtAuthGuard, RolesGuard)
+@Roles('superadmin')
+@Controller('admin')
+export class AdminController {
+ constructor(private admin: AdminService) {}
+
+ // ---- Venues
+
+ @Get('venues')
+ @ApiOperation({ summary: 'Every venue with what it holds' })
+ listVenues() {
+ return this.admin.listVenues();
+ }
+
+ @Post('venues')
+ @ApiOperation({ summary: 'Create a venue' })
+ createVenue(@Body() dto: VenueNameDto, @Req() req: any) {
+ return this.admin.createVenue(dto.name, req.user);
+ }
+
+ @Patch('venues/:venueId')
+ @ApiOperation({ summary: 'Rename a venue' })
+ renameVenue(@Param('venueId') venueId: string, @Body() dto: VenueNameDto, @Req() req: any) {
+ return this.admin.renameVenue(venueId, dto.name, req.user);
+ }
+
+ @Delete('venues/:venueId')
+ @ApiOperation({ summary: 'Delete a venue with everything in it, users and terminals included' })
+ @ApiQuery({ name: 'confirmName', description: "The venue's name" })
+ deleteVenue(@Param('venueId') venueId: string, @Query('confirmName') confirmName: string | undefined, @Req() req: any) {
+ return this.admin.deleteVenue(venueId, confirmName, req.user);
+ }
+
+ @Post('venues/:venueId/view')
+ @HttpCode(HttpStatus.OK)
+ @ApiOperation({ summary: "Start viewing the dashboard as the venue; recorded in the venue's audit log" })
+ viewVenue(@Param('venueId') venueId: string, @Req() req: any) {
+ return this.admin.viewVenue(venueId, req.user);
+ }
+
+ @Post('venues/:venueId/copy-catalog')
+ @HttpCode(HttpStatus.OK)
+ @ApiOperation({ summary: "Copy another venue's products that this venue doesn't have yet" })
+ copyCatalog(@Param('venueId') venueId: string, @Body() dto: CopyCatalogDto, @Req() req: any) {
+ return this.admin.copyCatalog(venueId, dto.fromVenueId, req.user);
+ }
+
+ @Post('venues/:venueId/reset')
+ @HttpCode(HttpStatus.OK)
+ @ApiOperation({ summary: "Wipe parts of a venue's data; users, terminals and products stay" })
+ resetVenue(@Param('venueId') venueId: string, @Body() dto: ResetVenueDto, @Req() req: any) {
+ return this.admin.resetVenue(venueId, dto.scopes, { stockValue: dto.stockValue, dryRun: dto.dryRun }, req.user);
+ }
+
+ @Get('venues/:venueId/consistency')
+ @ApiOperation({ summary: "Look for data in the venue that doesn't add up" })
+ consistency(@Param('venueId') venueId: string) {
+ return this.admin.consistency(venueId);
+ }
+
+ @Get('venues/:venueId/export')
+ @ApiOperation({ summary: 'Everything of the venue as JSON, without passwords and pictures' })
+ exportVenue(@Param('venueId') venueId: string, @Req() req: any) {
+ return this.admin.exportVenue(venueId, req.user);
+ }
+
+ @Post('venues/:venueId/registration-codes')
+ @ApiOperation({ summary: 'A pairing code for a terminal of the venue' })
+ createRegistrationCode(@Param('venueId') venueId: string, @Req() req: any) {
+ return this.admin.createRegistrationCode(venueId, req.user);
+ }
+
+ // ---- Terminals
+
+ @Get('devices')
+ @ApiOperation({ summary: 'Every terminal, unpaired ones included' })
+ listDevices() {
+ return this.admin.listDevices();
+ }
+
+ @Post('devices/:deviceId/revoke')
+ @HttpCode(HttpStatus.OK)
+ @ApiOperation({ summary: 'Unpair a terminal' })
+ revokeDevice(@Param('deviceId') deviceId: string, @Req() req: any) {
+ return this.admin.revokeDevice(deviceId, req.user);
+ }
+
+ @Get('registration-codes')
+ @ApiOperation({ summary: 'Pairing codes that can still be used' })
+ listRegistrationCodes() {
+ return this.admin.listRegistrationCodes();
+ }
+
+ @Post('registration-codes/:id/revoke')
+ @HttpCode(HttpStatus.OK)
+ @ApiOperation({ summary: 'Make a pairing code unusable' })
+ revokeRegistrationCode(@Param('id') id: string) {
+ return this.admin.revokeRegistrationCode(id);
+ }
+
+ @Post('registration-codes/cleanup')
+ @HttpCode(HttpStatus.OK)
+ @ApiOperation({ summary: 'Delete used and expired pairing codes' })
+ cleanupRegistrationCodes() {
+ return this.admin.cleanupRegistrationCodes();
+ }
+
+ @Post('commands/expire-stuck')
+ @HttpCode(HttpStatus.OK)
+ @ApiOperation({ summary: 'Expire every pending command' })
+ expireStuckCommands(@Body() dto: DryRunDto, @Req() req: any) {
+ return this.admin.expireStuckCommands(dto.dryRun ?? false, req.user);
+ }
+
+ // ---- Users
+
+ @Get('users')
+ @ApiOperation({ summary: 'Every dashboard user' })
+ listUsers() {
+ return this.admin.listUsers();
+ }
+
+ @Patch('users/:userId/role')
+ @ApiOperation({ summary: "Change a user's role (not your own)" })
+ updateRole(@Param('userId') userId: string, @Body() dto: UpdateRoleDto, @Req() req: any) {
+ return this.admin.updateRole(userId, dto.role, req.user);
+ }
+
+ @Post('users/:userId/password')
+ @HttpCode(HttpStatus.OK)
+ @ApiOperation({ summary: "Set a user's password; signs them out everywhere" })
+ resetPassword(@Param('userId') userId: string, @Body() dto: ResetPasswordDto, @Req() req: any) {
+ return this.admin.resetPassword(userId, dto.password, req.user);
+ }
+
+ @Post('users/:userId/logout')
+ @HttpCode(HttpStatus.OK)
+ @ApiOperation({ summary: 'Sign a user out everywhere, at once' })
+ logoutUser(@Param('userId') userId: string, @Req() req: any) {
+ return this.admin.logoutUser(userId, req.user);
+ }
+
+ @Delete('users/:userId')
+ @ApiOperation({ summary: 'Delete a user (not yourself)' })
+ deleteUser(@Param('userId') userId: string, @Req() req: any) {
+ return this.admin.deleteUser(userId, req.user);
+ }
+
+ @Post('sessions/revoke-all')
+ @HttpCode(HttpStatus.OK)
+ @ApiOperation({ summary: 'Sign out every user but yourself' })
+ logoutEveryone(@Req() req: any) {
+ return this.admin.logoutEveryone(req.user);
+ }
+
+ // ---- Maintenance
+
+ @Post('purge')
+ @HttpCode(HttpStatus.OK)
+ @ApiOperation({ summary: 'Delete old terminal events, audit entries or finished commands' })
+ purge(@Body() dto: PurgeDto, @Req() req: any) {
+ return this.admin.purge(dto.targets, dto.olderThanDays, dto.venueId, dto.dryRun ?? false, req.user);
+ }
+
+ @Post('images/cleanup')
+ @HttpCode(HttpStatus.OK)
+ @ApiOperation({ summary: 'Delete pictures no product uses (older than a week)' })
+ cleanupImages(@Body() dto: DryRunDto, @Req() req: any) {
+ return this.admin.cleanupImages(dto.dryRun ?? false, req.user);
+ }
+
+ @Post('email/test')
+ @HttpCode(HttpStatus.OK)
+ @ApiOperation({ summary: 'Send the test email to an address' })
+ sendTestEmail(@Body() dto: TestEmailDto, @Req() req: any) {
+ return this.admin.sendTestEmail(dto.to, req.user);
+ }
+
+ // ---- Insight
+
+ @Get('audit')
+ @ApiOperation({ summary: 'The audit log of every venue' })
+ @ApiQuery({ name: 'venueId', required: false })
+ @ApiQuery({ name: 'action', required: false, description: 'Part of the action name' })
+ @ApiQuery({ name: 'limit', required: false })
+ @ApiQuery({ name: 'offset', required: false })
+ auditLog(
+ @Query('venueId') venueId?: string,
+ @Query('action') action?: string,
+ @Query('limit') limit?: string,
+ @Query('offset') offset?: string,
+ ) {
+ return this.admin.auditLog({
+ venueId: venueId || undefined,
+ action: action || undefined,
+ limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200),
+ offset: Math.max(parseInt(offset ?? '', 10) || 0, 0),
+ });
+ }
+
+ @Get('health')
+ @ApiOperation({ summary: 'Database, migrations, live connections and background jobs' })
+ health() {
+ return this.admin.health();
+ }
+}
diff --git a/apps/api/src/admin/admin.module.ts b/apps/api/src/admin/admin.module.ts
new file mode 100644
index 0000000..ab44c45
--- /dev/null
+++ b/apps/api/src/admin/admin.module.ts
@@ -0,0 +1,14 @@
+import { Module } from '@nestjs/common';
+import { AuthModule } from '../auth/auth.module';
+import { DevicesModule } from '../devices/devices.module';
+import { RemindersModule } from '../reminders/reminders.module';
+import { JobsModule } from '../jobs/jobs.module';
+import { AdminController } from './admin.controller';
+import { AdminService } from './admin.service';
+
+@Module({
+ imports: [AuthModule, DevicesModule, RemindersModule, JobsModule],
+ controllers: [AdminController],
+ providers: [AdminService],
+})
+export class AdminModule {}
diff --git a/apps/api/src/admin/admin.service.ts b/apps/api/src/admin/admin.service.ts
new file mode 100644
index 0000000..b1de315
--- /dev/null
+++ b/apps/api/src/admin/admin.service.ts
@@ -0,0 +1,613 @@
+import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
+import * as bcrypt from 'bcrypt';
+import { v4 as uuidv4 } from 'uuid';
+import { PrismaService } from '../prisma/prisma.service';
+import { AuditService } from '../audit/audit.service';
+import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service';
+import { AuthService } from '../auth/auth.service';
+import { DevicesService } from '../devices/devices.service';
+import { RemindersService } from '../reminders/reminders.service';
+import { CommandExpiryJob } from '../jobs/command-expiry.job';
+import { createdFieldTimes } from '../stock/product-fields';
+import { checkConsistency } from './consistency';
+import { countReset, deleteVenue, lockVenue, resetVenue, ResetCounts, ResetScope } from './venue-reset';
+import { PurgeTarget } from './dto/admin.dto';
+
+/** The superadmin doing it. */
+export type Actor = { id: string; email: string; name: string; venueId: string };
+
+// What each reset scope changes on the dashboard
+const SCOPE_TOPICS: Record = {
+ tabs: ['tabs'],
+ settlements: ['tabs'],
+ tabEvents: [],
+ plannedTabs: ['planned-tabs', 'reminders'],
+ stock: ['stock'],
+ stockHistory: ['stock'],
+ commands: ['commands'],
+ auditLog: [],
+};
+
+// A terminal uploads a picture before the event that links it to a product, possibly much later when it was offline
+const ORPHAN_IMAGE_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+/**
+ * Everything the superadmin page does. Global actions (purging, signing everyone out) are written to the audit log
+ * of the superadmin's own venue, since the log is kept per venue.
+ */
+@Injectable()
+export class AdminService {
+ constructor(
+ private prisma: PrismaService,
+ private audit: AuditService,
+ private realtime: RealtimeService,
+ private authService: AuthService,
+ private devicesService: DevicesService,
+ private reminders: RemindersService,
+ private commandExpiry: CommandExpiryJob,
+ ) {}
+
+ // ---- Venues
+
+ async listVenues() {
+ const venues = await this.prisma.venue.findMany({
+ orderBy: { name: 'asc' },
+ select: {
+ id: true,
+ name: true,
+ createdAt: true,
+ _count: {
+ select: {
+ clientUsers: true,
+ devices: { where: { revokedAt: null } },
+ tabs: true,
+ products: { where: { archivedAt: null } },
+ plannedTabs: true,
+ settlements: true,
+ },
+ },
+ },
+ });
+ const lastSeen = await this.prisma.device.groupBy({
+ by: ['venueId'],
+ where: { revokedAt: null },
+ _max: { lastSeenAt: true },
+ });
+ const lastSeenByVenue = new Map(lastSeen.map((row) => [row.venueId, row._max.lastSeenAt]));
+
+ return {
+ venues: venues.map(({ _count, ...venue }) => ({
+ ...venue,
+ users: _count.clientUsers,
+ devices: _count.devices,
+ openTabs: _count.tabs,
+ products: _count.products,
+ plannedTabs: _count.plannedTabs,
+ settlements: _count.settlements,
+ lastSyncAt: lastSeenByVenue.get(venue.id) ?? null,
+ })),
+ };
+ }
+
+ async createVenue(name: string, actor: Actor) {
+ const venue = await this.prisma.venue.create({ data: { name: name.trim() } });
+ await this.audit.log(venue.id, 'client_user', actor.id, 'admin_venue_created', { name: venue.name, by: actor.email });
+ return venue;
+ }
+
+ async renameVenue(venueId: string, name: string, actor: Actor) {
+ const before = await this.findVenue(venueId);
+ const venue = await this.prisma.venue.update({ where: { id: venueId }, data: { name: name.trim() } });
+ await this.audit.log(venueId, 'client_user', actor.id, 'admin_venue_renamed', { from: before.name, to: venue.name, by: actor.email });
+ return venue;
+ }
+
+ /** [confirmName] must be the venue's name, so a wrong id can't delete a venue by accident. */
+ async deleteVenue(venueId: string, confirmName: string | undefined, actor: Actor) {
+ const venue = await this.findVenue(venueId);
+ if (venueId === actor.venueId) {
+ throw new BadRequestException({ code: 'cannot_delete_own_venue', message: 'You cannot delete the venue your account belongs to' });
+ }
+ if (confirmName !== venue.name) {
+ throw new BadRequestException({ code: 'confirm_name_mismatch', message: 'Type the venue name to confirm' });
+ }
+
+ await this.prisma.$transaction(async (tx) => {
+ await lockVenue(tx, venueId);
+ await deleteVenue(tx, venueId);
+ }, { timeout: 60000 });
+
+ await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_venue_deleted', { venueId, name: venue.name, by: actor.email });
+ return { ok: true };
+ }
+
+ /** Recorded in the venue's own audit log, so its owner can see that KoolTap looked. */
+ async viewVenue(venueId: string, actor: Actor) {
+ const venue = await this.findVenue(venueId);
+ await this.audit.log(venueId, 'client_user', actor.id, 'admin_viewed_venue', { by: actor.email });
+ return { id: venue.id, name: venue.name };
+ }
+
+ /**
+ * Copies the products of [fromVenueId] that [venueId] doesn't have yet (by name), with their pictures but without
+ * stock: stock is counted per venue.
+ */
+ async copyCatalog(venueId: string, fromVenueId: string, actor: Actor) {
+ if (venueId === fromVenueId) {
+ throw new BadRequestException({ code: 'same_venue', message: 'Pick another venue to copy from' });
+ }
+ await this.findVenue(venueId);
+ await this.findVenue(fromVenueId);
+
+ const created = await this.prisma.$transaction(async (tx) => {
+ const now = new Date();
+ await lockVenue(tx, venueId);
+ const [source, existing] = await Promise.all([
+ tx.product.findMany({ where: { venueId: fromVenueId, archivedAt: null } }),
+ tx.product.findMany({ where: { venueId, archivedAt: null }, select: { name: true } }),
+ ]);
+ const taken = new Set(existing.map((p) => p.name.trim().toLowerCase()));
+ const copies = source.filter((p) => !taken.has(p.name.trim().toLowerCase()));
+ if (copies.length > 0) {
+ await tx.product.createMany({
+ data: copies.map((p) => {
+ const fields = { name: p.name, category: p.category, priceInCents: p.priceInCents, lowStockThreshold: p.lowStockThreshold };
+ return {
+ venueId,
+ productId: uuidv4(),
+ ...fields,
+ stock: null,
+ countedAt: null,
+ reportedAt: now,
+ imageId: p.imageId,
+ fieldUpdatedAt: createdFieldTimes({ ...fields, archived: false, imageId: p.imageId }, now),
+ };
+ }),
+ });
+ }
+ return copies.length;
+ });
+
+ await this.audit.log(venueId, 'client_user', actor.id, 'admin_catalog_copied', { fromVenueId, created, by: actor.email });
+ if (created > 0) this.realtime.notify(venueId, 'stock');
+ return { created };
+ }
+
+ async resetVenue(venueId: string, scopes: ResetScope[], options: { stockValue?: number; dryRun?: boolean }, actor: Actor) {
+ await this.findVenue(venueId);
+ const unique = [...new Set(scopes)];
+
+ if (options.dryRun) {
+ return { dryRun: true, counts: await this.prisma.$transaction((tx) => countReset(tx, venueId, unique)) };
+ }
+
+ const stockValue = options.stockValue ?? 0;
+ const counts: ResetCounts = await this.prisma.$transaction(async (tx) => {
+ await lockVenue(tx, venueId);
+ return resetVenue(tx, venueId, unique, { stockValue, actor: { id: actor.id, name: actor.name || actor.email }, now: new Date() });
+ }, { timeout: 60000 });
+
+ // After the reset, so wiping the audit log keeps the entry that says who wiped it
+ await this.audit.log(venueId, 'client_user', actor.id, 'admin_venue_reset', { scopes: unique, stockValue, counts, by: actor.email });
+
+ const topics = new Set(unique.flatMap((scope) => SCOPE_TOPICS[scope]));
+ if (topics.size > 0) this.realtime.notify(venueId, ...topics);
+
+ return { dryRun: false, counts };
+ }
+
+ async consistency(venueId: string) {
+ await this.findVenue(venueId);
+ const where = { venueId };
+ const [settlements, tabs, products, pendingCommands, devices] = await Promise.all([
+ this.prisma.settlement.findMany({
+ where,
+ select: { id: true, customerName: true, amountInCents: true, lines: true, commandId: true },
+ }),
+ this.prisma.tab.findMany({ where, select: { id: true, customerName: true, lines: true } }),
+ this.prisma.product.findMany({
+ where: { venueId, archivedAt: null },
+ select: { productId: true, name: true, stock: true, countedAt: true },
+ }),
+ this.prisma.remoteCommand.findMany({
+ where: { venueId, status: 'pending' },
+ select: { id: true, expiresAt: true, device: { select: { name: true, revokedAt: true } } },
+ }),
+ this.prisma.device.findMany({ where, select: { id: true, name: true, revokedAt: true, tokenExpiresAt: true } }),
+ ]);
+
+ const findings = checkConsistency({ settlements, tabs, products, pendingCommands, devices }, new Date());
+ return { checkedAt: new Date(), findings };
+ }
+
+ /** Everything of a venue except password hashes and picture bytes, for a backup before a reset. */
+ async exportVenue(venueId: string, actor: Actor) {
+ const venue = await this.findVenue(venueId);
+ const where = { venueId };
+ const [users, devices, products, tabs, settlements, plannedTabs, reminders, stockAdjustments, commands, tabEvents, auditLog] =
+ await Promise.all([
+ this.prisma.clientUser.findMany({ where, select: { id: true, email: true, name: true, role: true, createdAt: true } }),
+ this.prisma.device.findMany({ where, select: { id: true, name: true, lastSeenAt: true, revokedAt: true, createdAt: true } }),
+ this.prisma.product.findMany({ where }),
+ this.prisma.tab.findMany({ where }),
+ this.prisma.settlement.findMany({ where, orderBy: { sequence: 'asc' } }),
+ this.prisma.plannedTab.findMany({ where, orderBy: { position: 'asc' } }),
+ this.prisma.tabReminder.findMany({ where, orderBy: { sentAt: 'asc' } }),
+ this.prisma.stockAdjustment.findMany({ where, orderBy: { createdAt: 'asc' } }),
+ this.prisma.remoteCommand.findMany({ where, orderBy: { createdAt: 'asc' } }),
+ this.prisma.tabEvent.findMany({ where, orderBy: { occurredAt: 'asc' } }),
+ this.prisma.auditLog.findMany({ where, orderBy: { createdAt: 'asc' } }),
+ ]);
+
+ await this.audit.log(venueId, 'client_user', actor.id, 'admin_venue_exported', { by: actor.email });
+
+ return {
+ exportedAt: new Date(),
+ venue,
+ users,
+ devices,
+ products,
+ tabs,
+ settlements,
+ plannedTabs,
+ reminders,
+ stockAdjustments,
+ commands,
+ tabEvents,
+ auditLog,
+ };
+ }
+
+ // ---- Terminals
+
+ async listDevices() {
+ const devices = await this.prisma.device.findMany({
+ orderBy: [{ revokedAt: { sort: 'desc', nulls: 'first' } }, { lastSeenAt: 'desc' }],
+ select: {
+ id: true,
+ name: true,
+ venueId: true,
+ tokenPrefix: true,
+ tokenExpiresAt: true,
+ lastSeenAt: true,
+ revokedAt: true,
+ createdAt: true,
+ venue: { select: { name: true } },
+ _count: { select: { remoteCommands: { where: { status: 'pending' } } } },
+ },
+ });
+ return {
+ devices: devices.map(({ venue, _count, ...device }) => ({
+ ...device,
+ venueName: venue.name,
+ pendingCommands: _count.remoteCommands,
+ })),
+ };
+ }
+
+ async revokeDevice(deviceId: string, actor: Actor) {
+ const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
+ if (!device) {
+ throw new NotFoundException({ code: 'device_not_found', message: 'Device not found' });
+ }
+ await this.devicesService.revoke(device.venueId, deviceId);
+ await this.audit.log(device.venueId, 'client_user', actor.id, 'admin_device_revoked', { deviceId, deviceName: device.name, by: actor.email });
+ return { ok: true };
+ }
+
+ async listRegistrationCodes() {
+ const codes = await this.prisma.registrationCode.findMany({
+ where: { usedAt: null, expiresAt: { gt: new Date() } },
+ orderBy: { createdAt: 'desc' },
+ select: { id: true, code: true, venueId: true, expiresAt: true, createdAt: true, venue: { select: { name: true } } },
+ });
+ const stale = await this.prisma.registrationCode.count({
+ where: { OR: [{ usedAt: { not: null } }, { expiresAt: { lte: new Date() } }] },
+ });
+ return { codes: codes.map(({ venue, ...code }) => ({ ...code, venueName: venue.name })), stale };
+ }
+
+ async createRegistrationCode(venueId: string, actor: Actor) {
+ await this.findVenue(venueId);
+ const code = await this.authService.generateRegistrationCode(venueId);
+ await this.audit.log(venueId, 'client_user', actor.id, 'admin_registration_code_created', { by: actor.email });
+ return code;
+ }
+
+ async revokeRegistrationCode(id: string) {
+ const { count } = await this.prisma.registrationCode.updateMany({
+ where: { id, usedAt: null, expiresAt: { gt: new Date() } },
+ data: { expiresAt: new Date() },
+ });
+ return { ok: true, revoked: count > 0 };
+ }
+
+ /** Deletes used and expired codes; they are only kept so a code can't be used twice, which expiry already ensures. */
+ async cleanupRegistrationCodes() {
+ const { count } = await this.prisma.registrationCode.deleteMany({
+ where: { OR: [{ usedAt: { not: null } }, { expiresAt: { lte: new Date() } }] },
+ });
+ return { deleted: count };
+ }
+
+ /**
+ * Expires every pending command. The server settles tabs itself now, so a pending command is one from before that
+ * whose terminal never picked it up.
+ */
+ async expireStuckCommands(dryRun: boolean, actor: Actor) {
+ const where = { status: 'pending' };
+ if (dryRun) {
+ return { dryRun: true, count: await this.prisma.remoteCommand.count({ where }) };
+ }
+
+ const stuck = await this.prisma.remoteCommand.findMany({ where, select: { id: true, venueId: true } });
+ if (stuck.length > 0) {
+ await this.prisma.remoteCommand.updateMany({
+ where: { id: { in: stuck.map((c) => c.id) }, status: 'pending' },
+ data: { status: 'expired', result: 'Expired by a KoolTap admin' },
+ });
+ }
+ const venues = new Set(stuck.map((c) => c.venueId));
+ for (const venueId of venues) {
+ const count = stuck.filter((c) => c.venueId === venueId).length;
+ await this.audit.log(venueId, 'client_user', actor.id, 'admin_commands_expired', { count, by: actor.email });
+ this.realtime.notify(venueId, 'commands');
+ }
+ return { dryRun: false, count: stuck.length };
+ }
+
+ // ---- Users
+
+ async listUsers() {
+ const now = new Date();
+ const [users, sessions] = await Promise.all([
+ this.prisma.clientUser.findMany({
+ orderBy: [{ venue: { name: 'asc' } }, { email: 'asc' }],
+ select: { id: true, email: true, name: true, role: true, venueId: true, createdAt: true, venue: { select: { name: true } } },
+ }),
+ this.prisma.refreshToken.groupBy({
+ by: ['userId'],
+ where: { revokedAt: null, expiresAt: { gt: now } },
+ _count: { _all: true },
+ }),
+ ]);
+ const sessionsByUser = new Map(sessions.map((row) => [row.userId, row._count._all]));
+ return {
+ users: users.map(({ venue, ...user }) => ({
+ ...user,
+ role: user.role === 'viewer' ? 'manager' : user.role,
+ venueName: venue.name,
+ activeSessions: sessionsByUser.get(user.id) ?? 0,
+ })),
+ };
+ }
+
+ async updateRole(userId: string, role: string, actor: Actor) {
+ // Demoting yourself could leave nobody who can open this page
+ if (userId === actor.id) {
+ throw new BadRequestException({ code: 'cannot_change_own_role', message: 'You cannot change your own role' });
+ }
+ const user = await this.findUser(userId);
+ await this.prisma.clientUser.update({ where: { id: userId }, data: { role } });
+ await this.audit.log(user.venueId, 'client_user', actor.id, 'admin_role_changed', { userId, email: user.email, from: user.role, to: role, by: actor.email });
+ this.realtime.notify(user.venueId, 'users');
+ return { ok: true };
+ }
+
+ /** Sets a new password and signs the user out everywhere. */
+ async resetPassword(userId: string, password: string, actor: Actor) {
+ // That would sign you out too; My Account changes your own password and keeps you signed in
+ if (userId === actor.id) {
+ throw new BadRequestException({ code: 'use_account_page', message: 'Change your own password on My Account' });
+ }
+ const user = await this.findUser(userId);
+ const passwordHash = await bcrypt.hash(password, 10);
+ const now = new Date();
+ await this.prisma.$transaction([
+ this.prisma.clientUser.update({ where: { id: userId }, data: { passwordHash, sessionsRevokedAt: now } }),
+ this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: now } }),
+ ]);
+ await this.audit.log(user.venueId, 'client_user', actor.id, 'admin_password_reset', { userId, email: user.email, by: actor.email });
+ return { ok: true };
+ }
+
+ /** Signs the user out everywhere, at once: their access tokens are refused too. */
+ async logoutUser(userId: string, actor: Actor) {
+ if (userId === actor.id) {
+ throw new BadRequestException({ code: 'cannot_logout_self', message: 'Use Logout to sign yourself out' });
+ }
+ const user = await this.findUser(userId);
+ const now = new Date();
+ await this.prisma.$transaction([
+ this.prisma.clientUser.update({ where: { id: userId }, data: { sessionsRevokedAt: now } }),
+ this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: now } }),
+ ]);
+ await this.audit.log(user.venueId, 'client_user', actor.id, 'admin_user_logged_out', { userId, email: user.email, by: actor.email });
+ return { ok: true };
+ }
+
+ /** Signs out every user but the superadmin doing it. */
+ async logoutEveryone(actor: Actor) {
+ const now = new Date();
+ const others = { id: { not: actor.id } };
+ const [users] = await this.prisma.$transaction([
+ this.prisma.clientUser.updateMany({ where: others, data: { sessionsRevokedAt: now } }),
+ this.prisma.refreshToken.updateMany({ where: { userId: { not: actor.id }, revokedAt: null }, data: { revokedAt: now } }),
+ ]);
+ await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_everyone_logged_out', { users: users.count, by: actor.email });
+ return { users: users.count };
+ }
+
+ async deleteUser(userId: string, actor: Actor) {
+ const user = await this.findUser(userId);
+ return this.authService.deleteUser(user.venueId, userId, actor);
+ }
+
+ // ---- Maintenance
+
+ /** Removes old history. Pending commands are never purged: a terminal may still pick them up. */
+ async purge(targets: PurgeTarget[], olderThanDays: number, venueId: string | undefined, dryRun: boolean, actor: Actor) {
+ if (venueId) await this.findVenue(venueId);
+ const before = new Date(Date.now() - olderThanDays * DAY_MS);
+ const venue = venueId ? { venueId } : {};
+ const wheres = {
+ tabEvents: { ...venue, receivedAt: { lt: before } },
+ auditLog: { ...venue, createdAt: { lt: before } },
+ commands: { ...venue, createdAt: { lt: before }, status: { not: 'pending' } },
+ };
+ const run = {
+ tabEvents: () => (dryRun ? this.prisma.tabEvent.count({ where: wheres.tabEvents }) : this.prisma.tabEvent.deleteMany({ where: wheres.tabEvents }).then((r) => r.count)),
+ auditLog: () => (dryRun ? this.prisma.auditLog.count({ where: wheres.auditLog }) : this.prisma.auditLog.deleteMany({ where: wheres.auditLog }).then((r) => r.count)),
+ commands: () => (dryRun ? this.prisma.remoteCommand.count({ where: wheres.commands }) : this.prisma.remoteCommand.deleteMany({ where: wheres.commands }).then((r) => r.count)),
+ };
+
+ const counts: Partial> = {};
+ for (const target of new Set(targets)) counts[target] = await run[target]();
+
+ if (!dryRun) {
+ await this.audit.log(venueId ?? actor.venueId, 'client_user', actor.id, 'admin_purged', {
+ targets, olderThanDays, venueId: venueId ?? 'all', counts, by: actor.email,
+ });
+ if (venueId && counts.commands) this.realtime.notify(venueId, 'commands');
+ }
+ return { dryRun, counts };
+ }
+
+ /** Deletes pictures no product uses, after a grace period for terminals that upload before they link. */
+ async cleanupImages(dryRun: boolean, actor: Actor) {
+ const where = { products: { none: {} }, createdAt: { lt: new Date(Date.now() - ORPHAN_IMAGE_GRACE_MS) } };
+ if (dryRun) {
+ const images = await this.prisma.productImage.findMany({ where, select: { id: true } });
+ const [{ bytes }] = images.length === 0 ? [{ bytes: 0n }] : await this.prisma.$queryRaw<{ bytes: bigint }[]>`
+ SELECT COALESCE(SUM(OCTET_LENGTH("bytes")), 0)::bigint AS bytes FROM "ProductImage" WHERE "id" = ANY(${images.map((i) => i.id)})`;
+ return { dryRun: true, count: images.length, bytes: Number(bytes) };
+ }
+ const { count } = await this.prisma.productImage.deleteMany({ where });
+ await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_images_cleaned', { count, by: actor.email });
+ return { dryRun: false, count };
+ }
+
+ async sendTestEmail(to: string, actor: Actor) {
+ const result = await this.reminders.sendTestEmailTo(to);
+ await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_test_email_sent', { to, by: actor.email });
+ return result;
+ }
+
+ // ---- Insight
+
+ async auditLog(options: { venueId?: string; action?: string; limit: number; offset: number }) {
+ const where = {
+ ...(options.venueId ? { venueId: options.venueId } : {}),
+ ...(options.action ? { action: { contains: options.action, mode: 'insensitive' as const } } : {}),
+ };
+ const [entries, total] = await Promise.all([
+ this.prisma.auditLog.findMany({
+ where,
+ orderBy: { createdAt: 'desc' },
+ take: options.limit,
+ skip: options.offset,
+ include: { venue: { select: { name: true } } },
+ }),
+ this.prisma.auditLog.count({ where }),
+ ]);
+
+ // Actors are stored as ids; show who they are
+ const actorIds = [...new Set(entries.map((e) => e.actorId))];
+ const [users, devices] = await Promise.all([
+ this.prisma.clientUser.findMany({ where: { id: { in: actorIds } }, select: { id: true, email: true } }),
+ this.prisma.device.findMany({ where: { id: { in: actorIds } }, select: { id: true, name: true } }),
+ ]);
+ const actorNames = new Map([
+ ...users.map((u): [string, string] => [u.id, u.email]),
+ ...devices.map((d): [string, string] => [d.id, d.name]),
+ ]);
+
+ return {
+ total,
+ entries: entries.map(({ venue, details, ...entry }) => ({
+ ...entry,
+ venueName: venue.name,
+ actorName: actorNames.get(entry.actorId) ?? null,
+ details: parseDetails(details),
+ })),
+ };
+ }
+
+ async health() {
+ const [[size], migrations, tables, stuckCommands] = await Promise.all([
+ this.prisma.$queryRaw<{ bytes: bigint }[]>`SELECT pg_database_size(current_database())::bigint AS bytes`,
+ this.prisma.$queryRaw<{ migration_name: string; finished_at: Date | null; rolled_back_at: Date | null }[]>`
+ SELECT "migration_name", "finished_at", "rolled_back_at" FROM "_prisma_migrations" ORDER BY "started_at" ASC`,
+ this.tableCounts(),
+ this.prisma.remoteCommand.count({ where: { status: 'pending' } }),
+ ]);
+ const applied = migrations.filter((m) => m.finished_at && !m.rolled_back_at);
+
+ return {
+ checkedAt: new Date(),
+ uptimeSeconds: Math.round(process.uptime()),
+ nodeVersion: process.version,
+ databaseBytes: Number(size.bytes),
+ tables,
+ migrations: {
+ applied: applied.length,
+ latest: applied.at(-1)?.migration_name ?? null,
+ // Started but never finished: the database may be half migrated
+ failed: migrations.filter((m) => !m.finished_at && !m.rolled_back_at).map((m) => m.migration_name),
+ },
+ realtimeConnections: this.realtime.connectionCount(),
+ commandExpiryLastRunAt: this.commandExpiry.lastRunAt,
+ stuckCommands,
+ emailConfigured: this.reminders.configured,
+ };
+ }
+
+ private async tableCounts() {
+ const p = this.prisma;
+ const entries = await Promise.all(Object.entries({
+ Venue: () => p.venue.count(),
+ ClientUser: () => p.clientUser.count(),
+ Device: () => p.device.count(),
+ RefreshToken: () => p.refreshToken.count(),
+ RegistrationCode: () => p.registrationCode.count(),
+ Tab: () => p.tab.count(),
+ TabEvent: () => p.tabEvent.count(),
+ Settlement: () => p.settlement.count(),
+ PlannedTab: () => p.plannedTab.count(),
+ TabReminder: () => p.tabReminder.count(),
+ Product: () => p.product.count(),
+ ProductImage: () => p.productImage.count(),
+ StockAdjustment: () => p.stockAdjustment.count(),
+ RemoteCommand: () => p.remoteCommand.count(),
+ AuditLog: () => p.auditLog.count(),
+ }).map(async ([table, count]) => ({ table, rows: await count() })));
+ return entries;
+ }
+
+ private async findVenue(venueId: string) {
+ const venue = await this.prisma.venue.findUnique({ where: { id: venueId } });
+ if (!venue) {
+ throw new NotFoundException({ code: 'venue_not_found', message: 'Venue not found' });
+ }
+ return venue;
+ }
+
+ private async findUser(userId: string) {
+ const user = await this.prisma.clientUser.findUnique({ where: { id: userId } });
+ if (!user) {
+ throw new NotFoundException({ code: 'user_not_found', message: 'User not found' });
+ }
+ return user;
+ }
+}
+
+function parseDetails(details: string | null): unknown {
+ if (!details) return null;
+ try {
+ return JSON.parse(details);
+ } catch {
+ return details;
+ }
+}
diff --git a/apps/api/src/admin/consistency.spec.ts b/apps/api/src/admin/consistency.spec.ts
new file mode 100644
index 0000000..e68cda8
--- /dev/null
+++ b/apps/api/src/admin/consistency.spec.ts
@@ -0,0 +1,97 @@
+import { checkConsistency, VenueSnapshot } from './consistency';
+
+const now = new Date('2026-10-26T12:00:00Z');
+const later = new Date('2026-10-27T12:00:00Z');
+const earlier = new Date('2026-10-25T12:00:00Z');
+
+const unit = (id: string, removedAt: string | null = null) => ({ id, addedAt: earlier.toISOString(), removedAt });
+
+function snapshot(overrides: Partial = {}): VenueSnapshot {
+ return { settlements: [], tabs: [], products: [], pendingCommands: [], devices: [], ...overrides };
+}
+
+const codes = (venue: VenueSnapshot) => checkConsistency(venue, now).map((f) => f.code);
+
+describe('checkConsistency', () => {
+ it('finds nothing in a venue whose data adds up', () => {
+ const venue = snapshot({
+ settlements: [{
+ id: 's1', customerName: 'Jan', amountInCents: 500, commandId: null,
+ lines: [{ id: 'l1', productId: 'p1', productName: 'Pils', unitPriceInCents: 250, quantity: 2, units: [] }],
+ }],
+ tabs: [{ id: 't1', customerName: 'Jan', lines: [{ id: 'l2', productId: 'p1', productName: 'Pils', unitPriceInCents: 250, units: [unit('u1')] }] }],
+ products: [{ productId: 'p1', name: 'Pils', stock: 10, countedAt: earlier }],
+ pendingCommands: [{ id: 'c1', expiresAt: later, device: { name: 'Bar', revokedAt: null } }],
+ devices: [{ id: 'd1', name: 'Bar', revokedAt: null, tokenExpiresAt: later }],
+ });
+ expect(checkConsistency(venue, now)).toEqual([]);
+ });
+
+ it('flags a settlement whose amount differs from its drinks', () => {
+ const venue = snapshot({
+ settlements: [{
+ id: 's1', customerName: 'Jan', amountInCents: 750, commandId: null,
+ lines: [{ id: 'l1', productId: 'p1', productName: 'Pils', unitPriceInCents: 250, quantity: 2, units: [] }],
+ }],
+ });
+ const [finding] = checkConsistency(venue, now);
+ expect(finding).toMatchObject({ severity: 'warning', code: 'settlement_amount_mismatch', subjectId: 's1' });
+ expect(finding.message).toContain('7.50');
+ expect(finding.message).toContain('5.00');
+ });
+
+ it('flags one close request paid twice as an error, before anything else', () => {
+ const settlement = { customerName: 'Jan', amountInCents: 0, lines: [], commandId: 'c1' };
+ const venue = snapshot({
+ settlements: [{ ...settlement, id: 's1' }, { ...settlement, id: 's2' }],
+ products: [{ productId: 'p1', name: 'Pils', stock: -2, countedAt: earlier }],
+ });
+ expect(codes(venue)).toEqual(['settlement_duplicate_command', 'stock_negative']);
+ });
+
+ it('flags two open tabs that only differ in case or spacing', () => {
+ const venue = snapshot({
+ tabs: [
+ { id: 't1', customerName: 'Jan Peeters', lines: [] },
+ { id: 't2', customerName: ' jan peeters ', lines: [] },
+ ],
+ });
+ expect(codes(venue)).toEqual(['tab_duplicate_customer']);
+ });
+
+ it('notes drinks of a product that is gone, but not ones already taken off the tab', () => {
+ const line = { id: 'l1', productId: 'gone', productName: 'Cava', unitPriceInCents: 500 };
+ expect(codes(snapshot({ tabs: [{ id: 't1', customerName: 'Jan', lines: [{ ...line, units: [unit('u1')] }] }] })))
+ .toEqual(['tab_unknown_product']);
+ expect(codes(snapshot({ tabs: [{ id: 't1', customerName: 'Jan', lines: [{ ...line, units: [unit('u1', now.toISOString())] }] }] })))
+ .toEqual([]);
+ });
+
+ it('notes tracked stock that was never counted', () => {
+ expect(codes(snapshot({ products: [{ productId: 'p1', name: 'Pils', stock: 4, countedAt: null }] }))).toEqual(['stock_never_counted']);
+ expect(codes(snapshot({ products: [{ productId: 'p1', name: 'Pils', stock: null, countedAt: null }] }))).toEqual([]);
+ });
+
+ it('flags pending commands that expired or wait for an unpaired terminal', () => {
+ const venue = snapshot({
+ pendingCommands: [
+ { id: 'c1', expiresAt: earlier, device: { name: 'Bar', revokedAt: null } },
+ { id: 'c2', expiresAt: later, device: { name: 'Old', revokedAt: earlier } },
+ ],
+ });
+ expect(checkConsistency(venue, now).map((f) => [f.code, f.subjectId])).toEqual([
+ ['command_stuck', 'c1'],
+ ['command_revoked_device', 'c2'],
+ ]);
+ });
+
+ it('notes a paired terminal whose token expired, but not an unpaired one', () => {
+ const venue = snapshot({
+ devices: [
+ { id: 'd1', name: 'Bar', revokedAt: null, tokenExpiresAt: earlier },
+ { id: 'd2', name: 'Old', revokedAt: earlier, tokenExpiresAt: earlier },
+ ],
+ });
+ expect(checkConsistency(venue, now).map((f) => f.subjectId)).toEqual(['d1']);
+ });
+});
diff --git a/apps/api/src/admin/consistency.ts b/apps/api/src/admin/consistency.ts
new file mode 100644
index 0000000..6cf4516
--- /dev/null
+++ b/apps/api/src/admin/consistency.ts
@@ -0,0 +1,146 @@
+import { customerNameKey } from '../planned-tabs/customer-name';
+import { Line, SettledLine, isActive } from '../tabs/tab-state';
+
+export type Severity = 'error' | 'warning' | 'info';
+
+export type Finding = {
+ severity: Severity;
+ // settlement_amount_mismatch | settlement_duplicate_command | tab_duplicate_customer | tab_unknown_product |
+ // stock_negative | stock_never_counted | command_stuck | command_revoked_device | device_token_expired
+ code: string;
+ message: string;
+ // What it is about: a settlement, tab, product, command or terminal id
+ subjectId: string;
+};
+
+export type VenueSnapshot = {
+ settlements: { id: string; customerName: string; amountInCents: number; lines: unknown; commandId: string | null }[];
+ tabs: { id: string; customerName: string; lines: unknown }[];
+ products: { productId: string; name: string; stock: number | null; countedAt: Date | null }[];
+ pendingCommands: { id: string; expiresAt: Date; device: { name: string; revokedAt: Date | null } | null }[];
+ devices: { id: string; name: string; revokedAt: Date | null; tokenExpiresAt: Date }[];
+};
+
+const euro = (cents: number) => (cents / 100).toFixed(2);
+
+/**
+ * Looks for data that doesn't add up in one venue. Pure: the caller loads the snapshot. Stock is not recomputed from
+ * the events, because which events count depends on the order terminals synced in; only what is visibly off is shown.
+ */
+export function checkConsistency(venue: VenueSnapshot, now: Date): Finding[] {
+ const findings: Finding[] = [];
+
+ // A settlement's amount is what the terminal charged; its lines are what the server had on the tab. A difference
+ // means the terminal and the server disagreed about the tab when it was paid
+ for (const settlement of venue.settlements) {
+ const lines = (settlement.lines as SettledLine[] | null) ?? [];
+ const linesTotal = lines.reduce((sum, line) => sum + line.quantity * line.unitPriceInCents, 0);
+ if (linesTotal !== settlement.amountInCents) {
+ findings.push({
+ severity: 'warning',
+ code: 'settlement_amount_mismatch',
+ message: `${settlement.customerName} paid € ${euro(settlement.amountInCents)}, the drinks add up to € ${euro(linesTotal)}`,
+ subjectId: settlement.id,
+ });
+ }
+ }
+
+ const byCommand = new Map();
+ 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();
+ for (const tab of venue.tabs) {
+ const key = customerNameKey(tab.customerName);
+ tabsByName.set(key, [...(tabsByName.get(key) ?? []), tab.id]);
+ }
+ for (const ids of tabsByName.values()) {
+ if (ids.length > 1) {
+ const name = venue.tabs.find((tab) => tab.id === ids[0])!.customerName;
+ findings.push({
+ severity: 'warning',
+ code: 'tab_duplicate_customer',
+ message: `${ids.length} open tabs are named ${name}`,
+ subjectId: ids[0],
+ });
+ }
+ }
+
+ const productIds = new Set(venue.products.map((p) => p.productId));
+ for (const tab of venue.tabs) {
+ for (const line of (tab.lines as Line[] | null) ?? []) {
+ if (line.productId && !productIds.has(line.productId) && line.units.some(isActive)) {
+ findings.push({
+ severity: 'info',
+ code: 'tab_unknown_product',
+ message: `${tab.customerName}'s tab has ${line.productName}, which is not in the catalog`,
+ subjectId: tab.id,
+ });
+ }
+ }
+ }
+
+ for (const product of venue.products) {
+ if (product.stock !== null && product.stock < 0) {
+ findings.push({
+ severity: 'warning',
+ code: 'stock_negative',
+ message: `${product.name} has a stock of ${product.stock}: more was sold than counted`,
+ subjectId: product.productId,
+ });
+ }
+ if (product.stock !== null && product.countedAt === null) {
+ findings.push({
+ severity: 'info',
+ code: 'stock_never_counted',
+ message: `${product.name}'s stock is tracked but was never counted`,
+ subjectId: product.productId,
+ });
+ }
+ }
+
+ for (const command of venue.pendingCommands) {
+ if (command.device?.revokedAt) {
+ findings.push({
+ severity: 'warning',
+ code: 'command_revoked_device',
+ message: `A close request waits for ${command.device.name}, which is unpaired`,
+ subjectId: command.id,
+ });
+ } else if (command.expiresAt <= now) {
+ findings.push({
+ severity: 'warning',
+ code: 'command_stuck',
+ message: 'A close request expired but is still pending',
+ subjectId: command.id,
+ });
+ }
+ }
+
+ for (const device of venue.devices) {
+ if (!device.revokedAt && device.tokenExpiresAt <= now) {
+ findings.push({
+ severity: 'info',
+ code: 'device_token_expired',
+ message: `${device.name}'s token expired: it has to be paired again`,
+ subjectId: device.id,
+ });
+ }
+ }
+
+ const order: Record = { error: 0, warning: 1, info: 2 };
+ return findings.sort((a, b) => order[a.severity] - order[b.severity]);
+}
diff --git a/apps/api/src/admin/dto/admin.dto.ts b/apps/api/src/admin/dto/admin.dto.ts
new file mode 100644
index 0000000..482cd00
--- /dev/null
+++ b/apps/api/src/admin/dto/admin.dto.ts
@@ -0,0 +1,95 @@
+import {
+ ArrayMinSize,
+ IsArray,
+ IsBoolean,
+ IsEmail,
+ IsIn,
+ IsInt,
+ IsNotEmpty,
+ IsOptional,
+ IsString,
+ Max,
+ MaxLength,
+ Min,
+ MinLength,
+} from 'class-validator';
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { RESET_SCOPES, ResetScope } from '../venue-reset';
+
+export const PURGE_TARGETS = ['tabEvents', 'auditLog', 'commands'] as const;
+export type PurgeTarget = (typeof PURGE_TARGETS)[number];
+
+export class VenueNameDto {
+ @ApiProperty({ example: 'De Kroeg' })
+ @IsString()
+ @IsNotEmpty()
+ @MaxLength(100)
+ name: string;
+}
+
+export class DryRunDto {
+ @ApiPropertyOptional({ description: 'Only count what would change' })
+ @IsOptional()
+ @IsBoolean()
+ dryRun?: boolean;
+}
+
+export class ResetVenueDto extends DryRunDto {
+ @ApiProperty({ enum: RESET_SCOPES, isArray: true })
+ @IsArray()
+ @ArrayMinSize(1)
+ @IsIn(RESET_SCOPES, { each: true })
+ scopes: ResetScope[];
+
+ @ApiPropertyOptional({ description: 'The count every tracked product gets with the stock scope', default: 0 })
+ @IsOptional()
+ @IsInt()
+ @Min(0)
+ @Max(100000)
+ stockValue?: number;
+}
+
+export class PurgeDto extends DryRunDto {
+ @ApiProperty({ example: 90 })
+ @IsInt()
+ @Min(1)
+ @Max(3650)
+ olderThanDays: number;
+
+ @ApiProperty({ enum: PURGE_TARGETS, isArray: true })
+ @IsArray()
+ @ArrayMinSize(1)
+ @IsIn(PURGE_TARGETS, { each: true })
+ targets: PurgeTarget[];
+
+ @ApiPropertyOptional({ description: 'Only this venue; every venue when left out' })
+ @IsOptional()
+ @IsString()
+ venueId?: string;
+}
+
+export class CopyCatalogDto {
+ @ApiProperty()
+ @IsString()
+ @IsNotEmpty()
+ fromVenueId: string;
+}
+
+export class UpdateRoleDto {
+ @ApiProperty({ enum: ['admin', 'manager', 'superadmin'] })
+ @IsIn(['admin', 'manager', 'superadmin'])
+ role: string;
+}
+
+export class ResetPasswordDto {
+ @ApiProperty({ example: 'SecurePass123!' })
+ @IsString()
+ @MinLength(6)
+ password: string;
+}
+
+export class TestEmailDto {
+ @ApiProperty({ example: 'you@example.com' })
+ @IsEmail()
+ to: string;
+}
diff --git a/apps/api/src/admin/venue-reset.ts b/apps/api/src/admin/venue-reset.ts
new file mode 100644
index 0000000..faa4c4a
--- /dev/null
+++ b/apps/api/src/admin/venue-reset.ts
@@ -0,0 +1,131 @@
+import { Prisma } from '@prisma/client';
+
+type Tx = Prisma.TransactionClient;
+
+/**
+ * What a superadmin can wipe from a venue. Users, terminals and the product catalog always stay: a reset is for
+ * starting a new event or season with the same bar.
+ */
+export const RESET_SCOPES = [
+ // The open tabs. Terminals drop them on their next sync; drinks a terminal still has to send bring a tab back
+ 'tabs',
+ // Paid tabs: the sales history
+ 'settlements',
+ // What the terminals reported. Also the record of which events were applied, so a terminal that re-sends an old
+ // event would apply it again: only wipe these when every terminal has synced
+ 'tabEvents',
+ // Planned customers and the reminders sent to them
+ 'plannedTabs',
+ // Every tracked product's stock is set to one count
+ 'stock',
+ // Stock deliveries, breakage and counts made on the dashboard
+ 'stockHistory',
+ // Command history
+ 'commands',
+ 'auditLog',
+] as const;
+
+export type ResetScope = (typeof RESET_SCOPES)[number];
+
+export type ResetCounts = Partial>;
+
+/** 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 {
+ const where = { venueId };
+ const counters: Record Promise> = {
+ tabs: () => tx.tab.count({ where }),
+ settlements: () => tx.settlement.count({ where }),
+ tabEvents: () => tx.tabEvent.count({ where }),
+ plannedTabs: () => tx.plannedTab.count({ where }),
+ stock: () => tx.product.count({ where: { venueId, stock: { not: null } } }),
+ stockHistory: () => tx.stockAdjustment.count({ where }),
+ commands: () => tx.remoteCommand.count({ where }),
+ auditLog: () => tx.auditLog.count({ where }),
+ };
+ const counts: ResetCounts = {};
+ for (const scope of scopes) counts[scope] = await counters[scope]();
+ return counts;
+}
+
+/**
+ * Wipes [scopes] of a venue inside [tx], which must have locked it. [stockValue] is the count every tracked product
+ * gets; it is recorded as an applied stock count, after the stock history is wiped, so the history explains it.
+ */
+export async function resetVenue(
+ tx: Tx,
+ venueId: string,
+ scopes: ResetScope[],
+ options: { stockValue: number; actor: { id: string; name: string }; now: Date },
+): Promise {
+ const where = { venueId };
+ const counts: ResetCounts = {};
+ const has = (scope: ResetScope) => scopes.includes(scope);
+
+ if (has('tabs')) counts.tabs = (await tx.tab.deleteMany({ where })).count;
+ if (has('settlements')) counts.settlements = (await tx.settlement.deleteMany({ where })).count;
+ if (has('tabEvents')) counts.tabEvents = (await tx.tabEvent.deleteMany({ where })).count;
+ if (has('plannedTabs')) {
+ await tx.tabReminder.deleteMany({ where });
+ counts.plannedTabs = (await tx.plannedTab.deleteMany({ where })).count;
+ }
+ if (has('stockHistory')) counts.stockHistory = (await tx.stockAdjustment.deleteMany({ where })).count;
+ if (has('stock')) {
+ const { now, actor, stockValue } = options;
+ const tracked = await tx.product.findMany({
+ where: { venueId, stock: { not: null } },
+ select: { productId: true, name: true },
+ });
+ await tx.product.updateMany({
+ where: { venueId, stock: { not: null } },
+ data: { stock: stockValue, countedAt: now },
+ });
+ if (tracked.length > 0) {
+ await tx.stockAdjustment.createMany({
+ data: tracked.map((product) => ({
+ venueId,
+ productId: product.productId,
+ productName: product.name,
+ type: 'set',
+ quantity: stockValue,
+ note: 'Stock reset',
+ requestedBy: actor.name,
+ requestedById: actor.id,
+ status: 'applied',
+ stockAfter: stockValue,
+ createdAt: now,
+ processedAt: now,
+ })),
+ });
+ }
+ counts.stock = tracked.length;
+ }
+ if (has('commands')) counts.commands = (await tx.remoteCommand.deleteMany({ where })).count;
+ if (has('auditLog')) counts.auditLog = (await tx.auditLog.deleteMany({ where })).count;
+
+ return counts;
+}
+
+/** Deletes a venue and everything in it, users and terminals included. */
+export async function deleteVenue(tx: Tx, venueId: string) {
+ const where = { venueId };
+ const users = await tx.clientUser.findMany({ where, select: { id: true } });
+ await tx.auditLog.deleteMany({ where });
+ await tx.tabEvent.deleteMany({ where });
+ await tx.tabReminder.deleteMany({ where });
+ await tx.plannedTab.deleteMany({ where });
+ await tx.remoteCommand.deleteMany({ where });
+ await tx.settlement.deleteMany({ where });
+ await tx.tab.deleteMany({ where });
+ await tx.stockAdjustment.deleteMany({ where });
+ await tx.product.deleteMany({ where });
+ await tx.registrationCode.deleteMany({ where });
+ await tx.device.deleteMany({ where });
+ await tx.refreshToken.deleteMany({ where: { userId: { in: users.map((u) => u.id) } } });
+ await tx.clientUser.deleteMany({ where });
+ await tx.venue.delete({ where: { id: venueId } });
+}
diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts
index ea34652..97b90e4 100644
--- a/apps/api/src/app.module.ts
+++ b/apps/api/src/app.module.ts
@@ -16,6 +16,7 @@ import { StockModule } from './stock/stock.module';
import { ImagesModule } from './images/images.module';
import { RealtimeModule } from './realtime/realtime.module';
import { EmailModule } from './email/email.module';
+import { AdminModule } from './admin/admin.module';
import { HealthController } from './health.controller';
@Module({
@@ -36,6 +37,7 @@ import { HealthController } from './health.controller';
RemindersModule,
StockModule,
ImagesModule,
+ AdminModule,
],
controllers: [HealthController],
providers: [
diff --git a/apps/api/src/auth/strategies/jwt.strategy.spec.ts b/apps/api/src/auth/strategies/jwt.strategy.spec.ts
index 2ae9f0a..0ab8c4b 100644
--- a/apps/api/src/auth/strategies/jwt.strategy.spec.ts
+++ b/apps/api/src/auth/strategies/jwt.strategy.spec.ts
@@ -1,9 +1,9 @@
import { UnauthorizedException } from '@nestjs/common';
-import { JwtStrategy } from './jwt.strategy';
+import { issuedBefore, JwtStrategy } from './jwt.strategy';
const mona = { id: 'user-1', email: 'mona@example.com', name: 'Mona', role: 'manager', venueId: 'venue-1' };
-function strategy(users: (typeof mona)[]) {
+function strategy(users: (typeof mona & { sessionsRevokedAt?: Date | null })[]) {
const prisma = { clientUser: { findUnique: async ({ where }: any) => users.find((u) => u.id === where.id) ?? null } };
return new JwtStrategy(prisma as any);
}
@@ -17,4 +17,25 @@ describe('JwtStrategy', () => {
it('refuses the token of a deleted user', async () => {
await expect(strategy([]).validate({ sub: 'user-1', role: 'admin', venueId: 'venue-1' })).rejects.toBeInstanceOf(UnauthorizedException);
});
+
+ it('refuses a token issued before the user was signed out everywhere, and accepts a newer one', async () => {
+ const signedOutAt = new Date('2026-10-26T12:00:00Z');
+ const users = [{ ...mona, sessionsRevokedAt: signedOutAt }];
+ const before = Math.floor(signedOutAt.getTime() / 1000) - 60;
+ const after = Math.floor(signedOutAt.getTime() / 1000) + 60;
+
+ await expect(strategy(users).validate({ sub: 'user-1', iat: before })).rejects.toBeInstanceOf(UnauthorizedException);
+ expect(await strategy(users).validate({ sub: 'user-1', iat: after })).toEqual(mona);
+ });
+});
+
+describe('issuedBefore', () => {
+ it('only refuses tokens from before the sign-out', () => {
+ const at = new Date('2026-10-26T12:00:00.500Z');
+ const second = Math.floor(at.getTime() / 1000);
+ expect(issuedBefore({ iat: second - 1 }, at)).toBe(true);
+ expect(issuedBefore({ iat: second }, at)).toBe(false);
+ expect(issuedBefore({ iat: second - 1 }, null)).toBe(false);
+ expect(issuedBefore({ iat: second - 1 }, undefined)).toBe(false);
+ });
});
diff --git a/apps/api/src/auth/strategies/jwt.strategy.ts b/apps/api/src/auth/strategies/jwt.strategy.ts
index 8eab809..7f42f56 100644
--- a/apps/api/src/auth/strategies/jwt.strategy.ts
+++ b/apps/api/src/auth/strategies/jwt.strategy.ts
@@ -14,16 +14,21 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
});
}
- // Looked up on every request: a deleted user loses access at once instead of when the token expires, and a
- // changed role or name applies right away
+ // Looked up on every request: a deleted or signed-out user loses access at once instead of when the token expires,
+ // and a changed role or name applies right away
async validate(payload: any) {
const user = await this.prisma.clientUser.findUnique({
where: { id: payload.sub },
- select: { id: true, email: true, name: true, role: true, venueId: true },
+ select: { id: true, email: true, name: true, role: true, venueId: true, sessionsRevokedAt: true },
});
- if (!user) {
+ if (!user || issuedBefore(payload, user.sessionsRevokedAt)) {
throw new UnauthorizedException({ code: 'unauthorized', message: 'Authentication required' });
}
- return user;
+ const { sessionsRevokedAt, ...rest } = user;
+ return rest;
}
-}
\ No newline at end of file
+}
+/** Whether the token was issued before the user's sessions were revoked. iat is in whole seconds. */
+export function issuedBefore(payload: { iat?: number }, revokedAt: Date | null | undefined): boolean {
+ return revokedAt != null && (payload.iat ?? 0) < Math.floor(revokedAt.getTime() / 1000);
+}
diff --git a/apps/api/src/common/guards/roles.guard.spec.ts b/apps/api/src/common/guards/roles.guard.spec.ts
index f156d87..9f872e7 100644
--- a/apps/api/src/common/guards/roles.guard.spec.ts
+++ b/apps/api/src/common/guards/roles.guard.spec.ts
@@ -24,6 +24,13 @@ describe('RolesGuard', () => {
expect(guard(['admin']).canActivate(context('viewer'))).toBe(false);
});
+ it('lets a superadmin do everything, and keeps the admin page for superadmins', () => {
+ expect(guard(['admin']).canActivate(context('superadmin'))).toBe(true);
+ expect(guard(['admin', 'manager']).canActivate(context('superadmin'))).toBe(true);
+ expect(guard(['superadmin']).canActivate(context('superadmin'))).toBe(true);
+ expect(guard(['superadmin']).canActivate(context('admin'))).toBe(false);
+ });
+
it('refuses a request without a user when a role is required', () => {
expect(guard(['manager']).canActivate(context(undefined))).toBe(false);
expect(guard(undefined).canActivate(context(undefined))).toBe(true);
diff --git a/apps/api/src/common/guards/roles.guard.ts b/apps/api/src/common/guards/roles.guard.ts
index 49886c7..7e09272 100644
--- a/apps/api/src/common/guards/roles.guard.ts
+++ b/apps/api/src/common/guards/roles.guard.ts
@@ -20,6 +20,7 @@ export class RolesGuard {
}
// "viewer" was the old name of a manager; tokens issued before the rename still carry it
const role = user.role === 'viewer' ? 'manager' : user.role;
- return requiredRoles.includes(role);
+ // A superadmin may do everything an admin or manager may, in every venue
+ return role === 'superadmin' || requiredRoles.includes(role);
}
}
\ No newline at end of file
diff --git a/apps/api/src/common/guards/venue-access.guard.spec.ts b/apps/api/src/common/guards/venue-access.guard.spec.ts
index cbebe01..98cc471 100644
--- a/apps/api/src/common/guards/venue-access.guard.spec.ts
+++ b/apps/api/src/common/guards/venue-access.guard.spec.ts
@@ -1,7 +1,7 @@
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { VenueAccessGuard } from './venue-access.guard';
-function contextFor(params: Record, user?: { venueId: string }): ExecutionContext {
+function contextFor(params: Record, user?: { venueId: string; role?: string }): ExecutionContext {
return {
switchToHttp: () => ({ getRequest: () => ({ params, user }) }),
} as unknown as ExecutionContext;
@@ -22,6 +22,11 @@ describe('VenueAccessGuard', () => {
expect(() => guard.canActivate(contextFor({ venueId: 'b' }, { venueId: 'a' }))).toThrow(ForbiddenException);
});
+ it('lets a superadmin into every venue', () => {
+ expect(guard.canActivate(contextFor({ venueId: 'b' }, { venueId: 'a', role: 'superadmin' }))).toBe(true);
+ expect(() => guard.canActivate(contextFor({ venueId: 'b' }, { venueId: 'a', role: 'admin' }))).toThrow(ForbiddenException);
+ });
+
it('rejects when no user is attached', () => {
expect(() => guard.canActivate(contextFor({ venueId: 'a' }))).toThrow(ForbiddenException);
});
diff --git a/apps/api/src/common/guards/venue-access.guard.ts b/apps/api/src/common/guards/venue-access.guard.ts
index ad87d9f..8987f06 100644
--- a/apps/api/src/common/guards/venue-access.guard.ts
+++ b/apps/api/src/common/guards/venue-access.guard.ts
@@ -1,8 +1,8 @@
import { Injectable, ExecutionContext, CanActivate, ForbiddenException } from '@nestjs/common';
/**
- * Ensures the authenticated user can only access the venue they belong to.
- * Must run after JwtAuthGuard so that `request.user` is populated.
+ * Ensures the authenticated user can only access the venue they belong to. A superadmin can access every venue: that
+ * is how they view the dashboard as a venue. Must run after JwtAuthGuard so that `request.user` is populated.
*/
@Injectable()
export class VenueAccessGuard implements CanActivate {
@@ -14,6 +14,10 @@ export class VenueAccessGuard implements CanActivate {
return true;
}
+ if (request.user?.role === 'superadmin') {
+ return true;
+ }
+
if (!request.user || request.user.venueId !== venueId) {
throw new ForbiddenException({ code: 'venue_forbidden', message: 'You do not have access to this venue' });
}
diff --git a/apps/api/src/devices/devices.controller.ts b/apps/api/src/devices/devices.controller.ts
index 25f3dba..e8f0ad6 100644
--- a/apps/api/src/devices/devices.controller.ts
+++ b/apps/api/src/devices/devices.controller.ts
@@ -8,6 +8,7 @@ import {
HttpCode,
HttpStatus,
Req,
+ Query,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
@@ -86,8 +87,9 @@ export class DevicesController {
@ApiBearerAuth()
@ApiOperation({ summary: 'Generate a registration code for pairing' })
@ApiResponse({ status: 200, description: 'Registration code generated' })
- async generateCode(@Req() req: any) {
- const venueId = req.user.venueId;
+ async generateCode(@Req() req: any, @Query('venueId') viewing?: string) {
+ // A superadmin pairs terminals for the venue they're viewing
+ const venueId = req.user.role === 'superadmin' && viewing ? viewing : req.user.venueId;
return this.authService.generateRegistrationCode(venueId);
}
}
\ No newline at end of file
diff --git a/apps/api/src/jobs/command-expiry.job.ts b/apps/api/src/jobs/command-expiry.job.ts
index 955a0b6..2fefb82 100644
--- a/apps/api/src/jobs/command-expiry.job.ts
+++ b/apps/api/src/jobs/command-expiry.job.ts
@@ -7,6 +7,8 @@ import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class CommandExpiryJob {
private readonly logger = new Logger(CommandExpiryJob.name);
+ /** Null until it ran since the server started; shown on the admin page. */
+ lastRunAt: Date | null = null;
constructor(
private prisma: PrismaService,
@@ -18,6 +20,7 @@ export class CommandExpiryJob {
this.logger.log('Running command expiry job...');
const now = new Date();
+ this.lastRunAt = now;
const expiredCommands = await this.prisma.remoteCommand.findMany({
where: {
diff --git a/apps/api/src/jobs/jobs.module.ts b/apps/api/src/jobs/jobs.module.ts
index 82f1ef9..ec1fe92 100644
--- a/apps/api/src/jobs/jobs.module.ts
+++ b/apps/api/src/jobs/jobs.module.ts
@@ -3,5 +3,6 @@ import { CommandExpiryJob } from './command-expiry.job';
@Module({
providers: [CommandExpiryJob],
+ exports: [CommandExpiryJob],
})
export class JobsModule {}
\ No newline at end of file
diff --git a/apps/api/src/realtime/realtime.gateway.ts b/apps/api/src/realtime/realtime.gateway.ts
index 69c641b..9616d81 100644
--- a/apps/api/src/realtime/realtime.gateway.ts
+++ b/apps/api/src/realtime/realtime.gateway.ts
@@ -1,4 +1,5 @@
import { JwtService } from '@nestjs/jwt';
+import { PrismaService } from '../prisma/prisma.service';
import { OnGatewayConnection, OnGatewayInit, WebSocketGateway } from '@nestjs/websockets';
import { Namespace, Socket } from 'socket.io';
import { RealtimeService, venueRoom } from './realtime.service';
@@ -11,6 +12,7 @@ export class RealtimeGateway implements OnGatewayInit, OnGatewayConnection {
constructor(
private jwtService: JwtService,
private realtime: RealtimeService,
+ private prisma: PrismaService,
) {}
afterInit(namespace: Namespace) {
@@ -22,6 +24,14 @@ export class RealtimeGateway implements OnGatewayInit, OnGatewayConnection {
try {
const payload = await this.jwtService.verifyAsync(String(socket.handshake.auth?.token ?? ''));
socket.data.venueId = payload.venueId;
+ // A superadmin viewing another venue follows that venue. The role is checked in the database, not the token,
+ // so a demoted superadmin can't keep following other venues
+ const viewing = socket.handshake.auth?.venueId;
+ if (typeof viewing === 'string' && viewing && viewing !== payload.venueId) {
+ const user = await this.prisma.clientUser.findUnique({ where: { id: payload.sub }, select: { role: true } });
+ if (user?.role !== 'superadmin') return next(new Error('unauthorized'));
+ socket.data.venueId = viewing;
+ }
next();
} catch {
next(new Error('unauthorized'));
diff --git a/apps/api/src/realtime/realtime.service.ts b/apps/api/src/realtime/realtime.service.ts
index 76dd34c..2bec40d 100644
--- a/apps/api/src/realtime/realtime.service.ts
+++ b/apps/api/src/realtime/realtime.service.ts
@@ -21,4 +21,9 @@ export class RealtimeService {
notify(venueId: string, ...topics: RealtimeTopic[]) {
this.namespace?.to(venueRoom(venueId)).emit('invalidate', { topics });
}
+
+ /** Dashboards connected right now. */
+ connectionCount(): number {
+ return this.namespace?.sockets.size ?? 0;
+ }
}
diff --git a/apps/api/src/reminders/reminders.module.ts b/apps/api/src/reminders/reminders.module.ts
index 90d4af0..0b78425 100644
--- a/apps/api/src/reminders/reminders.module.ts
+++ b/apps/api/src/reminders/reminders.module.ts
@@ -5,5 +5,6 @@ import { RemindersService } from './reminders.service';
@Module({
controllers: [RemindersController],
providers: [RemindersService],
+ exports: [RemindersService],
})
export class RemindersModule {}
\ No newline at end of file
diff --git a/apps/api/src/reminders/reminders.service.ts b/apps/api/src/reminders/reminders.service.ts
index e387fae..e8fcd6b 100644
--- a/apps/api/src/reminders/reminders.service.ts
+++ b/apps/api/src/reminders/reminders.service.ts
@@ -191,12 +191,7 @@ export class RemindersService {
}
async sendTestEmail(venueId: string, plannedTabId: string) {
- if (!this.resendApiKey) {
- throw new BadRequestException({
- code: 'resend_not_configured',
- message: 'Resend API key is not configured. Set RESEND_API_KEY environment variable.',
- });
- }
+ this.ensureConfigured();
const tab = await this.prisma.plannedTab.findFirst({
where: { id: plannedTabId, venueId },
@@ -210,12 +205,36 @@ export class RemindersService {
throw new BadRequestException({ code: 'no_email', message: 'This customer has no email address set' });
}
+ await this.sendTest(tab.email, tab.customerName, await this.venueName(venueId));
+
+ return { ok: true, email: tab.email, customerName: tab.customerName };
+ }
+
+ /** The test email to any address: for a superadmin checking that sending works. */
+ async sendTestEmailTo(to: string) {
+ this.ensureConfigured();
+ await this.sendTest(to, to, 'KoolTap');
+ return { ok: true, email: to };
+ }
+
+ /** Whether sending is set up, without sending anything. */
+ get configured(): boolean {
+ return this.resendApiKey !== '';
+ }
+
+ private ensureConfigured() {
+ if (!this.resendApiKey) {
+ throw new BadRequestException({
+ code: 'resend_not_configured',
+ message: 'Resend API key is not configured. Set RESEND_API_KEY environment variable.',
+ });
+ }
+ }
+
+ private async sendTest(to: string, customerName: string, venueName: string) {
let email: RenderedEmail;
try {
- email = await this.emailTemplates.render('test-email', {
- customerName: tab.customerName,
- venueName: await this.venueName(venueId),
- });
+ email = await this.emailTemplates.render('test-email', { customerName, venueName });
} catch (error) {
// Template mistakes are shown to the admin instead of a generic server error
if (error instanceof EmailTemplateError) {
@@ -224,15 +243,13 @@ export class RemindersService {
throw error;
}
- const error = await this.resend('/emails', { from: this.fromEmail, to: tab.email, ...email });
+ const error = await this.resend('/emails', { from: this.fromEmail, to, ...email });
if (error) {
throw new BadRequestException({
code: 'email_failed',
message: `Failed to send test email: ${error}`,
});
}
-
- return { ok: true, email: tab.email, customerName: tab.customerName };
}
private renderReminder(candidate: Recipient, venueName: string): Promise {
diff --git a/apps/dashboard/src/app/dashboard/admin/devices-section.tsx b/apps/dashboard/src/app/dashboard/admin/devices-section.tsx
new file mode 100644
index 0000000..2456cd0
--- /dev/null
+++ b/apps/dashboard/src/app/dashboard/admin/devices-section.tsx
@@ -0,0 +1,150 @@
+'use client';
+
+import { useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { RefreshCw } from 'lucide-react';
+import { adminApi, type AdminDevice } from '@/lib/api/client';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { DeviceStatusBadge } from '@/components/device-status-badge';
+import { ConfirmDialog } from '@/components/confirm-dialog';
+import { LoadingState } from '@/components/loading-state';
+import { ErrorState } from '@/components/error-state';
+import { toast } from '@/components/ui/toast';
+import { useLang } from '@/lib/i18n';
+import { Checkbox } from './shared';
+
+export function DevicesSection() {
+ const queryClient = useQueryClient();
+ const { t, te, relativeTime, dateTime } = useLang();
+ const [showUnpaired, setShowUnpaired] = useState(false);
+ const [unpairing, setUnpairing] = useState(null);
+
+ const { data, isLoading, error } = useQuery({
+ queryKey: ['admin', 'devices'],
+ queryFn: adminApi.devices,
+ // A terminal going offline sends no event, so its status is polled
+ refetchInterval: 15000,
+ });
+ const codes = useQuery({ queryKey: ['admin', 'registration-codes'], queryFn: adminApi.registrationCodes });
+
+ const refresh = () => queryClient.invalidateQueries({ queryKey: ['admin'] });
+ const onError = (err: unknown) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
+
+ const unpair = useMutation({
+ mutationFn: (device: AdminDevice) => adminApi.revokeDevice(device.id),
+ onSuccess: (_, device) => toast({ title: t('settings.unpaired', { name: device.name }), variant: 'success' }),
+ onError,
+ onSettled: () => {
+ setUnpairing(null);
+ refresh();
+ },
+ });
+ const revokeCode = useMutation({ mutationFn: adminApi.revokeRegistrationCode, onError, onSettled: refresh });
+ const cleanupCodes = useMutation({
+ mutationFn: adminApi.cleanupRegistrationCodes,
+ onSuccess: ({ deleted }) => toast({ title: t('admin.codes.cleaned', { count: deleted }), variant: 'success' }),
+ onError,
+ onSettled: refresh,
+ });
+
+ if (isLoading) return ;
+ if (error) return ;
+
+ const all = data?.devices ?? [];
+ const devices = showUnpaired ? all : all.filter((d) => !d.revokedAt);
+ const now = Date.now();
+
+ return (
+
+
+ !d.revokedAt).length })} />
+
+
+
+
+
+
+
+ {t('admin.devices.name')}
+ {t('admin.devices.venue')}
+ {t('admin.devices.status')}
+ {t('admin.devices.last_seen')}
+ {t('admin.devices.token_expires')}
+ {t('admin.devices.pending')}
+
+
+
+
+ {devices.map((device) => (
+
+
+ {device.name}
+ {device.tokenPrefix}…
+
+ {device.venueName}
+
+ {device.revokedAt ? {t('admin.devices.unpaired')} : }
+
+ {relativeTime(device.lastSeenAt)}
+
+ {dateTime(device.tokenExpiresAt)}
+
+ {device.pendingCommands || ''}
+
+ {!device.revokedAt && (
+
+ )}
+
+
+ ))}
+
+
+
+
+
+
+ {t('admin.codes.title')}
+ {t('admin.codes.desc')}
+
+
+ {(codes.data?.codes ?? []).length === 0 ? (
+ {t('admin.codes.none')}
+ ) : (
+
+ {codes.data!.codes.map((code) => (
+ -
+ {code.code}
+ {code.venueName}
+ {t('admin.venues.pair_expires', { time: dateTime(code.expiresAt) })}
+
+
+ ))}
+
+ )}
+
+
+
+
+
!open && !unpair.isPending && setUnpairing(null)}
+ title={t('settings.unpair_title', { name: unpairing?.name ?? '' })}
+ description={t('settings.unpair_desc')}
+ confirmLabel={t('settings.unpair')}
+ cancelLabel={t('common.cancel')}
+ variant="destructive"
+ loading={unpair.isPending}
+ onConfirm={() => unpairing && unpair.mutate(unpairing)}
+ />
+
+ );
+}
diff --git a/apps/dashboard/src/app/dashboard/admin/insight-sections.tsx b/apps/dashboard/src/app/dashboard/admin/insight-sections.tsx
new file mode 100644
index 0000000..f14e5d0
--- /dev/null
+++ b/apps/dashboard/src/app/dashboard/admin/insight-sections.tsx
@@ -0,0 +1,184 @@
+'use client';
+
+import { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { RefreshCw } from 'lucide-react';
+import { adminApi } from '@/lib/api/client';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { LoadingState } from '@/components/loading-state';
+import { ErrorState } from '@/components/error-state';
+import { useLang } from '@/lib/i18n';
+import { formatBytes } from './shared';
+
+const PAGE_SIZE = 50;
+const ALL_VENUES = 'all';
+
+export function AuditSection() {
+ const { t, te, dateTime } = useLang();
+ const [venueId, setVenueId] = useState(ALL_VENUES);
+ const [action, setAction] = useState('');
+ const [page, setPage] = useState(0);
+
+ const venues = useQuery({ queryKey: ['admin', 'venues'], queryFn: adminApi.venues });
+ const { data, isLoading, error, refetch } = useQuery({
+ queryKey: ['admin', 'audit', venueId, action.trim(), page],
+ queryFn: () => adminApi.audit({
+ venueId: venueId === ALL_VENUES ? undefined : venueId,
+ action: action.trim() || undefined,
+ limit: PAGE_SIZE,
+ offset: page * PAGE_SIZE,
+ }),
+ placeholderData: (previous) => previous,
+ });
+
+ const total = data?.total ?? 0;
+ const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
+
+ return (
+
+
+
+
+
+
{ setAction(e.target.value); setPage(0); }} className="max-w-xs" />
+
+
+
+ {isLoading ?
: error ?
: (
+
+
+
+
+ {t('admin.audit.when')}
+ {t('admin.devices.venue')}
+ {t('admin.audit.who')}
+ {t('admin.audit.action')}
+ {t('admin.audit.details')}
+
+
+
+ {(data?.entries ?? []).map((entry) => {
+ const details = entry.details === null ? '' : typeof entry.details === 'string' ? entry.details : JSON.stringify(entry.details);
+ return (
+
+ {dateTime(entry.createdAt)}
+ {entry.venueName}
+
+ {entry.actorName ?? {entry.actorId.slice(0, 8)}}
+ {entry.actorType}
+
+
+ {entry.action}
+
+
+ {details}
+
+
+ );
+ })}
+
+
+
+ )}
+
+
+ {t('admin.audit.page', { page: page + 1, pages, total })}
+
+
+
+
+ );
+}
+
+function formatUptime(seconds: number): string {
+ const days = Math.floor(seconds / 86400);
+ const hours = Math.floor((seconds % 86400) / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ return days > 0 ? `${days}d ${hours}h` : hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
+}
+
+export function HealthSection() {
+ const { t, te, relativeTime } = useLang();
+ const { data, isLoading, error, refetch, isFetching } = useQuery({
+ queryKey: ['admin', 'health'],
+ queryFn: adminApi.health,
+ refetchInterval: 30000,
+ });
+
+ if (isLoading) return ;
+ if (error || !data) return ;
+
+ const stats: { label: string; value: string; bad?: boolean }[] = [
+ { label: t('admin.health.database'), value: formatBytes(data.databaseBytes) },
+ { label: t('admin.health.uptime'), value: formatUptime(data.uptimeSeconds) },
+ { label: t('admin.health.connections'), value: String(data.realtimeConnections) },
+ {
+ label: t('admin.health.migrations'),
+ value: data.migrations.failed.length > 0 ? t('admin.health.migrations_failed', { count: data.migrations.failed.length }) : String(data.migrations.applied),
+ bad: data.migrations.failed.length > 0,
+ },
+ {
+ label: t('admin.health.expiry_job'),
+ value: data.commandExpiryLastRunAt ? relativeTime(data.commandExpiryLastRunAt) : t('admin.health.not_yet'),
+ },
+ { label: t('admin.health.stuck'), value: String(data.stuckCommands), bad: data.stuckCommands > 0 },
+ { label: t('admin.health.email'), value: data.emailConfigured ? t('admin.health.configured') : t('admin.health.not_configured'), bad: !data.emailConfigured },
+ { label: t('admin.health.node'), value: data.nodeVersion },
+ ];
+
+ return (
+
+
+
+
+
+ {stats.map((stat) => (
+
+
+ {stat.label}
+ {stat.value}
+
+
+ ))}
+
+
+ {data.migrations.failed.length > 0 && (
+
{t('admin.health.failed_list', { names: data.migrations.failed.join(', ') })}
+ )}
+ {data.migrations.latest && (
+
{t('admin.health.latest_migration')} {data.migrations.latest}
+ )}
+
+
+
+ {t('admin.health.tables')}
+
+
+
+ {data.tables.map(({ table, rows }) => (
+ -
+ {table}
+ {rows.toLocaleString()}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/dashboard/admin/maintenance-section.tsx b/apps/dashboard/src/app/dashboard/admin/maintenance-section.tsx
new file mode 100644
index 0000000..4774fa1
--- /dev/null
+++ b/apps/dashboard/src/app/dashboard/admin/maintenance-section.tsx
@@ -0,0 +1,220 @@
+'use client';
+
+import { useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { adminApi, PURGE_TARGETS, type AdminVenue, type PurgeTarget } from '@/lib/api/client';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { toast } from '@/components/ui/toast';
+import { useLang } from '@/lib/i18n';
+import { Checkbox, CountList, DangerDialog, formatBytes } from './shared';
+
+const ALL_VENUES = 'all';
+
+export function MaintenanceSection() {
+ return (
+
+ );
+}
+
+function useFeedback() {
+ const queryClient = useQueryClient();
+ const { t, te } = useLang();
+ return {
+ onError: (err: unknown) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
+ refresh: () => queryClient.invalidateQueries({ queryKey: ['admin'] }),
+ };
+}
+
+function StuckCommandsCard() {
+ const { t } = useLang();
+ const { onError, refresh } = useFeedback();
+ const preview = useQuery({ queryKey: ['admin', 'stuck-commands'], queryFn: () => adminApi.expireStuckCommands(true) });
+ const expire = useMutation({
+ mutationFn: () => adminApi.expireStuckCommands(false),
+ onSuccess: ({ count }) => toast({ title: t('admin.stuck.done', { count }), variant: 'success' }),
+ onError,
+ onSettled: refresh,
+ });
+ const count = preview.data?.count ?? 0;
+
+ return (
+
+
+ {t('admin.stuck.title')}
+ {t('admin.stuck.desc')}
+
+
+ {preview.isLoading ? t('common.loading') : t('admin.stuck.count', { count })}
+
+
+
+ );
+}
+
+function ImagesCard() {
+ const { t } = useLang();
+ const { onError, refresh } = useFeedback();
+ const [confirming, setConfirming] = useState(false);
+ const preview = useQuery({ queryKey: ['admin', 'orphan-images'], queryFn: () => adminApi.cleanupImages(true) });
+ const cleanup = useMutation({
+ mutationFn: () => adminApi.cleanupImages(false),
+ onSuccess: ({ count }) => {
+ toast({ title: t('admin.images.done', { count }), variant: 'success' });
+ setConfirming(false);
+ },
+ onError,
+ onSettled: refresh,
+ });
+ const count = preview.data?.count ?? 0;
+
+ return (
+
+
+ {t('admin.images.title')}
+ {t('admin.images.desc')}
+
+
+
+ {preview.isLoading ? t('common.loading') : t('admin.images.count', { count, size: formatBytes(preview.data?.bytes ?? 0) })}
+
+
+
+ cleanup.mutate()}
+ />
+
+ );
+}
+
+function PurgeCard() {
+ const { t, te } = useLang();
+ const { onError, refresh } = useFeedback();
+ const [targets, setTargets] = useState(['tabEvents', 'commands']);
+ const [days, setDays] = useState('90');
+ const [venueId, setVenueId] = useState(ALL_VENUES);
+ const [confirming, setConfirming] = useState(false);
+
+ const venues = useQuery({ queryKey: ['admin', 'venues'], queryFn: adminApi.venues });
+ const olderThanDays = parseInt(days, 10);
+ const valid = targets.length > 0 && Number.isInteger(olderThanDays) && olderThanDays >= 1;
+ const request = { targets, olderThanDays, venueId: venueId === ALL_VENUES ? undefined : venueId };
+
+ const preview = useQuery({
+ queryKey: ['admin', 'purge-preview', [...targets].sort().join(','), olderThanDays, venueId],
+ queryFn: () => adminApi.purge({ ...request, dryRun: true }),
+ enabled: confirming && valid,
+ });
+ const purge = useMutation({
+ mutationFn: () => adminApi.purge({ ...request, dryRun: false }),
+ onSuccess: ({ counts }) => {
+ const total = Object.values(counts).reduce((sum, n) => sum + (n ?? 0), 0);
+ toast({ title: t('admin.purge.done', { count: total }), variant: 'success' });
+ setConfirming(false);
+ },
+ onError,
+ onSettled: refresh,
+ });
+
+ function toggle(target: PurgeTarget, on: boolean) {
+ setTargets((current) => (on ? [...current, target] : current.filter((x) => x !== target)));
+ }
+
+ return (
+
+
+ {t('admin.purge.title')}
+ {t('admin.purge.desc')}
+
+
+
+ {PURGE_TARGETS.map((target) => (
+ toggle(target, on)}
+ label={t(`admin.purge.target.${target}`)} hint={t(`admin.purge.target.${target}_hint`)} />
+ ))}
+
+
+
+
+ setDays(e.target.value)} className="w-28" />
+
+
+
+
+
+
+
+
+ purge.mutate()}
+ >
+ {preview.data ? (
+ t(`admin.purge.target.${target}`)} />
+ ) : (
+ {preview.error ? te(preview.error) : t('common.loading')}
+ )}
+
+
+ );
+}
+
+function TestEmailCard() {
+ const { t } = useLang();
+ const { onError } = useFeedback();
+ const [to, setTo] = useState('');
+ const send = useMutation({
+ mutationFn: () => adminApi.testEmail(to.trim()),
+ onSuccess: ({ email }) => toast({ title: t('admin.email.sent', { email }), variant: 'success' }),
+ onError,
+ });
+
+ return (
+
+
+ {t('admin.email.title')}
+ {t('admin.email.desc')}
+
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/dashboard/admin/page.tsx b/apps/dashboard/src/app/dashboard/admin/page.tsx
new file mode 100644
index 0000000..bae0b6e
--- /dev/null
+++ b/apps/dashboard/src/app/dashboard/admin/page.tsx
@@ -0,0 +1,82 @@
+'use client';
+
+import { useState } from 'react';
+import { Activity, Building2, Monitor, ScrollText, Users, Wrench } from 'lucide-react';
+import { PageHeader } from '@/components/page-header';
+import { ErrorState } from '@/components/error-state';
+import { cn } from '@/lib/cn';
+import { useLang } from '@/lib/i18n';
+import { useSession } from '@/lib/session';
+import { VenuesSection } from './venues-section';
+import { DevicesSection } from './devices-section';
+import { UsersSection } from './users-section';
+import { MaintenanceSection } from './maintenance-section';
+import { AuditSection, HealthSection } from './insight-sections';
+
+const SECTIONS = [
+ { key: 'venues', icon: Building2, Component: VenuesSection },
+ { key: 'devices', icon: Monitor, Component: DevicesSection },
+ { key: 'users', icon: Users, Component: UsersSection },
+ { key: 'maintenance', icon: Wrench, Component: MaintenanceSection },
+ { key: 'audit', icon: ScrollText, Component: AuditSection },
+ { key: 'health', icon: Activity, Component: HealthSection },
+] as const;
+
+type SectionKey = (typeof SECTIONS)[number]['key'];
+
+const SECTION_KEY = 'kooltap-admin-section';
+
+function storedSection(): SectionKey {
+ try {
+ const stored = localStorage.getItem(SECTION_KEY);
+ return SECTIONS.some((s) => s.key === stored) ? (stored as SectionKey) : 'venues';
+ } catch {
+ return 'venues';
+ }
+}
+
+/** KoolTap's own admin page: every venue, terminal and user. Only for superadmins. */
+export default function AdminPage() {
+ const { role } = useSession();
+ const { t } = useLang();
+ const [section, setSection] = useState(storedSection);
+
+ if (role !== 'superadmin') return ;
+
+ function select(key: SectionKey) {
+ setSection(key);
+ try {
+ localStorage.setItem(SECTION_KEY, key);
+ } catch {
+ // Not remembered, but the section still opens
+ }
+ }
+
+ const { Component } = SECTIONS.find((s) => s.key === section)!;
+
+ return (
+
+
+
+
+ {SECTIONS.map(({ key, icon: Icon }) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/dashboard/admin/shared.tsx b/apps/dashboard/src/app/dashboard/admin/shared.tsx
new file mode 100644
index 0000000..5e1090b
--- /dev/null
+++ b/apps/dashboard/src/app/dashboard/admin/shared.tsx
@@ -0,0 +1,124 @@
+'use client';
+
+import { ReactNode, useEffect, useState } from 'react';
+import { adminApi } from '@/lib/api/client';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useLang } from '@/lib/i18n';
+
+/**
+ * A confirmation for something that can't be undone. With [confirmText] the button only works once that text is
+ * typed, so a misclick or the wrong row can't wipe a venue.
+ */
+export function DangerDialog({
+ open,
+ onOpenChange,
+ title,
+ description,
+ children,
+ confirmText,
+ confirmLabel,
+ loading,
+ disabled,
+ onConfirm,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: string;
+ description: string;
+ children?: ReactNode;
+ confirmText?: string;
+ confirmLabel: string;
+ loading: boolean;
+ disabled?: boolean;
+ onConfirm: () => void;
+}) {
+ const { t } = useLang();
+ const [typed, setTyped] = useState('');
+
+ useEffect(() => {
+ if (open) setTyped('');
+ }, [open]);
+
+ const matches = !confirmText || typed.trim() === confirmText;
+
+ return (
+
+ );
+}
+
+export function Checkbox({ checked, onChange, label, hint }: { checked: boolean; onChange: (checked: boolean) => void; label: string; hint?: string }) {
+ return (
+
+ );
+}
+
+/** Downloads everything of the venue as a JSON file: the backup taken before a reset or delete. */
+export async function downloadVenueExport(venue: { id: string; name: string }) {
+ const data = await adminApi.exportVenue(venue.id);
+ const slug = venue.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'venue';
+ const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
+ const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }));
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `kooltap-${slug}-${stamp}.json`;
+ link.click();
+ URL.revokeObjectURL(url);
+}
+
+export function formatBytes(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ const units = ['KB', 'MB', 'GB', 'TB'];
+ let value = bytes / 1024;
+ let unit = 0;
+ while (value >= 1024 && unit < units.length - 1) {
+ value /= 1024;
+ unit++;
+ }
+ return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
+}
+
+/** Counts from a dry run, one line each. */
+export function CountList({ counts, label }: { counts: Record; label: (key: string) => string }) {
+ const entries = Object.entries(counts).filter((entry): entry is [string, number] => entry[1] !== undefined);
+ return (
+
+ {entries.map(([key, count]) => (
+ -
+ {label(key)}
+ {count.toLocaleString()}
+
+ ))}
+
+ );
+}
diff --git a/apps/dashboard/src/app/dashboard/admin/users-section.tsx b/apps/dashboard/src/app/dashboard/admin/users-section.tsx
new file mode 100644
index 0000000..3bff47f
--- /dev/null
+++ b/apps/dashboard/src/app/dashboard/admin/users-section.tsx
@@ -0,0 +1,207 @@
+'use client';
+
+import { useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { KeyRound, LogOut, RefreshCw, Trash2 } from 'lucide-react';
+import { adminApi, type AdminUser, type Role } from '@/lib/api/client';
+import { Button } from '@/components/ui/button';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { ConfirmDialog } from '@/components/confirm-dialog';
+import { LoadingState } from '@/components/loading-state';
+import { ErrorState } from '@/components/error-state';
+import { toast } from '@/components/ui/toast';
+import { useLang } from '@/lib/i18n';
+import { useSession } from '@/lib/session';
+import { DangerDialog } from './shared';
+
+const ROLES: Role[] = ['manager', 'admin', 'superadmin'];
+
+export function UsersSection() {
+ const queryClient = useQueryClient();
+ const { userId } = useSession();
+ const { t, te, relativeTime } = useLang();
+ const [search, setSearch] = useState('');
+ const [action, setAction] = useState<{ kind: 'password' | 'logout' | 'delete'; user: AdminUser } | null>(null);
+ const [password, setPassword] = useState('');
+ const [logoutAll, setLogoutAll] = useState(false);
+
+ const { data, isLoading, error } = useQuery({ queryKey: ['admin', 'users'], queryFn: adminApi.users });
+
+ const refresh = () => queryClient.invalidateQueries({ queryKey: ['admin'] });
+ const onError = (err: unknown) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
+ const done = (title: string) => {
+ toast({ title, variant: 'success' });
+ setAction(null);
+ refresh();
+ };
+
+ const role = useMutation({
+ mutationFn: ({ user, role }: { user: AdminUser; role: Role }) => adminApi.updateRole(user.id, role),
+ onSuccess: (_, { user, role }) => done(t('admin.users.role_changed', { email: user.email, role: t(`users.role.${role}`) })),
+ onError,
+ });
+ const resetPassword = useMutation({
+ mutationFn: (user: AdminUser) => adminApi.resetPassword(user.id, password),
+ onSuccess: (_, user) => done(t('admin.users.password_set', { email: user.email })),
+ onError,
+ });
+ const logout = useMutation({
+ mutationFn: (user: AdminUser) => adminApi.logoutUser(user.id),
+ onSuccess: (_, user) => done(t('admin.users.logged_out', { email: user.email })),
+ onError,
+ });
+ const remove = useMutation({
+ mutationFn: (user: AdminUser) => adminApi.deleteUser(user.id),
+ onSuccess: (_, user) => done(t('users.deleted', { email: user.email })),
+ onError,
+ });
+ const everyone = useMutation({
+ mutationFn: adminApi.logoutEveryone,
+ onSuccess: ({ users }) => {
+ toast({ title: t('admin.users.everyone_done', { count: users }), variant: 'success' });
+ setLogoutAll(false);
+ refresh();
+ },
+ onError,
+ });
+
+ if (isLoading) return ;
+ if (error) return ;
+
+ const query = search.trim().toLowerCase();
+ const users = (data?.users ?? []).filter((u) =>
+ !query || [u.name, u.email, u.venueName].some((field) => field.toLowerCase().includes(query)));
+
+ return (
+
+
+
setSearch(e.target.value)} className="max-w-xs" />
+
+
+
+
+
+
+
+
+
+
+ {t('users.name')}
+ {t('admin.devices.venue')}
+ {t('users.role')}
+ {t('admin.users.sessions')}
+ {t('users.created')}
+
+
+
+
+ {users.map((user) => {
+ const self = user.id === userId;
+ return (
+
+
+ {user.name}
+ {user.email}
+
+ {user.venueName}
+
+ {/* You can't change your own role, so there is always a superadmin left */}
+
+
+ {user.activeSessions}
+ {relativeTime(user.createdAt)}
+
+ {/* Your own password is changed on My Account, which keeps you signed in */}
+ {!self && (
+ <>
+
+
+
+ >
+ )}
+
+
+ );
+ })}
+
+
+
+
+
+
+
!open && !logout.isPending && setAction(null)}
+ title={t('admin.users.logout_title', { email: action?.user.email ?? '' })}
+ description={t('admin.users.logout_desc')}
+ confirmLabel={t('admin.users.logout')}
+ cancelLabel={t('common.cancel')}
+ loading={logout.isPending}
+ onConfirm={() => action && logout.mutate(action.user)}
+ />
+
+ !open && !remove.isPending && setAction(null)}
+ title={t('users.delete_title', { name: action?.user.name ?? '' })}
+ description={t('users.delete_desc', { email: action?.user.email ?? '' })}
+ confirmLabel={t('users.delete')}
+ cancelLabel={t('common.cancel')}
+ variant="destructive"
+ loading={remove.isPending}
+ onConfirm={() => action && remove.mutate(action.user)}
+ />
+
+ everyone.mutate()}
+ />
+
+ );
+}
diff --git a/apps/dashboard/src/app/dashboard/admin/venues-section.tsx b/apps/dashboard/src/app/dashboard/admin/venues-section.tsx
new file mode 100644
index 0000000..7af9195
--- /dev/null
+++ b/apps/dashboard/src/app/dashboard/admin/venues-section.tsx
@@ -0,0 +1,463 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { AlertTriangle, CheckCircle2, Copy, Download, Eye, Info, KeyRound, Pencil, Plus, RefreshCw, RotateCcw, Stethoscope, Trash2 } from 'lucide-react';
+import { adminApi, RESET_SCOPES, type AdminVenue, type Finding, type ResetScope } from '@/lib/api/client';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { LoadingState } from '@/components/loading-state';
+import { ErrorState } from '@/components/error-state';
+import { toast } from '@/components/ui/toast';
+import { useLang } from '@/lib/i18n';
+import { useSession, viewVenue } from '@/lib/session';
+import { Checkbox, CountList, DangerDialog, downloadVenueExport } from './shared';
+
+// "Clear all tabs" and "full reset" are the common cases; any other mix can be ticked by hand
+const PRESETS: { key: string; scopes: ResetScope[] }[] = [
+ { key: 'admin.reset.preset_tabs', scopes: ['tabs'] },
+ { key: 'admin.reset.preset_event', scopes: ['tabs', 'plannedTabs', 'stock'] },
+ { key: 'admin.reset.preset_full', scopes: [...RESET_SCOPES] },
+];
+
+export function VenuesSection() {
+ const queryClient = useQueryClient();
+ const { t, te, relativeTime } = useLang();
+ const [selectedId, setSelectedId] = useState(null);
+ const [creating, setCreating] = useState(false);
+
+ const { data, isLoading, error } = useQuery({ queryKey: ['admin', 'venues'], queryFn: adminApi.venues });
+ const venues = data?.venues ?? [];
+ const selected = venues.find((v) => v.id === selectedId) ?? null;
+
+ if (isLoading) return ;
+ if (error) return ;
+
+ return (
+
+
+
{t('admin.venues.desc')}
+
+
+
+
+
+
+
+
+
+
+ {t('admin.venues.name')}
+ {t('admin.venues.users')}
+ {t('admin.venues.terminals')}
+ {t('admin.venues.open_tabs')}
+ {t('admin.venues.products')}
+ {t('admin.venues.last_sync')}
+
+
+
+ {venues.map((venue) => (
+ setSelectedId(venue.id === selectedId ? null : venue.id)}
+ >
+ {venue.name}
+ {venue.users}
+ {venue.devices}
+ {venue.openTabs}
+ {venue.products}
+ {venue.lastSyncAt ? relativeTime(venue.lastSyncAt) : t('admin.never')}
+
+ ))}
+
+
+
+
+ {selected ? (
+
setSelectedId(null)} />
+ ) : (
+ {t('admin.venues.pick')}
+ )}
+
+ {
+ const venue = await adminApi.createVenue(name);
+ toast({ title: t('admin.venues.created', { name: venue.name }), variant: 'success' });
+ setSelectedId(venue.id);
+ queryClient.invalidateQueries({ queryKey: ['admin'] });
+ }}
+ />
+
+ );
+}
+
+function VenuePanel({ venue, venues, onDeleted }: { venue: AdminVenue; venues: AdminVenue[]; onDeleted: () => void }) {
+ const queryClient = useQueryClient();
+ const { homeVenueId } = useSession();
+ const { t, te, dateTime } = useLang();
+ const [dialog, setDialog] = useState<'rename' | 'reset' | 'copy' | 'delete' | null>(null);
+ const [findings, setFindings] = useState(null);
+ const [pairCode, setPairCode] = useState<{ code: string; expiresAt: string } | null>(null);
+ const [busy, setBusy] = useState(null);
+
+ const refresh = () => queryClient.invalidateQueries({ queryKey: ['admin'] });
+
+ async function run(key: string, action: () => Promise) {
+ setBusy(key);
+ try {
+ await action();
+ } catch (err) {
+ toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
+ } finally {
+ setBusy(null);
+ }
+ }
+
+ return (
+
+
+ {venue.name}
+ {venue.id}
+
+
+
+
+
+
+
+
+
+
+ {venue.id !== homeVenueId && (
+
+ )}
+
+
+ {pairCode && (
+
+ {t('admin.venues.pair_code')} {pairCode.code}
+ {t('admin.venues.pair_expires', { time: dateTime(pairCode.expiresAt) })}
+
+ )}
+
+ {findings && }
+
+
+ setDialog(open ? 'rename' : null)}
+ title={t('admin.venues.rename_title', { name: venue.name })}
+ initial={venue.name}
+ onSave={async (name) => {
+ await adminApi.renameVenue(venue.id, name);
+ toast({ title: t('admin.venues.renamed'), variant: 'success' });
+ refresh();
+ }}
+ />
+ setDialog(open ? 'reset' : null)} venue={venue} onDone={refresh} />
+ setDialog(open ? 'copy' : null)} venue={venue} venues={venues} onDone={refresh} />
+ setDialog(open ? 'delete' : null)}
+ venue={venue}
+ onDone={() => {
+ onDeleted();
+ refresh();
+ }}
+ />
+
+ );
+}
+
+const SEVERITY_ICON = { error: AlertTriangle, warning: AlertTriangle, info: Info };
+const SEVERITY_BADGE = { error: 'destructive', warning: 'warning', info: 'info' } as const;
+
+function Findings({ findings }: { findings: Finding[] }) {
+ const { t } = useLang();
+ if (findings.length === 0) {
+ return (
+
+ {t('admin.check.clean')}
+
+ );
+ }
+ return (
+
+ {findings.map((finding, index) => {
+ const Icon = SEVERITY_ICON[finding.severity];
+ return (
+ -
+
+ {t(`admin.check.severity.${finding.severity}`)}
+ {finding.message}
+
+ );
+ })}
+
+ );
+}
+
+function NameDialog({ open, onOpenChange, title, initial, onSave }: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: string;
+ initial: string;
+ onSave: (name: string) => Promise;
+}) {
+ const { t, te } = useLang();
+ const [name, setName] = useState(initial);
+ const [saving, setSaving] = useState(false);
+
+ useEffect(() => {
+ if (open) setName(initial);
+ }, [open, initial]);
+
+ async function save() {
+ setSaving(true);
+ try {
+ await onSave(name.trim());
+ onOpenChange(false);
+ } catch (err) {
+ toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+ );
+}
+
+function ResetDialog({ open, onOpenChange, venue, onDone }: { open: boolean; onOpenChange: (open: boolean) => void; venue: AdminVenue; onDone: () => void }) {
+ const { t, te } = useLang();
+ const [scopes, setScopes] = useState(['tabs']);
+ const [stockValue, setStockValue] = useState('0');
+ const [exportFirst, setExportFirst] = useState(true);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (open) {
+ setScopes(['tabs']);
+ setStockValue('0');
+ setExportFirst(true);
+ }
+ }, [open]);
+
+ const stock = parseInt(stockValue, 10);
+ const stockValid = !scopes.includes('stock') || (Number.isInteger(stock) && stock >= 0);
+
+ // A fresh dry run whenever the selection changes, so the numbers shown are what will go
+ const preview = useQuery({
+ queryKey: ['admin', 'reset-preview', venue.id, [...scopes].sort().join(',')],
+ queryFn: () => adminApi.resetVenue(venue.id, { scopes, dryRun: true }),
+ enabled: open && scopes.length > 0,
+ });
+
+ function toggle(scope: ResetScope, on: boolean) {
+ setScopes((current) => (on ? [...current, scope] : current.filter((s) => s !== scope)));
+ }
+
+ async function confirm() {
+ setLoading(true);
+ try {
+ if (exportFirst) await downloadVenueExport(venue);
+ const { counts } = await adminApi.resetVenue(venue.id, { scopes, stockValue: stock, dryRun: false });
+ const total = Object.values(counts).reduce((sum, n) => sum + (n ?? 0), 0);
+ toast({ title: t('admin.reset.done', { name: venue.name }), description: t('admin.reset.done_desc', { count: total }), variant: 'success' });
+ onOpenChange(false);
+ onDone();
+ } catch (err) {
+ toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
+ {PRESETS.map((preset) => (
+
+ ))}
+
+
+ {RESET_SCOPES.map((scope) => (
+ toggle(scope, on)}
+ label={t(`admin.reset.scope.${scope}`)}
+ hint={t(`admin.reset.scope.${scope}_hint`)}
+ />
+ ))}
+
+ {scopes.includes('stock') && (
+
+
+ setStockValue(e.target.value)} className="w-32" />
+
+ )}
+ {scopes.length > 0 && (
+
+
{t('admin.preview')}
+ {preview.data ? (
+
t(`admin.reset.scope.${scope}`)} />
+ ) : (
+ {preview.error ? te(preview.error) : t('common.loading')}
+ )}
+
+ )}
+
+
+
+ );
+}
+
+function CopyCatalogDialog({ open, onOpenChange, venue, venues, onDone }: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ venue: AdminVenue;
+ venues: AdminVenue[];
+ onDone: () => void;
+}) {
+ const { t, te } = useLang();
+ const [fromId, setFromId] = useState('');
+ const others = venues.filter((v) => v.id !== venue.id);
+
+ const mutation = useMutation({
+ mutationFn: () => adminApi.copyCatalog(venue.id, fromId),
+ onSuccess: ({ created }) => {
+ toast({ title: t('admin.copy.done', { count: created }), variant: 'success' });
+ onOpenChange(false);
+ onDone();
+ },
+ onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
+ });
+
+ return (
+
+ );
+}
+
+function DeleteVenueDialog({ open, onOpenChange, venue, onDone }: { open: boolean; onOpenChange: (open: boolean) => void; venue: AdminVenue; onDone: () => void }) {
+ const { t, te } = useLang();
+ const [exportFirst, setExportFirst] = useState(true);
+ const [loading, setLoading] = useState(false);
+
+ async function confirm() {
+ setLoading(true);
+ try {
+ if (exportFirst) await downloadVenueExport(venue);
+ await adminApi.deleteVenue(venue.id, venue.name);
+ toast({ title: t('admin.delete.done', { name: venue.name }), variant: 'success' });
+ onOpenChange(false);
+ onDone();
+ } catch (err) {
+ toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/dashboard/layout.tsx b/apps/dashboard/src/app/dashboard/layout.tsx
index d1b1003..bd5dde3 100644
--- a/apps/dashboard/src/app/dashboard/layout.tsx
+++ b/apps/dashboard/src/app/dashboard/layout.tsx
@@ -15,6 +15,8 @@ import {
Bell,
Package,
UserCog,
+ ShieldAlert,
+ Eye,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/cn';
@@ -23,18 +25,22 @@ import { LanguageSelector } from '@/components/language-selector';
import { authApi, venuesApi } from '@/lib/api/client';
import { toast } from '@/components/ui/toast';
import { RealtimeProvider, useRealtime } from '@/lib/realtime';
-import { readSession, Session, SessionProvider } from '@/lib/session';
+import { canAdministerVenue, readSession, Session, SessionProvider, viewVenue } from '@/lib/session';
import { LoadingState } from '@/components/loading-state';
-const navItems = [
- { href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, adminOnly: false },
- { href: '/dashboard/tabs', key: 'nav.tabs', icon: ClipboardList, adminOnly: false },
- { href: '/dashboard/reminders', key: 'nav.reminders', icon: Bell, adminOnly: false },
- { href: '/dashboard/stock', key: 'nav.stock', icon: Package, adminOnly: false },
- { href: '/dashboard/users', key: 'nav.users', icon: Monitor, adminOnly: true },
- { href: '/dashboard/commands', key: 'nav.commands', icon: Terminal, adminOnly: false },
- { href: '/dashboard/settings', key: 'nav.settings', icon: Settings, adminOnly: false },
- { href: '/dashboard/account', key: 'nav.account', icon: UserCog, adminOnly: false },
+// Who sees each item: everyone, admins (and superadmins), or superadmins only
+type Access = 'all' | 'admin' | 'superadmin';
+
+const navItems: { href: string; key: string; icon: typeof LayoutDashboard; access: Access }[] = [
+ { href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, access: 'all' },
+ { href: '/dashboard/tabs', key: 'nav.tabs', icon: ClipboardList, access: 'all' },
+ { href: '/dashboard/reminders', key: 'nav.reminders', icon: Bell, access: 'all' },
+ { href: '/dashboard/stock', key: 'nav.stock', icon: Package, access: 'all' },
+ { href: '/dashboard/users', key: 'nav.users', icon: Monitor, access: 'admin' },
+ { href: '/dashboard/commands', key: 'nav.commands', icon: Terminal, access: 'all' },
+ { href: '/dashboard/settings', key: 'nav.settings', icon: Settings, access: 'all' },
+ { href: '/dashboard/account', key: 'nav.account', icon: UserCog, access: 'all' },
+ { href: '/dashboard/admin', key: 'nav.admin', icon: ShieldAlert, access: 'superadmin' },
];
export default function DashboardLayout({
@@ -108,9 +114,8 @@ export default function DashboardLayout({
const Icon = item.icon;
const active = pathname === item.href;
- if (item.adminOnly && user.role !== 'admin') {
- return null;
- }
+ if (item.access === 'admin' && !canAdministerVenue(user.role)) return null;
+ if (item.access === 'superadmin' && user.role !== 'superadmin') return null;
return (
+ {user.viewing && (
+
+
+ {t('admin.viewing_banner', { name: user.viewing.name })}
+
+
+ )}
{children}
diff --git a/apps/dashboard/src/app/dashboard/reminders/page.tsx b/apps/dashboard/src/app/dashboard/reminders/page.tsx
index 227b3d5..ac32acb 100644
--- a/apps/dashboard/src/app/dashboard/reminders/page.tsx
+++ b/apps/dashboard/src/app/dashboard/reminders/page.tsx
@@ -14,7 +14,7 @@ import { EmptyState } from '@/components/empty-state';
import { toast } from '@/components/ui/toast';
import { formatCents } from '@/lib/utils';
import { useLang } from '@/lib/i18n';
-import { useSession } from '@/lib/session';
+import { canManageVenue, useSession } from '@/lib/session';
import { Send, History, Search } from 'lucide-react';
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -29,7 +29,7 @@ export default function RemindersPage() {
const { t, tn, te, relativeTime, dateTime } = useLang();
const { venueId, role } = useSession();
// Admins and managers both run the venue
- const canManage = role === 'admin' || role === 'manager';
+ const canManage = canManageVenue(role);
const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const [search, setSearch] = useState('');
diff --git a/apps/dashboard/src/app/dashboard/settings/page.tsx b/apps/dashboard/src/app/dashboard/settings/page.tsx
index ec33f01..e832238 100644
--- a/apps/dashboard/src/app/dashboard/settings/page.tsx
+++ b/apps/dashboard/src/app/dashboard/settings/page.tsx
@@ -11,11 +11,12 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
import { ConfirmDialog } from '@/components/confirm-dialog';
import { toast } from '@/components/ui/toast';
import { useLang } from '@/lib/i18n';
-import { useSession } from '@/lib/session';
+import { canAdministerVenue, useSession } from '@/lib/session';
import { Monitor, Copy, Plus } from 'lucide-react';
export default function SettingsPage() {
- const { venueId, role } = useSession();
+ const { venueId, role, viewing } = useSession();
+ const isAdmin = canAdministerVenue(role);
const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null);
const [generating, setGenerating] = useState(false);
const [unpairing, setUnpairing] = useState(null);
@@ -49,7 +50,7 @@ export default function SettingsPage() {
async function handleGenerateCode() {
setGenerating(true);
try {
- const result = await devicesApi.generateCode();
+ const result = await devicesApi.generateCode(viewing?.id);
setRegDialog({ open: true, code: result.code, expiresAt: result.expiresAt });
} catch (err) {
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
@@ -100,7 +101,7 @@ export default function SettingsPage() {
{device.name}
{t('settings.since', { date: dateTime(device.createdAt) })}
- {role === 'admin' && (
+ {isAdmin && (
@@ -113,7 +114,7 @@ export default function SettingsPage() {
{t('settings.pair_desc')}
)}
- {role === 'admin' && (
+ {isAdmin && (