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

@@ -26,7 +26,9 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a
- admin diagnostics with runtime footprint, record counts, and persisted audit-trail visibility
- admin user management with account creation, activation, role assignment, and role-permission editing
- CRM/shipping audit coverage and startup validation surfaced through the admin diagnostics workflow
- backup/restore guidance and exportable support snapshots in the admin diagnostics workflow
- backup/restore guidance, richer startup diagnostics, and exportable support bundles in the admin diagnostics workflow
- backup verification checklist and restore-drill runbook in the admin diagnostics workflow
- support-log viewing and support debugging helpers in the admin diagnostics workflow
- Puppeteer PDF foundation
- single-container Docker deployment
@@ -123,8 +125,8 @@ If implementation changes invalidate those docs, update them in the same change
Near-term priorities are:
1. Backup verification checklist and restore drill guidance
2. Deeper startup diagnostics and support export helpers
1. Better user and session visibility for operational admins
2. Safer destructive-action confirmations and recovery messaging
When adding new modules, preserve the ability to extend the system without refactoring the existing app shell.

View File

@@ -6,6 +6,10 @@ This file is the running release and change log for MRP Codex. Keep it updated w
### Added
- Support-log capture for startup warnings, HTTP failures, and server errors, surfaced through admin diagnostics
- Exportable support bundles that now include backup guidance and recent support logs for support handoff
- Deeper startup diagnostics with writable-path checks, database-file validation, startup timing, and pass/warn/fail rollups
- Backup verification checklist and restore-drill runbook surfaced in admin diagnostics
- Backup/restore guidance surfaced in admin diagnostics with exportable support snapshot JSON for support handoff
- CRM customer/vendor changes and shipping mutations now feed the shared audit trail
- Startup validation now runs during server boot and surfaces readiness checks in admin diagnostics
@@ -47,7 +51,9 @@ This file is the running release and change log for MRP Codex. Keep it updated w
- The client entry bundle now stays lighter by loading major modules on demand instead of importing all route pages eagerly in `main.tsx`
- Company settings now acts as the staging area for admin surfaces while user administration lives on its own dedicated page instead of inside the company-profile form
- Admin diagnostics now includes startup-readiness status alongside runtime footprint and recent audit activity
- Admin diagnostics now includes structured startup summaries and a dedicated support-log view for faster debugging
- Roadmap and project docs now treat backup verification checklist and restore drill guidance as the next active priority after the backup/support-tooling slice
- Roadmap and project docs now treat user/session visibility and destructive-action safety as the next active priorities after the diagnostics/support-debugging slices
## 2026-03-15

View File

@@ -30,7 +30,9 @@ This repository implements the platform foundation milestone:
- admin diagnostics with runtime footprint, storage visibility, record counts, and recent audit activity
- admin user management with account creation, activation, role assignment, and role-permission editing
- CRM/shipping audit coverage and startup validation surfaced through diagnostics
- backup/restore guidance and exportable support snapshots in diagnostics
- backup/restore guidance, richer startup diagnostics, and exportable support bundles in diagnostics
- backup verification checklist and restore-drill runbook in diagnostics
- support-log viewing and support debugging helpers in diagnostics
- Dockerized single-container deployment
- Puppeteer PDF pipeline foundation
@@ -66,5 +68,5 @@ This repository implements the platform foundation milestone:
## Next roadmap candidates
- backup verification checklist and restore drill guidance
- deeper startup diagnostics and support export helpers
- better user and session visibility for operational admins
- safer destructive-action confirmations and recovery messaging

View File

