backup and restore

This commit is contained in:
2026-03-15 15:21:27 -05:00
parent e7cfff3eca
commit f858fe4785
16 changed files with 472 additions and 69 deletions

View File

@@ -9,6 +9,7 @@ import {
createAdminUser,
getBackupGuidance,
getAdminDiagnostics,
getSupportLogs,
getSupportSnapshot,
listAdminPermissions,
listAdminRoles,
@@ -50,6 +51,10 @@ adminRouter.get("/support-snapshot", requirePermissions([permissions.adminManage
return ok(response, await getSupportSnapshot());
});
adminRouter.get("/support-logs", requirePermissions([permissions.adminManage]), async (_request, response) => {
return ok(response, getSupportLogs());
});
adminRouter.get("/permissions", requirePermissions([permissions.adminManage]), async (_request, response) => {
return ok(response, await listAdminPermissions());
});

View File

@@ -8,8 +8,8 @@ import type {
AdminUserInput,
SupportSnapshotDto,
AuditEventDto,
SupportLogEntryDto,
} from "@mrp/shared";
import fs from "node:fs/promises";
import { env } from "../../config/env.js";
import { paths } from "../../config/paths.js";
@@ -17,6 +17,7 @@ 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;
@@ -45,6 +46,17 @@ function mapAuditEvent(record: {
};
}
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;
@@ -468,6 +480,7 @@ export async function updateAdminUser(userId: string, payload: AdminUserInput, a
export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
const startupReport = getLatestStartupReport();
const recentSupportLogs = listSupportLogs(50);
const [
companyProfile,
userCount,
@@ -519,8 +532,6 @@ export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
}),
]);
await Promise.all([fs.access(paths.dataDir), fs.access(paths.uploadsDir)]);
return {
serverTime: new Date().toISOString(),
nodeVersion: process.version,
@@ -544,9 +555,10 @@ export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
shipmentCount,
attachmentCount,
auditEventCount,
startupStatus: startupReport.status,
startupChecks: startupReport.checks,
supportLogCount: getSupportLogCount(),
startup: startupReport,
recentAuditEvents: recentAuditEvents.map(mapAuditEvent),
recentSupportLogs: recentSupportLogs.map(mapSupportLogEntry),
};
}
@@ -600,11 +612,70 @@ export function getBackupGuidance(): BackupGuidanceDto {
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 },
@@ -620,5 +691,11 @@ export async function getSupportSnapshot(): Promise<SupportSnapshotDto> {
userCount: diagnostics.userCount,
roleCount: roles,
activeUserEmails: users.map((user) => user.email),
backupGuidance,
recentSupportLogs: diagnostics.recentSupportLogs,
};
}
export function getSupportLogs() {
return listSupportLogs(100).map(mapSupportLogEntry);
}