2026-03-15 14:47:58 -05:00
import type {
AdminDiagnosticsDto ,
2026-03-15 15:04:18 -05:00
BackupGuidanceDto ,
2026-03-15 14:47:58 -05:00
AdminPermissionOptionDto ,
AdminRoleDto ,
AdminRoleInput ,
AdminUserDto ,
AdminUserInput ,
2026-03-15 15:04:18 -05:00
SupportSnapshotDto ,
2026-03-15 14:47:58 -05:00
AuditEventDto ,
2026-03-15 15:21:27 -05:00
SupportLogEntryDto ,
2026-03-15 14:47:58 -05:00
} from "@mrp/shared" ;
2026-03-15 14:11:21 -05:00
import { env } from "../../config/env.js" ;
import { paths } from "../../config/paths.js" ;
2026-03-15 14:47:58 -05:00
import { logAuditEvent } from "../../lib/audit.js" ;
import { hashPassword } from "../../lib/password.js" ;
2026-03-15 14:11:21 -05:00
import { prisma } from "../../lib/prisma.js" ;
2026-03-15 14:57:41 -05:00
import { getLatestStartupReport } from "../../lib/startup-state.js" ;
2026-03-15 15:21:27 -05:00
import { getSupportLogCount , listSupportLogs } from "../../lib/support-log.js" ;
2026-03-15 14:11:21 -05:00
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 ( ) ,
} ;
}
2026-03-15 15:21:27 -05:00
function mapSupportLogEntry ( record : SupportLogEntryDto ) : SupportLogEntryDto {
return {
id : record.id ,
level : record.level ,
source : record.source ,
message : record.message ,
contextJson : record.contextJson ,
createdAt : record.createdAt ,
} ;
}
2026-03-15 14:47:58 -05:00
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 ) } ;
}
2026-03-15 14:11:21 -05:00
export async function getAdminDiagnostics ( ) : Promise < AdminDiagnosticsDto > {
2026-03-15 14:57:41 -05:00
const startupReport = getLatestStartupReport ( ) ;
2026-03-15 15:21:27 -05:00
const recentSupportLogs = listSupportLogs ( 50 ) ;
2026-03-15 14:11:21 -05:00
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 ,
2026-03-15 15:21:27 -05:00
supportLogCount : getSupportLogCount ( ) ,
startup : startupReport ,
2026-03-15 14:11:21 -05:00
recentAuditEvents : recentAuditEvents.map ( mapAuditEvent ) ,
2026-03-15 15:21:27 -05:00
recentSupportLogs : recentSupportLogs.map ( mapSupportLogEntry ) ,
2026-03-15 14:11:21 -05:00
} ;
}
2026-03-15 15:04:18 -05:00
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." ,
} ,
] ,
2026-03-15 15:21:27 -05:00
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." ,
} ,
] ,
2026-03-15 15:04:18 -05:00
} ;
}
export async function getSupportSnapshot ( ) : Promise < SupportSnapshotDto > {
const diagnostics = await getAdminDiagnostics ( ) ;
2026-03-15 15:21:27 -05:00
const backupGuidance = getBackupGuidance ( ) ;
2026-03-15 15:04:18 -05:00
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 ) ,
2026-03-15 15:21:27 -05:00
backupGuidance ,
recentSupportLogs : diagnostics.recentSupportLogs ,
2026-03-15 15:04:18 -05:00
} ;
}
2026-03-15 15:21:27 -05:00
export function getSupportLogs() {
return listSupportLogs ( 100 ) . map ( mapSupportLogEntry ) ;
}