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(); 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 { const permissions = await prisma.permission.findMany({ orderBy: [{ key: "asc" }], }); return permissions.map((permission) => ({ key: permission.key, description: permission.description, })); } export async function listAdminRoles(): Promise { 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 { 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 { 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 { 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); }