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

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