auditing
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuditEvent" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"actorId" TEXT,
|
||||
"entityType" TEXT NOT NULL,
|
||||
"entityId" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"summary" TEXT NOT NULL,
|
||||
"metadataJson" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "AuditEvent_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditEvent_createdAt_idx" ON "AuditEvent"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditEvent_entityType_entityId_createdAt_idx" ON "AuditEvent"("entityType", "entityId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditEvent_actorId_createdAt_idx" ON "AuditEvent"("actorId", "createdAt");
|
||||
@@ -29,6 +29,7 @@ model User {
|
||||
salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy")
|
||||
salesOrderRevisionsCreated SalesOrderRevision[] @relation("SalesOrderRevisionCreatedBy")
|
||||
inventoryTransfersCreated InventoryTransfer[] @relation("InventoryTransferCreatedBy")
|
||||
auditEvents AuditEvent[]
|
||||
}
|
||||
|
||||
model Role {
|
||||
@@ -698,3 +699,19 @@ model PurchaseReceiptLine {
|
||||
@@index([purchaseReceiptId])
|
||||
@@index([purchaseOrderLineId])
|
||||
}
|
||||
|
||||
model AuditEvent {
|
||||
id String @id @default(cuid())
|
||||
actorId String?
|
||||
entityType String
|
||||
entityId String?
|
||||
action String
|
||||
summary String
|
||||
metadataJson String
|
||||
createdAt DateTime @default(now())
|
||||
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([entityType, entityId, createdAt])
|
||||
@@index([actorId, createdAt])
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { paths } from "./config/paths.js";
|
||||
import { verifyToken } from "./lib/auth.js";
|
||||
import { getCurrentUserById } from "./lib/current-user.js";
|
||||
import { fail, ok } from "./lib/http.js";
|
||||
import { adminRouter } from "./modules/admin/router.js";
|
||||
import { authRouter } from "./modules/auth/router.js";
|
||||
import { crmRouter } from "./modules/crm/router.js";
|
||||
import { documentsRouter } from "./modules/documents/router.js";
|
||||
@@ -53,6 +54,7 @@ export function createApp() {
|
||||
|
||||
app.get("/api/v1/health", (_request, response) => ok(response, { status: "ok" }));
|
||||
app.use("/api/v1/auth", authRouter);
|
||||
app.use("/api/v1/admin", adminRouter);
|
||||
app.use("/api/v1", settingsRouter);
|
||||
app.use("/api/v1/files", filesRouter);
|
||||
app.use("/api/v1/crm", crmRouter);
|
||||
|
||||
27
server/src/lib/audit.ts
Normal file
27
server/src/lib/audit.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import { prisma } from "./prisma.js";
|
||||
|
||||
type AuditClient = Prisma.TransactionClient | typeof prisma;
|
||||
|
||||
interface LogAuditEventInput {
|
||||
actorId?: string | null;
|
||||
entityType: string;
|
||||
entityId?: string | null;
|
||||
action: string;
|
||||
summary: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function logAuditEvent(input: LogAuditEventInput, client: AuditClient = prisma) {
|
||||
await client.auditEvent.create({
|
||||
data: {
|
||||
actorId: input.actorId ?? null,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId ?? null,
|
||||
action: input.action,
|
||||
summary: input.summary,
|
||||
metadataJson: JSON.stringify(input.metadata ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
12
server/src/modules/admin/router.ts
Normal file
12
server/src/modules/admin/router.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import { Router } from "express";
|
||||
|
||||
import { ok } from "../../lib/http.js";
|
||||
import { requirePermissions } from "../../lib/rbac.js";
|
||||
import { getAdminDiagnostics } from "./service.js";
|
||||
|
||||
export const adminRouter = Router();
|
||||
|
||||
adminRouter.get("/diagnostics", requirePermissions([permissions.adminManage]), async (_request, response) => {
|
||||
return ok(response, await getAdminDiagnostics());
|
||||
});
|
||||
114
server/src/modules/admin/service.ts
Normal file
114
server/src/modules/admin/service.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { AdminDiagnosticsDto, AuditEventDto } from "@mrp/shared";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import { env } from "../../config/env.js";
|
||||
import { paths } from "../../config/paths.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
function mapAuditEvent(record: {
|
||||
id: string;
|
||||
actorId: string | null;
|
||||
entityType: string;
|
||||
entityId: string | null;
|
||||
action: string;
|
||||
summary: string;
|
||||
metadataJson: string;
|
||||
createdAt: Date;
|
||||
actor: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
} | null;
|
||||
}): AuditEventDto {
|
||||
return {
|
||||
id: record.id,
|
||||
actorId: record.actorId,
|
||||
actorName: record.actor ? `${record.actor.firstName} ${record.actor.lastName}`.trim() : null,
|
||||
entityType: record.entityType,
|
||||
entityId: record.entityId,
|
||||
action: record.action,
|
||||
summary: record.summary,
|
||||
metadataJson: record.metadataJson,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
|
||||
const [
|
||||
companyProfile,
|
||||
userCount,
|
||||
activeUserCount,
|
||||
roleCount,
|
||||
permissionCount,
|
||||
customerCount,
|
||||
vendorCount,
|
||||
inventoryItemCount,
|
||||
warehouseCount,
|
||||
workOrderCount,
|
||||
projectCount,
|
||||
purchaseOrderCount,
|
||||
salesQuoteCount,
|
||||
salesOrderCount,
|
||||
shipmentCount,
|
||||
attachmentCount,
|
||||
auditEventCount,
|
||||
recentAuditEvents,
|
||||
] = await Promise.all([
|
||||
prisma.companyProfile.findFirst({ where: { isActive: true }, select: { id: true } }),
|
||||
prisma.user.count(),
|
||||
prisma.user.count({ where: { isActive: true } }),
|
||||
prisma.role.count(),
|
||||
prisma.permission.count(),
|
||||
prisma.customer.count(),
|
||||
prisma.vendor.count(),
|
||||
prisma.inventoryItem.count(),
|
||||
prisma.warehouse.count(),
|
||||
prisma.workOrder.count(),
|
||||
prisma.project.count(),
|
||||
prisma.purchaseOrder.count(),
|
||||
prisma.salesQuote.count(),
|
||||
prisma.salesOrder.count(),
|
||||
prisma.shipment.count(),
|
||||
prisma.fileAttachment.count(),
|
||||
prisma.auditEvent.count(),
|
||||
prisma.auditEvent.findMany({
|
||||
include: {
|
||||
actor: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
take: 25,
|
||||
}),
|
||||
]);
|
||||
|
||||
await Promise.all([fs.access(paths.dataDir), fs.access(paths.uploadsDir)]);
|
||||
|
||||
return {
|
||||
serverTime: new Date().toISOString(),
|
||||
nodeVersion: process.version,
|
||||
databaseUrl: env.DATABASE_URL,
|
||||
dataDir: paths.dataDir,
|
||||
uploadsDir: paths.uploadsDir,
|
||||
clientOrigin: env.CLIENT_ORIGIN,
|
||||
companyProfilePresent: Boolean(companyProfile),
|
||||
userCount,
|
||||
activeUserCount,
|
||||
roleCount,
|
||||
permissionCount,
|
||||
customerCount,
|
||||
vendorCount,
|
||||
inventoryItemCount,
|
||||
warehouseCount,
|
||||
workOrderCount,
|
||||
projectCount,
|
||||
purchaseOrderCount,
|
||||
salesDocumentCount: salesQuoteCount + salesOrderCount,
|
||||
shipmentCount,
|
||||
attachmentCount,
|
||||
auditEventCount,
|
||||
recentAuditEvents: recentAuditEvents.map(mapAuditEvent),
|
||||
};
|
||||
}
|
||||
@@ -148,7 +148,7 @@ inventoryRouter.post("/items", requirePermissions([permissions.inventoryWrite]),
|
||||
return fail(response, 400, "INVALID_INPUT", "Inventory item payload is invalid.");
|
||||
}
|
||||
|
||||
const item = await createInventoryItem(parsed.data);
|
||||
const item = await createInventoryItem(parsed.data, request.authUser?.id);
|
||||
if (!item) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Inventory item BOM references are invalid.");
|
||||
}
|
||||
@@ -167,7 +167,7 @@ inventoryRouter.put("/items/:itemId", requirePermissions([permissions.inventoryW
|
||||
return fail(response, 400, "INVALID_INPUT", "Inventory item payload is invalid.");
|
||||
}
|
||||
|
||||
const item = await updateInventoryItem(itemId, parsed.data);
|
||||
const item = await updateInventoryItem(itemId, parsed.data, request.authUser?.id);
|
||||
if (!item) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Inventory item or BOM references are invalid.");
|
||||
}
|
||||
@@ -224,7 +224,7 @@ inventoryRouter.post("/items/:itemId/reservations", requirePermissions([permissi
|
||||
return fail(response, 400, "INVALID_INPUT", "Inventory reservation payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await createInventoryReservation(itemId, parsed.data);
|
||||
const result = await createInventoryReservation(itemId, parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
@@ -256,7 +256,7 @@ inventoryRouter.post("/warehouses", requirePermissions([permissions.inventoryWri
|
||||
return fail(response, 400, "INVALID_INPUT", "Warehouse payload is invalid.");
|
||||
}
|
||||
|
||||
return ok(response, await createWarehouse(parsed.data), 201);
|
||||
return ok(response, await createWarehouse(parsed.data, request.authUser?.id), 201);
|
||||
});
|
||||
|
||||
inventoryRouter.put("/warehouses/:warehouseId", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
|
||||
@@ -270,7 +270,7 @@ inventoryRouter.put("/warehouses/:warehouseId", requirePermissions([permissions.
|
||||
return fail(response, 400, "INVALID_INPUT", "Warehouse payload is invalid.");
|
||||
}
|
||||
|
||||
const warehouse = await updateWarehouse(warehouseId, parsed.data);
|
||||
const warehouse = await updateWarehouse(warehouseId, parsed.data, request.authUser?.id);
|
||||
if (!warehouse) {
|
||||
return fail(response, 404, "WAREHOUSE_NOT_FOUND", "Warehouse was not found.");
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
InventoryUnitOfMeasure,
|
||||
} from "@mrp/shared/dist/inventory/types.js";
|
||||
|
||||
import { logAuditEvent } from "../../lib/audit.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
type BomLineRecord = {
|
||||
@@ -841,6 +842,21 @@ export async function createInventoryTransaction(itemId: string, payload: Invent
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId: createdById,
|
||||
entityType: "inventory-item",
|
||||
entityId: itemId,
|
||||
action: "transaction.created",
|
||||
summary: `Posted ${payload.transactionType.toLowerCase()} transaction for inventory item ${itemId}.`,
|
||||
metadata: {
|
||||
transactionType: payload.transactionType,
|
||||
quantity: payload.quantity,
|
||||
warehouseId: payload.warehouseId,
|
||||
locationId: payload.locationId,
|
||||
reference: payload.reference.trim(),
|
||||
},
|
||||
});
|
||||
|
||||
const nextDetail = await getInventoryItemById(itemId);
|
||||
return nextDetail ? { ok: true as const, item: nextDetail } : { ok: false as const, reason: "Unable to load updated inventory item." };
|
||||
}
|
||||
@@ -921,11 +937,26 @@ export async function createInventoryTransfer(itemId: string, payload: Inventory
|
||||
});
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId: createdById,
|
||||
entityType: "inventory-item",
|
||||
entityId: itemId,
|
||||
action: "transfer.created",
|
||||
summary: `Transferred ${payload.quantity} units for inventory item ${itemId}.`,
|
||||
metadata: {
|
||||
quantity: payload.quantity,
|
||||
fromWarehouseId: payload.fromWarehouseId,
|
||||
fromLocationId: payload.fromLocationId,
|
||||
toWarehouseId: payload.toWarehouseId,
|
||||
toLocationId: payload.toLocationId,
|
||||
},
|
||||
});
|
||||
|
||||
const nextDetail = await getInventoryItemById(itemId);
|
||||
return nextDetail ? { ok: true as const, item: nextDetail } : { ok: false as const, reason: "Unable to load updated inventory item." };
|
||||
}
|
||||
|
||||
export async function createInventoryReservation(itemId: string, payload: InventoryReservationInput) {
|
||||
export async function createInventoryReservation(itemId: string, payload: InventoryReservationInput, createdById?: string | null) {
|
||||
const item = await prisma.inventoryItem.findUnique({
|
||||
where: { id: itemId },
|
||||
select: { id: true },
|
||||
@@ -969,11 +1000,25 @@ export async function createInventoryReservation(itemId: string, payload: Invent
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId: createdById,
|
||||
entityType: "inventory-item",
|
||||
entityId: itemId,
|
||||
action: "reservation.created",
|
||||
summary: `Created manual reservation for inventory item ${itemId}.`,
|
||||
metadata: {
|
||||
quantity: payload.quantity,
|
||||
warehouseId: payload.warehouseId,
|
||||
locationId: payload.locationId,
|
||||
sourceType: "MANUAL",
|
||||
},
|
||||
});
|
||||
|
||||
const nextDetail = await getInventoryItemById(itemId);
|
||||
return nextDetail ? { ok: true as const, item: nextDetail } : { ok: false as const, reason: "Unable to load updated inventory item." };
|
||||
}
|
||||
|
||||
export async function createInventoryItem(payload: InventoryItemInput) {
|
||||
export async function createInventoryItem(payload: InventoryItemInput, actorId?: string | null) {
|
||||
const validatedBom = await validateBomLines(null, payload.bomLines);
|
||||
if (!validatedBom.ok) {
|
||||
return null;
|
||||
@@ -1012,10 +1057,24 @@ export async function createInventoryItem(payload: InventoryItemInput) {
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "inventory-item",
|
||||
entityId: item.id,
|
||||
action: "created",
|
||||
summary: `Created inventory item ${payload.sku}.`,
|
||||
metadata: {
|
||||
sku: payload.sku,
|
||||
name: payload.name,
|
||||
type: payload.type,
|
||||
status: payload.status,
|
||||
},
|
||||
});
|
||||
|
||||
return getInventoryItemById(item.id);
|
||||
}
|
||||
|
||||
export async function updateInventoryItem(itemId: string, payload: InventoryItemInput) {
|
||||
export async function updateInventoryItem(itemId: string, payload: InventoryItemInput, actorId?: string | null) {
|
||||
const existingItem = await prisma.inventoryItem.findUnique({
|
||||
where: { id: itemId },
|
||||
});
|
||||
@@ -1061,6 +1120,20 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "inventory-item",
|
||||
entityId: item.id,
|
||||
action: "updated",
|
||||
summary: `Updated inventory item ${payload.sku}.`,
|
||||
metadata: {
|
||||
sku: payload.sku,
|
||||
name: payload.name,
|
||||
type: payload.type,
|
||||
status: payload.status,
|
||||
},
|
||||
});
|
||||
|
||||
return getInventoryItemById(item.id);
|
||||
}
|
||||
|
||||
@@ -1092,7 +1165,7 @@ export async function getWarehouseById(warehouseId: string) {
|
||||
return warehouse ? mapWarehouseDetail(warehouse) : null;
|
||||
}
|
||||
|
||||
export async function createWarehouse(payload: WarehouseInput) {
|
||||
export async function createWarehouse(payload: WarehouseInput, actorId?: string | null) {
|
||||
const locations = normalizeWarehouseLocations(payload.locations);
|
||||
|
||||
const warehouse = await prisma.warehouse.create({
|
||||
@@ -1113,10 +1186,23 @@ export async function createWarehouse(payload: WarehouseInput) {
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "warehouse",
|
||||
entityId: warehouse.id,
|
||||
action: "created",
|
||||
summary: `Created warehouse ${warehouse.code}.`,
|
||||
metadata: {
|
||||
code: warehouse.code,
|
||||
name: warehouse.name,
|
||||
locationCount: warehouse.locations.length,
|
||||
},
|
||||
});
|
||||
|
||||
return mapWarehouseDetail(warehouse);
|
||||
}
|
||||
|
||||
export async function updateWarehouse(warehouseId: string, payload: WarehouseInput) {
|
||||
export async function updateWarehouse(warehouseId: string, payload: WarehouseInput, actorId?: string | null) {
|
||||
const existingWarehouse = await prisma.warehouse.findUnique({
|
||||
where: { id: warehouseId },
|
||||
});
|
||||
@@ -1145,5 +1231,18 @@ export async function updateWarehouse(warehouseId: string, payload: WarehouseInp
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "warehouse",
|
||||
entityId: warehouse.id,
|
||||
action: "updated",
|
||||
summary: `Updated warehouse ${warehouse.code}.`,
|
||||
metadata: {
|
||||
code: warehouse.code,
|
||||
name: warehouse.name,
|
||||
locationCount: warehouse.locations.length,
|
||||
},
|
||||
});
|
||||
|
||||
return mapWarehouseDetail(warehouse);
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ manufacturingRouter.post("/stations", requirePermissions([permissions.manufactur
|
||||
return fail(response, 400, "INVALID_INPUT", "Manufacturing station payload is invalid.");
|
||||
}
|
||||
|
||||
return ok(response, await createManufacturingStation(parsed.data), 201);
|
||||
return ok(response, await createManufacturingStation(parsed.data, request.authUser?.id), 201);
|
||||
});
|
||||
|
||||
manufacturingRouter.get("/work-orders", requirePermissions([permissions.manufacturingRead]), async (request, response) => {
|
||||
@@ -118,7 +118,7 @@ manufacturingRouter.post("/work-orders", requirePermissions([permissions.manufac
|
||||
return fail(response, 400, "INVALID_INPUT", "Work-order payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await createWorkOrder(parsed.data);
|
||||
const result = await createWorkOrder(parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
@@ -137,7 +137,7 @@ manufacturingRouter.put("/work-orders/:workOrderId", requirePermissions([permiss
|
||||
return fail(response, 400, "INVALID_INPUT", "Work-order payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updateWorkOrder(workOrderId, parsed.data);
|
||||
const result = await updateWorkOrder(workOrderId, parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
@@ -156,7 +156,7 @@ manufacturingRouter.patch("/work-orders/:workOrderId/status", requirePermissions
|
||||
return fail(response, 400, "INVALID_INPUT", "Work-order status payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updateWorkOrderStatus(workOrderId, parsed.data.status);
|
||||
const result = await updateWorkOrderStatus(workOrderId, parsed.data.status, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
WorkOrderSummaryDto,
|
||||
} from "@mrp/shared";
|
||||
|
||||
import { logAuditEvent } from "../../lib/audit.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
const workOrderModel = (prisma as any).workOrder;
|
||||
@@ -634,7 +635,7 @@ export async function listManufacturingStations(): Promise<ManufacturingStationD
|
||||
return stations.map(mapStation);
|
||||
}
|
||||
|
||||
export async function createManufacturingStation(payload: ManufacturingStationInput) {
|
||||
export async function createManufacturingStation(payload: ManufacturingStationInput, actorId?: string | null) {
|
||||
const station = await prisma.manufacturingStation.create({
|
||||
data: {
|
||||
code: payload.code.trim(),
|
||||
@@ -645,6 +646,20 @@ export async function createManufacturingStation(payload: ManufacturingStationIn
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "manufacturing-station",
|
||||
entityId: station.id,
|
||||
action: "created",
|
||||
summary: `Created manufacturing station ${station.code}.`,
|
||||
metadata: {
|
||||
code: station.code,
|
||||
name: station.name,
|
||||
queueDays: station.queueDays,
|
||||
isActive: station.isActive,
|
||||
},
|
||||
});
|
||||
|
||||
return mapStation(station);
|
||||
}
|
||||
|
||||
@@ -715,7 +730,7 @@ export async function getWorkOrderById(workOrderId: string) {
|
||||
return workOrder ? mapDetail(workOrder as WorkOrderRecord) : null;
|
||||
}
|
||||
|
||||
export async function createWorkOrder(payload: WorkOrderInput) {
|
||||
export async function createWorkOrder(payload: WorkOrderInput, actorId?: string | null) {
|
||||
const validated = await validateWorkOrderInput(payload);
|
||||
if (!validated.ok) {
|
||||
return { ok: false as const, reason: validated.reason };
|
||||
@@ -743,10 +758,26 @@ export async function createWorkOrder(payload: WorkOrderInput) {
|
||||
await syncWorkOrderReservations(created.id);
|
||||
|
||||
const workOrder = await getWorkOrderById(created.id);
|
||||
if (workOrder) {
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "work-order",
|
||||
entityId: created.id,
|
||||
action: "created",
|
||||
summary: `Created work order ${workOrder.workOrderNumber}.`,
|
||||
metadata: {
|
||||
workOrderNumber: workOrder.workOrderNumber,
|
||||
itemId: workOrder.itemId,
|
||||
projectId: workOrder.projectId,
|
||||
status: workOrder.status,
|
||||
quantity: workOrder.quantity,
|
||||
},
|
||||
});
|
||||
}
|
||||
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
|
||||
}
|
||||
|
||||
export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInput) {
|
||||
export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInput, actorId?: string | null) {
|
||||
const existing = await workOrderModel.findUnique({
|
||||
where: { id: workOrderId },
|
||||
select: {
|
||||
@@ -786,10 +817,26 @@ export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInp
|
||||
await syncWorkOrderReservations(workOrderId);
|
||||
|
||||
const workOrder = await getWorkOrderById(workOrderId);
|
||||
if (workOrder) {
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "work-order",
|
||||
entityId: workOrderId,
|
||||
action: "updated",
|
||||
summary: `Updated work order ${workOrder.workOrderNumber}.`,
|
||||
metadata: {
|
||||
workOrderNumber: workOrder.workOrderNumber,
|
||||
itemId: workOrder.itemId,
|
||||
projectId: workOrder.projectId,
|
||||
status: workOrder.status,
|
||||
quantity: workOrder.quantity,
|
||||
},
|
||||
});
|
||||
}
|
||||
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
|
||||
}
|
||||
|
||||
export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrderStatus) {
|
||||
export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrderStatus, actorId?: string | null) {
|
||||
const existing = await workOrderModel.findUnique({
|
||||
where: { id: workOrderId },
|
||||
select: {
|
||||
@@ -822,6 +869,19 @@ export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrd
|
||||
await syncWorkOrderReservations(workOrderId);
|
||||
|
||||
const workOrder = await getWorkOrderById(workOrderId);
|
||||
if (workOrder) {
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "work-order",
|
||||
entityId: workOrderId,
|
||||
action: "status.updated",
|
||||
summary: `Updated work order ${workOrder.workOrderNumber} to ${status}.`,
|
||||
metadata: {
|
||||
workOrderNumber: workOrder.workOrderNumber,
|
||||
status,
|
||||
},
|
||||
});
|
||||
}
|
||||
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
|
||||
}
|
||||
|
||||
@@ -910,6 +970,22 @@ export async function issueWorkOrderMaterial(workOrderId: string, payload: WorkO
|
||||
await syncWorkOrderReservations(workOrderId);
|
||||
|
||||
const nextWorkOrder = await getWorkOrderById(workOrderId);
|
||||
if (nextWorkOrder) {
|
||||
await logAuditEvent({
|
||||
actorId: createdById,
|
||||
entityType: "work-order",
|
||||
entityId: workOrderId,
|
||||
action: "material.issued",
|
||||
summary: `Issued material to work order ${nextWorkOrder.workOrderNumber}.`,
|
||||
metadata: {
|
||||
workOrderNumber: nextWorkOrder.workOrderNumber,
|
||||
componentItemId: payload.componentItemId,
|
||||
warehouseId: payload.warehouseId,
|
||||
locationId: payload.locationId,
|
||||
quantity: payload.quantity,
|
||||
},
|
||||
});
|
||||
}
|
||||
return nextWorkOrder ? { ok: true as const, workOrder: nextWorkOrder } : { ok: false as const, reason: "Unable to load updated work order." };
|
||||
}
|
||||
|
||||
@@ -970,5 +1046,19 @@ export async function recordWorkOrderCompletion(workOrderId: string, payload: Wo
|
||||
await syncWorkOrderReservations(workOrderId);
|
||||
|
||||
const nextWorkOrder = await getWorkOrderById(workOrderId);
|
||||
if (nextWorkOrder) {
|
||||
await logAuditEvent({
|
||||
actorId: createdById,
|
||||
entityType: "work-order",
|
||||
entityId: workOrderId,
|
||||
action: "completion.recorded",
|
||||
summary: `Recorded completion against work order ${nextWorkOrder.workOrderNumber}.`,
|
||||
metadata: {
|
||||
workOrderNumber: nextWorkOrder.workOrderNumber,
|
||||
quantity: payload.quantity,
|
||||
status: nextWorkOrder.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
return nextWorkOrder ? { ok: true as const, workOrder: nextWorkOrder } : { ok: false as const, reason: "Unable to load updated work order." };
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ projectsRouter.post("/", requirePermissions([permissions.projectsWrite]), async
|
||||
return fail(response, 400, "INVALID_INPUT", "Project payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await createProject(parsed.data);
|
||||
const result = await createProject(parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
@@ -130,7 +130,7 @@ projectsRouter.put("/:projectId", requirePermissions([permissions.projectsWrite]
|
||||
return fail(response, 400, "INVALID_INPUT", "Project payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updateProject(projectId, parsed.data);
|
||||
const result = await updateProject(projectId, parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ProjectSummaryDto,
|
||||
} from "@mrp/shared";
|
||||
|
||||
import { logAuditEvent } from "../../lib/audit.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
const projectModel = (prisma as any).project;
|
||||
@@ -356,7 +357,7 @@ export async function getProjectById(projectId: string) {
|
||||
return project ? mapProjectDetail(project as ProjectRecord) : null;
|
||||
}
|
||||
|
||||
export async function createProject(payload: ProjectInput) {
|
||||
export async function createProject(payload: ProjectInput, actorId?: string | null) {
|
||||
const validated = await validateProjectInput(payload);
|
||||
if (!validated.ok) {
|
||||
return { ok: false as const, reason: validated.reason };
|
||||
@@ -383,10 +384,25 @@ export async function createProject(payload: ProjectInput) {
|
||||
});
|
||||
|
||||
const project = await getProjectById(created.id);
|
||||
if (project) {
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "project",
|
||||
entityId: created.id,
|
||||
action: "created",
|
||||
summary: `Created project ${project.projectNumber}.`,
|
||||
metadata: {
|
||||
projectNumber: project.projectNumber,
|
||||
customerId: project.customerId,
|
||||
status: project.status,
|
||||
priority: project.priority,
|
||||
},
|
||||
});
|
||||
}
|
||||
return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." };
|
||||
}
|
||||
|
||||
export async function updateProject(projectId: string, payload: ProjectInput) {
|
||||
export async function updateProject(projectId: string, payload: ProjectInput, actorId?: string | null) {
|
||||
const existing = await projectModel.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { id: true },
|
||||
@@ -421,5 +437,20 @@ export async function updateProject(projectId: string, payload: ProjectInput) {
|
||||
});
|
||||
|
||||
const project = await getProjectById(projectId);
|
||||
if (project) {
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "project",
|
||||
entityId: projectId,
|
||||
action: "updated",
|
||||
summary: `Updated project ${project.projectNumber}.`,
|
||||
metadata: {
|
||||
projectNumber: project.projectNumber,
|
||||
customerId: project.customerId,
|
||||
status: project.status,
|
||||
priority: project.priority,
|
||||
},
|
||||
});
|
||||
}
|
||||
return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." };
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ purchasingRouter.post("/orders", requirePermissions(["purchasing.write"]), async
|
||||
return fail(response, 400, "INVALID_INPUT", "Purchase order payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await createPurchaseOrder(parsed.data);
|
||||
const result = await createPurchaseOrder(parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
@@ -115,7 +115,7 @@ purchasingRouter.put("/orders/:orderId", requirePermissions(["purchasing.write"]
|
||||
return fail(response, 400, "INVALID_INPUT", "Purchase order payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updatePurchaseOrder(orderId, parsed.data);
|
||||
const result = await updatePurchaseOrder(orderId, parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
@@ -134,7 +134,7 @@ purchasingRouter.patch("/orders/:orderId/status", requirePermissions(["purchasin
|
||||
return fail(response, 400, "INVALID_INPUT", "Purchase order status payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updatePurchaseOrderStatus(orderId, parsed.data.status);
|
||||
const result = await updatePurchaseOrderStatus(orderId, parsed.data.status, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Prisma } from "@prisma/client";
|
||||
import type { PurchaseLineInput, PurchaseOrderDetailDto, PurchaseOrderInput, PurchaseOrderStatus, PurchaseOrderSummaryDto, PurchaseVendorOptionDto } from "@mrp/shared";
|
||||
import type { PurchaseReceiptDto, PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js";
|
||||
|
||||
import { logAuditEvent } from "../../lib/audit.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
const purchaseOrderModel = prisma.purchaseOrder;
|
||||
@@ -530,7 +531,7 @@ export async function getPurchaseOrderById(documentId: string) {
|
||||
return record ? mapPurchaseOrder(record as unknown as PurchaseOrderRecord) : null;
|
||||
}
|
||||
|
||||
export async function createPurchaseOrder(payload: PurchaseOrderInput) {
|
||||
export async function createPurchaseOrder(payload: PurchaseOrderInput, actorId?: string | null) {
|
||||
const validatedLines = await validateLines(payload.lines);
|
||||
if (!validatedLines.ok) {
|
||||
return { ok: false as const, reason: validatedLines.reason };
|
||||
@@ -564,10 +565,25 @@ export async function createPurchaseOrder(payload: PurchaseOrderInput) {
|
||||
});
|
||||
|
||||
const detail = await getPurchaseOrderById(created.id);
|
||||
if (detail) {
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "purchase-order",
|
||||
entityId: created.id,
|
||||
action: "created",
|
||||
summary: `Created purchase order ${detail.documentNumber}.`,
|
||||
metadata: {
|
||||
documentNumber: detail.documentNumber,
|
||||
vendorId: detail.vendorId,
|
||||
status: detail.status,
|
||||
total: detail.total,
|
||||
},
|
||||
});
|
||||
}
|
||||
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load saved purchase order." };
|
||||
}
|
||||
|
||||
export async function updatePurchaseOrder(documentId: string, payload: PurchaseOrderInput) {
|
||||
export async function updatePurchaseOrder(documentId: string, payload: PurchaseOrderInput, actorId?: string | null) {
|
||||
const existing = await purchaseOrderModel.findUnique({
|
||||
where: { id: documentId },
|
||||
select: { id: true },
|
||||
@@ -609,10 +625,25 @@ export async function updatePurchaseOrder(documentId: string, payload: PurchaseO
|
||||
});
|
||||
|
||||
const detail = await getPurchaseOrderById(documentId);
|
||||
if (detail) {
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "purchase-order",
|
||||
entityId: documentId,
|
||||
action: "updated",
|
||||
summary: `Updated purchase order ${detail.documentNumber}.`,
|
||||
metadata: {
|
||||
documentNumber: detail.documentNumber,
|
||||
vendorId: detail.vendorId,
|
||||
status: detail.status,
|
||||
total: detail.total,
|
||||
},
|
||||
});
|
||||
}
|
||||
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load saved purchase order." };
|
||||
}
|
||||
|
||||
export async function updatePurchaseOrderStatus(documentId: string, status: PurchaseOrderStatus) {
|
||||
export async function updatePurchaseOrderStatus(documentId: string, status: PurchaseOrderStatus, actorId?: string | null) {
|
||||
const existing = await purchaseOrderModel.findUnique({
|
||||
where: { id: documentId },
|
||||
select: { id: true },
|
||||
@@ -629,6 +660,19 @@ export async function updatePurchaseOrderStatus(documentId: string, status: Purc
|
||||
});
|
||||
|
||||
const detail = await getPurchaseOrderById(documentId);
|
||||
if (detail) {
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "purchase-order",
|
||||
entityId: documentId,
|
||||
action: "status.updated",
|
||||
summary: `Updated purchase order ${detail.documentNumber} to ${status}.`,
|
||||
metadata: {
|
||||
documentNumber: detail.documentNumber,
|
||||
status,
|
||||
},
|
||||
});
|
||||
}
|
||||
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load updated purchase order." };
|
||||
}
|
||||
|
||||
@@ -677,6 +721,22 @@ export async function createPurchaseReceipt(orderId: string, payload: PurchaseRe
|
||||
});
|
||||
|
||||
const detail = await getPurchaseOrderById(orderId);
|
||||
if (detail) {
|
||||
await logAuditEvent({
|
||||
actorId: createdById,
|
||||
entityType: "purchase-order",
|
||||
entityId: orderId,
|
||||
action: "receipt.created",
|
||||
summary: `Received material against purchase order ${detail.documentNumber}.`,
|
||||
metadata: {
|
||||
documentNumber: detail.documentNumber,
|
||||
warehouseId: payload.warehouseId,
|
||||
locationId: payload.locationId,
|
||||
receivedAt: payload.receivedAt,
|
||||
lineCount: payload.lines.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load updated purchase order." };
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
SalesLineInput,
|
||||
} from "@mrp/shared/dist/sales/types.js";
|
||||
|
||||
import { logAuditEvent } from "../../lib/audit.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
export interface SalesDocumentPdfData {
|
||||
@@ -581,6 +582,19 @@ export async function createSalesDocument(type: SalesDocumentType, payload: Sale
|
||||
}
|
||||
|
||||
await createRevision(type, createdId, detail, payload.revisionReason?.trim() || "Initial issue", userId);
|
||||
await logAuditEvent({
|
||||
actorId: userId,
|
||||
entityType: type === "QUOTE" ? "sales-quote" : "sales-order",
|
||||
entityId: createdId,
|
||||
action: "created",
|
||||
summary: `Created ${type === "QUOTE" ? "quote" : "sales order"} ${detail.documentNumber}.`,
|
||||
metadata: {
|
||||
documentNumber: detail.documentNumber,
|
||||
customerId: detail.customerId,
|
||||
status: detail.status,
|
||||
total: detail.total,
|
||||
},
|
||||
});
|
||||
const refreshed = await getDocumentDetailOrNull(type, createdId);
|
||||
return refreshed ? { ok: true as const, document: refreshed } : { ok: false as const, reason: "Unable to load saved document." };
|
||||
}
|
||||
@@ -636,6 +650,20 @@ export async function updateSalesDocument(type: SalesDocumentType, documentId: s
|
||||
}
|
||||
|
||||
await createRevision(type, documentId, detail, payload.revisionReason?.trim() || "Document edited", userId);
|
||||
await logAuditEvent({
|
||||
actorId: userId,
|
||||
entityType: type === "QUOTE" ? "sales-quote" : "sales-order",
|
||||
entityId: documentId,
|
||||
action: "updated",
|
||||
summary: `Updated ${type === "QUOTE" ? "quote" : "sales order"} ${detail.documentNumber}.`,
|
||||
metadata: {
|
||||
documentNumber: detail.documentNumber,
|
||||
customerId: detail.customerId,
|
||||
status: detail.status,
|
||||
total: detail.total,
|
||||
revisionReason: payload.revisionReason?.trim() || null,
|
||||
},
|
||||
});
|
||||
const refreshed = await getDocumentDetailOrNull(type, documentId);
|
||||
return refreshed ? { ok: true as const, document: refreshed } : { ok: false as const, reason: "Unable to load saved document." };
|
||||
}
|
||||
@@ -666,6 +694,17 @@ export async function updateSalesDocumentStatus(type: SalesDocumentType, documen
|
||||
}
|
||||
|
||||
await createRevision(type, documentId, detail, `Status changed to ${status}`, userId);
|
||||
await logAuditEvent({
|
||||
actorId: userId,
|
||||
entityType: type === "QUOTE" ? "sales-quote" : "sales-order",
|
||||
entityId: documentId,
|
||||
action: "status.updated",
|
||||
summary: `Updated ${type === "QUOTE" ? "quote" : "sales order"} ${detail.documentNumber} to ${status}.`,
|
||||
metadata: {
|
||||
documentNumber: detail.documentNumber,
|
||||
status,
|
||||
},
|
||||
});
|
||||
const refreshed = await getDocumentDetailOrNull(type, documentId);
|
||||
return refreshed ? { ok: true as const, document: refreshed } : { ok: false as const, reason: "Unable to load updated document." };
|
||||
}
|
||||
@@ -700,6 +739,17 @@ export async function approveSalesDocument(type: SalesDocumentType, documentId:
|
||||
}
|
||||
|
||||
await createRevision(type, documentId, detail, "Document approved", userId);
|
||||
await logAuditEvent({
|
||||
actorId: userId,
|
||||
entityType: type === "QUOTE" ? "sales-quote" : "sales-order",
|
||||
entityId: documentId,
|
||||
action: "approved",
|
||||
summary: `Approved ${type === "QUOTE" ? "quote" : "sales order"} ${detail.documentNumber}.`,
|
||||
metadata: {
|
||||
documentNumber: detail.documentNumber,
|
||||
approvedAt: detail.approvedAt,
|
||||
},
|
||||
});
|
||||
const refreshed = await getDocumentDetailOrNull(type, documentId);
|
||||
return refreshed ? { ok: true as const, document: refreshed } : { ok: false as const, reason: "Unable to load approved document." };
|
||||
}
|
||||
@@ -751,6 +801,18 @@ export async function convertQuoteToSalesOrder(quoteId: string, userId?: string)
|
||||
}
|
||||
|
||||
await createRevision("ORDER", createdId, order, `Converted from quote ${mappedQuote.documentNumber}`, userId);
|
||||
await logAuditEvent({
|
||||
actorId: userId,
|
||||
entityType: "sales-order",
|
||||
entityId: createdId,
|
||||
action: "converted",
|
||||
summary: `Converted quote ${mappedQuote.documentNumber} into sales order ${order.documentNumber}.`,
|
||||
metadata: {
|
||||
sourceQuoteId: quoteId,
|
||||
sourceQuoteNumber: mappedQuote.documentNumber,
|
||||
salesOrderNumber: order.documentNumber,
|
||||
},
|
||||
});
|
||||
const refreshed = await getDocumentDetailOrNull("ORDER", createdId);
|
||||
return refreshed ? { ok: true as const, document: refreshed } : { ok: false as const, reason: "Unable to load converted sales order." };
|
||||
}
|
||||
|
||||
@@ -40,6 +40,5 @@ settingsRouter.put("/company-profile", requirePermissions([permissions.companyWr
|
||||
return fail(response, 400, "INVALID_INPUT", "Company settings payload is invalid.");
|
||||
}
|
||||
|
||||
return ok(response, await updateActiveCompanyProfile(parsed.data));
|
||||
return ok(response, await updateActiveCompanyProfile(parsed.data, request.authUser?.id));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CompanyProfileDto, CompanyProfileInput } from "@mrp/shared";
|
||||
|
||||
import { logAuditEvent } from "../../lib/audit.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
type CompanyProfileRecord = Awaited<ReturnType<typeof prisma.companyProfile.findFirstOrThrow>>;
|
||||
@@ -39,7 +40,7 @@ export async function getActiveCompanyProfile() {
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateActiveCompanyProfile(payload: CompanyProfileInput) {
|
||||
export async function updateActiveCompanyProfile(payload: CompanyProfileInput, actorId?: string | null) {
|
||||
const current = await prisma.companyProfile.findFirstOrThrow({
|
||||
where: { isActive: true },
|
||||
});
|
||||
@@ -67,6 +68,18 @@ export async function updateActiveCompanyProfile(payload: CompanyProfileInput) {
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "company-profile",
|
||||
entityId: profile.id,
|
||||
action: "updated",
|
||||
summary: `Updated company profile for ${profile.companyName}.`,
|
||||
metadata: {
|
||||
companyName: profile.companyName,
|
||||
legalName: profile.legalName,
|
||||
logoFileId: profile.logoFileId,
|
||||
},
|
||||
});
|
||||
|
||||
return mapCompanyProfile(profile);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user