Compare commits

..

3 Commits

Author SHA1 Message Date
f858fe4785 backup and restore 2026-03-15 15:21:27 -05:00
e7cfff3eca support snapshots 2026-03-15 15:04:18 -05:00
28b23bc355 admin services 2026-03-15 14:57:41 -05:00
20 changed files with 1005 additions and 44 deletions

View File

@@ -25,6 +25,10 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a
- planning gantt timelines backed by live project and manufacturing schedule data - planning gantt timelines backed by live project and manufacturing schedule data
- admin diagnostics with runtime footprint, record counts, and persisted audit-trail visibility - 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 - 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, 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 - Puppeteer PDF foundation
- single-container Docker deployment - single-container Docker deployment
@@ -121,8 +125,8 @@ If implementation changes invalidate those docs, update them in the same change
Near-term priorities are: Near-term priorities are:
1. CRM/shipping audit coverage and richer startup validation 1. Better user and session visibility for operational admins
2. Backup/restore workflow documentation and support-oriented admin tooling 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. When adding new modules, preserve the ability to extend the system without refactoring the existing app shell.

View File

@@ -6,6 +6,13 @@ This file is the running release and change log for MRP Codex. Keep it updated w
### Added ### 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
- Admin user-management screen with account creation, activation control, role assignment, and role-permission editing - Admin user-management screen with account creation, activation control, role assignment, and role-permission editing
- Route-level lazy loading and vendor chunking across the client so major operational modules no longer ship in the initial bundle - Route-level lazy loading and vendor chunking across the client so major operational modules no longer ship in the initial bundle
- Persisted audit events for core settings, inventory, purchasing, sales, project, and manufacturing write actions - Persisted audit events for core settings, inventory, purchasing, sales, project, and manufacturing write actions
@@ -43,7 +50,10 @@ This file is the running release and change log for MRP Codex. Keep it updated w
- Vendor CRM detail now exposes purchasing activity and can launch pre-seeded purchase orders - Vendor CRM detail now exposes purchasing activity and can launch pre-seeded purchase orders
- The client entry bundle now stays lighter by loading major modules on demand instead of importing all route pages eagerly in `main.tsx` - 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 - 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
- Roadmap and project docs now treat CRM/shipping audit coverage and richer startup validation as the next active priority after the user-management slice - 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 ## 2026-03-15

View File

@@ -29,6 +29,10 @@ This repository implements the platform foundation milestone:
- planning gantt timelines backed by live project and manufacturing schedule data - planning gantt timelines backed by live project and manufacturing schedule data
- admin diagnostics with runtime footprint, storage visibility, record counts, and recent audit activity - 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 - 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, 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 - Dockerized single-container deployment
- Puppeteer PDF pipeline foundation - Puppeteer PDF pipeline foundation
@@ -64,5 +68,5 @@ This repository implements the platform foundation milestone:
## Next roadmap candidates ## Next roadmap candidates
- CRM/shipping audit coverage and richer startup validation - better user and session visibility for operational admins
- backup/restore workflow depth and support-oriented admin tooling - safer destructive-action confirmations and recovery messaging

View File

@@ -28,6 +28,10 @@ Current foundation scope includes:
- planning gantt timelines with live project and manufacturing schedule data - planning gantt timelines with live project and manufacturing schedule data
- admin diagnostics with runtime footprint, record counts, and recent audit-trail visibility - 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 - 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, 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 - route-level code-splitting and vendor chunking for lighter initial client loads
- file storage and PDF rendering - file storage and PDF rendering
@@ -49,14 +53,14 @@ Current completed foundation areas:
Near-term priorities: Near-term priorities:
1. CRM/shipping audit coverage and richer startup validation 1. Better user and session visibility for operational admins
2. Backup/restore workflow documentation and support-oriented admin tooling 2. Safer destructive-action confirmations and recovery messaging
Revisit / deferred items: Revisit / deferred items:
- local Windows Prisma migration reliability - local Windows Prisma migration reliability
- deeper support diagnostics and startup validation - better user and session visibility for operational admins
- backup/restore workflow depth and support tooling - safer destructive-action confirmations and recovery messaging
Dashboard direction: Dashboard direction:
@@ -348,13 +352,17 @@ The current admin operations slice supports:
- persisted audit events for core settings, inventory, purchasing, sales, project, and manufacturing write actions - persisted audit events for core settings, inventory, purchasing, sales, project, and manufacturing write actions
- an admin diagnostics page for runtime footprint, data/storage path visibility, key record counts, and recent audit activity - 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 - 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, 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 - operator-facing review of recent high-impact changes without direct database access
Current follow-up direction: Current follow-up direction:
- deeper audit coverage across CRM and shipping mutations - better user and session visibility for operational admins
- richer environment validation and startup diagnostics - safer destructive-action confirmations and recovery messaging
- backup/restore workflow guidance and support-oriented admin tooling
## UI Notes ## UI Notes

View File

