623 lines
26 KiB
TypeScript
623 lines
26 KiB
TypeScript
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<ResetScope, RealtimeTopic[]> = {
|
|
// 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<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;
|
|
}
|
|
}
|