@@ -0,0 +1,613 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { DevicesService } from '../devices/devices.service';
|
||||
import { RemindersService } from '../reminders/reminders.service';
|
||||
import { CommandExpiryJob } from '../jobs/command-expiry.job';
|
||||
import { createdFieldTimes } from '../stock/product-fields';
|
||||
import { checkConsistency } from './consistency';
|
||||
import { countReset, deleteVenue, lockVenue, resetVenue, ResetCounts, ResetScope } from './venue-reset';
|
||||
import { PurgeTarget } from './dto/admin.dto';
|
||||
|
||||
/** The superadmin doing it. */
|
||||
export type Actor = { id: string; email: string; name: string; venueId: string };
|
||||
|
||||
// What each reset scope changes on the dashboard
|
||||
const SCOPE_TOPICS: Record<ResetScope, RealtimeTopic[]> = {
|
||||
tabs: ['tabs'],
|
||||
settlements: ['tabs'],
|
||||
tabEvents: [],
|
||||
plannedTabs: ['planned-tabs', 'reminders'],
|
||||
stock: ['stock'],
|
||||
stockHistory: ['stock'],
|
||||
commands: ['commands'],
|
||||
auditLog: [],
|
||||
};
|
||||
|
||||
// A terminal uploads a picture before the event that links it to a product, possibly much later when it was offline
|
||||
const ORPHAN_IMAGE_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Everything the superadmin page does. Global actions (purging, signing everyone out) are written to the audit log
|
||||
* of the superadmin's own venue, since the log is kept per venue.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private audit: AuditService,
|
||||
private realtime: RealtimeService,
|
||||
private authService: AuthService,
|
||||
private devicesService: DevicesService,
|
||||
private reminders: RemindersService,
|
||||
private commandExpiry: CommandExpiryJob,
|
||||
) {}
|
||||
|
||||
// ---- Venues
|
||||
|
||||
async listVenues() {
|
||||
const venues = await this.prisma.venue.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
clientUsers: true,
|
||||
devices: { where: { revokedAt: null } },
|
||||
tabs: true,
|
||||
products: { where: { archivedAt: null } },
|
||||
plannedTabs: true,
|
||||
settlements: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const lastSeen = await this.prisma.device.groupBy({
|
||||
by: ['venueId'],
|
||||
where: { revokedAt: null },
|
||||
_max: { lastSeenAt: true },
|
||||
});
|
||||
const lastSeenByVenue = new Map(lastSeen.map((row) => [row.venueId, row._max.lastSeenAt]));
|
||||
|
||||
return {
|
||||
venues: venues.map(({ _count, ...venue }) => ({
|
||||
...venue,
|
||||
users: _count.clientUsers,
|
||||
devices: _count.devices,
|
||||
openTabs: _count.tabs,
|
||||
products: _count.products,
|
||||
plannedTabs: _count.plannedTabs,
|
||||
settlements: _count.settlements,
|
||||
lastSyncAt: lastSeenByVenue.get(venue.id) ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async createVenue(name: string, actor: Actor) {
|
||||
const venue = await this.prisma.venue.create({ data: { name: name.trim() } });
|
||||
await this.audit.log(venue.id, 'client_user', actor.id, 'admin_venue_created', { name: venue.name, by: actor.email });
|
||||
return venue;
|
||||
}
|
||||
|
||||
async renameVenue(venueId: string, name: string, actor: Actor) {
|
||||
const before = await this.findVenue(venueId);
|
||||
const venue = await this.prisma.venue.update({ where: { id: venueId }, data: { name: name.trim() } });
|
||||
await this.audit.log(venueId, 'client_user', actor.id, 'admin_venue_renamed', { from: before.name, to: venue.name, by: actor.email });
|
||||
return venue;
|
||||
}
|
||||
|
||||
/** [confirmName] must be the venue's name, so a wrong id can't delete a venue by accident. */
|
||||
async deleteVenue(venueId: string, confirmName: string | undefined, actor: Actor) {
|
||||
const venue = await this.findVenue(venueId);
|
||||
if (venueId === actor.venueId) {
|
||||
throw new BadRequestException({ code: 'cannot_delete_own_venue', message: 'You cannot delete the venue your account belongs to' });
|
||||
}
|
||||
if (confirmName !== venue.name) {
|
||||
throw new BadRequestException({ code: 'confirm_name_mismatch', message: 'Type the venue name to confirm' });
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await lockVenue(tx, venueId);
|
||||
await deleteVenue(tx, venueId);
|
||||
}, { timeout: 60000 });
|
||||
|
||||
await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_venue_deleted', { venueId, name: venue.name, by: actor.email });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Recorded in the venue's own audit log, so its owner can see that KoolTap looked. */
|
||||
async viewVenue(venueId: string, actor: Actor) {
|
||||
const venue = await this.findVenue(venueId);
|
||||
await this.audit.log(venueId, 'client_user', actor.id, 'admin_viewed_venue', { by: actor.email });
|
||||
return { id: venue.id, name: venue.name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the products of [fromVenueId] that [venueId] doesn't have yet (by name), with their pictures but without
|
||||
* stock: stock is counted per venue.
|
||||
*/
|
||||
async copyCatalog(venueId: string, fromVenueId: string, actor: Actor) {
|
||||
if (venueId === fromVenueId) {
|
||||
throw new BadRequestException({ code: 'same_venue', message: 'Pick another venue to copy from' });
|
||||
}
|
||||
await this.findVenue(venueId);
|
||||
await this.findVenue(fromVenueId);
|
||||
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
await lockVenue(tx, venueId);
|
||||
const [source, existing] = await Promise.all([
|
||||
tx.product.findMany({ where: { venueId: fromVenueId, archivedAt: null } }),
|
||||
tx.product.findMany({ where: { venueId, archivedAt: null }, select: { name: true } }),
|
||||
]);
|
||||
const taken = new Set(existing.map((p) => p.name.trim().toLowerCase()));
|
||||
const copies = source.filter((p) => !taken.has(p.name.trim().toLowerCase()));
|
||||
if (copies.length > 0) {
|
||||
await tx.product.createMany({
|
||||
data: copies.map((p) => {
|
||||
const fields = { name: p.name, category: p.category, priceInCents: p.priceInCents, lowStockThreshold: p.lowStockThreshold };
|
||||
return {
|
||||
venueId,
|
||||
productId: uuidv4(),
|
||||
...fields,
|
||||
stock: null,
|
||||
countedAt: null,
|
||||
reportedAt: now,
|
||||
imageId: p.imageId,
|
||||
fieldUpdatedAt: createdFieldTimes({ ...fields, archived: false, imageId: p.imageId }, now),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
return copies.length;
|
||||
});
|
||||
|
||||
await this.audit.log(venueId, 'client_user', actor.id, 'admin_catalog_copied', { fromVenueId, created, by: actor.email });
|
||||
if (created > 0) this.realtime.notify(venueId, 'stock');
|
||||
return { created };
|
||||
}
|
||||
|
||||
async resetVenue(venueId: string, scopes: ResetScope[], options: { stockValue?: number; dryRun?: boolean }, actor: Actor) {
|
||||
await this.findVenue(venueId);
|
||||
const unique = [...new Set(scopes)];
|
||||
|
||||
if (options.dryRun) {
|
||||
return { dryRun: true, counts: await this.prisma.$transaction((tx) => countReset(tx, venueId, unique)) };
|
||||
}
|
||||
|
||||
const stockValue = options.stockValue ?? 0;
|
||||
const counts: ResetCounts = await this.prisma.$transaction(async (tx) => {
|
||||
await lockVenue(tx, venueId);
|
||||
return resetVenue(tx, venueId, unique, { stockValue, actor: { id: actor.id, name: actor.name || actor.email }, now: new Date() });
|
||||
}, { timeout: 60000 });
|
||||
|
||||
// After the reset, so wiping the audit log keeps the entry that says who wiped it
|
||||
await this.audit.log(venueId, 'client_user', actor.id, 'admin_venue_reset', { scopes: unique, stockValue, counts, by: actor.email });
|
||||
|
||||
const topics = new Set(unique.flatMap((scope) => SCOPE_TOPICS[scope]));
|
||||
if (topics.size > 0) this.realtime.notify(venueId, ...topics);
|
||||
|
||||
return { dryRun: false, counts };
|
||||
}
|
||||
|
||||
async consistency(venueId: string) {
|
||||
await this.findVenue(venueId);
|
||||
const where = { venueId };
|
||||
const [settlements, tabs, products, pendingCommands, devices] = await Promise.all([
|
||||
this.prisma.settlement.findMany({
|
||||
where,
|
||||
select: { id: true, customerName: true, amountInCents: true, lines: true, commandId: true },
|
||||
}),
|
||||
this.prisma.tab.findMany({ where, select: { id: true, customerName: true, lines: true } }),
|
||||
this.prisma.product.findMany({
|
||||
where: { venueId, archivedAt: null },
|
||||
select: { productId: true, name: true, stock: true, countedAt: true },
|
||||
}),
|
||||
this.prisma.remoteCommand.findMany({
|
||||
where: { venueId, status: 'pending' },
|
||||
select: { id: true, expiresAt: true, device: { select: { name: true, revokedAt: true } } },
|
||||
}),
|
||||
this.prisma.device.findMany({ where, select: { id: true, name: true, revokedAt: true, tokenExpiresAt: true } }),
|
||||
]);
|
||||
|
||||
const findings = checkConsistency({ settlements, tabs, products, pendingCommands, devices }, new Date());
|
||||
return { checkedAt: new Date(), findings };
|
||||
}
|
||||
|
||||
/** Everything of a venue except password hashes and picture bytes, for a backup before a reset. */
|
||||
async exportVenue(venueId: string, actor: Actor) {
|
||||
const venue = await this.findVenue(venueId);
|
||||
const where = { venueId };
|
||||
const [users, devices, products, tabs, settlements, plannedTabs, reminders, stockAdjustments, commands, tabEvents, auditLog] =
|
||||
await Promise.all([
|
||||
this.prisma.clientUser.findMany({ where, select: { id: true, email: true, name: true, role: true, createdAt: true } }),
|
||||
this.prisma.device.findMany({ where, select: { id: true, name: true, lastSeenAt: true, revokedAt: true, createdAt: true } }),
|
||||
this.prisma.product.findMany({ where }),
|
||||
this.prisma.tab.findMany({ where }),
|
||||
this.prisma.settlement.findMany({ where, orderBy: { sequence: 'asc' } }),
|
||||
this.prisma.plannedTab.findMany({ where, orderBy: { position: 'asc' } }),
|
||||
this.prisma.tabReminder.findMany({ where, orderBy: { sentAt: 'asc' } }),
|
||||
this.prisma.stockAdjustment.findMany({ where, orderBy: { createdAt: 'asc' } }),
|
||||
this.prisma.remoteCommand.findMany({ where, orderBy: { createdAt: 'asc' } }),
|
||||
this.prisma.tabEvent.findMany({ where, orderBy: { occurredAt: 'asc' } }),
|
||||
this.prisma.auditLog.findMany({ where, orderBy: { createdAt: 'asc' } }),
|
||||
]);
|
||||
|
||||
await this.audit.log(venueId, 'client_user', actor.id, 'admin_venue_exported', { by: actor.email });
|
||||
|
||||
return {
|
||||
exportedAt: new Date(),
|
||||
venue,
|
||||
users,
|
||||
devices,
|
||||
products,
|
||||
tabs,
|
||||
settlements,
|
||||
plannedTabs,
|
||||
reminders,
|
||||
stockAdjustments,
|
||||
commands,
|
||||
tabEvents,
|
||||
auditLog,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Terminals
|
||||
|
||||
async listDevices() {
|
||||
const devices = await this.prisma.device.findMany({
|
||||
orderBy: [{ revokedAt: { sort: 'desc', nulls: 'first' } }, { lastSeenAt: 'desc' }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
venueId: true,
|
||||
tokenPrefix: true,
|
||||
tokenExpiresAt: true,
|
||||
lastSeenAt: true,
|
||||
revokedAt: true,
|
||||
createdAt: true,
|
||||
venue: { select: { name: true } },
|
||||
_count: { select: { remoteCommands: { where: { status: 'pending' } } } },
|
||||
},
|
||||
});
|
||||
return {
|
||||
devices: devices.map(({ venue, _count, ...device }) => ({
|
||||
...device,
|
||||
venueName: venue.name,
|
||||
pendingCommands: _count.remoteCommands,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async revokeDevice(deviceId: string, actor: Actor) {
|
||||
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
|
||||
if (!device) {
|
||||
throw new NotFoundException({ code: 'device_not_found', message: 'Device not found' });
|
||||
}
|
||||
await this.devicesService.revoke(device.venueId, deviceId);
|
||||
await this.audit.log(device.venueId, 'client_user', actor.id, 'admin_device_revoked', { deviceId, deviceName: device.name, by: actor.email });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async listRegistrationCodes() {
|
||||
const codes = await this.prisma.registrationCode.findMany({
|
||||
where: { usedAt: null, expiresAt: { gt: new Date() } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true, code: true, venueId: true, expiresAt: true, createdAt: true, venue: { select: { name: true } } },
|
||||
});
|
||||
const stale = await this.prisma.registrationCode.count({
|
||||
where: { OR: [{ usedAt: { not: null } }, { expiresAt: { lte: new Date() } }] },
|
||||
});
|
||||
return { codes: codes.map(({ venue, ...code }) => ({ ...code, venueName: venue.name })), stale };
|
||||
}
|
||||
|
||||
async createRegistrationCode(venueId: string, actor: Actor) {
|
||||
await this.findVenue(venueId);
|
||||
const code = await this.authService.generateRegistrationCode(venueId);
|
||||
await this.audit.log(venueId, 'client_user', actor.id, 'admin_registration_code_created', { by: actor.email });
|
||||
return code;
|
||||
}
|
||||
|
||||
async revokeRegistrationCode(id: string) {
|
||||
const { count } = await this.prisma.registrationCode.updateMany({
|
||||
where: { id, usedAt: null, expiresAt: { gt: new Date() } },
|
||||
data: { expiresAt: new Date() },
|
||||
});
|
||||
return { ok: true, revoked: count > 0 };
|
||||
}
|
||||
|
||||
/** Deletes used and expired codes; they are only kept so a code can't be used twice, which expiry already ensures. */
|
||||
async cleanupRegistrationCodes() {
|
||||
const { count } = await this.prisma.registrationCode.deleteMany({
|
||||
where: { OR: [{ usedAt: { not: null } }, { expiresAt: { lte: new Date() } }] },
|
||||
});
|
||||
return { deleted: count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Expires every pending command. The server settles tabs itself now, so a pending command is one from before that
|
||||
* whose terminal never picked it up.
|
||||
*/
|
||||
async expireStuckCommands(dryRun: boolean, actor: Actor) {
|
||||
const where = { status: 'pending' };
|
||||
if (dryRun) {
|
||||
return { dryRun: true, count: await this.prisma.remoteCommand.count({ where }) };
|
||||
}
|
||||
|
||||
const stuck = await this.prisma.remoteCommand.findMany({ where, select: { id: true, venueId: true } });
|
||||
if (stuck.length > 0) {
|
||||
await this.prisma.remoteCommand.updateMany({
|
||||
where: { id: { in: stuck.map((c) => c.id) }, status: 'pending' },
|
||||
data: { status: 'expired', result: 'Expired by a KoolTap admin' },
|
||||
});
|
||||
}
|
||||
const venues = new Set(stuck.map((c) => c.venueId));
|
||||
for (const venueId of venues) {
|
||||
const count = stuck.filter((c) => c.venueId === venueId).length;
|
||||
await this.audit.log(venueId, 'client_user', actor.id, 'admin_commands_expired', { count, by: actor.email });
|
||||
this.realtime.notify(venueId, 'commands');
|
||||
}
|
||||
return { dryRun: false, count: stuck.length };
|
||||
}
|
||||
|
||||
// ---- Users
|
||||
|
||||
async listUsers() {
|
||||
const now = new Date();
|
||||
const [users, sessions] = await Promise.all([
|
||||
this.prisma.clientUser.findMany({
|
||||
orderBy: [{ venue: { name: 'asc' } }, { email: 'asc' }],
|
||||
select: { id: true, email: true, name: true, role: true, venueId: true, createdAt: true, venue: { select: { name: true } } },
|
||||
}),
|
||||
this.prisma.refreshToken.groupBy({
|
||||
by: ['userId'],
|
||||
where: { revokedAt: null, expiresAt: { gt: now } },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
const sessionsByUser = new Map(sessions.map((row) => [row.userId, row._count._all]));
|
||||
return {
|
||||
users: users.map(({ venue, ...user }) => ({
|
||||
...user,
|
||||
role: user.role === 'viewer' ? 'manager' : user.role,
|
||||
venueName: venue.name,
|
||||
activeSessions: sessionsByUser.get(user.id) ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async updateRole(userId: string, role: string, actor: Actor) {
|
||||
// Demoting yourself could leave nobody who can open this page
|
||||
if (userId === actor.id) {
|
||||
throw new BadRequestException({ code: 'cannot_change_own_role', message: 'You cannot change your own role' });
|
||||
}
|
||||
const user = await this.findUser(userId);
|
||||
await this.prisma.clientUser.update({ where: { id: userId }, data: { role } });
|
||||
await this.audit.log(user.venueId, 'client_user', actor.id, 'admin_role_changed', { userId, email: user.email, from: user.role, to: role, by: actor.email });
|
||||
this.realtime.notify(user.venueId, 'users');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Sets a new password and signs the user out everywhere. */
|
||||
async resetPassword(userId: string, password: string, actor: Actor) {
|
||||
// That would sign you out too; My Account changes your own password and keeps you signed in
|
||||
if (userId === actor.id) {
|
||||
throw new BadRequestException({ code: 'use_account_page', message: 'Change your own password on My Account' });
|
||||
}
|
||||
const user = await this.findUser(userId);
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const now = new Date();
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.clientUser.update({ where: { id: userId }, data: { passwordHash, sessionsRevokedAt: now } }),
|
||||
this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: now } }),
|
||||
]);
|
||||
await this.audit.log(user.venueId, 'client_user', actor.id, 'admin_password_reset', { userId, email: user.email, by: actor.email });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Signs the user out everywhere, at once: their access tokens are refused too. */
|
||||
async logoutUser(userId: string, actor: Actor) {
|
||||
if (userId === actor.id) {
|
||||
throw new BadRequestException({ code: 'cannot_logout_self', message: 'Use Logout to sign yourself out' });
|
||||
}
|
||||
const user = await this.findUser(userId);
|
||||
const now = new Date();
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.clientUser.update({ where: { id: userId }, data: { sessionsRevokedAt: now } }),
|
||||
this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: now } }),
|
||||
]);
|
||||
await this.audit.log(user.venueId, 'client_user', actor.id, 'admin_user_logged_out', { userId, email: user.email, by: actor.email });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Signs out every user but the superadmin doing it. */
|
||||
async logoutEveryone(actor: Actor) {
|
||||
const now = new Date();
|
||||
const others = { id: { not: actor.id } };
|
||||
const [users] = await this.prisma.$transaction([
|
||||
this.prisma.clientUser.updateMany({ where: others, data: { sessionsRevokedAt: now } }),
|
||||
this.prisma.refreshToken.updateMany({ where: { userId: { not: actor.id }, revokedAt: null }, data: { revokedAt: now } }),
|
||||
]);
|
||||
await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_everyone_logged_out', { users: users.count, by: actor.email });
|
||||
return { users: users.count };
|
||||
}
|
||||
|
||||
async deleteUser(userId: string, actor: Actor) {
|
||||
const user = await this.findUser(userId);
|
||||
return this.authService.deleteUser(user.venueId, userId, actor);
|
||||
}
|
||||
|
||||
// ---- Maintenance
|
||||
|
||||
/** Removes old history. Pending commands are never purged: a terminal may still pick them up. */
|
||||
async purge(targets: PurgeTarget[], olderThanDays: number, venueId: string | undefined, dryRun: boolean, actor: Actor) {
|
||||
if (venueId) await this.findVenue(venueId);
|
||||
const before = new Date(Date.now() - olderThanDays * DAY_MS);
|
||||
const venue = venueId ? { venueId } : {};
|
||||
const wheres = {
|
||||
tabEvents: { ...venue, receivedAt: { lt: before } },
|
||||
auditLog: { ...venue, createdAt: { lt: before } },
|
||||
commands: { ...venue, createdAt: { lt: before }, status: { not: 'pending' } },
|
||||
};
|
||||
const run = {
|
||||
tabEvents: () => (dryRun ? this.prisma.tabEvent.count({ where: wheres.tabEvents }) : this.prisma.tabEvent.deleteMany({ where: wheres.tabEvents }).then((r) => r.count)),
|
||||
auditLog: () => (dryRun ? this.prisma.auditLog.count({ where: wheres.auditLog }) : this.prisma.auditLog.deleteMany({ where: wheres.auditLog }).then((r) => r.count)),
|
||||
commands: () => (dryRun ? this.prisma.remoteCommand.count({ where: wheres.commands }) : this.prisma.remoteCommand.deleteMany({ where: wheres.commands }).then((r) => r.count)),
|
||||
};
|
||||
|
||||
const counts: Partial<Record<PurgeTarget, number>> = {};
|
||||
for (const target of new Set(targets)) counts[target] = await run[target]();
|
||||
|
||||
if (!dryRun) {
|
||||
await this.audit.log(venueId ?? actor.venueId, 'client_user', actor.id, 'admin_purged', {
|
||||
targets, olderThanDays, venueId: venueId ?? 'all', counts, by: actor.email,
|
||||
});
|
||||
if (venueId && counts.commands) this.realtime.notify(venueId, 'commands');
|
||||
}
|
||||
return { dryRun, counts };
|
||||
}
|
||||
|
||||
/** Deletes pictures no product uses, after a grace period for terminals that upload before they link. */
|
||||
async cleanupImages(dryRun: boolean, actor: Actor) {
|
||||
const where = { products: { none: {} }, createdAt: { lt: new Date(Date.now() - ORPHAN_IMAGE_GRACE_MS) } };
|
||||
if (dryRun) {
|
||||
const images = await this.prisma.productImage.findMany({ where, select: { id: true } });
|
||||
const [{ bytes }] = images.length === 0 ? [{ bytes: 0n }] : await this.prisma.$queryRaw<{ bytes: bigint }[]>`
|
||||
SELECT COALESCE(SUM(OCTET_LENGTH("bytes")), 0)::bigint AS bytes FROM "ProductImage" WHERE "id" = ANY(${images.map((i) => i.id)})`;
|
||||
return { dryRun: true, count: images.length, bytes: Number(bytes) };
|
||||
}
|
||||
const { count } = await this.prisma.productImage.deleteMany({ where });
|
||||
await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_images_cleaned', { count, by: actor.email });
|
||||
return { dryRun: false, count };
|
||||
}
|
||||
|
||||
async sendTestEmail(to: string, actor: Actor) {
|
||||
const result = await this.reminders.sendTestEmailTo(to);
|
||||
await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_test_email_sent', { to, by: actor.email });
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- Insight
|
||||
|
||||
async auditLog(options: { venueId?: string; action?: string; limit: number; offset: number }) {
|
||||
const where = {
|
||||
...(options.venueId ? { venueId: options.venueId } : {}),
|
||||
...(options.action ? { action: { contains: options.action, mode: 'insensitive' as const } } : {}),
|
||||
};
|
||||
const [entries, total] = await Promise.all([
|
||||
this.prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: options.limit,
|
||||
skip: options.offset,
|
||||
include: { venue: { select: { name: true } } },
|
||||
}),
|
||||
this.prisma.auditLog.count({ where }),
|
||||
]);
|
||||
|
||||
// Actors are stored as ids; show who they are
|
||||
const actorIds = [...new Set(entries.map((e) => e.actorId))];
|
||||
const [users, devices] = await Promise.all([
|
||||
this.prisma.clientUser.findMany({ where: { id: { in: actorIds } }, select: { id: true, email: true } }),
|
||||
this.prisma.device.findMany({ where: { id: { in: actorIds } }, select: { id: true, name: true } }),
|
||||
]);
|
||||
const actorNames = new Map<string, string>([
|
||||
...users.map((u): [string, string] => [u.id, u.email]),
|
||||
...devices.map((d): [string, string] => [d.id, d.name]),
|
||||
]);
|
||||
|
||||
return {
|
||||
total,
|
||||
entries: entries.map(({ venue, details, ...entry }) => ({
|
||||
...entry,
|
||||
venueName: venue.name,
|
||||
actorName: actorNames.get(entry.actorId) ?? null,
|
||||
details: parseDetails(details),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async health() {
|
||||
const [[size], migrations, tables, stuckCommands] = await Promise.all([
|
||||
this.prisma.$queryRaw<{ bytes: bigint }[]>`SELECT pg_database_size(current_database())::bigint AS bytes`,
|
||||
this.prisma.$queryRaw<{ migration_name: string; finished_at: Date | null; rolled_back_at: Date | null }[]>`
|
||||
SELECT "migration_name", "finished_at", "rolled_back_at" FROM "_prisma_migrations" ORDER BY "started_at" ASC`,
|
||||
this.tableCounts(),
|
||||
this.prisma.remoteCommand.count({ where: { status: 'pending' } }),
|
||||
]);
|
||||
const applied = migrations.filter((m) => m.finished_at && !m.rolled_back_at);
|
||||
|
||||
return {
|
||||
checkedAt: new Date(),
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
nodeVersion: process.version,
|
||||
databaseBytes: Number(size.bytes),
|
||||
tables,
|
||||
migrations: {
|
||||
applied: applied.length,
|
||||
latest: applied.at(-1)?.migration_name ?? null,
|
||||
// Started but never finished: the database may be half migrated
|
||||
failed: migrations.filter((m) => !m.finished_at && !m.rolled_back_at).map((m) => m.migration_name),
|
||||
},
|
||||
realtimeConnections: this.realtime.connectionCount(),
|
||||
commandExpiryLastRunAt: this.commandExpiry.lastRunAt,
|
||||
stuckCommands,
|
||||
emailConfigured: this.reminders.configured,
|
||||
};
|
||||
}
|
||||
|
||||
private async tableCounts() {
|
||||
const p = this.prisma;
|
||||
const entries = await Promise.all(Object.entries({
|
||||
Venue: () => p.venue.count(),
|
||||
ClientUser: () => p.clientUser.count(),
|
||||
Device: () => p.device.count(),
|
||||
RefreshToken: () => p.refreshToken.count(),
|
||||
RegistrationCode: () => p.registrationCode.count(),
|
||||
Tab: () => p.tab.count(),
|
||||
TabEvent: () => p.tabEvent.count(),
|
||||
Settlement: () => p.settlement.count(),
|
||||
PlannedTab: () => p.plannedTab.count(),
|
||||
TabReminder: () => p.tabReminder.count(),
|
||||
Product: () => p.product.count(),
|
||||
ProductImage: () => p.productImage.count(),
|
||||
StockAdjustment: () => p.stockAdjustment.count(),
|
||||
RemoteCommand: () => p.remoteCommand.count(),
|
||||
AuditLog: () => p.auditLog.count(),
|
||||
}).map(async ([table, count]) => ({ table, rows: await count() })));
|
||||
return entries;
|
||||
}
|
||||
|
||||
private async findVenue(venueId: string) {
|
||||
const venue = await this.prisma.venue.findUnique({ where: { id: venueId } });
|
||||
if (!venue) {
|
||||
throw new NotFoundException({ code: 'venue_not_found', message: 'Venue not found' });
|
||||
}
|
||||
return venue;
|
||||
}
|
||||
|
||||
private async findUser(userId: string) {
|
||||
const user = await this.prisma.clientUser.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException({ code: 'user_not_found', message: 'User not found' });
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
function parseDetails(details: string | null): unknown {
|
||||
if (!details) return null;
|
||||
try {
|
||||
return JSON.parse(details);
|
||||
} catch {
|
||||
return details;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user