diff --git a/AGENTS.md b/AGENTS.md index b6243b3..2ca4e1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a - planning gantt timelines backed by live project and manufacturing schedule data - admin diagnostics with runtime footprint, record counts, and persisted audit-trail visibility - admin user management with account creation, activation, role assignment, and role-permission editing +- CRM/shipping audit coverage and startup validation surfaced through the admin diagnostics workflow - Puppeteer PDF foundation - single-container Docker deployment @@ -121,8 +122,8 @@ If implementation changes invalidate those docs, update them in the same change Near-term priorities are: -1. CRM/shipping audit coverage and richer startup validation -2. Backup/restore workflow documentation and support-oriented admin tooling +1. Backup/restore workflow documentation and support-oriented admin tooling +2. Deeper startup diagnostics and support export helpers When adding new modules, preserve the ability to extend the system without refactoring the existing app shell. diff --git a/CHANGELOG.md b/CHANGELOG.md index 47d7c07..8bf4318 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ This file is the running release and change log for MRP Codex. Keep it updated w ### Added +- 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 - 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 @@ -43,7 +45,8 @@ 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 - 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 -- 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 +- Roadmap and project docs now treat backup/restore workflow documentation and support-oriented admin tooling as the next active priority after the CRM/shipping audit and startup-validation slice ## 2026-03-15 diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index 509bde5..655d208 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -29,6 +29,7 @@ This repository implements the platform foundation milestone: - 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 user management with account creation, activation, role assignment, and role-permission editing +- CRM/shipping audit coverage and startup validation surfaced through diagnostics - Dockerized single-container deployment - Puppeteer PDF pipeline foundation @@ -64,5 +65,5 @@ This repository implements the platform foundation milestone: ## Next roadmap candidates -- CRM/shipping audit coverage and richer startup validation - backup/restore workflow depth and support-oriented admin tooling +- deeper startup diagnostics and support export helpers diff --git a/README.md b/README.md index 2e96c36..5c59de7 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Current foundation scope includes: - planning gantt timelines with live project and manufacturing schedule data - admin diagnostics with runtime footprint, record counts, and recent audit-trail visibility - admin user management with account creation, activation, role assignment, and role-permission editing +- CRM and shipping audit coverage plus startup validation surfaced through the admin diagnostics page - route-level code-splitting and vendor chunking for lighter initial client loads - file storage and PDF rendering @@ -49,14 +50,14 @@ Current completed foundation areas: Near-term priorities: -1. CRM/shipping audit coverage and richer startup validation -2. Backup/restore workflow documentation and support-oriented admin tooling +1. Backup/restore workflow documentation and support-oriented admin tooling +2. Deeper startup diagnostics and support export helpers Revisit / deferred items: - local Windows Prisma migration reliability -- deeper support diagnostics and startup validation - backup/restore workflow depth and support tooling +- deeper startup diagnostics and support export helpers Dashboard direction: @@ -348,13 +349,14 @@ The current admin operations slice supports: - 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 - a dedicated user-management page for account creation, activation, role assignment, password reset-style updates, and role-permission administration +- CRM customer/vendor changes and shipping mutations now flow into the shared audit trail +- startup validation now checks storage paths, database connectivity, client bundle readiness, Chromium availability, and risky production defaults during server boot - operator-facing review of recent high-impact changes without direct database access Current follow-up direction: -- deeper audit coverage across CRM and shipping mutations -- richer environment validation and startup diagnostics - backup/restore workflow guidance and support-oriented admin tooling +- deeper startup diagnostics and support export helpers ## UI Notes diff --git a/ROADMAP.md b/ROADMAP.md index cff33c0..54ddced 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -50,6 +50,8 @@ 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 - Admin diagnostics screen with runtime footprint, record counts, storage-path visibility, and recent audit activity - Dedicated user-management screen for account creation, activation, role assignment, and role-permission editing +- CRM customer/vendor changes and shipping mutations covered by the shared audit trail +- Startup validation during server boot with checks for storage paths, database connectivity, client bundle readiness, Chromium availability, and risky production defaults - Route-level frontend code-splitting and vendor chunking to keep the initial client payload lighter - SKU-searchable BOM component selection for inventory-scale datasets - Theme persistence fixes and denser responsive workspace layouts @@ -255,6 +257,8 @@ Foundation slice shipped: - 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 - Expanded role-management UI with account creation, activation, role assignment, and permission administration +- CRM customer/vendor changes and shipping mutations covered by the shared audit trail +- Startup validation during server boot with checks for storage paths, database connectivity, client bundle readiness, Chromium availability, and risky production defaults - Expanded role management UI - Permission assignment administration @@ -289,5 +293,5 @@ QOL subfeatures: ## Near-term priority order -1. CRM/shipping audit coverage plus richer startup validation -2. Backup/restore workflow documentation and support-oriented admin tooling +1. Backup/restore workflow documentation and support-oriented admin tooling +2. Deeper startup diagnostics and support export helpers diff --git a/client/src/modules/settings/AdminDiagnosticsPage.tsx b/client/src/modules/settings/AdminDiagnosticsPage.tsx index d78f232..bab0542 100644 --- a/client/src/modules/settings/AdminDiagnosticsPage.tsx +++ b/client/src/modules/settings/AdminDiagnosticsPage.tsx @@ -78,6 +78,13 @@ export function AdminDiagnosticsPage() { ["Shipments", diagnostics.shipmentCount.toString()], ]; + const startupStatusTone = + diagnostics.startupStatus === "PASS" + ? "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-200" + : diagnostics.startupStatus === "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"; + return (
@@ -108,6 +115,29 @@ export function AdminDiagnosticsPage() {
+
+
+
+

Startup Validation

+

Boot-time readiness checks

+
+ + {diagnostics.startupStatus} + +
+
+ {diagnostics.startupChecks.map((check) => ( +
+
+

{check.label}

+ {check.status} +
+

{check.message}

+
+ ))} +
+
+

System Footprint

diff --git a/server/src/lib/startup-state.ts b/server/src/lib/startup-state.ts new file mode 100644 index 0000000..201ab3c --- /dev/null +++ b/server/src/lib/startup-state.ts @@ -0,0 +1,19 @@ +import type { StartupValidationCheckDto } from "@mrp/shared"; + +interface StartupValidationReport { + status: "PASS" | "WARN" | "FAIL"; + checks: StartupValidationCheckDto[]; +} + +let latestStartupReport: StartupValidationReport = { + status: "WARN", + checks: [], +}; + +export function setLatestStartupReport(report: StartupValidationReport) { + latestStartupReport = report; +} + +export function getLatestStartupReport() { + return latestStartupReport; +} diff --git a/server/src/lib/startup-validation.ts b/server/src/lib/startup-validation.ts new file mode 100644 index 0000000..15c0d93 --- /dev/null +++ b/server/src/lib/startup-validation.ts @@ -0,0 +1,138 @@ +import type { StartupValidationCheckDto } from "@mrp/shared"; +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"; + +interface StartupValidationReport { + status: "PASS" | "WARN" | "FAIL"; + checks: StartupValidationCheckDto[]; +} + +async function pathExists(targetPath: string) { + try { + await fs.access(targetPath); + return true; + } catch { + return false; + } +} + +export async function collectStartupValidationReport(): Promise { + const checks: StartupValidationCheckDto[] = []; + + checks.push({ + id: "data-dir", + label: "Data directory", + status: (await pathExists(paths.dataDir)) ? "PASS" : "FAIL", + message: (await pathExists(paths.dataDir)) ? `Data directory available at ${paths.dataDir}.` : `Data directory is missing: ${paths.dataDir}.`, + }); + + checks.push({ + id: "uploads-dir", + label: "Uploads directory", + status: (await pathExists(paths.uploadsDir)) ? "PASS" : "FAIL", + message: (await pathExists(paths.uploadsDir)) + ? `Uploads directory available at ${paths.uploadsDir}.` + : `Uploads directory is missing: ${paths.uploadsDir}.`, + }); + + checks.push({ + id: "prisma-dir", + label: "Prisma directory", + status: (await pathExists(paths.prismaDir)) ? "PASS" : "FAIL", + message: (await pathExists(paths.prismaDir)) + ? `Prisma data directory available at ${paths.prismaDir}.` + : `Prisma data directory is missing: ${paths.prismaDir}.`, + }); + + 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: (await pathExists(path.join(paths.clientDistDir, "index.html"))) ? "PASS" : "FAIL", + message: (await pathExists(path.join(paths.clientDistDir, "index.html"))) + ? `Client bundle found at ${paths.clientDistDir}.` + : `Production client bundle is missing from ${paths.clientDistDir}.`, + }); + } else { + checks.push({ + id: "client-dist", + label: "Client bundle", + status: "PASS", + message: "Client bundle check skipped outside production mode.", + }); + } + + const puppeteerPath = env.PUPPETEER_EXECUTABLE_PATH || "/usr/bin/chromium"; + const puppeteerExists = await pathExists(puppeteerPath); + 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: "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, + 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; +} diff --git a/server/src/modules/admin/service.ts b/server/src/modules/admin/service.ts index 14a063a..1620786 100644 --- a/server/src/modules/admin/service.ts +++ b/server/src/modules/admin/service.ts @@ -14,6 +14,7 @@ import { paths } from "../../config/paths.js"; import { logAuditEvent } from "../../lib/audit.js"; import { hashPassword } from "../../lib/password.js"; import { prisma } from "../../lib/prisma.js"; +import { getLatestStartupReport } from "../../lib/startup-state.js"; function mapAuditEvent(record: { id: string; @@ -464,6 +465,7 @@ export async function updateAdminUser(userId: string, payload: AdminUserInput, a } export async function getAdminDiagnostics(): Promise { + const startupReport = getLatestStartupReport(); const [ companyProfile, userCount, @@ -540,6 +542,8 @@ export async function getAdminDiagnostics(): Promise { shipmentCount, attachmentCount, auditEventCount, + startupStatus: startupReport.status, + startupChecks: startupReport.checks, recentAuditEvents: recentAuditEvents.map(mapAuditEvent), }; } diff --git a/server/src/modules/crm/router.ts b/server/src/modules/crm/router.ts index b6d8de7..1e2b587 100644 --- a/server/src/modules/crm/router.ts +++ b/server/src/modules/crm/router.ts @@ -119,7 +119,7 @@ crmRouter.post("/customers", requirePermissions([permissions.crmWrite]), async ( 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) { 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."); } - const customer = await updateCustomer(customerId, parsed.data); + const customer = await updateCustomer(customerId, parsed.data, request.authUser?.id); if (!customer) { 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."); } - const contact = await createCustomerContact(customerId, parsed.data); + const contact = await createCustomerContact(customerId, parsed.data, request.authUser?.id); if (!contact) { 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 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) => { @@ -241,7 +241,7 @@ crmRouter.put("/vendors/:vendorId", requirePermissions([permissions.crmWrite]), 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) { 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."); } - const contact = await createVendorContact(vendorId, parsed.data); + const contact = await createVendorContact(vendorId, parsed.data, request.authUser?.id); if (!contact) { return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found."); } diff --git a/server/src/modules/crm/service.ts b/server/src/modules/crm/service.ts index ed7b837..6aa6deb 100644 --- a/server/src/modules/crm/service.ts +++ b/server/src/modules/crm/service.ts @@ -15,6 +15,7 @@ import type { } from "@mrp/shared/dist/crm/types.js"; import type { Customer, Vendor } from "@prisma/client"; +import { logAuditEvent } from "../../lib/audit.js"; import { prisma } from "../../lib/prisma.js"; function mapSummary(record: Customer | Vendor): CrmRecordSummaryDto { @@ -397,7 +398,7 @@ export async function getCustomerById(customerId: string) { return mapCustomerDetail(customer, attachmentCount); } -export async function createCustomer(payload: CrmRecordInput) { +export async function createCustomer(payload: CrmRecordInput, actorId?: string | null) { if (payload.parentCustomerId) { const parentCustomer = await prisma.customer.findUnique({ 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 { ...mapDetail(customer), 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({ 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 { ...mapDetail(customer), isReseller: customer.isReseller, @@ -630,7 +659,7 @@ export async function getVendorById(vendorId: string) { 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({ data: { 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 { ...mapDetail(vendor), 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({ 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 { ...mapDetail(vendor), 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); } @@ -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); } -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({ 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); } -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({ 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); } diff --git a/server/src/modules/shipping/router.ts b/server/src/modules/shipping/router.ts index 78ee94c..b829379 100644 --- a/server/src/modules/shipping/router.ts +++ b/server/src/modules/shipping/router.ts @@ -67,7 +67,7 @@ shippingRouter.post("/shipments", requirePermissions([permissions.shippingWrite] 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) { 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."); } - const result = await updateShipment(shipmentId, parsed.data); + const result = await updateShipment(shipmentId, parsed.data, request.authUser?.id); if (!result.ok) { 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."); } - const result = await updateShipmentStatus(shipmentId, parsed.data.status); + const result = await updateShipmentStatus(shipmentId, parsed.data.status, request.authUser?.id); if (!result.ok) { return fail(response, 400, "INVALID_INPUT", result.reason); } diff --git a/server/src/modules/shipping/service.ts b/server/src/modules/shipping/service.ts index ec4ef46..4d8e6e7 100644 --- a/server/src/modules/shipping/service.ts +++ b/server/src/modules/shipping/service.ts @@ -6,6 +6,7 @@ import type { ShipmentSummaryDto, } from "@mrp/shared/dist/shipping/types.js"; +import { logAuditEvent } from "../../lib/audit.js"; import { prisma } from "../../lib/prisma.js"; export interface ShipmentPackingSlipData { @@ -168,7 +169,7 @@ export async function getShipmentById(shipmentId: string) { 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({ where: { id: payload.salesOrderId }, select: { id: true }, @@ -195,10 +196,25 @@ export async function createShipment(payload: ShipmentInput) { }); 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." }; } -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({ where: { id: shipmentId }, select: { id: true }, @@ -233,10 +249,25 @@ export async function updateShipment(shipmentId: string, payload: ShipmentInput) }); 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." }; } -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({ where: { id: shipmentId }, select: { id: true }, @@ -253,6 +284,19 @@ export async function updateShipmentStatus(shipmentId: string, status: ShipmentS }); 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." }; } diff --git a/server/src/server.ts b/server/src/server.ts index 407885f..dc90ab9 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -2,9 +2,17 @@ import { createApp } from "./app.js"; import { env } from "./config/env.js"; import { bootstrapAppData } from "./lib/bootstrap.js"; import { prisma } from "./lib/prisma.js"; +import { setLatestStartupReport } from "./lib/startup-state.js"; +import { assertStartupReadiness } from "./lib/startup-validation.js"; async function start() { await bootstrapAppData(); + const startupReport = await assertStartupReadiness(); + setLatestStartupReport(startupReport); + + for (const check of startupReport.checks.filter((entry) => entry.status !== "PASS")) { + console.warn(`[startup:${check.status.toLowerCase()}] ${check.label}: ${check.message}`); + } const app = createApp(); const server = app.listen(env.PORT, () => { @@ -25,4 +33,3 @@ start().catch(async (error) => { await prisma.$disconnect(); process.exit(1); }); - diff --git a/shared/src/admin/types.ts b/shared/src/admin/types.ts index 28a525b..e051566 100644 --- a/shared/src/admin/types.ts +++ b/shared/src/admin/types.ts @@ -53,6 +53,13 @@ export interface AdminUserInput { password: string | null; } +export interface StartupValidationCheckDto { + id: string; + label: string; + status: "PASS" | "WARN" | "FAIL"; + message: string; +} + export interface AdminDiagnosticsDto { serverTime: string; nodeVersion: string; @@ -76,5 +83,7 @@ export interface AdminDiagnosticsDto { shipmentCount: number; attachmentCount: number; auditEventCount: number; + startupStatus: "PASS" | "WARN" | "FAIL"; + startupChecks: StartupValidationCheckDto[]; recentAuditEvents: AuditEventDto[]; }