Files
mrp/server/src/modules/admin/service.ts
2026-03-15 15:21:27 -05:00

702 lines
20 KiB
TypeScript

import type {
AdminDiagnosticsDto,
BackupGuidanceDto,
AdminPermissionOptionDto,
AdminRoleDto,
AdminRoleInput,
AdminUserDto,
AdminUserInput,
SupportSnapshotDto,
AuditEventDto,
SupportLogEntryDto,
} from "@mrp/shared";
import { env } from "../../config/env.js";
import { paths } from "../../config/paths.js";
import { logAuditEvent } from "../../lib/audit.js";
import { hashPassword } from "../../lib/password.js";
import { prisma } from "../../lib/prisma.js";
import { getLatestStartupReport } from "../../lib/startup-state.js";
import { getSupportLogCount, listSupportLogs } from "../../lib/support-log.js";
function mapAuditEvent(record: {
id: string;
actorId: string | null;
entityType: string;
entityId: string | null;
action: string;
summary: string;
metadataJson: string;
createdAt: Date;
actor: {
firstName: string;
lastName: string;
} | null;
}): AuditEventDto {
return {
id: record.id,
actorId: record.actorId,
actorName: record.actor ? `${record.actor.firstName} ${record.actor.lastName}`.trim() : null,
entityType: record.entityType,
entityId: record.entityId,
action: record.action,
summary: record.summary,
metadataJson: record.metadataJson,
createdAt: record.createdAt.toISOString(),
};
}
function mapSupportLogEntry(record: SupportLogEntryDto): SupportLogEntryDto {
return {
id: record.id,
level: record.level,
source: record.source,
message: record.message,
contextJson: record.contextJson,
createdAt: record.createdAt,
};
}
function mapRole(record: {
id: string;
name: string;
description: string;
createdAt: Date;
updatedAt: Date;
rolePermissions: Array<{
permission: {
key: string;
};
}>;
_count: {
userRoles: number;
};
}): AdminRoleDto {
return {
id: record.id,
name: record.name,
description: record.description,
permissionKeys: record.rolePermissions.map((rolePermission) => rolePermission.permission.key).sort(),
userCount: record._count.userRoles,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
function mapUser(record: {
id: string;
email: string;
firstName: string;
lastName: string;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
userRoles: Array<{
role: {
id: string;
name: string;
rolePermissions: Array<{
permission: {
key: string;
};
}>;
};
}>;
}): AdminUserDto {
const permissionKeys = new Set<string>();
for (const userRole of record.userRoles) {
for (const rolePermission of userRole.role.rolePermissions) {
permissionKeys.add(rolePermission.permission.key);
}
}
return {
id: record.id,
email: record.email,
firstName: record.firstName,
lastName: record.lastName,
isActive: record.isActive,
roleIds: record.userRoles.map((userRole) => userRole.role.id),
roleNames: record.userRoles.map((userRole) => userRole.role.name),
permissionKeys: [...permissionKeys].sort(),
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
async function validatePermissionKeys(permissionKeys: string[]) {
const uniquePermissionKeys = [...new Set(permissionKeys)];
const permissions = await prisma.permission.findMany({
where: {
key: {
in: uniquePermissionKeys,
},
},
select: {
id: true,
key: true,
},
});
if (permissions.length !== uniquePermissionKeys.length) {
return { ok: false as const, reason: "One or more selected permissions are invalid." };
}
return { ok: true as const, permissions };
}
async function validateRoleIds(roleIds: string[]) {
const uniqueRoleIds = [...new Set(roleIds)];
const roles = await prisma.role.findMany({
where: {
id: {
in: uniqueRoleIds,
},
},
select: {
id: true,
name: true,
},
});
if (roles.length !== uniqueRoleIds.length) {
return { ok: false as const, reason: "One or more selected roles are invalid." };
}
return { ok: true as const, roles };
}
export async function listAdminPermissions(): Promise<AdminPermissionOptionDto[]> {
const permissions = await prisma.permission.findMany({
orderBy: [{ key: "asc" }],
});
return permissions.map((permission) => ({
key: permission.key,
description: permission.description,
}));
}
export async function listAdminRoles(): Promise<AdminRoleDto[]> {
const roles = await prisma.role.findMany({
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
_count: {
select: {
userRoles: true,
},
},
},
orderBy: [{ name: "asc" }],
});
return roles.map(mapRole);
}
export async function createAdminRole(payload: AdminRoleInput, actorId?: string | null) {
const validatedPermissions = await validatePermissionKeys(payload.permissionKeys);
if (!validatedPermissions.ok) {
return { ok: false as const, reason: validatedPermissions.reason };
}
const role = await prisma.role.create({
data: {
name: payload.name.trim(),
description: payload.description,
rolePermissions: {
create: validatedPermissions.permissions.map((permission) => ({
permissionId: permission.id,
})),
},
},
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
_count: {
select: {
userRoles: true,
},
},
},
});
await logAuditEvent({
actorId,
entityType: "role",
entityId: role.id,
action: "created",
summary: `Created role ${role.name}.`,
metadata: {
name: role.name,
permissionKeys: role.rolePermissions.map((rolePermission) => rolePermission.permission.key),
},
});
return { ok: true as const, role: mapRole(role) };
}
export async function updateAdminRole(roleId: string, payload: AdminRoleInput, actorId?: string | null) {
const existingRole = await prisma.role.findUnique({
where: { id: roleId },
select: { id: true, name: true },
});
if (!existingRole) {
return { ok: false as const, reason: "Role was not found." };
}
const validatedPermissions = await validatePermissionKeys(payload.permissionKeys);
if (!validatedPermissions.ok) {
return { ok: false as const, reason: validatedPermissions.reason };
}
const role = await prisma.role.update({
where: { id: roleId },
data: {
name: payload.name.trim(),
description: payload.description,
rolePermissions: {
deleteMany: {},
create: validatedPermissions.permissions.map((permission) => ({
permissionId: permission.id,
})),
},
},
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
_count: {
select: {
userRoles: true,
},
},
},
});
await logAuditEvent({
actorId,
entityType: "role",
entityId: role.id,
action: "updated",
summary: `Updated role ${role.name}.`,
metadata: {
previousName: existingRole.name,
name: role.name,
permissionKeys: role.rolePermissions.map((rolePermission) => rolePermission.permission.key),
},
});
return { ok: true as const, role: mapRole(role) };
}
export async function listAdminUsers(): Promise<AdminUserDto[]> {
const users = await prisma.user.findMany({
include: {
userRoles: {
include: {
role: {
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
},
},
},
},
},
orderBy: [{ firstName: "asc" }, { lastName: "asc" }, { email: "asc" }],
});
return users.map(mapUser);
}
export async function createAdminUser(payload: AdminUserInput, actorId?: string | null) {
if (!payload.password || payload.password.trim().length < 8) {
return { ok: false as const, reason: "A password with at least 8 characters is required for new users." };
}
const validatedRoles = await validateRoleIds(payload.roleIds);
if (!validatedRoles.ok) {
return { ok: false as const, reason: validatedRoles.reason };
}
const user = await prisma.user.create({
data: {
email: payload.email.trim().toLowerCase(),
firstName: payload.firstName.trim(),
lastName: payload.lastName.trim(),
isActive: payload.isActive,
passwordHash: await hashPassword(payload.password.trim()),
userRoles: {
create: validatedRoles.roles.map((role) => ({
roleId: role.id,
assignedBy: actorId ?? null,
})),
},
},
include: {
userRoles: {
include: {
role: {
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
},
},
},
},
},
});
await logAuditEvent({
actorId,
entityType: "user",
entityId: user.id,
action: "created",
summary: `Created user account for ${user.email}.`,
metadata: {
email: user.email,
isActive: user.isActive,
roleNames: user.userRoles.map((userRole) => userRole.role.name),
},
});
return { ok: true as const, user: mapUser(user) };
}
export async function updateAdminUser(userId: string, payload: AdminUserInput, actorId?: string | null) {
const existingUser = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
},
});
if (!existingUser) {
return { ok: false as const, reason: "User was not found." };
}
const validatedRoles = await validateRoleIds(payload.roleIds);
if (!validatedRoles.ok) {
return { ok: false as const, reason: validatedRoles.reason };
}
const data = {
email: payload.email.trim().toLowerCase(),
firstName: payload.firstName.trim(),
lastName: payload.lastName.trim(),
isActive: payload.isActive,
...(payload.password?.trim()
? {
passwordHash: await hashPassword(payload.password.trim()),
}
: {}),
userRoles: {
deleteMany: {},
create: validatedRoles.roles.map((role) => ({
roleId: role.id,
assignedBy: actorId ?? null,
})),
},
};
const user = await prisma.user.update({
where: { id: userId },
data,
include: {
userRoles: {
include: {
role: {
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
},
},
},
},
},
});
await logAuditEvent({
actorId,
entityType: "user",
entityId: user.id,
action: "updated",
summary: `Updated user account for ${user.email}.`,
metadata: {
previousEmail: existingUser.email,
email: user.email,
isActive: user.isActive,
roleNames: user.userRoles.map((userRole) => userRole.role.name),
passwordReset: Boolean(payload.password?.trim()),
},
});
return { ok: true as const, user: mapUser(user) };
}
export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
const startupReport = getLatestStartupReport();
const recentSupportLogs = listSupportLogs(50);
const [
companyProfile,
userCount,
activeUserCount,
roleCount,
permissionCount,
customerCount,
vendorCount,
inventoryItemCount,
warehouseCount,
workOrderCount,
projectCount,
purchaseOrderCount,
salesQuoteCount,
salesOrderCount,
shipmentCount,
attachmentCount,
auditEventCount,
recentAuditEvents,
] = await Promise.all([
prisma.companyProfile.findFirst({ where: { isActive: true }, select: { id: true } }),
prisma.user.count(),
prisma.user.count({ where: { isActive: true } }),
prisma.role.count(),
prisma.permission.count(),
prisma.customer.count(),
prisma.vendor.count(),
prisma.inventoryItem.count(),
prisma.warehouse.count(),
prisma.workOrder.count(),
prisma.project.count(),
prisma.purchaseOrder.count(),
prisma.salesQuote.count(),
prisma.salesOrder.count(),
prisma.shipment.count(),
prisma.fileAttachment.count(),
prisma.auditEvent.count(),
prisma.auditEvent.findMany({
include: {
actor: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ createdAt: "desc" }],
take: 25,
}),
]);
return {
serverTime: new Date().toISOString(),
nodeVersion: process.version,
databaseUrl: env.DATABASE_URL,
dataDir: paths.dataDir,
uploadsDir: paths.uploadsDir,
clientOrigin: env.CLIENT_ORIGIN,
companyProfilePresent: Boolean(companyProfile),
userCount,
activeUserCount,
roleCount,
permissionCount,
customerCount,
vendorCount,
inventoryItemCount,
warehouseCount,
workOrderCount,
projectCount,
purchaseOrderCount,
salesDocumentCount: salesQuoteCount + salesOrderCount,
shipmentCount,
attachmentCount,
auditEventCount,
supportLogCount: getSupportLogCount(),
startup: startupReport,
recentAuditEvents: recentAuditEvents.map(mapAuditEvent),
recentSupportLogs: recentSupportLogs.map(mapSupportLogEntry),
};
}
export function getBackupGuidance(): BackupGuidanceDto {
return {
dataPath: paths.dataDir,
databasePath: `${paths.prismaDir}/app.db`,
uploadsPath: paths.uploadsDir,
recommendedBackupTarget: "/mnt/user/backups/mrp-codex",
backupSteps: [
{
id: "stop-app",
label: "Stop writes before copying data",
detail: "Stop the container or application process before copying the data directory so SQLite and attachments stay consistent.",
},
{
id: "copy-data",
label: "Back up the full data directory",
detail: `Copy the full data directory at ${paths.dataDir}, not just the SQLite file, so uploads and attachments are preserved with the database.`,
},
{
id: "retain-metadata",
label: "Keep timestamps and structure",
detail: "Preserve directory structure, filenames, and timestamps during backup so support recovery remains straightforward.",
},
{
id: "record-build",
label: "Record image/version context",
detail: "Capture the deployed image tag or commit alongside the backup so schema and runtime expectations are clear during restore.",
},
],
restoreSteps: [
{
id: "stop-target",
label: "Stop the target app before restore",
detail: "Do not restore into a running instance. Stop the target container or process before replacing the data directory.",
},
{
id: "replace-data",
label: "Restore the full data directory",
detail: `Replace the target data directory with the backed-up copy so ${paths.prismaDir}/app.db and uploads come back together.`,
},
{
id: "start-and-migrate",
label: "Start the app and let migrations run",
detail: "Restart the application after restore and allow the normal startup migration flow to complete before validation.",
},
{
id: "validate-core",
label: "Validate login, files, and PDFs",
detail: "Confirm admin login, attachment access, and PDF generation after restore to verify the operational surface is healthy.",
},
],
verificationChecklist: [
{
id: "backup-size-check",
label: "Confirm backup contains data and uploads",
detail: "Verify the backup archive or copied directory includes the SQLite database and uploads tree rather than only one of them.",
evidence: "Directory listing or archive manifest showing prisma/app.db and uploads/ content.",
},
{
id: "timestamp-check",
label: "Check backup freshness",
detail: "Confirm the backup timestamp matches the expected backup window and is newer than the last major data-entry period you need to protect.",
evidence: "Backup timestamp recorded in your scheduler, NAS share, or copied folder metadata.",
},
{
id: "snapshot-export",
label: "Capture a support snapshot with the backup",
detail: "Export the support snapshot from diagnostics when taking a formal backup so the runtime state and active-user footprint are recorded alongside it.",
evidence: "JSON support snapshot stored with the backup set or support ticket.",
},
{
id: "app-stop-check",
label: "Verify writes were stopped before copy",
detail: "Use a controlled maintenance stop or container stop before backup to reduce the chance of a partial SQLite copy.",
evidence: "Maintenance log entry, Docker stop event, or operator note recorded with the backup.",
},
],
restoreDrillSteps: [
{
id: "prepare-drill-target",
label: "Prepare isolated restore target",
detail: "Restore into an isolated container or duplicate environment instead of the live production instance.",
expectedOutcome: "A clean target environment is ready to receive the backed-up data directory without impacting production.",
},
{
id: "load-backed-up-data",
label: "Load the full backup set",
detail: `Restore the full backed-up data directory so ${paths.prismaDir}/app.db and uploads are returned together.`,
expectedOutcome: "The restore target contains both database and file assets with the original directory structure intact.",
},
{
id: "boot-restored-app",
label: "Start the restored application",
detail: "Launch the restored app and allow startup validation plus migrations to complete normally.",
expectedOutcome: "The application starts without startup-validation failures and the diagnostics page loads.",
},
{
id: "run-functional-checks",
label: "Run post-restore functional checks",
detail: "Verify login, one attachment download, one PDF render, and one representative transactional detail page such as inventory, purchasing, or shipping.",
expectedOutcome: "Core operational flows work in the restored environment and file/PDF dependencies remain valid.",
},
{
id: "record-drill-results",
label: "Record restore-drill results",
detail: "Capture the drill date, backup source used, startup status, and any gaps discovered so future recovery work improves over time.",
expectedOutcome: "A dated restore-drill record exists for support and disaster-recovery review.",
},
],
};
}
export async function getSupportSnapshot(): Promise<SupportSnapshotDto> {
const diagnostics = await getAdminDiagnostics();
const backupGuidance = getBackupGuidance();
const [users, roles] = await Promise.all([
prisma.user.findMany({
where: { isActive: true },
select: { email: true },
orderBy: [{ email: "asc" }],
}),
prisma.role.count(),
]);
return {
generatedAt: new Date().toISOString(),
diagnostics,
userCount: diagnostics.userCount,
roleCount: roles,
activeUserEmails: users.map((user) => user.email),
backupGuidance,
recentSupportLogs: diagnostics.recentSupportLogs,
};
}
export function getSupportLogs() {
return listSupportLogs(100).map(mapSupportLogEntry);
}