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 { ensureDefaultCustomers } from '../planned-tabs/default-customers'; import { PurgeTarget } from './dto/admin.dto'; /** The superadmin doing it. */ export type Actor = { id: string; email: string; name: string; venueId: string }; // What each reset scope changes on the dashboard const SCOPE_TOPICS: Record = { // Plans the Free Drinks customer again tabs: ['tabs', 'planned-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.$transaction(async (tx) => { const created = await tx.venue.create({ data: { name: name.trim() } }); await ensureDefaultCustomers(tx, created.id); return created; }); await this.audit.log(venue.id, 'client_user', actor.id, 'admin_venue_created', { name: venue.name, by: actor.email }); return venue; } 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, buyPriceInCents: p.buyPriceInCents, lowStockThreshold: p.lowStockThreshold, desiredStock: p.desiredStock, }; return { venueId, productId: uuidv4(), ...fields, stock: null, countedAt: null, reportedAt: now, imageId: p.imageId, fieldUpdatedAt: createdFieldTimes({ ...fields, archived: false, imageId: p.imageId }, now), }; }), }); } 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; } }