@@ -50,6 +50,11 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
- Audit trail coverage across core settings, inventory, purchasing, project, sales, and manufacturing write flows - Audit trail coverage across core settings, inventory, purchasing, project, sales, and manufacturing write flows
- Admin diagnostics screen with runtime footprint, record counts, storage-path visibility, and recent audit activity - 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 - 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, 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 - Route-level frontend code-splitting and vendor chunking to keep the initial client payload lighter
- SKU-searchable BOM component selection for inventory-scale datasets - SKU-searchable BOM component selection for inventory-scale datasets
- Theme persistence fixes and denser responsive workspace layouts - Theme persistence fixes and denser responsive workspace layouts
@@ -255,6 +260,9 @@ Foundation slice shipped:
- Audit trail coverage across core write flows for settings, inventory, sales, purchasing, projects, and manufacturing - Audit trail coverage across core write flows for settings, inventory, sales, purchasing, projects, and manufacturing
- Admin diagnostics screen for runtime footprint, storage visibility, key record counts, and recent audit activity - 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 - 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, 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 - Expanded role management UI
- Permission assignment administration - Permission assignment administration
@@ -268,7 +276,7 @@ QOL subfeatures:
- Safer destructive-action confirmations and recovery messaging - Safer destructive-action confirmations and recovery messaging
- Better user/session visibility for operational admins - Better user/session visibility for operational admins
- More explicit environment validation on startup - 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 - Backup verification checklist and restore drill guidance
## Revisit / Deferred Items ## Revisit / Deferred Items
@@ -289,5 +297,5 @@ QOL subfeatures:
## Near-term priority order ## Near-term priority order
1. CRM/shipping audit coverage plus richer startup validation 1. Better user and session visibility for operational admins
2. Backup/restore workflow documentation and support-oriented admin tooling 2. Safer destructive-action confirmations and recovery messaging

View File