@@ -29,7 +29,9 @@ Current foundation scope includes:
- admin diagnostics with runtime footprint, record counts, and recent audit-trail visibility
- admin user management with account creation, activation, role assignment, and role-permission editing
- CRM and shipping audit coverage plus startup validation surfaced through the admin diagnostics page
- backup/restore guidance and exportable support snapshots in the admin diagnostics workflow
- backup/restore guidance, richer startup diagnostics, and exportable support bundles in the admin diagnostics workflow
- backup verification checklist and restore-drill runbook surfaced in admin diagnostics
- support-log viewing and support debugging helpers in admin diagnostics
- route-level code-splitting and vendor chunking for lighter initial client loads
- file storage and PDF rendering
@@ -51,14 +53,14 @@ Current completed foundation areas:
Near-term priorities:
1. Backup verification checklist and restore drill guidance
2. Deeper startup diagnostics and support export helpers
1. Better user and session visibility for operational admins
2. Safer destructive-action confirmations and recovery messaging
Revisit / deferred items:
- local Windows Prisma migration reliability
- backup verification checklist and restore drill depth
- deeper startup diagnostics and support export helpers
- better user and session visibility for operational admins
- safer destructive-action confirmations and recovery messaging
Dashboard direction:
@@ -351,14 +353,16 @@ The current admin operations slice supports:
- an admin diagnostics page for runtime footprint, data/storage path visibility, key record counts, and recent audit activity
- a dedicated user-management page for account creation, activation, role assignment, password reset-style updates, and role-permission administration
- CRM customer/vendor changes and shipping mutations now flow into the shared audit trail
- startup validation now checks storage paths, database connectivity, client bundle readiness, Chromium availability, and risky production defaults during server boot
- backup and restore guidance now surfaces directly in diagnostics, along with exportable support snapshot JSON for support handoff
- startup validation now checks storage paths, writable storage readiness, database connectivity, client bundle readiness, Chromium availability, and risky production defaults during server boot
- backup and restore guidance now surfaces directly in diagnostics, along with exportable support bundles for support handoff
- support logs now capture startup warnings, HTTP failures, and server errors for admin-side debugging review
- backup verification items and restore-drill expected outcomes now live in the admin runbook surface
- operator-facing review of recent high-impact changes without direct database access
Current follow-up direction:
- backup verification checklist and restore drill guidance
- deeper startup diagnostics and support export helpers
- better user and session visibility for operational admins
- safer destructive-action confirmations and recovery messaging
## UI Notes

View File

@@ -51,8 +51,10 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
- Admin diagnostics screen with runtime footprint, record counts, storage-path visibility, and recent audit activity
- Dedicated user-management screen for account creation, activation, role assignment, and role-permission editing
- CRM customer/vendor changes and shipping mutations covered by the shared audit trail
- Startup validation during server boot with checks for storage paths, database connectivity, client bundle readiness, Chromium availability, and risky production defaults
- Backup/restore guidance and exportable support snapshots surfaced through the admin diagnostics workflow
- Startup validation during server boot with checks for storage paths, writable directories, database connectivity, client bundle readiness, Chromium availability, and risky production defaults
- Backup/restore guidance and exportable support bundles surfaced through the admin diagnostics workflow
- Backup verification checklist and restore-drill runbook surfaced through the admin diagnostics workflow
- Support-log viewing for startup warnings, HTTP failures, and server errors surfaced through the admin diagnostics workflow
- Route-level frontend code-splitting and vendor chunking to keep the initial client payload lighter
- SKU-searchable BOM component selection for inventory-scale datasets
- Theme persistence fixes and denser responsive workspace layouts
@@ -259,7 +261,8 @@ Foundation slice shipped:
- Admin diagnostics screen for runtime footprint, storage visibility, key record counts, and recent audit activity
- Expanded role-management UI with account creation, activation, role assignment, and permission administration
- CRM customer/vendor changes and shipping mutations covered by the shared audit trail
- Startup validation during server boot with checks for storage paths, database connectivity, client bundle readiness, Chromium availability, and risky production defaults
- Startup validation during server boot with checks for storage paths, writable directories, database connectivity, client bundle readiness, Chromium availability, and risky production defaults
- Backup/restore guidance, support-bundle exports, and support-log viewing surfaced through the admin diagnostics workflow
- Expanded role management UI
- Permission assignment administration
@@ -273,7 +276,7 @@ QOL subfeatures:
- Safer destructive-action confirmations and recovery messaging
- Better user/session visibility for operational admins
- More explicit environment validation on startup
- Log-view and export helpers for support/debugging
- Support-log filtering, retention controls, and broader support-package polish
- Backup verification checklist and restore drill guidance
## Revisit / Deferred Items
@@ -294,5 +297,5 @@ QOL subfeatures:
## Near-term priority order
1. Backup verification checklist and restore drill guidance
2. Deeper startup diagnostics and support export helpers
1. Better user and session visibility for operational admins
2. Safer destructive-action confirmations and recovery messaging

View File

@@ -36,7 +36,7 @@
- Organize domain modules under `src/modules/<domain>`.
- Keep HTTP routers thin; place business logic in services.
- Centralize Prisma access, auth middleware, and file storage utilities in `src/lib`.
- Centralize Prisma access, auth middleware, file storage utilities, startup validation, and support logging in `src/lib`.
- Store persistence-related constants under `src/config`.
- Serve the built frontend from the API layer in production.

View File

