backup and restore
This commit is contained in:
@@ -11,6 +11,7 @@ import { paths } from "./config/paths.js";
|
||||
import { verifyToken } from "./lib/auth.js";
|
||||
import { getCurrentUserById } from "./lib/current-user.js";
|
||||
import { fail, ok } from "./lib/http.js";
|
||||
import { recordSupportLog } from "./lib/support-log.js";
|
||||
import { adminRouter } from "./modules/admin/router.js";
|
||||
import { authRouter } from "./modules/auth/router.js";
|
||||
import { crmRouter } from "./modules/crm/router.js";
|
||||
@@ -52,6 +53,29 @@ export function createApp() {
|
||||
next();
|
||||
});
|
||||
|
||||
app.use((request, response, next) => {
|
||||
response.on("finish", () => {
|
||||
if (response.locals.supportLogRecorded || response.statusCode < 400 || request.path === "/api/v1/health") {
|
||||
return;
|
||||
}
|
||||
|
||||
recordSupportLog({
|
||||
level: response.statusCode >= 500 ? "ERROR" : "WARN",
|
||||
source: "http-response",
|
||||
message: `${request.method} ${request.originalUrl} returned ${response.statusCode}.`,
|
||||
context: {
|
||||
method: request.method,
|
||||
path: request.originalUrl,
|
||||
statusCode: response.statusCode,
|
||||
actorId: request.authUser?.id ?? null,
|
||||
ip: request.ip,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
app.get("/api/v1/health", (_request, response) => ok(response, { status: "ok" }));
|
||||
app.use("/api/v1/auth", authRouter);
|
||||
app.use("/api/v1/admin", adminRouter);
|
||||
@@ -74,7 +98,19 @@ export function createApp() {
|
||||
});
|
||||
}
|
||||
|
||||
app.use((error: Error, _request: express.Request, response: express.Response, _next: express.NextFunction) => {
|
||||
app.use((error: Error, request: express.Request, response: express.Response, _next: express.NextFunction) => {
|
||||
response.locals.supportLogRecorded = true;
|
||||
recordSupportLog({
|
||||
level: "ERROR",
|
||||
source: "express-error",
|
||||
message: error.message || "Unexpected server error.",
|
||||
context: {
|
||||
method: request.method,
|
||||
path: request.originalUrl,
|
||||
actorId: request.authUser?.id ?? null,
|
||||
stack: error.stack ?? null,
|
||||
},
|
||||
});
|
||||
return fail(response, 500, "INTERNAL_ERROR", error.message || "Unexpected server error.");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { StartupValidationCheckDto } from "@mrp/shared";
|
||||
import type { StartupValidationReportDto } from "@mrp/shared";
|
||||
|
||||
interface StartupValidationReport {
|
||||
status: "PASS" | "WARN" | "FAIL";
|
||||
checks: StartupValidationCheckDto[];
|
||||
}
|
||||
|
||||
let latestStartupReport: StartupValidationReport = {
|
||||
let latestStartupReport: StartupValidationReportDto = {
|
||||
status: "WARN",
|
||||
generatedAt: new Date(0).toISOString(),
|
||||
durationMs: 0,
|
||||
passCount: 0,
|
||||
warnCount: 0,
|
||||
failCount: 0,
|
||||
checks: [],
|
||||
};
|
||||
|
||||
export function setLatestStartupReport(report: StartupValidationReport) {
|
||||
export function setLatestStartupReport(report: StartupValidationReportDto) {
|
||||
latestStartupReport = report;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { StartupValidationCheckDto } from "@mrp/shared";
|
||||
import type { StartupValidationCheckDto, StartupValidationReportDto } from "@mrp/shared";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -6,11 +7,6 @@ import { env } from "../config/env.js";
|
||||
import { paths } from "../config/paths.js";
|
||||
import { prisma } from "./prisma.js";
|
||||
|
||||
interface StartupValidationReport {
|
||||
status: "PASS" | "WARN" | "FAIL";
|
||||
checks: StartupValidationCheckDto[];
|
||||
}
|
||||
|
||||
async function pathExists(targetPath: string) {
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
@@ -20,32 +16,70 @@ async function pathExists(targetPath: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectStartupValidationReport(): Promise<StartupValidationReport> {
|
||||
async function canWritePath(targetPath: string) {
|
||||
try {
|
||||
await fs.access(targetPath, fsConstants.W_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectStartupValidationReport(): Promise<StartupValidationReportDto> {
|
||||
const startedAt = Date.now();
|
||||
const checks: StartupValidationCheckDto[] = [];
|
||||
const dataDirExists = await pathExists(paths.dataDir);
|
||||
const uploadsDirExists = await pathExists(paths.uploadsDir);
|
||||
const prismaDirExists = await pathExists(paths.prismaDir);
|
||||
const databaseFilePath = path.join(paths.prismaDir, "app.db");
|
||||
const databaseFileExists = await pathExists(databaseFilePath);
|
||||
const clientBundlePath = path.join(paths.clientDistDir, "index.html");
|
||||
const clientBundleExists = await pathExists(clientBundlePath);
|
||||
const puppeteerPath = env.PUPPETEER_EXECUTABLE_PATH || "/usr/bin/chromium";
|
||||
const puppeteerExists = await pathExists(puppeteerPath);
|
||||
const dataDirWritable = dataDirExists && (await canWritePath(paths.dataDir));
|
||||
const uploadsDirWritable = uploadsDirExists && (await canWritePath(paths.uploadsDir));
|
||||
|
||||
checks.push({
|
||||
id: "data-dir",
|
||||
label: "Data directory",
|
||||
status: (await pathExists(paths.dataDir)) ? "PASS" : "FAIL",
|
||||
message: (await pathExists(paths.dataDir)) ? `Data directory available at ${paths.dataDir}.` : `Data directory is missing: ${paths.dataDir}.`,
|
||||
status: dataDirExists ? "PASS" : "FAIL",
|
||||
message: dataDirExists ? `Data directory available at ${paths.dataDir}.` : `Data directory is missing: ${paths.dataDir}.`,
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "uploads-dir",
|
||||
label: "Uploads directory",
|
||||
status: (await pathExists(paths.uploadsDir)) ? "PASS" : "FAIL",
|
||||
message: (await pathExists(paths.uploadsDir))
|
||||
? `Uploads directory available at ${paths.uploadsDir}.`
|
||||
: `Uploads directory is missing: ${paths.uploadsDir}.`,
|
||||
status: uploadsDirExists ? "PASS" : "FAIL",
|
||||
message: uploadsDirExists ? `Uploads directory available at ${paths.uploadsDir}.` : `Uploads directory is missing: ${paths.uploadsDir}.`,
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "prisma-dir",
|
||||
label: "Prisma directory",
|
||||
status: (await pathExists(paths.prismaDir)) ? "PASS" : "FAIL",
|
||||
message: (await pathExists(paths.prismaDir))
|
||||
? `Prisma data directory available at ${paths.prismaDir}.`
|
||||
: `Prisma data directory is missing: ${paths.prismaDir}.`,
|
||||
status: prismaDirExists ? "PASS" : "FAIL",
|
||||
message: prismaDirExists ? `Prisma data directory available at ${paths.prismaDir}.` : `Prisma data directory is missing: ${paths.prismaDir}.`,
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "database-file",
|
||||
label: "Database file",
|
||||
status: databaseFileExists ? "PASS" : env.NODE_ENV === "production" ? "FAIL" : "WARN",
|
||||
message: databaseFileExists ? `SQLite database file found at ${databaseFilePath}.` : `SQLite database file is missing: ${databaseFilePath}.`,
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "data-dir-write",
|
||||
label: "Data directory writable",
|
||||
status: dataDirWritable ? "PASS" : "FAIL",
|
||||
message: dataDirWritable ? `Application can write to ${paths.dataDir}.` : `Application cannot write to ${paths.dataDir}.`,
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "uploads-dir-write",
|
||||
label: "Uploads directory writable",
|
||||
status: uploadsDirWritable ? "PASS" : "FAIL",
|
||||
message: uploadsDirWritable ? `Application can write to ${paths.uploadsDir}.` : `Application cannot write to ${paths.uploadsDir}.`,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -69,10 +103,8 @@ export async function collectStartupValidationReport(): Promise<StartupValidatio
|
||||
checks.push({
|
||||
id: "client-dist",
|
||||
label: "Client bundle",
|
||||
status: (await pathExists(path.join(paths.clientDistDir, "index.html"))) ? "PASS" : "FAIL",
|
||||
message: (await pathExists(path.join(paths.clientDistDir, "index.html")))
|
||||
? `Client bundle found at ${paths.clientDistDir}.`
|
||||
: `Production client bundle is missing from ${paths.clientDistDir}.`,
|
||||
status: clientBundleExists ? "PASS" : "FAIL",
|
||||
message: clientBundleExists ? `Client bundle found at ${paths.clientDistDir}.` : `Production client bundle is missing from ${paths.clientDistDir}.`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
@@ -83,8 +115,6 @@ export async function collectStartupValidationReport(): Promise<StartupValidatio
|
||||
});
|
||||
}
|
||||
|
||||
const puppeteerPath = env.PUPPETEER_EXECUTABLE_PATH || "/usr/bin/chromium";
|
||||
const puppeteerExists = await pathExists(puppeteerPath);
|
||||
checks.push({
|
||||
id: "puppeteer-runtime",
|
||||
label: "PDF runtime",
|
||||
@@ -104,6 +134,16 @@ export async function collectStartupValidationReport(): Promise<StartupValidatio
|
||||
: `Client origin is configured as ${env.CLIENT_ORIGIN}.`,
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "jwt-secret",
|
||||
label: "JWT secret",
|
||||
status: env.NODE_ENV === "production" && env.JWT_SECRET === "change-me" ? "WARN" : "PASS",
|
||||
message:
|
||||
env.NODE_ENV === "production" && env.JWT_SECRET === "change-me"
|
||||
? "Production is still using the default JWT secret."
|
||||
: "JWT secret is not using the default production value.",
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "admin-password",
|
||||
label: "Bootstrap admin password",
|
||||
@@ -122,6 +162,11 @@ export async function collectStartupValidationReport(): Promise<StartupValidatio
|
||||
|
||||
return {
|
||||
status,
|
||||
generatedAt: new Date().toISOString(),
|
||||
durationMs: Date.now() - startedAt,
|
||||
passCount: checks.filter((check) => check.status === "PASS").length,
|
||||
warnCount: checks.filter((check) => check.status === "WARN").length,
|
||||
failCount: checks.filter((check) => check.status === "FAIL").length,
|
||||
checks,
|
||||
};
|
||||
}
|
||||
|
||||
46
server/src/lib/support-log.ts
Normal file
46
server/src/lib/support-log.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { SupportLogEntryDto } from "@mrp/shared";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const SUPPORT_LOG_LIMIT = 200;
|
||||
|
||||
const supportLogs: SupportLogEntryDto[] = [];
|
||||
|
||||
function serializeContext(context?: Record<string, unknown>) {
|
||||
if (!context) {
|
||||
return "{}";
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(context);
|
||||
} catch {
|
||||
return JSON.stringify({ serializationError: "Unable to serialize support log context." });
|
||||
}
|
||||
}
|
||||
|
||||
export function recordSupportLog(entry: {
|
||||
level: SupportLogEntryDto["level"];
|
||||
source: string;
|
||||
message: string;
|
||||
context?: Record<string, unknown>;
|
||||
}) {
|
||||
supportLogs.unshift({
|
||||
id: randomUUID(),
|
||||
level: entry.level,
|
||||
source: entry.source,
|
||||
message: entry.message,
|
||||
contextJson: serializeContext(entry.context),
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (supportLogs.length > SUPPORT_LOG_LIMIT) {
|
||||
supportLogs.length = SUPPORT_LOG_LIMIT;
|
||||
}
|
||||
}
|
||||
|
||||
export function listSupportLogs(limit = 50) {
|
||||
return supportLogs.slice(0, Math.max(0, limit));
|
||||
}
|
||||
|
||||
export function getSupportLogCount() {
|
||||
return supportLogs.length;
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -4,14 +4,37 @@ import { bootstrapAppData } from "./lib/bootstrap.js";
|
||||
import { prisma } from "./lib/prisma.js";
|
||||
import { setLatestStartupReport } from "./lib/startup-state.js";
|
||||
import { assertStartupReadiness } from "./lib/startup-validation.js";
|
||||
import { recordSupportLog } from "./lib/support-log.js";
|
||||
|
||||
async function start() {
|
||||
await bootstrapAppData();
|
||||
const startupReport = await assertStartupReadiness();
|
||||
setLatestStartupReport(startupReport);
|
||||
|
||||
recordSupportLog({
|
||||
level: startupReport.status === "PASS" ? "INFO" : startupReport.status === "WARN" ? "WARN" : "ERROR",
|
||||
source: "startup-validation",
|
||||
message: `Startup validation completed with status ${startupReport.status}.`,
|
||||
context: {
|
||||
generatedAt: startupReport.generatedAt,
|
||||
durationMs: startupReport.durationMs,
|
||||
passCount: startupReport.passCount,
|
||||
warnCount: startupReport.warnCount,
|
||||
failCount: startupReport.failCount,
|
||||
},
|
||||
});
|
||||
|
||||
for (const check of startupReport.checks.filter((entry) => entry.status !== "PASS")) {
|
||||
console.warn(`[startup:${check.status.toLowerCase()}] ${check.label}: ${check.message}`);
|
||||
recordSupportLog({
|
||||
level: check.status === "WARN" ? "WARN" : "ERROR",
|
||||
source: "startup-check",
|
||||
message: `${check.label}: ${check.message}`,
|
||||
context: {
|
||||
checkId: check.id,
|
||||
status: check.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const app = createApp();
|
||||
@@ -30,6 +53,14 @@ async function start() {
|
||||
|
||||
start().catch(async (error) => {
|
||||
console.error(error);
|
||||
recordSupportLog({
|
||||
level: "ERROR",
|
||||
source: "server-startup",
|
||||
message: error instanceof Error ? error.message : "Server startup failed.",
|
||||
context: {
|
||||
stack: error instanceof Error ? error.stack ?? null : null,
|
||||
},
|
||||
});
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user