diff --git a/AGENTS.md b/AGENTS.md index 2534770..d6197d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a - projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments - manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, and attachments - planning gantt timelines backed by live project and manufacturing schedule data +- admin diagnostics with runtime footprint, record counts, and persisted audit-trail visibility - Puppeteer PDF foundation - single-container Docker deployment @@ -119,8 +120,8 @@ If implementation changes invalidate those docs, update them in the same change Near-term priorities are: -1. Broader audit-trail coverage and operational diagnostics -2. Code-splitting and bundle-size reduction +1. Code-splitting and bundle-size reduction +2. Expanded role-management UI, permission assignment administration, and deeper support diagnostics 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 47e0af2..afe1e69 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 +- Persisted audit events for core settings, inventory, purchasing, sales, project, and manufacturing write actions +- Admin diagnostics page with runtime footprint, storage-path visibility, key record counts, and recent audit activity - Inventory transfers with paired physical stock movement posting between warehouses and locations - Manual inventory reservations plus automatic work-order-driven component reservations - Reserved and available stock visibility on inventory item detail and stock-by-location views @@ -37,7 +39,7 @@ This file is the running release and change log for MRP Codex. Keep it updated w - Project detail now surfaces linked work orders and can launch pre-seeded manufacturing records - Purchase-order detail now links back to the vendor CRM record and supports direct supporting-document management on the PO itself - Vendor CRM detail now exposes purchasing activity and can launch pre-seeded purchase orders -- Roadmap and project docs now treat broader audit-trail coverage and operational diagnostics as the next active priority after the inventory-control slice +- Roadmap and project docs now treat code-splitting and bundle-size reduction as the next active priority after the audit/diagnostics slice ## 2026-03-15 diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index fccec52..19104f2 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -27,6 +27,7 @@ This repository implements the platform foundation milestone: - projects with customer/commercial/shipment linkage, owners, due dates, notes, attachments, and dashboard visibility - manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, attachments, and dashboard visibility - planning gantt timelines backed by live project and manufacturing schedule data +- admin diagnostics with runtime footprint, storage visibility, record counts, and recent audit activity - Dockerized single-container deployment - Puppeteer PDF pipeline foundation @@ -62,5 +63,5 @@ This repository implements the platform foundation milestone: ## Next roadmap candidates -- broader audit and operations maturity - code-splitting and bundle-size reduction +- expanded role-management and support diagnostics diff --git a/README.md b/README.md index 35b310e..ce1c24c 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Current foundation scope includes: - projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments - manufacturing work orders with project linkage, station-based operation templates, material issue posting, completion posting, and work-order attachments - planning gantt timelines with live project and manufacturing schedule data +- admin diagnostics with runtime footprint, record counts, and recent audit-trail visibility - file storage and PDF rendering ## Product Map @@ -40,19 +41,20 @@ Current completed foundation areas: - projects foundation - manufacturing foundation - planning foundation +- audit and diagnostics foundation - branding, attachments, auth/RBAC, and PDF infrastructure Near-term priorities: -1. Broader audit-trail coverage and operational diagnostics -2. Code-splitting and bundle-size reduction +1. Code-splitting and bundle-size reduction +2. Expanded role-management UI, permission assignment administration, and deeper support diagnostics Revisit / deferred items: - local Windows Prisma migration reliability - frontend code-splitting and bundle-size reduction -- inventory transfers, reservations, and deeper stock controls -- deeper audit-trail coverage +- expanded role-management and permission administration +- deeper support diagnostics and startup validation Dashboard direction: @@ -337,6 +339,20 @@ As of March 14, 2026, the latest committed domain migrations include: Recent roadmap-driving migrations should always be applied before validating new CRM, inventory, sales, shipping, or purchasing features in a running environment. +## Audit And Diagnostics + +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 +- 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 +- expanded role and permission administration beyond the bootstrap defaults + ## UI Notes - Dark mode persistence is handled through the frontend theme provider and should remain stable across page navigation. diff --git a/ROADMAP.md b/ROADMAP.md index 288858a..efa347d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -47,6 +47,8 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni - Manufacturing stations, item routing templates, and automatic work-order operation planning for gantt scheduling - Vendor invoice/supporting-document attachments directly on purchase orders - Vendor-detail purchasing visibility with recent purchase-order activity +- 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 - SKU-searchable BOM component selection for inventory-scale datasets - Theme persistence fixes and denser responsive workspace layouts - Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens @@ -247,6 +249,11 @@ QOL subfeatures: ### Phase 8: Security, audit, and operations maturity +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 - Permission assignment administration - Audit trail coverage across critical records @@ -266,9 +273,7 @@ QOL subfeatures: - Local Windows Prisma migration reliability still needs a cleaner documented workflow or tooling wrapper - Frontend bundle splitting is still deferred; the Vite chunk-size warning remains -- Inventory transactions exist, but transfers, reservations, and more advanced stock controls still need follow-up - CRM document rollups and broader account-role depth were deferred until more downstream modules exist -- Audit-trail depth is still thin outside the current record/update flows - Some generated document and workflow screens still need additional polish for dense, keyboard-efficient operational use - Dashboard cards now use live data, but richer recent-activity widgets and exception queues are still deferred @@ -283,5 +288,5 @@ QOL subfeatures: ## Near-term priority order -1. Broader audit-trail coverage and operational diagnostics -2. Code-splitting and bundle-size reduction +1. Code-splitting and bundle-size reduction +2. Expanded role-management UI, permission assignment administration, and deeper support diagnostics diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index b3e4c36..ab709ea 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1,4 +1,5 @@ import type { + AdminDiagnosticsDto, ApiResponse, CompanyProfileDto, CompanyProfileInput, @@ -127,6 +128,9 @@ export const api = { me(token: string) { return request("/api/v1/auth/me", undefined, token); }, + getAdminDiagnostics(token: string) { + return request("/api/v1/admin/diagnostics", undefined, token); + }, getCompanyProfile(token: string) { return request("/api/v1/company-profile", undefined, token); }, diff --git a/client/src/main.tsx b/client/src/main.tsx index 4e9976d..84e16ee 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -9,6 +9,7 @@ import { ProtectedRoute } from "./components/ProtectedRoute"; import { AuthProvider } from "./auth/AuthProvider"; import { DashboardPage } from "./modules/dashboard/DashboardPage"; import { LoginPage } from "./modules/login/LoginPage"; +import { AdminDiagnosticsPage } from "./modules/settings/AdminDiagnosticsPage"; import { CompanySettingsPage } from "./modules/settings/CompanySettingsPage"; import { CrmDetailPage } from "./modules/crm/CrmDetailPage"; import { CrmFormPage } from "./modules/crm/CrmFormPage"; @@ -54,6 +55,10 @@ const router = createBrowserRouter([ element: , children: [{ path: "/settings/company", element: }], }, + { + element: , + children: [{ path: "/settings/admin-diagnostics", element: }], + }, { element: , children: [ diff --git a/client/src/modules/settings/AdminDiagnosticsPage.tsx b/client/src/modules/settings/AdminDiagnosticsPage.tsx new file mode 100644 index 0000000..d09b42d --- /dev/null +++ b/client/src/modules/settings/AdminDiagnosticsPage.tsx @@ -0,0 +1,169 @@ +import type { AdminDiagnosticsDto } from "@mrp/shared"; +import { Link } from "react-router-dom"; +import { useEffect, useState } from "react"; + +import { useAuth } from "../../auth/AuthProvider"; +import { api } from "../../lib/api"; + +function formatDateTime(value: string) { + return new Date(value).toLocaleString(); +} + +function parseMetadata(metadataJson: string) { + try { + return JSON.parse(metadataJson) as Record; + } catch { + return {}; + } +} + +export function AdminDiagnosticsPage() { + const { token } = useAuth(); + const [diagnostics, setDiagnostics] = useState(null); + const [status, setStatus] = useState("Loading diagnostics..."); + + useEffect(() => { + if (!token) { + return; + } + + let active = true; + + api + .getAdminDiagnostics(token) + .then((nextDiagnostics) => { + if (!active) { + return; + } + setDiagnostics(nextDiagnostics); + setStatus("Diagnostics loaded."); + }) + .catch((error: Error) => { + if (!active) { + return; + } + setStatus(error.message || "Unable to load diagnostics."); + }); + + return () => { + active = false; + }; + }, [token]); + + if (!diagnostics) { + return
{status}
; + } + + const summaryCards = [ + ["Server time", formatDateTime(diagnostics.serverTime)], + ["Node runtime", diagnostics.nodeVersion], + ["Audit events", diagnostics.auditEventCount.toString()], + ["Active users", `${diagnostics.activeUserCount} / ${diagnostics.userCount}`], + ["Sales docs", diagnostics.salesDocumentCount.toString()], + ["Work orders", diagnostics.workOrderCount.toString()], + ["Projects", diagnostics.projectCount.toString()], + ["Attachments", diagnostics.attachmentCount.toString()], + ]; + + const footprintCards = [ + ["Database URL", diagnostics.databaseUrl], + ["Data directory", diagnostics.dataDir], + ["Uploads directory", diagnostics.uploadsDir], + ["Client origin", diagnostics.clientOrigin], + ["Company profile", diagnostics.companyProfilePresent ? "Present" : "Missing"], + ["Roles / permissions", `${diagnostics.roleCount} / ${diagnostics.permissionCount}`], + ["Customers / vendors", `${diagnostics.customerCount} / ${diagnostics.vendorCount}`], + ["Inventory / warehouses", `${diagnostics.inventoryItemCount} / ${diagnostics.warehouseCount}`], + ["Purchase orders", diagnostics.purchaseOrderCount.toString()], + ["Shipments", diagnostics.shipmentCount.toString()], + ]; + + return ( +
+
+
+
+

Admin Diagnostics

+

Operational runtime and audit visibility

+

+ This view surfaces environment footprint, record counts, and recent change activity so admin review does not require direct database access. +

+
+
+ + Company settings + +
+
+
+ {summaryCards.map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))} +
+
+ +
+

