Compare commits
3 Commits
3197e68749
...
f858fe4785
| Author | SHA1 | Date | |
|---|---|---|---|
| f858fe4785 | |||
| e7cfff3eca | |||
| 28b23bc355 |
@@ -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.
|
||||||
|
|
||||||
|
|||||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
14
ROADMAP.md
14
ROADMAP.md
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
19
server/src/lib/startup-state.ts
Normal file
19
server/src/lib/startup-state.ts
Normal 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;
|
||||||
|
}
|
||||||
183
server/src/lib/startup-validation.ts
Normal file
183
server/src/lib/startup-validation.ts
Normal 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;
|
||||||
|
}
|
||||||
46
server/src/lib/support-log.ts
Normal file
46
server/src/lib/support-log.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { SupportLogEntryDto } from "@mrp/shared";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
const SUPPORT_LOG_LIMIT = 200;
|
||||||
|
|
||||||
|
const supportLogs: SupportLogEntryDto[] = [];
|
||||||
|
|
||||||
|
function serializeContext(context?: Record<string, unknown>) {
|
||||||
|
if (!context) {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(context);
|
||||||
|
} catch {
|
||||||
|
return JSON.stringify({ serializationError: "Unable to serialize support log context." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordSupportLog(entry: {
|
||||||
|
level: SupportLogEntryDto["level"];
|
||||||
|
source: string;
|
||||||
|
message: string;
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
supportLogs.unshift({
|
||||||
|
id: randomUUID(),
|
||||||
|
level: entry.level,
|
||||||
|
source: entry.source,
|
||||||
|
message: entry.message,
|
||||||
|
contextJson: serializeContext(entry.context),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (supportLogs.length > SUPPORT_LOG_LIMIT) {
|
||||||
|
supportLogs.length = SUPPORT_LOG_LIMIT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSupportLogs(limit = 50) {
|
||||||
|
return supportLogs.slice(0, Math.max(0, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSupportLogCount() {
|
||||||
|
return supportLogs.length;
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user