From f858fe47854a27912092871c1003d31ce4b18ac6 Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 15 Mar 2026 15:21:27 -0500 Subject: [PATCH] backup and restore --- AGENTS.md | 8 +- CHANGELOG.md | 6 + INSTRUCTIONS.md | 8 +- README.md | 22 +-- ROADMAP.md | 15 ++- STRUCTURE.md | 2 +- client/src/lib/api.ts | 4 + .../modules/settings/AdminDiagnosticsPage.tsx | 126 ++++++++++++++++-- server/src/app.ts | 38 +++++- server/src/lib/startup-state.ts | 16 +-- server/src/lib/startup-validation.ts | 91 +++++++++---- server/src/lib/support-log.ts | 46 +++++++ server/src/modules/admin/router.ts | 5 + server/src/modules/admin/service.ts | 87 +++++++++++- server/src/server.ts | 31 +++++ shared/src/admin/types.ts | 36 ++++- 16 files changed, 472 insertions(+), 69 deletions(-) create mode 100644 server/src/lib/support-log.ts diff --git a/AGENTS.md b/AGENTS.md index 1ed0b30..c2369c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 46417c0..277579b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index 4afe80b..996095d 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -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 diff --git a/README.md b/README.md index aec72e1..98b1508 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 98e7c1a..f783f35 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/STRUCTURE.md b/STRUCTURE.md index 3a0f85f..49f0b6a 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -36,7 +36,7 @@ - Organize domain modules under `src/modules/`. - 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. diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 645a1b3..99acda6 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -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("/api/v1/admin/support-snapshot", undefined, token); }, + getSupportLogs(token: string) { + return request("/api/v1/admin/support-logs", undefined, token); + }, getAdminPermissions(token: string) { return request("/api/v1/admin/permissions", undefined, token); }, diff --git a/client/src/modules/settings/AdminDiagnosticsPage.tsx b/client/src/modules/settings/AdminDiagnosticsPage.tsx index 30595dc..7fd7630 100644 --- a/client/src/modules/settings/AdminDiagnosticsPage.tsx +++ b/client/src/modules/settings/AdminDiagnosticsPage.tsx @@ -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(null); const [backupGuidance, setBackupGuidance] = useState(null); + const [supportLogs, setSupportLogs] = useState([]); 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 (
@@ -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 + + User management @@ -178,6 +211,32 @@ export function AdminDiagnosticsPage() {
+
+
+

Backup verification checklist

+
+ {backupGuidance.verificationChecklist.map((item) => ( +
+

{item.label}

+

{item.detail}

+

Evidence: {item.evidence}

+
+ ))} +
+
+
+

Restore drill runbook

+
+ {backupGuidance.restoreDrillSteps.map((step) => ( +
+

{step.label}

+

{step.detail}

+

Expected outcome: {step.expectedOutcome}

+
+ ))} +
+
+
@@ -187,11 +246,11 @@ export function AdminDiagnosticsPage() {

Boot-time readiness checks

- {diagnostics.startupStatus} + {diagnostics.startup.status}
- {diagnostics.startupChecks.map((check) => ( + {diagnostics.startup.checks.map((check) => (

{check.label}

@@ -201,6 +260,14 @@ export function AdminDiagnosticsPage() {
))}
+
+ {startupSummaryCards.map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))} +
@@ -215,6 +282,49 @@ export function AdminDiagnosticsPage() {
+
+
+
+

Support Logs

+

Recent runtime warnings and failures

+
+

{supportLogs.length} entries loaded

+
+
+ + + + + + + + + + + + {supportLogs.map((entry) => { + const context = parseMetadata(entry.contextJson); + return ( + + + + + + + + ); + })} + +
WhenLevelSourceMessageContext
{formatDateTime(entry.createdAt)} + + {entry.level} + + {entry.source}{entry.message} + {Object.keys(context).length > 0 ? JSON.stringify(context) : "No context"} +
+
+
+
diff --git a/server/src/app.ts b/server/src/app.ts index eadfc8a..b38ef25 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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."); }); diff --git a/server/src/lib/startup-state.ts b/server/src/lib/startup-state.ts index 201ab3c..664267d 100644 --- a/server/src/lib/startup-state.ts +++ b/server/src/lib/startup-state.ts @@ -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; } diff --git a/server/src/lib/startup-validation.ts b/server/src/lib/startup-validation.ts index 15c0d93..e8b83aa 100644 --- a/server/src/lib/startup-validation.ts +++ b/server/src/lib/startup-validation.ts @@ -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 { +async function canWritePath(targetPath: string) { + try { + await fs.access(targetPath, fsConstants.W_OK); + return true; + } catch { + return false; + } +} + +export async function collectStartupValidationReport(): Promise { + 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 check.status === "PASS").length, + warnCount: checks.filter((check) => check.status === "WARN").length, + failCount: checks.filter((check) => check.status === "FAIL").length, checks, }; } diff --git a/server/src/lib/support-log.ts b/server/src/lib/support-log.ts new file mode 100644 index 0000000..4a242d7 --- /dev/null +++ b/server/src/lib/support-log.ts @@ -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) { + 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; +}) { + 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; +} diff --git a/server/src/modules/admin/router.ts b/server/src/modules/admin/router.ts index b01b060..4649385 100644 --- a/server/src/modules/admin/router.ts +++ b/server/src/modules/admin/router.ts @@ -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()); }); diff --git a/server/src/modules/admin/service.ts b/server/src/modules/admin/service.ts index b9c59cb..01f8c1b 100644 --- a/server/src/modules/admin/service.ts +++ b/server/src/modules/admin/service.ts @@ -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 { const startupReport = getLatestStartupReport(); + const recentSupportLogs = listSupportLogs(50); const [ companyProfile, userCount, @@ -519,8 +532,6 @@ export async function getAdminDiagnostics(): Promise { }), ]); - 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 { 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 { 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 { userCount: diagnostics.userCount, roleCount: roles, activeUserEmails: users.map((user) => user.email), + backupGuidance, + recentSupportLogs: diagnostics.recentSupportLogs, }; } + +export function getSupportLogs() { + return listSupportLogs(100).map(mapSupportLogEntry); +} diff --git a/server/src/server.ts b/server/src/server.ts index dc90ab9..10ab72a 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -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); }); diff --git a/shared/src/admin/types.ts b/shared/src/admin/types.ts index 0cc98b0..bec2446 100644 --- a/shared/src/admin/types.ts +++ b/shared/src/admin/types.ts @@ -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[]; }