auditing
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
24
README.md
24
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.
|
||||
|
||||
13
ROADMAP.md
13
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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AdminDiagnosticsDto,
|
||||
ApiResponse,
|
||||
CompanyProfileDto,
|
||||
CompanyProfileInput,
|
||||
@@ -127,6 +128,9 @@ export const api = {
|
||||
me(token: string) {
|
||||
return request<LoginResponse["user"]>("/api/v1/auth/me", undefined, token);
|
||||
},
|
||||
getAdminDiagnostics(token: string) {
|
||||
return request<AdminDiagnosticsDto>("/api/v1/admin/diagnostics", undefined, token);
|
||||
},
|
||||
getCompanyProfile(token: string) {
|
||||
return request<CompanyProfileDto>("/api/v1/company-profile", undefined, token);
|
||||
},
|
||||
|
||||
@@ -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: <ProtectedRoute requiredPermissions={[permissions.companyRead]} />,
|
||||
children: [{ path: "/settings/company", element: <CompanySettingsPage /> }],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.adminManage]} />,
|
||||
children: [{ path: "/settings/admin-diagnostics", element: <AdminDiagnosticsPage /> }],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.crmRead]} />,
|
||||
children: [
|
||||
|
||||
169
client/src/modules/settings/AdminDiagnosticsPage.tsx
Normal file
169
client/src/modules/settings/AdminDiagnosticsPage.tsx
Normal file
@@ -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<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function AdminDiagnosticsPage() {
|
||||
const { token } = useAuth();
|
||||
const [diagnostics, setDiagnostics] = useState<AdminDiagnosticsDto | null>(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 <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<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">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Admin Diagnostics</p>
|
||||
<h3 className="mt-2 text-lg font-bold text-text">Operational runtime and audit visibility</h3>
|
||||
<p className="mt-2 max-w-3xl text-sm text-muted">
|
||||
This view surfaces environment footprint, record counts, and recent change activity so admin review does not require direct database access.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link to="/settings/company" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||
Company settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summaryCards.map(([label, value]) => (
|
||||
<div key={label} className="rounded-3xl border border-line/70 bg-page/70 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">{label}</p>
|
||||
<p className="mt-3 text-lg font-bold 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">
|
||||
<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">
|
||||
{footprintCards.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 break-all 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">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Recent Audit Trail</p>
|
||||
<h3 className="mt-2 text-lg font-bold text-text">Latest cross-module write activity</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted">{status}</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">Actor</th>
|
||||
<th className="px-3 py-3">Entity</th>
|
||||
<th className="px-3 py-3">Action</th>
|
||||
<th className="px-3 py-3">Summary</th>
|
||||
<th className="px-3 py-3">Metadata</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/70">
|
||||
{diagnostics.recentAuditEvents.map((event) => {
|
||||
const metadata = parseMetadata(event.metadataJson);
|
||||
return (
|
||||
<tr key={event.id} className="align-top">
|
||||
<td className="px-3 py-3 text-muted">{formatDateTime(event.createdAt)}</td>
|
||||
<td className="px-3 py-3 text-text">{event.actorName ?? "System"}</td>
|
||||
<td className="px-3 py-3 text-text">
|
||||
<div>{event.entityType}</div>
|
||||
{event.entityId ? <div className="text-xs text-muted">{event.entityId}</div> : null}
|
||||
</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">
|
||||
{event.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-text">{event.summary}</td>
|
||||
<td className="px-3 py-3 text-xs text-muted">
|
||||
{Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : "No metadata"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<CompanyProfileInput | null>(null);
|
||||
const [companyId, setCompanyId] = useState<string | null>(null);
|
||||
@@ -145,6 +146,20 @@ export function CompanySettingsPage() {
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={handleSave}>
|
||||
{user?.permissions.includes("admin.manage") ? (
|
||||
<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-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Admin</p>
|
||||
<h3 className="mt-2 text-lg font-bold text-text">Diagnostics and audit trail</h3>
|
||||
<p className="mt-2 text-sm text-muted">Review runtime footprint and recent change activity from the admin diagnostics surface.</p>
|
||||
</div>
|
||||
<Link to="/settings/admin-diagnostics" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||
Open diagnostics
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<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-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
|
||||
@@ -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");
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
27
server/src/lib/audit.ts
Normal file
27
server/src/lib/audit.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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 ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
12
server/src/modules/admin/router.ts
Normal file
12
server/src/modules/admin/router.ts
Normal file
@@ -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());
|
||||
});
|
||||
114
server/src/modules/admin/service.ts
Normal file
114
server/src/modules/admin/service.ts
Normal file
@@ -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<AdminDiagnosticsDto> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<ManufacturingStationD
|
||||
return stations.map(mapStation);
|
||||
}
|
||||
|
||||
export async function createManufacturingStation(payload: ManufacturingStationInput) {
|
||||
export async function createManufacturingStation(payload: ManufacturingStationInput, actorId?: string | null) {
|
||||
const station = await prisma.manufacturingStation.create({
|
||||
data: {
|
||||
code: payload.code.trim(),
|
||||
@@ -645,6 +646,20 @@ export async function createManufacturingStation(payload: ManufacturingStationIn
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "manufacturing-station",
|
||||
entityId: station.id,
|
||||
action: "created",
|
||||
summary: `Created manufacturing station ${station.code}.`,
|
||||
metadata: {
|
||||
code: station.code,
|
||||
name: station.name,
|
||||
queueDays: station.queueDays,
|
||||
isActive: station.isActive,
|
||||
},
|
||||
});
|
||||
|
||||
return mapStation(station);
|
||||
}
|
||||
|
||||
@@ -715,7 +730,7 @@ export async function getWorkOrderById(workOrderId: string) {
|
||||
return workOrder ? mapDetail(workOrder as WorkOrderRecord) : null;
|
||||
}
|
||||
|
||||
export async function createWorkOrder(payload: WorkOrderInput) {
|
||||
export async function createWorkOrder(payload: WorkOrderInput, actorId?: string | null) {
|
||||
const validated = await validateWorkOrderInput(payload);
|
||||
if (!validated.ok) {
|
||||
return { ok: false as const, reason: validated.reason };
|
||||
@@ -743,10 +758,26 @@ export async function createWorkOrder(payload: WorkOrderInput) {
|
||||
await syncWorkOrderReservations(created.id);
|
||||
|
||||
const workOrder = await getWorkOrderById(created.id);
|
||||
if (workOrder) {
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "work-order",
|
||||
entityId: created.id,
|
||||
action: "created",
|
||||
summary: `Created work order ${workOrder.workOrderNumber}.`,
|
||||
metadata: {
|
||||
workOrderNumber: workOrder.workOrderNumber,
|
||||
itemId: workOrder.itemId,
|
||||
projectId: workOrder.projectId,
|
||||
status: workOrder.status,
|
||||
quantity: workOrder.quantity,
|
||||
},
|
||||
});
|
||||
}
|
||||
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
|
||||
}
|
||||
|
||||
export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInput) {
|
||||
export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInput, actorId?: string | null) {
|
||||
const existing = await workOrderModel.findUnique({
|
||||
where: { id: workOrderId },
|
||||
select: {
|
||||
@@ -786,10 +817,26 @@ export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInp
|
||||
await syncWorkOrderReservations(workOrderId);
|
||||
|
||||
const workOrder = await getWorkOrderById(workOrderId);
|
||||
if (workOrder) {
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "work-order",
|
||||
entityId: workOrderId,
|
||||
action: "updated",
|
||||
summary: `Updated work order ${workOrder.workOrderNumber}.`,
|
||||
metadata: {
|
||||
workOrderNumber: workOrder.workOrderNumber,
|
||||
itemId: workOrder.itemId,
|
||||
projectId: workOrder.projectId,
|
||||
status: workOrder.status,
|
||||
quantity: workOrder.quantity,
|
||||
},
|
||||
});
|
||||
}
|
||||
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
|
||||
}
|
||||
|
||||
export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrderStatus) {
|
||||
export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrderStatus, actorId?: string | null) {
|
||||
const existing = await workOrderModel.findUnique({
|
||||
where: { id: workOrderId },
|
||||
select: {
|
||||
@@ -822,6 +869,19 @@ export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrd
|
||||
await syncWorkOrderReservations(workOrderId);
|
||||
|
||||
const workOrder = await getWorkOrderById(workOrderId);
|
||||
if (workOrder) {
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "work-order",
|
||||
entityId: workOrderId,
|
||||
action: "status.updated",
|
||||
summary: `Updated work order ${workOrder.workOrderNumber} to ${status}.`,
|
||||
metadata: {
|
||||
workOrderNumber: workOrder.workOrderNumber,
|
||||
status,
|
||||
},
|
||||
});
|
||||
}
|
||||
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
|
||||
}
|
||||
|
||||
@@ -910,6 +970,22 @@ export async function issueWorkOrderMaterial(workOrderId: string, payload: WorkO
|
||||
await syncWorkOrderReservations(workOrderId);
|
||||
|
||||
const nextWorkOrder = await getWorkOrderById(workOrderId);
|
||||
if (nextWorkOrder) {
|
||||
await logAuditEvent({
|
||||
actorId: createdById,
|
||||
entityType: "work-order",
|
||||
entityId: workOrderId,
|
||||
action: "material.issued",
|
||||
summary: `Issued material to work order ${nextWorkOrder.workOrderNumber}.`,
|
||||
metadata: {
|
||||
workOrderNumber: nextWorkOrder.workOrderNumber,
|
||||
componentItemId: payload.componentItemId,
|
||||
warehouseId: payload.warehouseId,
|
||||
locationId: payload.locationId,
|
||||
quantity: payload.quantity,
|
||||
},
|
||||
});
|
||||
}
|
||||
return nextWorkOrder ? { ok: true as const, workOrder: nextWorkOrder } : { ok: false as const, reason: "Unable to load updated work order." };
|
||||
}
|
||||
|
||||
@@ -970,5 +1046,19 @@ export async function recordWorkOrderCompletion(workOrderId: string, payload: Wo
|
||||
await syncWorkOrderReservations(workOrderId);
|
||||
|
||||
const nextWorkOrder = await getWorkOrderById(workOrderId);
|
||||
if (nextWorkOrder) {
|
||||
await logAuditEvent({
|
||||
actorId: createdById,
|
||||
entityType: "work-order",
|
||||
entityId: workOrderId,
|
||||
action: "completion.recorded",
|
||||
summary: `Recorded completion against work order ${nextWorkOrder.workOrderNumber}.`,
|
||||
metadata: {
|
||||
workOrderNumber: nextWorkOrder.workOrderNumber,
|
||||
quantity: payload.quantity,
|
||||
status: nextWorkOrder.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
return nextWorkOrder ? { ok: true as const, workOrder: nextWorkOrder } : { ok: false as const, reason: "Unable to load updated work order." };
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ projectsRouter.post("/", requirePermissions([permissions.projectsWrite]), async
|
||||
return fail(response, 400, "INVALID_INPUT", "Project payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await createProject(parsed.data);
|
||||
const result = await createProject(parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
@@ -130,7 +130,7 @@ projectsRouter.put("/:projectId", requirePermissions([permissions.projectsWrite]
|
||||
return fail(response, 400, "INVALID_INPUT", "Project payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updateProject(projectId, parsed.data);
|
||||
const result = await updateProject(projectId, parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ProjectSummaryDto,
|
||||
} from "@mrp/shared";
|
||||
|
||||
import { logAuditEvent } from "../../lib/audit.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
const projectModel = (prisma as any).project;
|
||||
@@ -356,7 +357,7 @@ export async function getProjectById(projectId: string) {
|
||||
return project ? mapProjectDetail(project as ProjectRecord) : null;
|
||||
}
|
||||
|
||||
export async function createProject(payload: ProjectInput) {
|
||||
export async function createProject(payload: ProjectInput, actorId?: string | null) {
|
||||
const validated = await validateProjectInput(payload);
|
||||
if (!validated.ok) {
|
||||
return { ok: false as const, reason: validated.reason };
|
||||
@@ -383,10 +384,25 @@ export async function createProject(payload: ProjectInput) {
|
||||
});
|
||||
|
||||
const project = await getProjectById(created.id);
|
||||
if (project) {
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "project",
|
||||
entityId: created.id,
|
||||
action: "created",
|
||||
summary: `Created project ${project.projectNumber}.`,
|
||||
metadata: {
|
||||
projectNumber: project.projectNumber,
|
||||
customerId: project.customerId,
|
||||
status: project.status,
|
||||
priority: project.priority,
|
||||
},
|
||||
});
|
||||
}
|
||||
return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." };
|
||||
}
|
||||
|
||||
export async function updateProject(projectId: string, payload: ProjectInput) {
|
||||
export async function updateProject(projectId: string, payload: ProjectInput, actorId?: string | null) {
|
||||
const existing = await projectModel.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { id: true },
|
||||
@@ -421,5 +437,20 @@ export async function updateProject(projectId: string, payload: ProjectInput) {
|
||||
});
|
||||
|
||||
const project = await getProjectById(projectId);
|
||||
if (project) {
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "project",
|
||||
entityId: projectId,
|
||||
action: "updated",
|
||||
summary: `Updated project ${project.projectNumber}.`,
|
||||
metadata: {
|
||||
projectNumber: project.projectNumber,
|
||||
customerId: project.customerId,
|
||||
status: project.status,
|
||||
priority: project.priority,
|
||||
},
|
||||
});
|
||||
}
|
||||
return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." };
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ purchasingRouter.post("/orders", requirePermissions(["purchasing.write"]), async
|
||||
return fail(response, 400, "INVALID_INPUT", "Purchase order payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await createPurchaseOrder(parsed.data);
|
||||
const result = await createPurchaseOrder(parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
@@ -115,7 +115,7 @@ purchasingRouter.put("/orders/:orderId", requirePermissions(["purchasing.write"]
|
||||
return fail(response, 400, "INVALID_INPUT", "Purchase order payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updatePurchaseOrder(orderId, parsed.data);
|
||||
const result = await updatePurchaseOrder(orderId, parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
@@ -134,7 +134,7 @@ purchasingRouter.patch("/orders/:orderId/status", requirePermissions(["purchasin
|
||||
return fail(response, 400, "INVALID_INPUT", "Purchase order status payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updatePurchaseOrderStatus(orderId, parsed.data.status);
|
||||
const result = await updatePurchaseOrderStatus(orderId, parsed.data.status, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Prisma } from "@prisma/client";
|
||||
import type { PurchaseLineInput, PurchaseOrderDetailDto, PurchaseOrderInput, PurchaseOrderStatus, PurchaseOrderSummaryDto, PurchaseVendorOptionDto } from "@mrp/shared";
|
||||
import type { PurchaseReceiptDto, PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js";
|
||||
|
||||
import { logAuditEvent } from "../../lib/audit.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
const purchaseOrderModel = prisma.purchaseOrder;
|
||||
@@ -530,7 +531,7 @@ export async function getPurchaseOrderById(documentId: string) {
|
||||
return record ? mapPurchaseOrder(record as unknown as PurchaseOrderRecord) : null;
|
||||
}
|
||||
|
||||
export async function createPurchaseOrder(payload: PurchaseOrderInput) {
|
||||
export async function createPurchaseOrder(payload: PurchaseOrderInput, actorId?: string | null) {
|
||||
const validatedLines = await validateLines(payload.lines);
|
||||
if (!validatedLines.ok) {
|
||||
return { ok: false as const, reason: validatedLines.reason };
|
||||
@@ -564,10 +565,25 @@ export async function createPurchaseOrder(payload: PurchaseOrderInput) {
|
||||
});
|
||||
|
||||
const detail = await getPurchaseOrderById(created.id);
|
||||
if (detail) {
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "purchase-order",
|
||||
entityId: created.id,
|
||||
action: "created",
|
||||
summary: `Created purchase order ${detail.documentNumber}.`,
|
||||
metadata: {
|
||||
documentNumber: detail.documentNumber,
|
||||
vendorId: detail.vendorId,
|
||||
status: detail.status,
|
||||
total: detail.total,
|
||||
},
|
||||
});
|
||||
}
|
||||
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load saved purchase order." };
|
||||
}
|
||||
|
||||
export async function updatePurchaseOrder(documentId: string, payload: PurchaseOrderInput) {
|
||||
export async function updatePurchaseOrder(documentId: string, payload: PurchaseOrderInput, actorId?: string | null) {
|
||||
const existing = await purchaseOrderModel.findUnique({
|
||||
where: { id: documentId },
|
||||
select: { id: true },
|
||||
@@ -609,10 +625,25 @@ export async function updatePurchaseOrder(documentId: string, payload: PurchaseO
|
||||
});
|
||||
|
||||
const detail = await getPurchaseOrderById(documentId);
|
||||
if (detail) {
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "purchase-order",
|
||||
entityId: documentId,
|
||||
action: "updated",
|
||||
summary: `Updated purchase order ${detail.documentNumber}.`,
|
||||
metadata: {
|
||||
documentNumber: detail.documentNumber,
|
||||
vendorId: detail.vendorId,
|
||||
status: detail.status,
|
||||
total: detail.total,
|
||||
},
|
||||
});
|
||||
}
|
||||
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load saved purchase order." };
|
||||
}
|
||||
|
||||
export async function updatePurchaseOrderStatus(documentId: string, status: PurchaseOrderStatus) {
|
||||
export async function updatePurchaseOrderStatus(documentId: string, status: PurchaseOrderStatus, actorId?: string | null) {
|
||||
const existing = await purchaseOrderModel.findUnique({
|
||||
where: { id: documentId },
|
||||
select: { id: true },
|
||||
@@ -629,6 +660,19 @@ export async function updatePurchaseOrderStatus(documentId: string, status: Purc
|
||||
});
|
||||
|
||||
const detail = await getPurchaseOrderById(documentId);
|
||||
if (detail) {
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "purchase-order",
|
||||
entityId: documentId,
|
||||
action: "status.updated",
|
||||
summary: `Updated purchase order ${detail.documentNumber} to ${status}.`,
|
||||
metadata: {
|
||||
documentNumber: detail.documentNumber,
|
||||
status,
|
||||
},
|
||||
});
|
||||
}
|
||||
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load updated purchase order." };
|
||||
}
|
||||
|
||||
@@ -677,6 +721,22 @@ export async function createPurchaseReceipt(orderId: string, payload: PurchaseRe
|
||||
});
|
||||
|
||||
const detail = await getPurchaseOrderById(orderId);
|
||||
if (detail) {
|
||||
await logAuditEvent({
|
||||
actorId: createdById,
|
||||
entityType: "purchase-order",
|
||||
entityId: orderId,
|
||||
action: "receipt.created",
|
||||
summary: `Received material against purchase order ${detail.documentNumber}.`,
|
||||
metadata: {
|
||||
documentNumber: detail.documentNumber,
|
||||
warehouseId: payload.warehouseId,
|
||||
locationId: payload.locationId,
|
||||
receivedAt: payload.receivedAt,
|
||||
lineCount: payload.lines.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load updated purchase order." };
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
SalesLineInput,
|
||||
} from "@mrp/shared/dist/sales/types.js";
|
||||
|
||||
import { logAuditEvent } from "../../lib/audit.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
export interface SalesDocumentPdfData {
|
||||
@@ -581,6 +582,19 @@ export async function createSalesDocument(type: SalesDocumentType, payload: Sale
|
||||
}
|
||||
|
||||
await createRevision(type, createdId, detail, payload.revisionReason?.trim() || "Initial issue", userId);
|
||||
await logAuditEvent({
|
||||
actorId: userId,
|
||||
entityType: type === "QUOTE" ? "sales-quote" : "sales-order",
|
||||
entityId: createdId,
|
||||
action: "created",
|
||||
summary: `Created ${type === "QUOTE" ? "quote" : "sales order"} ${detail.documentNumber}.`,
|
||||
metadata: {
|
||||
documentNumber: detail.documentNumber,
|
||||
customerId: detail.customerId,
|
||||
status: detail.status,
|
||||
total: detail.total,
|
||||
},
|
||||
});
|
||||
const refreshed = await getDocumentDetailOrNull(type, createdId);
|
||||
return refreshed ? { ok: true as const, document: refreshed } : { ok: false as const, reason: "Unable to load saved document." };
|
||||
}
|
||||
@@ -636,6 +650,20 @@ export async function updateSalesDocument(type: SalesDocumentType, documentId: s
|
||||
}
|
||||
|
||||
await createRevision(type, documentId, detail, payload.revisionReason?.trim() || "Document edited", userId);
|
||||
await logAuditEvent({
|
||||
actorId: userId,
|
||||
entityType: type === "QUOTE" ? "sales-quote" : "sales-order",
|
||||
entityId: documentId,
|
||||
action: "updated",
|
||||
summary: `Updated ${type === "QUOTE" ? "quote" : "sales order"} ${detail.documentNumber}.`,
|
||||
metadata: {
|
||||
documentNumber: detail.documentNumber,
|
||||
customerId: detail.customerId,
|
||||
status: detail.status,
|
||||
total: detail.total,
|
||||
revisionReason: payload.revisionReason?.trim() || null,
|
||||
},
|
||||
});
|
||||
const refreshed = await getDocumentDetailOrNull(type, documentId);
|
||||
return refreshed ? { ok: true as const, document: refreshed } : { ok: false as const, reason: "Unable to load saved document." };
|
||||
}
|
||||
@@ -666,6 +694,17 @@ export async function updateSalesDocumentStatus(type: SalesDocumentType, documen
|
||||
}
|
||||
|
||||
await createRevision(type, documentId, detail, `Status changed to ${status}`, userId);
|
||||
await logAuditEvent({
|
||||
actorId: userId,
|
||||
entityType: type === "QUOTE" ? "sales-quote" : "sales-order",
|
||||
entityId: documentId,
|
||||
action: "status.updated",
|
||||
summary: `Updated ${type === "QUOTE" ? "quote" : "sales order"} ${detail.documentNumber} to ${status}.`,
|
||||
metadata: {
|
||||
documentNumber: detail.documentNumber,
|
||||
status,
|
||||
},
|
||||
});
|
||||
const refreshed = await getDocumentDetailOrNull(type, documentId);
|
||||
return refreshed ? { ok: true as const, document: refreshed } : { ok: false as const, reason: "Unable to load updated document." };
|
||||
}
|
||||
@@ -700,6 +739,17 @@ export async function approveSalesDocument(type: SalesDocumentType, documentId:
|
||||
}
|
||||
|
||||
await createRevision(type, documentId, detail, "Document approved", userId);
|
||||
await logAuditEvent({
|
||||
actorId: userId,
|
||||
entityType: type === "QUOTE" ? "sales-quote" : "sales-order",
|
||||
entityId: documentId,
|
||||
action: "approved",
|
||||
summary: `Approved ${type === "QUOTE" ? "quote" : "sales order"} ${detail.documentNumber}.`,
|
||||
metadata: {
|
||||
documentNumber: detail.documentNumber,
|
||||
approvedAt: detail.approvedAt,
|
||||
},
|
||||
});
|
||||
const refreshed = await getDocumentDetailOrNull(type, documentId);
|
||||
return refreshed ? { ok: true as const, document: refreshed } : { ok: false as const, reason: "Unable to load approved document." };
|
||||
}
|
||||
@@ -751,6 +801,18 @@ export async function convertQuoteToSalesOrder(quoteId: string, userId?: string)
|
||||
}
|
||||
|
||||
await createRevision("ORDER", createdId, order, `Converted from quote ${mappedQuote.documentNumber}`, userId);
|
||||
await logAuditEvent({
|
||||
actorId: userId,
|
||||
entityType: "sales-order",
|
||||
entityId: createdId,
|
||||
action: "converted",
|
||||
summary: `Converted quote ${mappedQuote.documentNumber} into sales order ${order.documentNumber}.`,
|
||||
metadata: {
|
||||
sourceQuoteId: quoteId,
|
||||
sourceQuoteNumber: mappedQuote.documentNumber,
|
||||
salesOrderNumber: order.documentNumber,
|
||||
},
|
||||
});
|
||||
const refreshed = await getDocumentDetailOrNull("ORDER", createdId);
|
||||
return refreshed ? { ok: true as const, document: refreshed } : { ok: false as const, reason: "Unable to load converted sales order." };
|
||||
}
|
||||
|
||||
@@ -40,6 +40,5 @@ settingsRouter.put("/company-profile", requirePermissions([permissions.companyWr
|
||||
return fail(response, 400, "INVALID_INPUT", "Company settings payload is invalid.");
|
||||
}
|
||||
|
||||
return ok(response, await updateActiveCompanyProfile(parsed.data));
|
||||
return ok(response, await updateActiveCompanyProfile(parsed.data, request.authUser?.id));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CompanyProfileDto, CompanyProfileInput } from "@mrp/shared";
|
||||
|
||||
import { logAuditEvent } from "../../lib/audit.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
type CompanyProfileRecord = Awaited<ReturnType<typeof prisma.companyProfile.findFirstOrThrow>>;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
37
shared/src/admin/types.ts
Normal file
37
shared/src/admin/types.ts
Normal file
@@ -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[];
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./admin/types.js";
|
||||
export * from "./auth/permissions.js";
|
||||
export * from "./auth/types.js";
|
||||
export * from "./common/api.js";
|
||||
|
||||
Reference in New Issue
Block a user