This commit is contained in:
2026-03-15 14:11:21 -05:00
parent 1fcb0c5480
commit 857d34397e
28 changed files with 848 additions and 45 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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);
},

View File

@@ -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: [

View 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>
);
}

View File

@@ -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>

View File

@@ -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");

View File

@@ -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])
}

View File

@@ -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
View 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 ?? {}),
},
});
}

View 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());
});

View 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),
};
}

View File

@@ -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.");
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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." };
}

View File

@@ -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);
}

View File

@@ -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." };
}

View File

@@ -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);
}

View File

@@ -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." };
}

View File

@@ -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." };
}

View File

@@ -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));
});

View File

@@ -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
View 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[];
}

View File

@@ -1,3 +1,4 @@
export * from "./admin/types.js";
export * from "./auth/permissions.js";
export * from "./auth/types.js";
export * from "./common/api.js";