System Footprint

+
+ {footprintCards.map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))} +
+
+ +
+
+
+

Recent Audit Trail

+

Latest cross-module write activity

+
+

{status}

+
+
+ + + + + + + + + + + + + {diagnostics.recentAuditEvents.map((event) => { + const metadata = parseMetadata(event.metadataJson); + return ( + + + + + + + + + ); + })} + +
WhenActorEntityActionSummaryMetadata
{formatDateTime(event.createdAt)}{event.actorName ?? "System"} +
{event.entityType}
+ {event.entityId ?
{event.entityId}
: null} +
+ + {event.action} + + {event.summary} + {Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : "No metadata"} +
+
+
+
+ ); +} diff --git a/client/src/modules/settings/CompanySettingsPage.tsx b/client/src/modules/settings/CompanySettingsPage.tsx index 64cfc8b..3b028c8 100644 --- a/client/src/modules/settings/CompanySettingsPage.tsx +++ b/client/src/modules/settings/CompanySettingsPage.tsx @@ -1,4 +1,5 @@ import type { CompanyProfileInput } from "@mrp/shared"; +import { Link } from "react-router-dom"; import { useEffect, useState } from "react"; import { useAuth } from "../../auth/AuthProvider"; @@ -6,7 +7,7 @@ import { api } from "../../lib/api"; import { useTheme } from "../../theme/ThemeProvider"; export function CompanySettingsPage() { - const { token } = useAuth(); + const { token, user } = useAuth(); const { applyBrandProfile } = useTheme(); const [form, setForm] = useState(null); const [companyId, setCompanyId] = useState(null); @@ -145,6 +146,20 @@ export function CompanySettingsPage() { return (
+ {user?.permissions.includes("admin.manage") ? ( +
+
+
+

Admin

+

Diagnostics and audit trail

+

Review runtime footprint and recent change activity from the admin diagnostics surface.

+
+ + Open diagnostics + +
+
+ ) : null}
diff --git a/server/prisma/migrations/20260315203000_audit_trail_and_diagnostics/migration.sql b/server/prisma/migrations/20260315203000_audit_trail_and_diagnostics/migration.sql new file mode 100644 index 0000000..1365ca0 --- /dev/null +++ b/server/prisma/migrations/20260315203000_audit_trail_and_diagnostics/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "AuditEvent" ( + "id" TEXT NOT NULL PRIMARY KEY, + "actorId" TEXT, + "entityType" TEXT NOT NULL, + "entityId" TEXT, + "action" TEXT NOT NULL, + "summary" TEXT NOT NULL, + "metadataJson" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AuditEvent_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "AuditEvent_createdAt_idx" ON "AuditEvent"("createdAt"); + +-- CreateIndex +CREATE INDEX "AuditEvent_entityType_entityId_createdAt_idx" ON "AuditEvent"("entityType", "entityId", "createdAt"); + +-- CreateIndex +CREATE INDEX "AuditEvent_actorId_createdAt_idx" ON "AuditEvent"("actorId", "createdAt"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 63f4bef..3f7a60f 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -29,6 +29,7 @@ model User { salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy") salesOrderRevisionsCreated SalesOrderRevision[] @relation("SalesOrderRevisionCreatedBy") inventoryTransfersCreated InventoryTransfer[] @relation("InventoryTransferCreatedBy") + auditEvents AuditEvent[] } model Role { @@ -698,3 +699,19 @@ model PurchaseReceiptLine { @@index([purchaseReceiptId]) @@index([purchaseOrderLineId]) } + +model AuditEvent { + id String @id @default(cuid()) + actorId String? + entityType String + entityId String? + action String + summary String + metadataJson String + createdAt DateTime @default(now()) + actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull) + + @@index([createdAt]) + @@index([entityType, entityId, createdAt]) + @@index([actorId, createdAt]) +} diff --git a/server/src/app.ts b/server/src/app.ts index 8de6f96..eadfc8a 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -11,6 +11,7 @@ import { paths } from "./config/paths.js"; import { verifyToken } from "./lib/auth.js"; import { getCurrentUserById } from "./lib/current-user.js"; import { fail, ok } from "./lib/http.js"; +import { adminRouter } from "./modules/admin/router.js"; import { authRouter } from "./modules/auth/router.js"; import { crmRouter } from "./modules/crm/router.js"; import { documentsRouter } from "./modules/documents/router.js"; @@ -53,6 +54,7 @@ export function createApp() { app.get("/api/v1/health", (_request, response) => ok(response, { status: "ok" })); app.use("/api/v1/auth", authRouter); + app.use("/api/v1/admin", adminRouter); app.use("/api/v1", settingsRouter); app.use("/api/v1/files", filesRouter); app.use("/api/v1/crm", crmRouter); diff --git a/server/src/lib/audit.ts b/server/src/lib/audit.ts new file mode 100644 index 0000000..cea1bb5 --- /dev/null +++ b/server/src/lib/audit.ts @@ -0,0 +1,27 @@ +import type { Prisma } from "@prisma/client"; + +import { prisma } from "./prisma.js"; + +type AuditClient = Prisma.TransactionClient | typeof prisma; + +interface LogAuditEventInput { + actorId?: string | null; + entityType: string; + entityId?: string | null; + action: string; + summary: string; + metadata?: Record; +} + +export async function logAuditEvent(input: LogAuditEventInput, client: AuditClient = prisma) { + await client.auditEvent.create({ + data: { + actorId: input.actorId ?? null, + entityType: input.entityType, + entityId: input.entityId ?? null, + action: input.action, + summary: input.summary, + metadataJson: JSON.stringify(input.metadata ?? {}), + }, + }); +} diff --git a/server/src/modules/admin/router.ts b/server/src/modules/admin/router.ts new file mode 100644 index 0000000..5b5834e --- /dev/null +++ b/server/src/modules/admin/router.ts @@ -0,0 +1,12 @@ +import { permissions } from "@mrp/shared"; +import { Router } from "express"; + +import { ok } from "../../lib/http.js"; +import { requirePermissions } from "../../lib/rbac.js"; +import { getAdminDiagnostics } from "./service.js"; + +export const adminRouter = Router(); + +adminRouter.get("/diagnostics", requirePermissions([permissions.adminManage]), async (_request, response) => { + return ok(response, await getAdminDiagnostics()); +}); diff --git a/server/src/modules/admin/service.ts b/server/src/modules/admin/service.ts new file mode 100644 index 0000000..f8a8f81 --- /dev/null +++ b/server/src/modules/admin/service.ts @@ -0,0 +1,114 @@ +import type { AdminDiagnosticsDto, AuditEventDto } from "@mrp/shared"; +import fs from "node:fs/promises"; + +import { env } from "../../config/env.js"; +import { paths } from "../../config/paths.js"; +import { prisma } from "../../lib/prisma.js"; + +function mapAuditEvent(record: { + id: string; + actorId: string | null; + entityType: string; + entityId: string | null; + action: string; + summary: string; + metadataJson: string; + createdAt: Date; + actor: { + firstName: string; + lastName: string; + } | null; +}): AuditEventDto { + return { + id: record.id, + actorId: record.actorId, + actorName: record.actor ? `${record.actor.firstName} ${record.actor.lastName}`.trim() : null, + entityType: record.entityType, + entityId: record.entityId, + action: record.action, + summary: record.summary, + metadataJson: record.metadataJson, + createdAt: record.createdAt.toISOString(), + }; +} + +export async function getAdminDiagnostics(): Promise { + const [ + companyProfile, + userCount, + activeUserCount, + roleCount, + permissionCount, + customerCount, + vendorCount, + inventoryItemCount, + warehouseCount, + workOrderCount, + projectCount, + purchaseOrderCount, + salesQuoteCount, + salesOrderCount, + shipmentCount, + attachmentCount, + auditEventCount, + recentAuditEvents, + ] = await Promise.all([ + prisma.companyProfile.findFirst({ where: { isActive: true }, select: { id: true } }), + prisma.user.count(), + prisma.user.count({ where: { isActive: true } }), + prisma.role.count(), + prisma.permission.count(), + prisma.customer.count(), + prisma.vendor.count(), + prisma.inventoryItem.count(), + prisma.warehouse.count(), + prisma.workOrder.count(), + prisma.project.count(), + prisma.purchaseOrder.count(), + prisma.salesQuote.count(), + prisma.salesOrder.count(), + prisma.shipment.count(), + prisma.fileAttachment.count(), + prisma.auditEvent.count(), + prisma.auditEvent.findMany({ + include: { + actor: { + select: { + firstName: true, + lastName: true, + }, + }, + }, + orderBy: [{ createdAt: "desc" }], + take: 25, + }), + ]); + + await Promise.all([fs.access(paths.dataDir), fs.access(paths.uploadsDir)]); + + return { + serverTime: new Date().toISOString(), + nodeVersion: process.version, + databaseUrl: env.DATABASE_URL, + dataDir: paths.dataDir, + uploadsDir: paths.uploadsDir, + clientOrigin: env.CLIENT_ORIGIN, + companyProfilePresent: Boolean(companyProfile), + userCount, + activeUserCount, + roleCount, + permissionCount, + customerCount, + vendorCount, + inventoryItemCount, + warehouseCount, + workOrderCount, + projectCount, + purchaseOrderCount, + salesDocumentCount: salesQuoteCount + salesOrderCount, + shipmentCount, + attachmentCount, + auditEventCount, + recentAuditEvents: recentAuditEvents.map(mapAuditEvent), + }; +} diff --git a/server/src/modules/inventory/router.ts b/server/src/modules/inventory/router.ts index 619f4e5..8bbcf2d 100644 --- a/server/src/modules/inventory/router.ts +++ b/server/src/modules/inventory/router.ts @@ -148,7 +148,7 @@ inventoryRouter.post("/items", requirePermissions([permissions.inventoryWrite]), return fail(response, 400, "INVALID_INPUT", "Inventory item payload is invalid."); } - const item = await createInventoryItem(parsed.data); + const item = await createInventoryItem(parsed.data, request.authUser?.id); if (!item) { return fail(response, 400, "INVALID_INPUT", "Inventory item BOM references are invalid."); } @@ -167,7 +167,7 @@ inventoryRouter.put("/items/:itemId", requirePermissions([permissions.inventoryW return fail(response, 400, "INVALID_INPUT", "Inventory item payload is invalid."); } - const item = await updateInventoryItem(itemId, parsed.data); + const item = await updateInventoryItem(itemId, parsed.data, request.authUser?.id); if (!item) { return fail(response, 400, "INVALID_INPUT", "Inventory item or BOM references are invalid."); } @@ -224,7 +224,7 @@ inventoryRouter.post("/items/:itemId/reservations", requirePermissions([permissi return fail(response, 400, "INVALID_INPUT", "Inventory reservation payload is invalid."); } - const result = await createInventoryReservation(itemId, parsed.data); + const result = await createInventoryReservation(itemId, parsed.data, request.authUser?.id); if (!result.ok) { return fail(response, 400, "INVALID_INPUT", result.reason); } @@ -256,7 +256,7 @@ inventoryRouter.post("/warehouses", requirePermissions([permissions.inventoryWri return fail(response, 400, "INVALID_INPUT", "Warehouse payload is invalid."); } - return ok(response, await createWarehouse(parsed.data), 201); + return ok(response, await createWarehouse(parsed.data, request.authUser?.id), 201); }); inventoryRouter.put("/warehouses/:warehouseId", requirePermissions([permissions.inventoryWrite]), async (request, response) => { @@ -270,7 +270,7 @@ inventoryRouter.put("/warehouses/:warehouseId", requirePermissions([permissions. return fail(response, 400, "INVALID_INPUT", "Warehouse payload is invalid."); } - const warehouse = await updateWarehouse(warehouseId, parsed.data); + const warehouse = await updateWarehouse(warehouseId, parsed.data, request.authUser?.id); if (!warehouse) { return fail(response, 404, "WAREHOUSE_NOT_FOUND", "Warehouse was not found."); } diff --git a/server/src/modules/inventory/service.ts b/server/src/modules/inventory/service.ts index 9ace969..b1a83a1 100644 --- a/server/src/modules/inventory/service.ts +++ b/server/src/modules/inventory/service.ts @@ -25,6 +25,7 @@ import type { InventoryUnitOfMeasure, } from "@mrp/shared/dist/inventory/types.js"; +import { logAuditEvent } from "../../lib/audit.js"; import { prisma } from "../../lib/prisma.js"; type BomLineRecord = { @@ -841,6 +842,21 @@ export async function createInventoryTransaction(itemId: string, payload: Invent }, }); + await logAuditEvent({ + actorId: createdById, + entityType: "inventory-item", + entityId: itemId, + action: "transaction.created", + summary: `Posted ${payload.transactionType.toLowerCase()} transaction for inventory item ${itemId}.`, + metadata: { + transactionType: payload.transactionType, + quantity: payload.quantity, + warehouseId: payload.warehouseId, + locationId: payload.locationId, + reference: payload.reference.trim(), + }, + }); + const nextDetail = await getInventoryItemById(itemId); return nextDetail ? { ok: true as const, item: nextDetail } : { ok: false as const, reason: "Unable to load updated inventory item." }; } @@ -921,11 +937,26 @@ export async function createInventoryTransfer(itemId: string, payload: Inventory }); }); + await logAuditEvent({ + actorId: createdById, + entityType: "inventory-item", + entityId: itemId, + action: "transfer.created", + summary: `Transferred ${payload.quantity} units for inventory item ${itemId}.`, + metadata: { + quantity: payload.quantity, + fromWarehouseId: payload.fromWarehouseId, + fromLocationId: payload.fromLocationId, + toWarehouseId: payload.toWarehouseId, + toLocationId: payload.toLocationId, + }, + }); + const nextDetail = await getInventoryItemById(itemId); return nextDetail ? { ok: true as const, item: nextDetail } : { ok: false as const, reason: "Unable to load updated inventory item." }; } -export async function createInventoryReservation(itemId: string, payload: InventoryReservationInput) { +export async function createInventoryReservation(itemId: string, payload: InventoryReservationInput, createdById?: string | null) { const item = await prisma.inventoryItem.findUnique({ where: { id: itemId }, select: { id: true }, @@ -969,11 +1000,25 @@ export async function createInventoryReservation(itemId: string, payload: Invent }, }); + await logAuditEvent({ + actorId: createdById, + entityType: "inventory-item", + entityId: itemId, + action: "reservation.created", + summary: `Created manual reservation for inventory item ${itemId}.`, + metadata: { + quantity: payload.quantity, + warehouseId: payload.warehouseId, + locationId: payload.locationId, + sourceType: "MANUAL", + }, + }); + const nextDetail = await getInventoryItemById(itemId); return nextDetail ? { ok: true as const, item: nextDetail } : { ok: false as const, reason: "Unable to load updated inventory item." }; } -export async function createInventoryItem(payload: InventoryItemInput) { +export async function createInventoryItem(payload: InventoryItemInput, actorId?: string | null) { const validatedBom = await validateBomLines(null, payload.bomLines); if (!validatedBom.ok) { return null; @@ -1012,10 +1057,24 @@ export async function createInventoryItem(payload: InventoryItemInput) { }, }); + await logAuditEvent({ + actorId, + entityType: "inventory-item", + entityId: item.id, + action: "created", + summary: `Created inventory item ${payload.sku}.`, + metadata: { + sku: payload.sku, + name: payload.name, + type: payload.type, + status: payload.status, + }, + }); + return getInventoryItemById(item.id); } -export async function updateInventoryItem(itemId: string, payload: InventoryItemInput) { +export async function updateInventoryItem(itemId: string, payload: InventoryItemInput, actorId?: string | null) { const existingItem = await prisma.inventoryItem.findUnique({ where: { id: itemId }, }); @@ -1061,6 +1120,20 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem }, }); + await logAuditEvent({ + actorId, + entityType: "inventory-item", + entityId: item.id, + action: "updated", + summary: `Updated inventory item ${payload.sku}.`, + metadata: { + sku: payload.sku, + name: payload.name, + type: payload.type, + status: payload.status, + }, + }); + return getInventoryItemById(item.id); } @@ -1092,7 +1165,7 @@ export async function getWarehouseById(warehouseId: string) { return warehouse ? mapWarehouseDetail(warehouse) : null; } -export async function createWarehouse(payload: WarehouseInput) { +export async function createWarehouse(payload: WarehouseInput, actorId?: string | null) { const locations = normalizeWarehouseLocations(payload.locations); const warehouse = await prisma.warehouse.create({ @@ -1113,10 +1186,23 @@ export async function createWarehouse(payload: WarehouseInput) { }, }); + await logAuditEvent({ + actorId, + entityType: "warehouse", + entityId: warehouse.id, + action: "created", + summary: `Created warehouse ${warehouse.code}.`, + metadata: { + code: warehouse.code, + name: warehouse.name, + locationCount: warehouse.locations.length, + }, + }); + return mapWarehouseDetail(warehouse); } -export async function updateWarehouse(warehouseId: string, payload: WarehouseInput) { +export async function updateWarehouse(warehouseId: string, payload: WarehouseInput, actorId?: string | null) { const existingWarehouse = await prisma.warehouse.findUnique({ where: { id: warehouseId }, }); @@ -1145,5 +1231,18 @@ export async function updateWarehouse(warehouseId: string, payload: WarehouseInp }, }); + await logAuditEvent({ + actorId, + entityType: "warehouse", + entityId: warehouse.id, + action: "updated", + summary: `Updated warehouse ${warehouse.code}.`, + metadata: { + code: warehouse.code, + name: warehouse.name, + locationCount: warehouse.locations.length, + }, + }); + return mapWarehouseDetail(warehouse); } diff --git a/server/src/modules/manufacturing/router.ts b/server/src/modules/manufacturing/router.ts index c9b7c18..a67e302 100644 --- a/server/src/modules/manufacturing/router.ts +++ b/server/src/modules/manufacturing/router.ts @@ -86,7 +86,7 @@ manufacturingRouter.post("/stations", requirePermissions([permissions.manufactur return fail(response, 400, "INVALID_INPUT", "Manufacturing station payload is invalid."); } - return ok(response, await createManufacturingStation(parsed.data), 201); + return ok(response, await createManufacturingStation(parsed.data, request.authUser?.id), 201); }); manufacturingRouter.get("/work-orders", requirePermissions([permissions.manufacturingRead]), async (request, response) => { @@ -118,7 +118,7 @@ manufacturingRouter.post("/work-orders", requirePermissions([permissions.manufac return fail(response, 400, "INVALID_INPUT", "Work-order payload is invalid."); } - const result = await createWorkOrder(parsed.data); + const result = await createWorkOrder(parsed.data, request.authUser?.id); if (!result.ok) { return fail(response, 400, "INVALID_INPUT", result.reason); } @@ -137,7 +137,7 @@ manufacturingRouter.put("/work-orders/:workOrderId", requirePermissions([permiss return fail(response, 400, "INVALID_INPUT", "Work-order payload is invalid."); } - const result = await updateWorkOrder(workOrderId, parsed.data); + const result = await updateWorkOrder(workOrderId, parsed.data, request.authUser?.id); if (!result.ok) { return fail(response, 400, "INVALID_INPUT", result.reason); } @@ -156,7 +156,7 @@ manufacturingRouter.patch("/work-orders/:workOrderId/status", requirePermissions return fail(response, 400, "INVALID_INPUT", "Work-order status payload is invalid."); } - const result = await updateWorkOrderStatus(workOrderId, parsed.data.status); + const result = await updateWorkOrderStatus(workOrderId, parsed.data.status, request.authUser?.id); if (!result.ok) { return fail(response, 400, "INVALID_INPUT", result.reason); } diff --git a/server/src/modules/manufacturing/service.ts b/server/src/modules/manufacturing/service.ts index 4fb211d..c6ac2a9 100644 --- a/server/src/modules/manufacturing/service.ts +++ b/server/src/modules/manufacturing/service.ts @@ -12,6 +12,7 @@ import type { WorkOrderSummaryDto, } from "@mrp/shared"; +import { logAuditEvent } from "../../lib/audit.js"; import { prisma } from "../../lib/prisma.js"; const workOrderModel = (prisma as any).workOrder; @@ -634,7 +635,7 @@ export async function listManufacturingStations(): Promise>; @@ -39,7 +40,7 @@ export async function getActiveCompanyProfile() { ); } -export async function updateActiveCompanyProfile(payload: CompanyProfileInput) { +export async function updateActiveCompanyProfile(payload: CompanyProfileInput, actorId?: string | null) { const current = await prisma.companyProfile.findFirstOrThrow({ where: { isActive: true }, }); @@ -67,6 +68,18 @@ export async function updateActiveCompanyProfile(payload: CompanyProfileInput) { }, }); + await logAuditEvent({ + actorId, + entityType: "company-profile", + entityId: profile.id, + action: "updated", + summary: `Updated company profile for ${profile.companyName}.`, + metadata: { + companyName: profile.companyName, + legalName: profile.legalName, + logoFileId: profile.logoFileId, + }, + }); + return mapCompanyProfile(profile); } - diff --git a/shared/src/admin/types.ts b/shared/src/admin/types.ts new file mode 100644 index 0000000..38674d4 --- /dev/null +++ b/shared/src/admin/types.ts @@ -0,0 +1,37 @@ +export interface AuditEventDto { + id: string; + actorId: string | null; + actorName: string | null; + entityType: string; + entityId: string | null; + action: string; + summary: string; + metadataJson: string; + createdAt: string; +} + +export interface AdminDiagnosticsDto { + serverTime: string; + nodeVersion: string; + databaseUrl: string; + dataDir: string; + uploadsDir: string; + clientOrigin: string; + companyProfilePresent: boolean; + userCount: number; + activeUserCount: number; + roleCount: number; + permissionCount: number; + customerCount: number; + vendorCount: number; + inventoryItemCount: number; + warehouseCount: number; + workOrderCount: number; + projectCount: number; + purchaseOrderCount: number; + salesDocumentCount: number; + shipmentCount: number; + attachmentCount: number; + auditEventCount: number; + recentAuditEvents: AuditEventDto[]; +} diff --git a/shared/src/index.ts b/shared/src/index.ts index b4338d9..9001b6d 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1,3 +1,4 @@ +export * from "./admin/types.js"; export * from "./auth/permissions.js"; export * from "./auth/types.js"; export * from "./common/api.js";