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

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