@@ -36,7 +36,7 @@
- Organize domain modules under `src/modules/<domain>`. - Organize domain modules under `src/modules/<domain>`.
- Keep HTTP routers thin; place business logic in services. - 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`. - Store persistence-related constants under `src/config`.
- Serve the built frontend from the API layer in production. - Serve the built frontend from the API layer in production.

View File

@@ -1,8 +1,11 @@
import type { import type {
AdminDiagnosticsDto, AdminDiagnosticsDto,
BackupGuidanceDto,
AdminPermissionOptionDto, AdminPermissionOptionDto,
AdminRoleDto, AdminRoleDto,
AdminRoleInput, AdminRoleInput,
SupportLogEntryDto,
SupportSnapshotDto,
AdminUserDto, AdminUserDto,
AdminUserInput, AdminUserInput,
ApiResponse, ApiResponse,
@@ -136,6 +139,15 @@ export const api = {
getAdminDiagnostics(token: string) { getAdminDiagnostics(token: string) {
return request<AdminDiagnosticsDto>("/api/v1/admin/diagnostics", undefined, token); return request<AdminDiagnosticsDto>("/api/v1/admin/diagnostics", undefined, token);
}, },
getBackupGuidance(token: string) {
return request<BackupGuidanceDto>("/api/v1/admin/backup-guidance", undefined, token);
},
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) { getAdminPermissions(token: string) {
return request<AdminPermissionOptionDto[]>("/api/v1/admin/permissions", undefined, token); return request<AdminPermissionOptionDto[]>("/api/v1/admin/permissions", undefined, token);
}, },

View File

@@ -1,4 +1,4 @@
import type { AdminDiagnosticsDto } from "@mrp/shared"; import type { AdminDiagnosticsDto, BackupGuidanceDto, SupportLogEntryDto } from "@mrp/shared";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -20,6 +20,8 @@ function parseMetadata(metadataJson: string) {
export function AdminDiagnosticsPage() { export function AdminDiagnosticsPage() {
const { token } = useAuth(); const { token } = useAuth();
const [diagnostics, setDiagnostics] = useState<AdminDiagnosticsDto | null>(null); 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..."); const [status, setStatus] = useState("Loading diagnostics...");
useEffect(() => { useEffect(() => {
@@ -29,13 +31,14 @@ export function AdminDiagnosticsPage() {
let active = true; let active = true;
api Promise.all([api.getAdminDiagnostics(token), api.getBackupGuidance(token), api.getSupportLogs(token)])
.getAdminDiagnostics(token) .then(([nextDiagnostics, nextBackupGuidance, nextSupportLogs]) => {
.then((nextDiagnostics) => {
if (!active) { if (!active) {
return; return;
} }
setDiagnostics(nextDiagnostics); setDiagnostics(nextDiagnostics);
setBackupGuidance(nextBackupGuidance);
setSupportLogs(nextSupportLogs);
setStatus("Diagnostics loaded."); setStatus("Diagnostics loaded.");
}) })
.catch((error: Error) => { .catch((error: Error) => {
@@ -50,14 +53,48 @@ export function AdminDiagnosticsPage() {
}; };
}, [token]); }, [token]);
if (!diagnostics) { if (!diagnostics || !backupGuidance) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>; return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
} }
async function handleExportSupportSnapshot() {
if (!token) {
return;
}
const snapshot = await api.getSupportSnapshot(token);
const blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: "application/json" });
const objectUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objectUrl;
link.download = `mrp-codex-support-snapshot-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
link.click();
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000);
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 = [ const summaryCards = [
["Server time", formatDateTime(diagnostics.serverTime)], ["Server time", formatDateTime(diagnostics.serverTime)],
["Node runtime", diagnostics.nodeVersion], ["Node runtime", diagnostics.nodeVersion],
["Audit events", diagnostics.auditEventCount.toString()], ["Audit events", diagnostics.auditEventCount.toString()],
["Support logs", diagnostics.supportLogCount.toString()],
["Active users", `${diagnostics.activeUserCount} / ${diagnostics.userCount}`], ["Active users", `${diagnostics.activeUserCount} / ${diagnostics.userCount}`],
["Sales docs", diagnostics.salesDocumentCount.toString()], ["Sales docs", diagnostics.salesDocumentCount.toString()],
["Work orders", diagnostics.workOrderCount.toString()], ["Work orders", diagnostics.workOrderCount.toString()],
@@ -78,6 +115,19 @@ export function AdminDiagnosticsPage() {
["Shipments", diagnostics.shipmentCount.toString()], ["Shipments", diagnostics.shipmentCount.toString()],
]; ];
const startupStatusTone =
diagnostics.startup.status === "PASS"
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-200"
: 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 ( return (
<div className="space-y-6"> <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"> <section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
@@ -90,6 +140,20 @@ export function AdminDiagnosticsPage() {
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<button
type="button"
onClick={handleExportSupportSnapshot}
className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text"
>
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"> <Link to="/settings/users" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
User management User management
</Link> </Link>
@@ -108,6 +172,104 @@ export function AdminDiagnosticsPage() {
</div> </div>
</section> </section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Backup And Restore</p>
<h3 className="mt-2 text-lg font-bold text-text">Operational backup workflow</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">
Use these paths and steps as the support baseline for manual backup and restore procedures.
</p>
</div>
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-muted">
<div>Data: {backupGuidance.dataPath}</div>
<div>DB: {backupGuidance.databasePath}</div>
<div>Uploads: {backupGuidance.uploadsPath}</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 checklist</p>
<div className="mt-3 space-y-3">
{backupGuidance.backupSteps.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>
</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 checklist</p>
<div className="mt-3 space-y-3">
{backupGuidance.restoreSteps.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>
</div>
))}
</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">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Startup Validation</p>
<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.startup.status}
</span>
</div>
<div className="mt-5 grid gap-3 xl:grid-cols-2">
{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>
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{check.status}</span>
</div>
<p className="mt-2 text-sm text-muted">{check.message}</p>
</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"> <section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">System Footprint</p> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">System Footprint</p>
<div className="mt-5 grid gap-3 xl:grid-cols-2"> <div className="mt-5 grid gap-3 xl:grid-cols-2">
@@ -120,6 +282,49 @@ export function AdminDiagnosticsPage() {
</div> </div>
</section> </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"> <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 className="flex items-center justify-between gap-3">
<div> <div>

View File

@@ -11,6 +11,7 @@ import { paths } from "./config/paths.js";
import { verifyToken } from "./lib/auth.js"; import { verifyToken } from "./lib/auth.js";
import { getCurrentUserById } from "./lib/current-user.js"; import { getCurrentUserById } from "./lib/current-user.js";
import { fail, ok } from "./lib/http.js"; import { fail, ok } from "./lib/http.js";
import { recordSupportLog } from "./lib/support-log.js";
import { adminRouter } from "./modules/admin/router.js"; import { adminRouter } from "./modules/admin/router.js";
import { authRouter } from "./modules/auth/router.js"; import { authRouter } from "./modules/auth/router.js";
import { crmRouter } from "./modules/crm/router.js"; import { crmRouter } from "./modules/crm/router.js";
@@ -52,6 +53,29 @@ export function createApp() {
next(); 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.get("/api/v1/health", (_request, response) => ok(response, { status: "ok" }));
app.use("/api/v1/auth", authRouter); app.use("/api/v1/auth", authRouter);
app.use("/api/v1/admin", adminRouter); 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."); return fail(response, 500, "INTERNAL_ERROR", error.message || "Unexpected server error.");
}); });

View File

@@ -0,0 +1,19 @@
import type { StartupValidationReportDto } from "@mrp/shared";
let latestStartupReport: StartupValidationReportDto = {
status: "WARN",
generatedAt: new Date(0).toISOString(),
durationMs: 0,
passCount: 0,
warnCount: 0,
failCount: 0,
checks: [],
};
export function setLatestStartupReport(report: StartupValidationReportDto) {
latestStartupReport = report;
}
export function getLatestStartupReport() {
return latestStartupReport;
}

View File

@@ -0,0 +1,183 @@
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";
import { env } from "../config/env.js";
import { paths } from "../config/paths.js";
import { prisma } from "./prisma.js";
async function pathExists(targetPath: string) {
try {
await fs.access(targetPath);
return true;
} catch {
return false;
}
}
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: 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: 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: 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 {
await prisma.$queryRawUnsafe("SELECT 1");
checks.push({
id: "database-connection",
label: "Database connection",
status: "PASS",
message: "SQLite connection check succeeded.",
});
} catch (error) {
checks.push({
id: "database-connection",
label: "Database connection",
status: "FAIL",
message: error instanceof Error ? error.message : "SQLite connection check failed.",
});
}
if (env.NODE_ENV === "production") {
checks.push({
id: "client-dist",
label: "Client bundle",
status: clientBundleExists ? "PASS" : "FAIL",
message: clientBundleExists ? `Client bundle found at ${paths.clientDistDir}.` : `Production client bundle is missing from ${paths.clientDistDir}.`,
});
} else {
checks.push({
id: "client-dist",
label: "Client bundle",
status: "PASS",
message: "Client bundle check skipped outside production mode.",
});
}
checks.push({
id: "puppeteer-runtime",
label: "PDF runtime",
status: puppeteerExists ? "PASS" : env.NODE_ENV === "production" ? "FAIL" : "WARN",
message: puppeteerExists
? `Chromium runtime available at ${puppeteerPath}.`
: `Chromium runtime was not found at ${puppeteerPath}.`,
});
checks.push({
id: "client-origin",
label: "Client origin",
status: env.NODE_ENV === "production" && env.CLIENT_ORIGIN.includes("localhost") ? "WARN" : "PASS",
message:
env.NODE_ENV === "production" && env.CLIENT_ORIGIN.includes("localhost")
? `Production CLIENT_ORIGIN still points to localhost: ${env.CLIENT_ORIGIN}.`
: `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",
status: env.NODE_ENV === "production" && env.ADMIN_PASSWORD === "ChangeMe123!" ? "WARN" : "PASS",
message:
env.NODE_ENV === "production" && env.ADMIN_PASSWORD === "ChangeMe123!"
? "Production is still using the default bootstrap admin password."
: "Bootstrap admin credentials are not using the default production password.",
});
const status = checks.some((check) => check.status === "FAIL")
? "FAIL"
: checks.some((check) => check.status === "WARN")
? "WARN"
: "PASS";
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,
};
}
export async function assertStartupReadiness() {
const report = await collectStartupValidationReport();
if (report.status === "FAIL") {
const failedChecks = report.checks.filter((check) => check.status === "FAIL").map((check) => `${check.label}: ${check.message}`);
throw new Error(`Startup validation failed. ${failedChecks.join(" | ")}`);
}
return report;
}

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

@@ -7,7 +7,10 @@ import { requirePermissions } from "../../lib/rbac.js";
import { import {
createAdminRole, createAdminRole,
createAdminUser, createAdminUser,
getBackupGuidance,
getAdminDiagnostics, getAdminDiagnostics,
getSupportLogs,
getSupportSnapshot,
listAdminPermissions, listAdminPermissions,
listAdminRoles, listAdminRoles,
listAdminUsers, listAdminUsers,
@@ -40,6 +43,18 @@ adminRouter.get("/diagnostics", requirePermissions([permissions.adminManage]), a
return ok(response, await getAdminDiagnostics()); return ok(response, await getAdminDiagnostics());
}); });
adminRouter.get("/backup-guidance", requirePermissions([permissions.adminManage]), async (_request, response) => {
return ok(response, getBackupGuidance());
});
adminRouter.get("/support-snapshot", requirePermissions([permissions.adminManage]), async (_request, response) => {
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) => { adminRouter.get("/permissions", requirePermissions([permissions.adminManage]), async (_request, response) => {
return ok(response, await listAdminPermissions()); return ok(response, await listAdminPermissions());
}); });

View File

@@ -1,19 +1,23 @@
import type { import type {
AdminDiagnosticsDto, AdminDiagnosticsDto,
BackupGuidanceDto,
AdminPermissionOptionDto, AdminPermissionOptionDto,
AdminRoleDto, AdminRoleDto,
AdminRoleInput, AdminRoleInput,
AdminUserDto, AdminUserDto,
AdminUserInput, AdminUserInput,
SupportSnapshotDto,
AuditEventDto, AuditEventDto,
SupportLogEntryDto,
} from "@mrp/shared"; } from "@mrp/shared";
import fs from "node:fs/promises";
import { env } from "../../config/env.js"; import { env } from "../../config/env.js";
import { paths } from "../../config/paths.js"; import { paths } from "../../config/paths.js";
import { logAuditEvent } from "../../lib/audit.js"; import { logAuditEvent } from "../../lib/audit.js";
import { hashPassword } from "../../lib/password.js"; import { hashPassword } from "../../lib/password.js";
import { prisma } from "../../lib/prisma.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: { function mapAuditEvent(record: {
id: string; id: string;
@@ -42,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: { function mapRole(record: {
id: string; id: string;
name: string; name: string;
@@ -464,6 +479,8 @@ export async function updateAdminUser(userId: string, payload: AdminUserInput, a
} }
export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> { export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
const startupReport = getLatestStartupReport();
const recentSupportLogs = listSupportLogs(50);
const [ const [
companyProfile, companyProfile,
userCount, userCount,
@@ -515,8 +532,6 @@ export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
}), }),
]); ]);
await Promise.all([fs.access(paths.dataDir), fs.access(paths.uploadsDir)]);
return { return {
serverTime: new Date().toISOString(), serverTime: new Date().toISOString(),
nodeVersion: process.version, nodeVersion: process.version,
@@ -540,6 +555,147 @@ export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
shipmentCount, shipmentCount,
attachmentCount, attachmentCount,
auditEventCount, auditEventCount,
supportLogCount: getSupportLogCount(),
startup: startupReport,
recentAuditEvents: recentAuditEvents.map(mapAuditEvent), recentAuditEvents: recentAuditEvents.map(mapAuditEvent),
recentSupportLogs: recentSupportLogs.map(mapSupportLogEntry),
}; };
} }
export function getBackupGuidance(): BackupGuidanceDto {
return {
dataPath: paths.dataDir,
databasePath: `${paths.prismaDir}/app.db`,
uploadsPath: paths.uploadsDir,
recommendedBackupTarget: "/mnt/user/backups/mrp-codex",
backupSteps: [
{
id: "stop-app",
label: "Stop writes before copying data",
detail: "Stop the container or application process before copying the data directory so SQLite and attachments stay consistent.",
},
{
id: "copy-data",
label: "Back up the full data directory",
detail: `Copy the full data directory at ${paths.dataDir}, not just the SQLite file, so uploads and attachments are preserved with the database.`,
},
{
id: "retain-metadata",
label: "Keep timestamps and structure",
detail: "Preserve directory structure, filenames, and timestamps during backup so support recovery remains straightforward.",
},
{
id: "record-build",
label: "Record image/version context",
detail: "Capture the deployed image tag or commit alongside the backup so schema and runtime expectations are clear during restore.",
},
],
restoreSteps: [
{
id: "stop-target",
label: "Stop the target app before restore",
detail: "Do not restore into a running instance. Stop the target container or process before replacing the data directory.",
},
{
id: "replace-data",
label: "Restore the full data directory",
detail: `Replace the target data directory with the backed-up copy so ${paths.prismaDir}/app.db and uploads come back together.`,
},
{
id: "start-and-migrate",
label: "Start the app and let migrations run",
detail: "Restart the application after restore and allow the normal startup migration flow to complete before validation.",
},
{
id: "validate-core",
label: "Validate login, files, and PDFs",
detail: "Confirm admin login, attachment access, and PDF generation after restore to verify the operational surface is healthy.",
},
],
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 },
select: { email: true },
orderBy: [{ email: "asc" }],
}),
prisma.role.count(),
]);
return {
generatedAt: new Date().toISOString(),
diagnostics,
userCount: diagnostics.userCount,
roleCount: roles,
activeUserEmails: users.map((user) => user.email),
backupGuidance,
recentSupportLogs: diagnostics.recentSupportLogs,
};
}
export function getSupportLogs() {
return listSupportLogs(100).map(mapSupportLogEntry);
}

View File

@@ -119,7 +119,7 @@ crmRouter.post("/customers", requirePermissions([permissions.crmWrite]), async (
return fail(response, 400, "INVALID_INPUT", "Customer payload is invalid."); return fail(response, 400, "INVALID_INPUT", "Customer payload is invalid.");
} }
const customer = await createCustomer(parsed.data); const customer = await createCustomer(parsed.data, request.authUser?.id);
if (!customer) { if (!customer) {
return fail(response, 400, "INVALID_INPUT", "Customer reseller relationship is invalid."); return fail(response, 400, "INVALID_INPUT", "Customer reseller relationship is invalid.");
} }
@@ -143,7 +143,7 @@ crmRouter.put("/customers/:customerId", requirePermissions([permissions.crmWrite
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found."); return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
} }
const customer = await updateCustomer(customerId, parsed.data); const customer = await updateCustomer(customerId, parsed.data, request.authUser?.id);
if (!customer) { if (!customer) {
return fail(response, 400, "INVALID_INPUT", "Customer reseller relationship is invalid."); return fail(response, 400, "INVALID_INPUT", "Customer reseller relationship is invalid.");
} }
@@ -181,7 +181,7 @@ crmRouter.post("/customers/:customerId/contacts", requirePermissions([permission
return fail(response, 400, "INVALID_INPUT", "CRM contact is invalid."); return fail(response, 400, "INVALID_INPUT", "CRM contact is invalid.");
} }
const contact = await createCustomerContact(customerId, parsed.data); const contact = await createCustomerContact(customerId, parsed.data, request.authUser?.id);
if (!contact) { if (!contact) {
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found."); return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
} }
@@ -227,7 +227,7 @@ crmRouter.post("/vendors", requirePermissions([permissions.crmWrite]), async (re
return fail(response, 400, "INVALID_INPUT", "Vendor payload is invalid."); return fail(response, 400, "INVALID_INPUT", "Vendor payload is invalid.");
} }
return ok(response, await createVendor(parsed.data), 201); return ok(response, await createVendor(parsed.data, request.authUser?.id), 201);
}); });
crmRouter.put("/vendors/:vendorId", requirePermissions([permissions.crmWrite]), async (request, response) => { crmRouter.put("/vendors/:vendorId", requirePermissions([permissions.crmWrite]), async (request, response) => {
@@ -241,7 +241,7 @@ crmRouter.put("/vendors/:vendorId", requirePermissions([permissions.crmWrite]),
return fail(response, 400, "INVALID_INPUT", "Vendor payload is invalid."); return fail(response, 400, "INVALID_INPUT", "Vendor payload is invalid.");
} }
const vendor = await updateVendor(vendorId, parsed.data); const vendor = await updateVendor(vendorId, parsed.data, request.authUser?.id);
if (!vendor) { if (!vendor) {
return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found."); return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found.");
} }
@@ -279,7 +279,7 @@ crmRouter.post("/vendors/:vendorId/contacts", requirePermissions([permissions.cr
return fail(response, 400, "INVALID_INPUT", "CRM contact is invalid."); return fail(response, 400, "INVALID_INPUT", "CRM contact is invalid.");
} }
const contact = await createVendorContact(vendorId, parsed.data); const contact = await createVendorContact(vendorId, parsed.data, request.authUser?.id);
if (!contact) { if (!contact) {
return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found."); return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found.");
} }

View File

@@ -15,6 +15,7 @@ import type {
} from "@mrp/shared/dist/crm/types.js"; } from "@mrp/shared/dist/crm/types.js";
import type { Customer, Vendor } from "@prisma/client"; import type { Customer, Vendor } from "@prisma/client";
import { logAuditEvent } from "../../lib/audit.js";
import { prisma } from "../../lib/prisma.js"; import { prisma } from "../../lib/prisma.js";
function mapSummary(record: Customer | Vendor): CrmRecordSummaryDto { function mapSummary(record: Customer | Vendor): CrmRecordSummaryDto {
@@ -397,7 +398,7 @@ export async function getCustomerById(customerId: string) {
return mapCustomerDetail(customer, attachmentCount); return mapCustomerDetail(customer, attachmentCount);
} }
export async function createCustomer(payload: CrmRecordInput) { export async function createCustomer(payload: CrmRecordInput, actorId?: string | null) {
if (payload.parentCustomerId) { if (payload.parentCustomerId) {
const parentCustomer = await prisma.customer.findUnique({ const parentCustomer = await prisma.customer.findUnique({
where: { id: payload.parentCustomerId }, where: { id: payload.parentCustomerId },
@@ -436,6 +437,20 @@ export async function createCustomer(payload: CrmRecordInput) {
}, },
}); });
await logAuditEvent({
actorId,
entityType: "crm-customer",
entityId: customer.id,
action: "created",
summary: `Created customer ${customer.name}.`,
metadata: {
name: customer.name,
status: customer.status,
lifecycleStage: customer.lifecycleStage,
isReseller: customer.isReseller,
},
});
return { return {
...mapDetail(customer), ...mapDetail(customer),
isReseller: customer.isReseller, isReseller: customer.isReseller,
@@ -463,7 +478,7 @@ export async function createCustomer(payload: CrmRecordInput) {
}; };
} }
export async function updateCustomer(customerId: string, payload: CrmRecordInput) { export async function updateCustomer(customerId: string, payload: CrmRecordInput, actorId?: string | null) {
const existingCustomer = await prisma.customer.findUnique({ const existingCustomer = await prisma.customer.findUnique({
where: { id: customerId }, where: { id: customerId },
}); });
@@ -515,6 +530,20 @@ export async function updateCustomer(customerId: string, payload: CrmRecordInput
}, },
}); });
await logAuditEvent({
actorId,
entityType: "crm-customer",
entityId: customer.id,
action: "updated",
summary: `Updated customer ${customer.name}.`,
metadata: {
name: customer.name,
status: customer.status,
lifecycleStage: customer.lifecycleStage,
isReseller: customer.isReseller,
},
});
return { return {
...mapDetail(customer), ...mapDetail(customer),
isReseller: customer.isReseller, isReseller: customer.isReseller,
@@ -630,7 +659,7 @@ export async function getVendorById(vendorId: string) {
return mapVendorDetail(vendor, attachmentCount); return mapVendorDetail(vendor, attachmentCount);
} }
export async function createVendor(payload: CrmRecordInput) { export async function createVendor(payload: CrmRecordInput, actorId?: string | null) {
const vendor = await prisma.vendor.create({ const vendor = await prisma.vendor.create({
data: { data: {
name: payload.name, name: payload.name,
@@ -656,6 +685,19 @@ export async function createVendor(payload: CrmRecordInput) {
}, },
}); });
await logAuditEvent({
actorId,
entityType: "crm-vendor",
entityId: vendor.id,
action: "created",
summary: `Created vendor ${vendor.name}.`,
metadata: {
name: vendor.name,
status: vendor.status,
lifecycleStage: vendor.lifecycleStage,
},
});
return { return {
...mapDetail(vendor), ...mapDetail(vendor),
paymentTerms: vendor.paymentTerms, paymentTerms: vendor.paymentTerms,
@@ -677,7 +719,7 @@ export async function createVendor(payload: CrmRecordInput) {
}; };
} }
export async function updateVendor(vendorId: string, payload: CrmRecordInput) { export async function updateVendor(vendorId: string, payload: CrmRecordInput, actorId?: string | null) {
const existingVendor = await prisma.vendor.findUnique({ const existingVendor = await prisma.vendor.findUnique({
where: { id: vendorId }, where: { id: vendorId },
}); });
@@ -712,6 +754,19 @@ export async function updateVendor(vendorId: string, payload: CrmRecordInput) {
}, },
}); });
await logAuditEvent({
actorId,
entityType: "crm-vendor",
entityId: vendor.id,
action: "updated",
summary: `Updated vendor ${vendor.name}.`,
metadata: {
name: vendor.name,
status: vendor.status,
lifecycleStage: vendor.lifecycleStage,
},
});
return { return {
...mapDetail(vendor), ...mapDetail(vendor),
paymentTerms: vendor.paymentTerms, paymentTerms: vendor.paymentTerms,
@@ -756,6 +811,19 @@ export async function createCustomerContactEntry(customerId: string, payload: Cr
}, },
}); });
await logAuditEvent({
actorId: createdById,
entityType: "crm-customer",
entityId: customerId,
action: "contact-entry.created",
summary: `Added ${payload.type.toLowerCase()} contact history for customer ${existingCustomer.name}.`,
metadata: {
type: payload.type,
summary: payload.summary,
contactAt: payload.contactAt,
},
});
return mapContactEntry(entry); return mapContactEntry(entry);
} }
@@ -782,10 +850,23 @@ export async function createVendorContactEntry(vendorId: string, payload: CrmCon
}, },
}); });
await logAuditEvent({
actorId: createdById,
entityType: "crm-vendor",
entityId: vendorId,
action: "contact-entry.created",
summary: `Added ${payload.type.toLowerCase()} contact history for vendor ${existingVendor.name}.`,
metadata: {
type: payload.type,
summary: payload.summary,
contactAt: payload.contactAt,
},
});
return mapContactEntry(entry); return mapContactEntry(entry);
} }
export async function createCustomerContact(customerId: string, payload: CrmContactInput) { export async function createCustomerContact(customerId: string, payload: CrmContactInput, actorId?: string | null) {
const existingCustomer = await prisma.customer.findUnique({ const existingCustomer = await prisma.customer.findUnique({
where: { id: customerId }, where: { id: customerId },
}); });
@@ -812,10 +893,24 @@ export async function createCustomerContact(customerId: string, payload: CrmCont
}, },
}); });
await logAuditEvent({
actorId,
entityType: "crm-customer",
entityId: customerId,
action: "contact.created",
summary: `Added contact ${contact.fullName} to customer ${existingCustomer.name}.`,
metadata: {
fullName: contact.fullName,
role: contact.role,
email: contact.email,
isPrimary: contact.isPrimary,
},
});
return mapCrmContact(contact); return mapCrmContact(contact);
} }
export async function createVendorContact(vendorId: string, payload: CrmContactInput) { export async function createVendorContact(vendorId: string, payload: CrmContactInput, actorId?: string | null) {
const existingVendor = await prisma.vendor.findUnique({ const existingVendor = await prisma.vendor.findUnique({
where: { id: vendorId }, where: { id: vendorId },
}); });
@@ -842,5 +937,19 @@ export async function createVendorContact(vendorId: string, payload: CrmContactI
}, },
}); });
await logAuditEvent({
actorId,
entityType: "crm-vendor",
entityId: vendorId,
action: "contact.created",
summary: `Added contact ${contact.fullName} to vendor ${existingVendor.name}.`,
metadata: {
fullName: contact.fullName,
role: contact.role,
email: contact.email,
isPrimary: contact.isPrimary,
},
});
return mapCrmContact(contact); return mapCrmContact(contact);
} }

View File

@@ -67,7 +67,7 @@ shippingRouter.post("/shipments", requirePermissions([permissions.shippingWrite]
return fail(response, 400, "INVALID_INPUT", "Shipment payload is invalid."); return fail(response, 400, "INVALID_INPUT", "Shipment payload is invalid.");
} }
const result = await createShipment(parsed.data); const result = await createShipment(parsed.data, request.authUser?.id);
if (!result.ok) { if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason); return fail(response, 400, "INVALID_INPUT", result.reason);
} }
@@ -86,7 +86,7 @@ shippingRouter.put("/shipments/:shipmentId", requirePermissions([permissions.shi
return fail(response, 400, "INVALID_INPUT", "Shipment payload is invalid."); return fail(response, 400, "INVALID_INPUT", "Shipment payload is invalid.");
} }
const result = await updateShipment(shipmentId, parsed.data); const result = await updateShipment(shipmentId, parsed.data, request.authUser?.id);
if (!result.ok) { if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason); return fail(response, 400, "INVALID_INPUT", result.reason);
} }
@@ -105,7 +105,7 @@ shippingRouter.patch("/shipments/:shipmentId/status", requirePermissions([permis
return fail(response, 400, "INVALID_INPUT", "Shipment status payload is invalid."); return fail(response, 400, "INVALID_INPUT", "Shipment status payload is invalid.");
} }
const result = await updateShipmentStatus(shipmentId, parsed.data.status); const result = await updateShipmentStatus(shipmentId, parsed.data.status, request.authUser?.id);
if (!result.ok) { if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason); return fail(response, 400, "INVALID_INPUT", result.reason);
} }

View File

@@ -6,6 +6,7 @@ import type {
ShipmentSummaryDto, ShipmentSummaryDto,
} from "@mrp/shared/dist/shipping/types.js"; } from "@mrp/shared/dist/shipping/types.js";
import { logAuditEvent } from "../../lib/audit.js";
import { prisma } from "../../lib/prisma.js"; import { prisma } from "../../lib/prisma.js";
export interface ShipmentPackingSlipData { export interface ShipmentPackingSlipData {
@@ -168,7 +169,7 @@ export async function getShipmentById(shipmentId: string) {
return shipment ? mapShipment(shipment) : null; return shipment ? mapShipment(shipment) : null;
} }
export async function createShipment(payload: ShipmentInput) { export async function createShipment(payload: ShipmentInput, actorId?: string | null) {
const order = await prisma.salesOrder.findUnique({ const order = await prisma.salesOrder.findUnique({
where: { id: payload.salesOrderId }, where: { id: payload.salesOrderId },
select: { id: true }, select: { id: true },
@@ -195,10 +196,25 @@ export async function createShipment(payload: ShipmentInput) {
}); });
const detail = await getShipmentById(shipment.id); const detail = await getShipmentById(shipment.id);
if (detail) {
await logAuditEvent({
actorId,
entityType: "shipment",
entityId: shipment.id,
action: "created",
summary: `Created shipment ${detail.shipmentNumber}.`,
metadata: {
shipmentNumber: detail.shipmentNumber,
salesOrderId: detail.salesOrderId,
status: detail.status,
carrier: detail.carrier,
},
});
}
return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load saved shipment." }; return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load saved shipment." };
} }
export async function updateShipment(shipmentId: string, payload: ShipmentInput) { export async function updateShipment(shipmentId: string, payload: ShipmentInput, actorId?: string | null) {
const existing = await prisma.shipment.findUnique({ const existing = await prisma.shipment.findUnique({
where: { id: shipmentId }, where: { id: shipmentId },
select: { id: true }, select: { id: true },
@@ -233,10 +249,25 @@ export async function updateShipment(shipmentId: string, payload: ShipmentInput)
}); });
const detail = await getShipmentById(shipmentId); const detail = await getShipmentById(shipmentId);
if (detail) {
await logAuditEvent({
actorId,
entityType: "shipment",
entityId: shipmentId,
action: "updated",
summary: `Updated shipment ${detail.shipmentNumber}.`,
metadata: {
shipmentNumber: detail.shipmentNumber,
salesOrderId: detail.salesOrderId,
status: detail.status,
carrier: detail.carrier,
},
});
}
return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load saved shipment." }; return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load saved shipment." };
} }
export async function updateShipmentStatus(shipmentId: string, status: ShipmentStatus) { export async function updateShipmentStatus(shipmentId: string, status: ShipmentStatus, actorId?: string | null) {
const existing = await prisma.shipment.findUnique({ const existing = await prisma.shipment.findUnique({
where: { id: shipmentId }, where: { id: shipmentId },
select: { id: true }, select: { id: true },
@@ -253,6 +284,19 @@ export async function updateShipmentStatus(shipmentId: string, status: ShipmentS
}); });
const detail = await getShipmentById(shipmentId); const detail = await getShipmentById(shipmentId);
if (detail) {
await logAuditEvent({
actorId,
entityType: "shipment",
entityId: shipmentId,
action: "status.updated",
summary: `Updated shipment ${detail.shipmentNumber} to ${status}.`,
metadata: {
shipmentNumber: detail.shipmentNumber,
status,
},
});
}
return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load updated shipment." }; return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load updated shipment." };
} }

View File

@@ -2,9 +2,40 @@ import { createApp } from "./app.js";
import { env } from "./config/env.js"; import { env } from "./config/env.js";
import { bootstrapAppData } from "./lib/bootstrap.js"; import { bootstrapAppData } from "./lib/bootstrap.js";
import { prisma } from "./lib/prisma.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() { async function start() {
await bootstrapAppData(); 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(); const app = createApp();
const server = app.listen(env.PORT, () => { const server = app.listen(env.PORT, () => {
@@ -22,7 +53,14 @@ async function start() {
start().catch(async (error) => { start().catch(async (error) => {
console.error(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(); await prisma.$disconnect();
process.exit(1); process.exit(1);
}); });

View File

@@ -53,6 +53,67 @@ export interface AdminUserInput {
password: string | null; password: string | null;
} }
export interface StartupValidationCheckDto {
id: string;
label: string;
status: "PASS" | "WARN" | "FAIL";
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;
uploadsPath: string;
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 {
generatedAt: string;
diagnostics: AdminDiagnosticsDto;
userCount: number;
roleCount: number;
activeUserEmails: string[];
backupGuidance: BackupGuidanceDto;
recentSupportLogs: SupportLogEntryDto[];
}
export interface AdminDiagnosticsDto { export interface AdminDiagnosticsDto {
serverTime: string; serverTime: string;
nodeVersion: string; nodeVersion: string;
@@ -76,5 +137,8 @@ export interface AdminDiagnosticsDto {
shipmentCount: number; shipmentCount: number;
attachmentCount: number; attachmentCount: number;
auditEventCount: number; auditEventCount: number;
supportLogCount: number;
startup: StartupValidationReportDto;
recentAuditEvents: AuditEventDto[]; recentAuditEvents: AuditEventDto[];
recentSupportLogs: SupportLogEntryDto[];
} }