@@ -4,6 +4,7 @@ import type {
AdminPermissionOptionDto,
AdminRoleDto,
AdminRoleInput,
SupportLogEntryDto,
SupportSnapshotDto,
AdminUserDto,
AdminUserInput,
@@ -144,6 +145,9 @@ export const api = {
getSupportSnapshot(token: string) {
return request<SupportSnapshotDto>("/api/v1/admin/support-snapshot", undefined, token);
},
getSupportLogs(token: string) {
return request<SupportLogEntryDto[]>("/api/v1/admin/support-logs", undefined, token);
},
getAdminPermissions(token: string) {
return request<AdminPermissionOptionDto[]>("/api/v1/admin/permissions", undefined, token);
},

View File

@@ -1,4 +1,4 @@
import type { AdminDiagnosticsDto, BackupGuidanceDto } from "@mrp/shared";
import type { AdminDiagnosticsDto, BackupGuidanceDto, SupportLogEntryDto } from "@mrp/shared";
import { Link } from "react-router-dom";
import { useEffect, useState } from "react";
@@ -21,6 +21,7 @@ export function AdminDiagnosticsPage() {
const { token } = useAuth();
const [diagnostics, setDiagnostics] = useState<AdminDiagnosticsDto | null>(null);
const [backupGuidance, setBackupGuidance] = useState<BackupGuidanceDto | null>(null);
const [supportLogs, setSupportLogs] = useState<SupportLogEntryDto[]>([]);
const [status, setStatus] = useState("Loading diagnostics...");
useEffect(() => {
@@ -30,13 +31,14 @@ export function AdminDiagnosticsPage() {
let active = true;
Promise.all([api.getAdminDiagnostics(token), api.getBackupGuidance(token)])
.then(([nextDiagnostics, nextBackupGuidance]) => {
Promise.all([api.getAdminDiagnostics(token), api.getBackupGuidance(token), api.getSupportLogs(token)])
.then(([nextDiagnostics, nextBackupGuidance, nextSupportLogs]) => {
if (!active) {
return;
}
setDiagnostics(nextDiagnostics);
setBackupGuidance(nextBackupGuidance);
setSupportLogs(nextSupportLogs);
setStatus("Diagnostics loaded.");
})
.catch((error: Error) => {
@@ -71,10 +73,28 @@ export function AdminDiagnosticsPage() {
setStatus("Support snapshot exported.");
}
async function handleExportSupportLogs() {
if (!token) {
return;
}
const logs = await api.getSupportLogs(token);
const blob = new Blob([JSON.stringify(logs, null, 2)], { type: "application/json" });
const objectUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objectUrl;
link.download = `mrp-codex-support-logs-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
link.click();
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000);
setSupportLogs(logs);
setStatus("Support logs exported.");
}
const summaryCards = [
["Server time", formatDateTime(diagnostics.serverTime)],
["Node runtime", diagnostics.nodeVersion],
["Audit events", diagnostics.auditEventCount.toString()],
["Support logs", diagnostics.supportLogCount.toString()],
["Active users", `${diagnostics.activeUserCount} / ${diagnostics.userCount}`],
["Sales docs", diagnostics.salesDocumentCount.toString()],
["Work orders", diagnostics.workOrderCount.toString()],
@@ -96,12 +116,18 @@ export function AdminDiagnosticsPage() {
];
const startupStatusTone =
diagnostics.startupStatus === "PASS"
diagnostics.startup.status === "PASS"
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-200"
: diagnostics.startupStatus === "WARN"
: diagnostics.startup.status === "WARN"
? "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-200"
: "bg-rose-100 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200";
const startupSummaryCards = [
["Generated", formatDateTime(diagnostics.startup.generatedAt)],
["Duration", `${diagnostics.startup.durationMs} ms`],
["Pass / Warn / Fail", `${diagnostics.startup.passCount} / ${diagnostics.startup.warnCount} / ${diagnostics.startup.failCount}`],
];
return (
<div className="space-y-6">
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
@@ -119,7 +145,14 @@ export function AdminDiagnosticsPage() {
onClick={handleExportSupportSnapshot}
className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text"
>
Export support snapshot
Export support bundle
</button>
<button
type="button"
onClick={handleExportSupportLogs}
className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text"
>
Export support logs
</button>
<Link to="/settings/users" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
User management
@@ -178,6 +211,32 @@ export function AdminDiagnosticsPage() {
</div>
</div>
</div>
<div className="mt-5 grid gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-line/70 bg-page/70 p-4">
<p className="text-sm font-semibold text-text">Backup verification checklist</p>
<div className="mt-3 space-y-3">
{backupGuidance.verificationChecklist.map((item) => (
<div key={item.id}>
<p className="text-sm font-semibold text-text">{item.label}</p>
<p className="mt-1 text-sm text-muted">{item.detail}</p>
<p className="mt-1 text-xs text-muted">Evidence: {item.evidence}</p>
</div>
))}
</div>
</div>
<div className="rounded-2xl border border-line/70 bg-page/70 p-4">
<p className="text-sm font-semibold text-text">Restore drill runbook</p>
<div className="mt-3 space-y-3">
{backupGuidance.restoreDrillSteps.map((step) => (
<div key={step.id}>
<p className="text-sm font-semibold text-text">{step.label}</p>
<p className="mt-1 text-sm text-muted">{step.detail}</p>
<p className="mt-1 text-xs text-muted">Expected outcome: {step.expectedOutcome}</p>
</div>
))}
</div>
</div>
</div>
</section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
@@ -187,11 +246,11 @@ export function AdminDiagnosticsPage() {
<h3 className="mt-2 text-lg font-bold text-text">Boot-time readiness checks</h3>
</div>
<span className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${startupStatusTone}`}>
{diagnostics.startupStatus}
{diagnostics.startup.status}
</span>
</div>
<div className="mt-5 grid gap-3 xl:grid-cols-2">
{diagnostics.startupChecks.map((check) => (
{diagnostics.startup.checks.map((check) => (
<div key={check.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-text">{check.label}</p>
@@ -201,6 +260,14 @@ export function AdminDiagnosticsPage() {
</div>
))}
</div>
<div className="mt-5 grid gap-3 lg:grid-cols-3">
{startupSummaryCards.map(([label, value]) => (
<div key={label} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
<p className="mt-2 text-sm text-text">{value}</p>
</div>
))}
</div>
</section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
@@ -215,6 +282,49 @@ export function AdminDiagnosticsPage() {
</div>
</section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Support Logs</p>
<h3 className="mt-2 text-lg font-bold text-text">Recent runtime warnings and failures</h3>
</div>
<p className="text-sm text-muted">{supportLogs.length} entries loaded</p>
</div>
<div className="mt-5 overflow-x-auto">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead>
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
<th className="px-3 py-3">When</th>
<th className="px-3 py-3">Level</th>
<th className="px-3 py-3">Source</th>
<th className="px-3 py-3">Message</th>
<th className="px-3 py-3">Context</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70">
{supportLogs.map((entry) => {
const context = parseMetadata(entry.contextJson);
return (
<tr key={entry.id} className="align-top">
<td className="px-3 py-3 text-muted">{formatDateTime(entry.createdAt)}</td>
<td className="px-3 py-3">
<span className="rounded-full bg-page px-2 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-text">
{entry.level}
</span>
</td>
<td className="px-3 py-3 text-text">{entry.source}</td>
<td className="px-3 py-3 text-text">{entry.message}</td>
<td className="px-3 py-3 text-xs text-muted">
{Object.keys(context).length > 0 ? JSON.stringify(context) : "No context"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<div className="flex items-center justify-between gap-3">
<div>

View File

@@ -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.");
});

View File

@@ -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;
}

View File

@@ -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,
};
}

View 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;
}

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);
}

View File

@@ -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);
});

View File

@@ -60,12 +60,30 @@ export interface StartupValidationCheckDto {
message: string;
}
export interface StartupValidationReportDto {
status: "PASS" | "WARN" | "FAIL";
generatedAt: string;
durationMs: number;
passCount: number;
warnCount: number;
failCount: number;
checks: StartupValidationCheckDto[];
}
export interface BackupChecklistItemDto {
id: string;
label: string;
detail: string;
}
export interface BackupVerificationItemDto extends BackupChecklistItemDto {
evidence: string;
}
export interface RestoreDrillStepDto extends BackupChecklistItemDto {
expectedOutcome: string;
}
export interface BackupGuidanceDto {
dataPath: string;
databasePath: string;
@@ -73,6 +91,17 @@ export interface BackupGuidanceDto {
recommendedBackupTarget: string;
backupSteps: BackupChecklistItemDto[];
restoreSteps: BackupChecklistItemDto[];
verificationChecklist: BackupVerificationItemDto[];
restoreDrillSteps: RestoreDrillStepDto[];
}
export interface SupportLogEntryDto {
id: string;
level: "INFO" | "WARN" | "ERROR";
source: string;
message: string;
contextJson: string;
createdAt: string;
}
export interface SupportSnapshotDto {
@@ -81,6 +110,8 @@ export interface SupportSnapshotDto {
userCount: number;
roleCount: number;
activeUserEmails: string[];
backupGuidance: BackupGuidanceDto;
recentSupportLogs: SupportLogEntryDto[];
}
export interface AdminDiagnosticsDto {
@@ -106,7 +137,8 @@ export interface AdminDiagnosticsDto {
shipmentCount: number;
attachmentCount: number;
auditEventCount: number;
startupStatus: "PASS" | "WARN" | "FAIL";
startupChecks: StartupValidationCheckDto[];
supportLogCount: number;
startup: StartupValidationReportDto;
recentAuditEvents: AuditEventDto[];
recentSupportLogs: SupportLogEntryDto[];
}