This commit is contained in:
jason
2026-03-16 14:38:00 -05:00
commit 3d05e3929d
193 changed files with 40238 additions and 0 deletions

41
server/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "@mrp/server",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/server.js",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"lint": "tsc -p tsconfig.json --noEmit",
"prisma:generate": "prisma generate",
"prisma:migrate": "dotenv -e ../.env -- prisma migrate dev --name foundation",
"prisma:deploy": "dotenv -e ../.env -- prisma migrate deploy"
},
"dependencies": {
"@mrp/shared": "0.1.0",
"@prisma/client": "^6.16.2",
"bcryptjs": "^3.0.3",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^4.22.1",
"express-async-errors": "^3.1.1",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.1.1",
"pino-http": "^11.0.0",
"prisma": "^6.16.2",
"puppeteer": "^24.39.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.0.0",
"@types/node": "^24.5.2",
"dotenv-cli": "^8.0.0",
"tsx": "^4.20.5"
}
}

View File

@@ -0,0 +1,139 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Role" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Permission" (
"id" TEXT NOT NULL PRIMARY KEY,
"key" TEXT NOT NULL,
"description" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "UserRole" (
"userId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"assignedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"assignedBy" TEXT,
PRIMARY KEY ("userId", "roleId"),
CONSTRAINT "UserRole_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "RolePermission" (
"roleId" TEXT NOT NULL,
"permissionId" TEXT NOT NULL,
"grantedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("roleId", "permissionId"),
CONSTRAINT "RolePermission_permissionId_fkey" FOREIGN KEY ("permissionId") REFERENCES "Permission" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "RolePermission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "CompanyProfile" (
"id" TEXT NOT NULL PRIMARY KEY,
"companyName" TEXT NOT NULL,
"legalName" TEXT NOT NULL,
"email" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"website" TEXT NOT NULL,
"taxId" TEXT NOT NULL,
"addressLine1" TEXT NOT NULL,
"addressLine2" TEXT NOT NULL,
"city" TEXT NOT NULL,
"state" TEXT NOT NULL,
"postalCode" TEXT NOT NULL,
"country" TEXT NOT NULL,
"primaryColor" TEXT NOT NULL DEFAULT '#185ADB',
"accentColor" TEXT NOT NULL DEFAULT '#00A6A6',
"surfaceColor" TEXT NOT NULL DEFAULT '#F4F7FB',
"fontFamily" TEXT NOT NULL DEFAULT 'Manrope',
"logoFileId" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "CompanyProfile_logoFileId_fkey" FOREIGN KEY ("logoFileId") REFERENCES "FileAttachment" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "FileAttachment" (
"id" TEXT NOT NULL PRIMARY KEY,
"originalName" TEXT NOT NULL,
"storedName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"sizeBytes" INTEGER NOT NULL,
"relativePath" TEXT NOT NULL,
"ownerType" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Customer" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"addressLine1" TEXT NOT NULL,
"addressLine2" TEXT NOT NULL,
"city" TEXT NOT NULL,
"state" TEXT NOT NULL,
"postalCode" TEXT NOT NULL,
"country" TEXT NOT NULL,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Vendor" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"addressLine1" TEXT NOT NULL,
"addressLine2" TEXT NOT NULL,
"city" TEXT NOT NULL,
"state" TEXT NOT NULL,
"postalCode" TEXT NOT NULL,
"country" TEXT NOT NULL,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Permission_key_key" ON "Permission"("key");
-- CreateIndex
CREATE UNIQUE INDEX "CompanyProfile_logoFileId_key" ON "CompanyProfile"("logoFileId");

View File

@@ -0,0 +1,7 @@
ALTER TABLE "Customer" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'ACTIVE';
ALTER TABLE "Vendor" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'ACTIVE';
CREATE INDEX "Customer_status_idx" ON "Customer"("status");
CREATE INDEX "Vendor_status_idx" ON "Vendor"("status");

View File

@@ -0,0 +1,19 @@
CREATE TABLE "CrmContactEntry" (
"id" TEXT NOT NULL PRIMARY KEY,
"type" TEXT NOT NULL DEFAULT 'NOTE',
"summary" TEXT NOT NULL,
"body" TEXT NOT NULL,
"contactAt" DATETIME NOT NULL,
"customerId" TEXT,
"vendorId" TEXT,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "CrmContactEntry_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "CrmContactEntry_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "CrmContactEntry_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE INDEX "CrmContactEntry_customerId_contactAt_idx" ON "CrmContactEntry"("customerId", "contactAt");
CREATE INDEX "CrmContactEntry_vendorId_contactAt_idx" ON "CrmContactEntry"("vendorId", "contactAt");

View File

@@ -0,0 +1,7 @@
ALTER TABLE "Customer" ADD COLUMN "isReseller" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Customer" ADD COLUMN "resellerDiscountPercent" REAL NOT NULL DEFAULT 0;
ALTER TABLE "Customer" ADD COLUMN "parentCustomerId" TEXT REFERENCES "Customer" ("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "Customer_parentCustomerId_idx" ON "Customer"("parentCustomerId");

View File

@@ -0,0 +1,27 @@
ALTER TABLE "Customer" ADD COLUMN "paymentTerms" TEXT;
ALTER TABLE "Customer" ADD COLUMN "currencyCode" TEXT DEFAULT 'USD';
ALTER TABLE "Customer" ADD COLUMN "taxExempt" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Customer" ADD COLUMN "creditHold" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Vendor" ADD COLUMN "paymentTerms" TEXT;
ALTER TABLE "Vendor" ADD COLUMN "currencyCode" TEXT DEFAULT 'USD';
ALTER TABLE "Vendor" ADD COLUMN "taxExempt" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Vendor" ADD COLUMN "creditHold" BOOLEAN NOT NULL DEFAULT false;
CREATE TABLE "CrmContact" (
"id" TEXT NOT NULL PRIMARY KEY,
"fullName" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'OTHER',
"email" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"isPrimary" BOOLEAN NOT NULL DEFAULT false,
"customerId" TEXT,
"vendorId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "CrmContact_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "CrmContact_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "CrmContact_customerId_idx" ON "CrmContact"("customerId");
CREATE INDEX "CrmContact_vendorId_idx" ON "CrmContact"("vendorId");

View File

@@ -0,0 +1,11 @@
ALTER TABLE "Customer" ADD COLUMN "lifecycleStage" TEXT NOT NULL DEFAULT 'ACTIVE';
ALTER TABLE "Customer" ADD COLUMN "preferredAccount" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Customer" ADD COLUMN "strategicAccount" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Customer" ADD COLUMN "requiresApproval" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Customer" ADD COLUMN "blockedAccount" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Vendor" ADD COLUMN "lifecycleStage" TEXT NOT NULL DEFAULT 'ACTIVE';
ALTER TABLE "Vendor" ADD COLUMN "preferredAccount" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Vendor" ADD COLUMN "strategicAccount" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Vendor" ADD COLUMN "requiresApproval" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Vendor" ADD COLUMN "blockedAccount" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,33 @@
CREATE TABLE "InventoryItem" (
"id" TEXT NOT NULL PRIMARY KEY,
"sku" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"type" TEXT NOT NULL,
"status" TEXT NOT NULL,
"unitOfMeasure" TEXT NOT NULL,
"isSellable" BOOLEAN NOT NULL DEFAULT true,
"isPurchasable" BOOLEAN NOT NULL DEFAULT true,
"defaultCost" REAL,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
CREATE TABLE "InventoryBomLine" (
"id" TEXT NOT NULL PRIMARY KEY,
"parentItemId" TEXT NOT NULL,
"componentItemId" TEXT NOT NULL,
"quantity" REAL NOT NULL,
"unitOfMeasure" TEXT NOT NULL,
"notes" TEXT NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "InventoryBomLine_parentItemId_fkey" FOREIGN KEY ("parentItemId") REFERENCES "InventoryItem" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "InventoryBomLine_componentItemId_fkey" FOREIGN KEY ("componentItemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "InventoryItem_sku_key" ON "InventoryItem"("sku");
CREATE INDEX "InventoryBomLine_parentItemId_position_idx" ON "InventoryBomLine"("parentItemId", "position");
CREATE INDEX "InventoryBomLine_componentItemId_idx" ON "InventoryBomLine"("componentItemId");

View File

@@ -0,0 +1,23 @@
CREATE TABLE "Warehouse" (
"id" TEXT NOT NULL PRIMARY KEY,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
CREATE TABLE "WarehouseLocation" (
"id" TEXT NOT NULL PRIMARY KEY,
"warehouseId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WarehouseLocation_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "Warehouse_code_key" ON "Warehouse"("code");
CREATE UNIQUE INDEX "WarehouseLocation_warehouseId_code_key" ON "WarehouseLocation"("warehouseId", "code");
CREATE INDEX "WarehouseLocation_warehouseId_idx" ON "WarehouseLocation"("warehouseId");

View File

@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "InventoryTransaction" (
"id" TEXT NOT NULL PRIMARY KEY,
"itemId" TEXT NOT NULL,
"warehouseId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"transactionType" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"reference" TEXT NOT NULL,
"notes" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "InventoryTransaction_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "InventoryTransaction_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "InventoryTransaction_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "WarehouseLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "InventoryTransaction_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "InventoryTransaction_itemId_createdAt_idx" ON "InventoryTransaction"("itemId", "createdAt");
-- CreateIndex
CREATE INDEX "InventoryTransaction_warehouseId_createdAt_idx" ON "InventoryTransaction"("warehouseId", "createdAt");
-- CreateIndex
CREATE INDEX "InventoryTransaction_locationId_createdAt_idx" ON "InventoryTransaction"("locationId", "createdAt");

View File

@@ -0,0 +1,70 @@
-- CreateTable
CREATE TABLE "SalesQuote" (
"id" TEXT NOT NULL PRIMARY KEY,
"documentNumber" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"issueDate" DATETIME NOT NULL,
"expiresAt" DATETIME,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "SalesQuote_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "SalesQuoteLine" (
"id" TEXT NOT NULL PRIMARY KEY,
"quoteId" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"description" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"unitOfMeasure" TEXT NOT NULL,
"unitPrice" REAL NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "SalesQuoteLine_quoteId_fkey" FOREIGN KEY ("quoteId") REFERENCES "SalesQuote" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "SalesQuoteLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "SalesOrder" (
"id" TEXT NOT NULL PRIMARY KEY,
"documentNumber" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"issueDate" DATETIME NOT NULL,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "SalesOrder_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "SalesOrderLine" (
"id" TEXT NOT NULL PRIMARY KEY,
"orderId" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"description" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"unitOfMeasure" TEXT NOT NULL,
"unitPrice" REAL NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "SalesOrderLine_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "SalesOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "SalesOrderLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "SalesQuote_documentNumber_key" ON "SalesQuote"("documentNumber");
-- CreateIndex
CREATE INDEX "SalesQuoteLine_quoteId_position_idx" ON "SalesQuoteLine"("quoteId", "position");
-- CreateIndex
CREATE UNIQUE INDEX "SalesOrder_documentNumber_key" ON "SalesOrder"("documentNumber");
-- CreateIndex
CREATE INDEX "SalesOrderLine_orderId_position_idx" ON "SalesOrderLine"("orderId", "position");

View File

@@ -0,0 +1 @@
ALTER TABLE "InventoryItem" ADD COLUMN "defaultPrice" REAL;

View File

@@ -0,0 +1,7 @@
ALTER TABLE "SalesQuote" ADD COLUMN "discountPercent" REAL NOT NULL DEFAULT 0;
ALTER TABLE "SalesQuote" ADD COLUMN "taxPercent" REAL NOT NULL DEFAULT 0;
ALTER TABLE "SalesQuote" ADD COLUMN "freightAmount" REAL NOT NULL DEFAULT 0;
ALTER TABLE "SalesOrder" ADD COLUMN "discountPercent" REAL NOT NULL DEFAULT 0;
ALTER TABLE "SalesOrder" ADD COLUMN "taxPercent" REAL NOT NULL DEFAULT 0;
ALTER TABLE "SalesOrder" ADD COLUMN "freightAmount" REAL NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,18 @@
CREATE TABLE "Shipment" (
"id" TEXT NOT NULL PRIMARY KEY,
"shipmentNumber" TEXT NOT NULL,
"salesOrderId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"shipDate" DATETIME,
"carrier" TEXT NOT NULL,
"serviceLevel" TEXT NOT NULL,
"trackingNumber" TEXT NOT NULL,
"packageCount" INTEGER NOT NULL DEFAULT 1,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Shipment_salesOrderId_fkey" FOREIGN KEY ("salesOrderId") REFERENCES "SalesOrder" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "Shipment_shipmentNumber_key" ON "Shipment"("shipmentNumber");
CREATE INDEX "Shipment_salesOrderId_createdAt_idx" ON "Shipment"("salesOrderId", "createdAt");

View File

@@ -0,0 +1,36 @@
-- CreateTable
CREATE TABLE "PurchaseOrder" (
"id" TEXT NOT NULL PRIMARY KEY,
"documentNumber" TEXT NOT NULL,
"vendorId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"issueDate" DATETIME NOT NULL,
"taxPercent" REAL NOT NULL DEFAULT 0,
"freightAmount" REAL NOT NULL DEFAULT 0,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "PurchaseOrder_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "PurchaseOrderLine" (
"id" TEXT NOT NULL PRIMARY KEY,
"purchaseOrderId" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"description" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"unitOfMeasure" TEXT NOT NULL,
"unitCost" REAL NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "PurchaseOrderLine_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "PurchaseOrderLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "PurchaseOrder_documentNumber_key" ON "PurchaseOrder"("documentNumber");
-- CreateIndex
CREATE INDEX "PurchaseOrderLine_purchaseOrderId_position_idx" ON "PurchaseOrderLine"("purchaseOrderId", "position");

View File

@@ -0,0 +1,34 @@
CREATE TABLE "PurchaseReceipt" (
"id" TEXT NOT NULL PRIMARY KEY,
"receiptNumber" TEXT NOT NULL,
"purchaseOrderId" TEXT NOT NULL,
"warehouseId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"receivedAt" DATETIME NOT NULL,
"notes" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "PurchaseReceipt_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "PurchaseReceipt_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "PurchaseReceipt_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "WarehouseLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "PurchaseReceipt_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE TABLE "PurchaseReceiptLine" (
"id" TEXT NOT NULL PRIMARY KEY,
"purchaseReceiptId" TEXT NOT NULL,
"purchaseOrderLineId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "PurchaseReceiptLine_purchaseReceiptId_fkey" FOREIGN KEY ("purchaseReceiptId") REFERENCES "PurchaseReceipt" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "PurchaseReceiptLine_purchaseOrderLineId_fkey" FOREIGN KEY ("purchaseOrderLineId") REFERENCES "PurchaseOrderLine" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "PurchaseReceipt_receiptNumber_key" ON "PurchaseReceipt"("receiptNumber");
CREATE INDEX "PurchaseReceipt_purchaseOrderId_createdAt_idx" ON "PurchaseReceipt"("purchaseOrderId", "createdAt");
CREATE INDEX "PurchaseReceipt_warehouseId_createdAt_idx" ON "PurchaseReceipt"("warehouseId", "createdAt");
CREATE INDEX "PurchaseReceipt_locationId_createdAt_idx" ON "PurchaseReceipt"("locationId", "createdAt");
CREATE INDEX "PurchaseReceiptLine_purchaseReceiptId_idx" ON "PurchaseReceiptLine"("purchaseReceiptId");
CREATE INDEX "PurchaseReceiptLine_purchaseOrderLineId_idx" ON "PurchaseReceiptLine"("purchaseOrderLineId");

View File

@@ -0,0 +1,26 @@
CREATE TABLE "Project" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectNumber" TEXT NOT NULL,
"name" TEXT NOT NULL,
"status" TEXT NOT NULL,
"priority" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"salesQuoteId" TEXT,
"salesOrderId" TEXT,
"shipmentId" TEXT,
"ownerId" TEXT,
"dueDate" DATETIME,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Project_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Project_salesQuoteId_fkey" FOREIGN KEY ("salesQuoteId") REFERENCES "SalesQuote" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Project_salesOrderId_fkey" FOREIGN KEY ("salesOrderId") REFERENCES "SalesOrder" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Project_shipmentId_fkey" FOREIGN KEY ("shipmentId") REFERENCES "Shipment" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "Project_projectNumber_key" ON "Project"("projectNumber");
CREATE INDEX "Project_customerId_createdAt_idx" ON "Project"("customerId", "createdAt");
CREATE INDEX "Project_ownerId_dueDate_idx" ON "Project"("ownerId", "dueDate");
CREATE INDEX "Project_status_priority_idx" ON "Project"("status", "priority");

View File

@@ -0,0 +1,76 @@
-- CreateTable
CREATE TABLE "WorkOrder" (
"id" TEXT NOT NULL PRIMARY KEY,
"workOrderNumber" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"projectId" TEXT,
"warehouseId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"completedQuantity" INTEGER NOT NULL DEFAULT 0,
"dueDate" DATETIME,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WorkOrder_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WorkOrder_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "WorkOrder_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WorkOrder_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "WarehouseLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "WorkOrderMaterialIssue" (
"id" TEXT NOT NULL PRIMARY KEY,
"workOrderId" TEXT NOT NULL,
"componentItemId" TEXT NOT NULL,
"warehouseId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"notes" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WorkOrderMaterialIssue_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WorkOrderMaterialIssue_componentItemId_fkey" FOREIGN KEY ("componentItemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WorkOrderMaterialIssue_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WorkOrderMaterialIssue_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "WarehouseLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WorkOrderMaterialIssue_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "WorkOrderCompletion" (
"id" TEXT NOT NULL PRIMARY KEY,
"workOrderId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"notes" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WorkOrderCompletion_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WorkOrderCompletion_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "WorkOrder_workOrderNumber_key" ON "WorkOrder"("workOrderNumber");
-- CreateIndex
CREATE INDEX "WorkOrder_itemId_createdAt_idx" ON "WorkOrder"("itemId", "createdAt");
-- CreateIndex
CREATE INDEX "WorkOrder_projectId_dueDate_idx" ON "WorkOrder"("projectId", "dueDate");
-- CreateIndex
CREATE INDEX "WorkOrder_status_dueDate_idx" ON "WorkOrder"("status", "dueDate");
-- CreateIndex
CREATE INDEX "WorkOrder_warehouseId_createdAt_idx" ON "WorkOrder"("warehouseId", "createdAt");
-- CreateIndex
CREATE INDEX "WorkOrderMaterialIssue_workOrderId_createdAt_idx" ON "WorkOrderMaterialIssue"("workOrderId", "createdAt");
-- CreateIndex
CREATE INDEX "WorkOrderMaterialIssue_componentItemId_createdAt_idx" ON "WorkOrderMaterialIssue"("componentItemId", "createdAt");
-- CreateIndex
CREATE INDEX "WorkOrderCompletion_workOrderId_createdAt_idx" ON "WorkOrderCompletion"("workOrderId", "createdAt");

View File

@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "AuthSession" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"lastSeenAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ipAddress" TEXT,
"userAgent" TEXT,
"revokedAt" DATETIME,
"revokedById" TEXT,
"revokedReason" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "AuthSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "AuthSession_revokedById_fkey" FOREIGN KEY ("revokedById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "AuthSession_userId_createdAt_idx" ON "AuthSession"("userId", "createdAt");
-- CreateIndex
CREATE INDEX "AuthSession_expiresAt_idx" ON "AuthSession"("expiresAt");
-- CreateIndex
CREATE INDEX "AuthSession_revokedAt_idx" ON "AuthSession"("revokedAt");

View File

@@ -0,0 +1,53 @@
-- AlterTable
ALTER TABLE "SalesQuote" ADD COLUMN "approvedAt" DATETIME;
ALTER TABLE "SalesQuote" ADD COLUMN "approvedById" TEXT;
-- AlterTable
ALTER TABLE "SalesOrder" ADD COLUMN "approvedAt" DATETIME;
ALTER TABLE "SalesOrder" ADD COLUMN "approvedById" TEXT;
-- CreateTable
CREATE TABLE "SalesQuoteRevision" (
"id" TEXT NOT NULL PRIMARY KEY,
"quoteId" TEXT NOT NULL,
"revisionNumber" INTEGER NOT NULL,
"reason" TEXT NOT NULL,
"snapshot" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "SalesQuoteRevision_quoteId_fkey" FOREIGN KEY ("quoteId") REFERENCES "SalesQuote" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "SalesQuoteRevision_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "SalesOrderRevision" (
"id" TEXT NOT NULL PRIMARY KEY,
"orderId" TEXT NOT NULL,
"revisionNumber" INTEGER NOT NULL,
"reason" TEXT NOT NULL,
"snapshot" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "SalesOrderRevision_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "SalesOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "SalesOrderRevision_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "SalesQuote_approvedById_idx" ON "SalesQuote"("approvedById");
-- CreateIndex
CREATE INDEX "SalesOrder_approvedById_idx" ON "SalesOrder"("approvedById");
-- CreateIndex
CREATE UNIQUE INDEX "SalesQuoteRevision_quoteId_revisionNumber_key" ON "SalesQuoteRevision"("quoteId", "revisionNumber");
-- CreateIndex
CREATE INDEX "SalesQuoteRevision_quoteId_createdAt_idx" ON "SalesQuoteRevision"("quoteId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "SalesOrderRevision_orderId_revisionNumber_key" ON "SalesOrderRevision"("orderId", "revisionNumber");
-- CreateIndex
CREATE INDEX "SalesOrderRevision_orderId_createdAt_idx" ON "SalesOrderRevision"("orderId", "createdAt");

View File

@@ -0,0 +1,49 @@
CREATE TABLE "ManufacturingStation" (
"id" TEXT NOT NULL PRIMARY KEY,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"queueDays" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
CREATE TABLE "InventoryItemOperation" (
"id" TEXT NOT NULL PRIMARY KEY,
"itemId" TEXT NOT NULL,
"stationId" TEXT NOT NULL,
"setupMinutes" INTEGER NOT NULL DEFAULT 0,
"runMinutesPerUnit" INTEGER NOT NULL DEFAULT 0,
"moveMinutes" INTEGER NOT NULL DEFAULT 0,
"notes" TEXT NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "InventoryItemOperation_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "InventoryItemOperation_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "ManufacturingStation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE TABLE "WorkOrderOperation" (
"id" TEXT NOT NULL PRIMARY KEY,
"workOrderId" TEXT NOT NULL,
"stationId" TEXT NOT NULL,
"sequence" INTEGER NOT NULL,
"setupMinutes" INTEGER NOT NULL DEFAULT 0,
"runMinutesPerUnit" INTEGER NOT NULL DEFAULT 0,
"moveMinutes" INTEGER NOT NULL DEFAULT 0,
"plannedMinutes" INTEGER NOT NULL DEFAULT 0,
"plannedStart" DATETIME NOT NULL,
"plannedEnd" DATETIME NOT NULL,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WorkOrderOperation_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WorkOrderOperation_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "ManufacturingStation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "ManufacturingStation_code_key" ON "ManufacturingStation"("code");
CREATE INDEX "InventoryItemOperation_itemId_position_idx" ON "InventoryItemOperation"("itemId", "position");
CREATE INDEX "InventoryItemOperation_stationId_idx" ON "InventoryItemOperation"("stationId");
CREATE INDEX "WorkOrderOperation_workOrderId_sequence_idx" ON "WorkOrderOperation"("workOrderId", "sequence");
CREATE INDEX "WorkOrderOperation_stationId_plannedStart_idx" ON "WorkOrderOperation"("stationId", "plannedStart");

View File

@@ -0,0 +1,43 @@
CREATE TABLE "InventoryTransfer" (
"id" TEXT NOT NULL PRIMARY KEY,
"itemId" TEXT NOT NULL,
"fromWarehouseId" TEXT NOT NULL,
"fromLocationId" TEXT NOT NULL,
"toWarehouseId" TEXT NOT NULL,
"toLocationId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"notes" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "InventoryTransfer_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "InventoryTransfer_fromWarehouseId_fkey" FOREIGN KEY ("fromWarehouseId") REFERENCES "Warehouse" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "InventoryTransfer_fromLocationId_fkey" FOREIGN KEY ("fromLocationId") REFERENCES "WarehouseLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "InventoryTransfer_toWarehouseId_fkey" FOREIGN KEY ("toWarehouseId") REFERENCES "Warehouse" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "InventoryTransfer_toLocationId_fkey" FOREIGN KEY ("toLocationId") REFERENCES "WarehouseLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "InventoryTransfer_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE TABLE "InventoryReservation" (
"id" TEXT NOT NULL PRIMARY KEY,
"itemId" TEXT NOT NULL,
"warehouseId" TEXT,
"locationId" TEXT,
"workOrderId" TEXT,
"sourceType" TEXT NOT NULL,
"sourceId" TEXT,
"quantity" INTEGER NOT NULL,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "InventoryReservation_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "InventoryReservation_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "InventoryReservation_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "WarehouseLocation" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "InventoryReservation_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "InventoryTransfer_itemId_createdAt_idx" ON "InventoryTransfer"("itemId", "createdAt");
CREATE INDEX "InventoryReservation_itemId_status_createdAt_idx" ON "InventoryReservation"("itemId", "status", "createdAt");
CREATE INDEX "InventoryReservation_warehouseId_locationId_status_idx" ON "InventoryReservation"("warehouseId", "locationId", "status");
CREATE INDEX "InventoryReservation_workOrderId_status_idx" ON "InventoryReservation"("workOrderId", "status");

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

@@ -0,0 +1,13 @@
ALTER TABLE "InventoryItem" ADD COLUMN "preferredVendorId" TEXT;
ALTER TABLE "WorkOrder" ADD COLUMN "salesOrderId" TEXT;
ALTER TABLE "WorkOrder" ADD COLUMN "salesOrderLineId" TEXT;
ALTER TABLE "PurchaseOrderLine" ADD COLUMN "salesOrderId" TEXT;
ALTER TABLE "PurchaseOrderLine" ADD COLUMN "salesOrderLineId" TEXT;
CREATE INDEX "InventoryItem_preferredVendorId_idx" ON "InventoryItem"("preferredVendorId");
CREATE INDEX "WorkOrder_salesOrderId_dueDate_idx" ON "WorkOrder"("salesOrderId", "dueDate");
CREATE INDEX "WorkOrder_salesOrderLineId_dueDate_idx" ON "WorkOrder"("salesOrderLineId", "dueDate");
CREATE INDEX "PurchaseOrderLine_salesOrderId_position_idx" ON "PurchaseOrderLine"("salesOrderId", "position");
CREATE INDEX "PurchaseOrderLine_salesOrderLineId_position_idx" ON "PurchaseOrderLine"("salesOrderLineId", "position");

View File

@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "PurchaseOrderRevision" (
"id" TEXT NOT NULL PRIMARY KEY,
"purchaseOrderId" TEXT NOT NULL,
"revisionNumber" INTEGER NOT NULL,
"reason" TEXT NOT NULL,
"snapshot" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "PurchaseOrderRevision_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "PurchaseOrderRevision_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "PurchaseOrderRevision_purchaseOrderId_revisionNumber_key" ON "PurchaseOrderRevision"("purchaseOrderId", "revisionNumber");
-- CreateIndex
CREATE INDEX "PurchaseOrderRevision_purchaseOrderId_createdAt_idx" ON "PurchaseOrderRevision"("purchaseOrderId", "createdAt");

View File

@@ -0,0 +1,78 @@
-- CreateTable
CREATE TABLE "InventorySkuFamily" (
"id" TEXT NOT NULL PRIMARY KEY,
"code" TEXT NOT NULL,
"sequenceCode" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"nextSequenceNumber" INTEGER NOT NULL DEFAULT 1,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "InventorySkuNode" (
"id" TEXT NOT NULL PRIMARY KEY,
"familyId" TEXT NOT NULL,
"parentNodeId" TEXT,
"code" TEXT NOT NULL,
"label" TEXT NOT NULL,
"description" TEXT NOT NULL,
"path" TEXT NOT NULL,
"level" INTEGER NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "InventorySkuNode_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "InventorySkuFamily" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "InventorySkuNode_parentNodeId_fkey" FOREIGN KEY ("parentNodeId") REFERENCES "InventorySkuNode" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_InventoryItem" (
"id" TEXT NOT NULL PRIMARY KEY,
"sku" TEXT NOT NULL,
"skuFamilyId" TEXT,
"skuNodeId" TEXT,
"skuSequenceNumber" INTEGER,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"type" TEXT NOT NULL,
"status" TEXT NOT NULL,
"unitOfMeasure" TEXT NOT NULL,
"isSellable" BOOLEAN NOT NULL DEFAULT true,
"isPurchasable" BOOLEAN NOT NULL DEFAULT true,
"preferredVendorId" TEXT,
"defaultCost" REAL,
"defaultPrice" REAL,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "InventoryItem_preferredVendorId_fkey" FOREIGN KEY ("preferredVendorId") REFERENCES "Vendor" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "InventoryItem_skuFamilyId_fkey" FOREIGN KEY ("skuFamilyId") REFERENCES "InventorySkuFamily" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "InventoryItem_skuNodeId_fkey" FOREIGN KEY ("skuNodeId") REFERENCES "InventorySkuNode" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_InventoryItem" ("createdAt", "defaultCost", "defaultPrice", "description", "id", "isPurchasable", "isSellable", "name", "notes", "preferredVendorId", "sku", "status", "type", "unitOfMeasure", "updatedAt") SELECT "createdAt", "defaultCost", "defaultPrice", "description", "id", "isPurchasable", "isSellable", "name", "notes", "preferredVendorId", "sku", "status", "type", "unitOfMeasure", "updatedAt" FROM "InventoryItem";
DROP TABLE "InventoryItem";
ALTER TABLE "new_InventoryItem" RENAME TO "InventoryItem";
CREATE UNIQUE INDEX "InventoryItem_sku_key" ON "InventoryItem"("sku");
CREATE INDEX "InventoryItem_preferredVendorId_idx" ON "InventoryItem"("preferredVendorId");
CREATE INDEX "InventoryItem_skuFamilyId_idx" ON "InventoryItem"("skuFamilyId");
CREATE INDEX "InventoryItem_skuNodeId_idx" ON "InventoryItem"("skuNodeId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "InventorySkuFamily_code_key" ON "InventorySkuFamily"("code");
-- CreateIndex
CREATE UNIQUE INDEX "InventorySkuFamily_sequenceCode_key" ON "InventorySkuFamily"("sequenceCode");
-- CreateIndex
CREATE UNIQUE INDEX "InventorySkuNode_familyId_path_key" ON "InventorySkuNode"("familyId", "path");
-- CreateIndex
CREATE INDEX "InventorySkuNode_familyId_parentNodeId_sortOrder_idx" ON "InventorySkuNode"("familyId", "parentNodeId", "sortOrder");

View File

@@ -0,0 +1,2 @@
provider = "sqlite"

821
server/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,821 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"]
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
firstName String
lastName String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userRoles UserRole[]
authSessions AuthSession[] @relation("AuthSessionUser")
revokedAuthSessions AuthSession[] @relation("AuthSessionRevokedBy")
contactEntries CrmContactEntry[]
inventoryTransactions InventoryTransaction[]
purchaseReceipts PurchaseReceipt[]
ownedProjects Project[] @relation("ProjectOwner")
workOrderMaterialIssues WorkOrderMaterialIssue[]
workOrderCompletions WorkOrderCompletion[]
approvedSalesQuotes SalesQuote[] @relation("SalesQuoteApprovedBy")
approvedSalesOrders SalesOrder[] @relation("SalesOrderApprovedBy")
salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy")
salesOrderRevisionsCreated SalesOrderRevision[] @relation("SalesOrderRevisionCreatedBy")
purchaseOrderRevisionsCreated PurchaseOrderRevision[]
inventoryTransfersCreated InventoryTransfer[] @relation("InventoryTransferCreatedBy")
auditEvents AuditEvent[]
}
model Role {
id String @id @default(cuid())
name String @unique
description String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userRoles UserRole[]
rolePermissions RolePermission[]
}
model Permission {
id String @id @default(cuid())
key String @unique
description String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
rolePermissions RolePermission[]
}
model UserRole {
userId String
roleId String
assignedAt DateTime @default(now())
assignedBy String?
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, roleId])
}
model RolePermission {
roleId String
permissionId String
grantedAt DateTime @default(now())
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@id([roleId, permissionId])
}
model AuthSession {
id String @id @default(cuid())
userId String
expiresAt DateTime
lastSeenAt DateTime @default(now())
ipAddress String?
userAgent String?
revokedAt DateTime?
revokedById String?
revokedReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation("AuthSessionUser", fields: [userId], references: [id], onDelete: Cascade)
revokedBy User? @relation("AuthSessionRevokedBy", fields: [revokedById], references: [id], onDelete: SetNull)
@@index([userId, createdAt])
@@index([expiresAt])
@@index([revokedAt])
}
model CompanyProfile {
id String @id @default(cuid())
companyName String
legalName String
email String
phone String
website String
taxId String
addressLine1 String
addressLine2 String
city String
state String
postalCode String
country String
primaryColor String @default("#185ADB")
accentColor String @default("#00A6A6")
surfaceColor String @default("#F4F7FB")
fontFamily String @default("Manrope")
logoFileId String? @unique
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
logoFile FileAttachment? @relation("CompanyLogo", fields: [logoFileId], references: [id], onDelete: SetNull)
}
model FileAttachment {
id String @id @default(cuid())
originalName String
storedName String
mimeType String
sizeBytes Int
relativePath String
ownerType String
ownerId String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
companyLogoFor CompanyProfile? @relation("CompanyLogo")
}
model InventoryItem {
id String @id @default(cuid())
sku String @unique
skuFamilyId String?
skuNodeId String?
skuSequenceNumber Int?
name String
description String
type String
status String
unitOfMeasure String
isSellable Boolean @default(true)
isPurchasable Boolean @default(true)
preferredVendorId String?
defaultCost Float?
defaultPrice Float?
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bomLines InventoryBomLine[] @relation("InventoryBomParent")
usedInBomLines InventoryBomLine[] @relation("InventoryBomComponent")
inventoryTransactions InventoryTransaction[]
salesQuoteLines SalesQuoteLine[]
salesOrderLines SalesOrderLine[]
purchaseOrderLines PurchaseOrderLine[]
workOrders WorkOrder[]
workOrderMaterialIssues WorkOrderMaterialIssue[]
operations InventoryItemOperation[]
reservations InventoryReservation[]
transfers InventoryTransfer[]
preferredVendor Vendor? @relation(fields: [preferredVendorId], references: [id], onDelete: SetNull)
skuFamily InventorySkuFamily? @relation(fields: [skuFamilyId], references: [id], onDelete: SetNull)
skuNode InventorySkuNode? @relation(fields: [skuNodeId], references: [id], onDelete: SetNull)
@@index([preferredVendorId])
@@index([skuFamilyId])
@@index([skuNodeId])
}
model InventorySkuFamily {
id String @id @default(cuid())
code String @unique
sequenceCode String @unique
name String
description String
nextSequenceNumber Int @default(1)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
nodes InventorySkuNode[]
items InventoryItem[]
}
model InventorySkuNode {
id String @id @default(cuid())
familyId String
parentNodeId String?
code String
label String
description String
path String
level Int
sortOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
family InventorySkuFamily @relation(fields: [familyId], references: [id], onDelete: Cascade)
parentNode InventorySkuNode? @relation("InventorySkuNodeTree", fields: [parentNodeId], references: [id], onDelete: Cascade)
childNodes InventorySkuNode[] @relation("InventorySkuNodeTree")
items InventoryItem[]
@@unique([familyId, path])
@@index([familyId, parentNodeId, sortOrder])
}
model Warehouse {
id String @id @default(cuid())
code String @unique
name String
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
locations WarehouseLocation[]
inventoryTransactions InventoryTransaction[]
purchaseReceipts PurchaseReceipt[]
workOrders WorkOrder[]
workOrderMaterialIssues WorkOrderMaterialIssue[]
reservations InventoryReservation[]
transferSources InventoryTransfer[] @relation("InventoryTransferFromWarehouse")
transferDestinations InventoryTransfer[] @relation("InventoryTransferToWarehouse")
}
model Customer {
id String @id @default(cuid())
name String
email String
phone String
addressLine1 String
addressLine2 String
city String
state String
postalCode String
country String
status String @default("ACTIVE")
lifecycleStage String @default("ACTIVE")
isReseller Boolean @default(false)
resellerDiscountPercent Float @default(0)
parentCustomerId String?
paymentTerms String?
currencyCode String? @default("USD")
taxExempt Boolean @default(false)
creditHold Boolean @default(false)
preferredAccount Boolean @default(false)
strategicAccount Boolean @default(false)
requiresApproval Boolean @default(false)
blockedAccount Boolean @default(false)
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contactEntries CrmContactEntry[]
contacts CrmContact[]
parentCustomer Customer? @relation("CustomerHierarchy", fields: [parentCustomerId], references: [id], onDelete: SetNull)
childCustomers Customer[] @relation("CustomerHierarchy")
salesQuotes SalesQuote[]
salesOrders SalesOrder[]
projects Project[]
}
model InventoryBomLine {
id String @id @default(cuid())
parentItemId String
componentItemId String
quantity Float
unitOfMeasure String
notes String
position Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parentItem InventoryItem @relation("InventoryBomParent", fields: [parentItemId], references: [id], onDelete: Cascade)
componentItem InventoryItem @relation("InventoryBomComponent", fields: [componentItemId], references: [id], onDelete: Restrict)
@@index([parentItemId, position])
@@index([componentItemId])
}
model WarehouseLocation {
id String @id @default(cuid())
warehouseId String
code String
name String
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Cascade)
inventoryTransactions InventoryTransaction[]
purchaseReceipts PurchaseReceipt[]
workOrders WorkOrder[]
workOrderMaterialIssues WorkOrderMaterialIssue[]
reservations InventoryReservation[]
transferSourceLocations InventoryTransfer[] @relation("InventoryTransferFromLocation")
transferDestinationLocations InventoryTransfer[] @relation("InventoryTransferToLocation")
@@unique([warehouseId, code])
@@index([warehouseId])
}
model InventoryTransaction {
id String @id @default(cuid())
itemId String
warehouseId String
locationId String
transactionType String
quantity Int
reference String
notes String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict)
location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@index([itemId, createdAt])
@@index([warehouseId, createdAt])
@@index([locationId, createdAt])
}
model InventoryTransfer {
id String @id @default(cuid())
itemId String
fromWarehouseId String
fromLocationId String
toWarehouseId String
toLocationId String
quantity Int
notes String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
fromWarehouse Warehouse @relation("InventoryTransferFromWarehouse", fields: [fromWarehouseId], references: [id], onDelete: Restrict)
fromLocation WarehouseLocation @relation("InventoryTransferFromLocation", fields: [fromLocationId], references: [id], onDelete: Restrict)
toWarehouse Warehouse @relation("InventoryTransferToWarehouse", fields: [toWarehouseId], references: [id], onDelete: Restrict)
toLocation WarehouseLocation @relation("InventoryTransferToLocation", fields: [toLocationId], references: [id], onDelete: Restrict)
createdBy User? @relation("InventoryTransferCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
@@index([itemId, createdAt])
}
model InventoryReservation {
id String @id @default(cuid())
itemId String
warehouseId String?
locationId String?
workOrderId String?
sourceType String
sourceId String?
quantity Int
status String @default("ACTIVE")
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
warehouse Warehouse? @relation(fields: [warehouseId], references: [id], onDelete: SetNull)
location WarehouseLocation? @relation(fields: [locationId], references: [id], onDelete: SetNull)
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
@@index([itemId, status, createdAt])
@@index([warehouseId, locationId, status])
@@index([workOrderId, status])
}
model Vendor {
id String @id @default(cuid())
name String
email String
phone String
addressLine1 String
addressLine2 String
city String
state String
postalCode String
country String
status String @default("ACTIVE")
lifecycleStage String @default("ACTIVE")
paymentTerms String?
currencyCode String? @default("USD")
taxExempt Boolean @default(false)
creditHold Boolean @default(false)
preferredAccount Boolean @default(false)
strategicAccount Boolean @default(false)
requiresApproval Boolean @default(false)
blockedAccount Boolean @default(false)
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contactEntries CrmContactEntry[]
contacts CrmContact[]
purchaseOrders PurchaseOrder[]
preferredSupplyItems InventoryItem[]
}
model CrmContactEntry {
id String @id @default(cuid())
type String @default("NOTE")
summary String
body String
contactAt DateTime
customerId String?
vendorId String?
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer? @relation(fields: [customerId], references: [id], onDelete: Cascade)
vendor Vendor? @relation(fields: [vendorId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
}
model CrmContact {
id String @id @default(cuid())
fullName String
role String @default("OTHER")
email String
phone String
isPrimary Boolean @default(false)
customerId String?
vendorId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer? @relation(fields: [customerId], references: [id], onDelete: Cascade)
vendor Vendor? @relation(fields: [vendorId], references: [id], onDelete: Cascade)
}
model SalesQuote {
id String @id @default(cuid())
documentNumber String @unique
customerId String
status String
issueDate DateTime
expiresAt DateTime?
approvedAt DateTime?
approvedById String?
discountPercent Float @default(0)
taxPercent Float @default(0)
freightAmount Float @default(0)
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
approvedBy User? @relation("SalesQuoteApprovedBy", fields: [approvedById], references: [id], onDelete: SetNull)
lines SalesQuoteLine[]
projects Project[]
revisions SalesQuoteRevision[]
}
model SalesQuoteLine {
id String @id @default(cuid())
quoteId String
itemId String
description String
quantity Int
unitOfMeasure String
unitPrice Float
position Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
quote SalesQuote @relation(fields: [quoteId], references: [id], onDelete: Cascade)
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
@@index([quoteId, position])
}
model SalesOrder {
id String @id @default(cuid())
documentNumber String @unique
customerId String
status String
issueDate DateTime
approvedAt DateTime?
approvedById String?
discountPercent Float @default(0)
taxPercent Float @default(0)
freightAmount Float @default(0)
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
approvedBy User? @relation("SalesOrderApprovedBy", fields: [approvedById], references: [id], onDelete: SetNull)
lines SalesOrderLine[]
shipments Shipment[]
projects Project[]
revisions SalesOrderRevision[]
workOrders WorkOrder[]
purchaseOrderLines PurchaseOrderLine[]
}
model SalesOrderLine {
id String @id @default(cuid())
orderId String
itemId String
description String
quantity Int
unitOfMeasure String
unitPrice Float
position Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order SalesOrder @relation(fields: [orderId], references: [id], onDelete: Cascade)
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
workOrders WorkOrder[]
purchaseOrderLines PurchaseOrderLine[]
@@index([orderId, position])
}
model SalesQuoteRevision {
id String @id @default(cuid())
quoteId String
revisionNumber Int
reason String
snapshot String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
quote SalesQuote @relation(fields: [quoteId], references: [id], onDelete: Cascade)
createdBy User? @relation("SalesQuoteRevisionCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
@@unique([quoteId, revisionNumber])
@@index([quoteId, createdAt])
}
model SalesOrderRevision {
id String @id @default(cuid())
orderId String
revisionNumber Int
reason String
snapshot String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order SalesOrder @relation(fields: [orderId], references: [id], onDelete: Cascade)
createdBy User? @relation("SalesOrderRevisionCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
@@unique([orderId, revisionNumber])
@@index([orderId, createdAt])
}
model Shipment {
id String @id @default(cuid())
shipmentNumber String @unique
salesOrderId String
status String
shipDate DateTime?
carrier String
serviceLevel String
trackingNumber String
packageCount Int @default(1)
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
salesOrder SalesOrder @relation(fields: [salesOrderId], references: [id], onDelete: Restrict)
projects Project[]
@@index([salesOrderId, createdAt])
}
model Project {
id String @id @default(cuid())
projectNumber String @unique
name String
status String
priority String
customerId String
salesQuoteId String?
salesOrderId String?
shipmentId String?
ownerId String?
dueDate DateTime?
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
salesQuote SalesQuote? @relation(fields: [salesQuoteId], references: [id], onDelete: SetNull)
salesOrder SalesOrder? @relation(fields: [salesOrderId], references: [id], onDelete: SetNull)
shipment Shipment? @relation(fields: [shipmentId], references: [id], onDelete: SetNull)
owner User? @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: SetNull)
workOrders WorkOrder[]
@@index([customerId, createdAt])
@@index([ownerId, dueDate])
@@index([status, priority])
}
model WorkOrder {
id String @id @default(cuid())
workOrderNumber String @unique
itemId String
projectId String?
salesOrderId String?
salesOrderLineId String?
warehouseId String
locationId String
status String
quantity Int
completedQuantity Int @default(0)
dueDate DateTime?
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
salesOrder SalesOrder? @relation(fields: [salesOrderId], references: [id], onDelete: SetNull)
salesOrderLine SalesOrderLine? @relation(fields: [salesOrderLineId], references: [id], onDelete: SetNull)
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict)
location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
operations WorkOrderOperation[]
materialIssues WorkOrderMaterialIssue[]
completions WorkOrderCompletion[]
reservations InventoryReservation[]
@@index([itemId, createdAt])
@@index([projectId, dueDate])
@@index([salesOrderId, dueDate])
@@index([salesOrderLineId, dueDate])
@@index([status, dueDate])
@@index([warehouseId, createdAt])
}
model ManufacturingStation {
id String @id @default(cuid())
code String @unique
name String
description String
queueDays Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
itemOperations InventoryItemOperation[]
workOrderOperations WorkOrderOperation[]
}
model InventoryItemOperation {
id String @id @default(cuid())
itemId String
stationId String
setupMinutes Int @default(0)
runMinutesPerUnit Int @default(0)
moveMinutes Int @default(0)
notes String
position Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
station ManufacturingStation @relation(fields: [stationId], references: [id], onDelete: Restrict)
@@index([itemId, position])
@@index([stationId])
}
model WorkOrderOperation {
id String @id @default(cuid())
workOrderId String
stationId String
sequence Int
setupMinutes Int @default(0)
runMinutesPerUnit Int @default(0)
moveMinutes Int @default(0)
plannedMinutes Int @default(0)
plannedStart DateTime
plannedEnd DateTime
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
station ManufacturingStation @relation(fields: [stationId], references: [id], onDelete: Restrict)
@@index([workOrderId, sequence])
@@index([stationId, plannedStart])
}
model WorkOrderMaterialIssue {
id String @id @default(cuid())
workOrderId String
componentItemId String
warehouseId String
locationId String
quantity Int
notes String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
componentItem InventoryItem @relation(fields: [componentItemId], references: [id], onDelete: Restrict)
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict)
location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@index([workOrderId, createdAt])
@@index([componentItemId, createdAt])
}
model WorkOrderCompletion {
id String @id @default(cuid())
workOrderId String
quantity Int
notes String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@index([workOrderId, createdAt])
}
model PurchaseOrder {
id String @id @default(cuid())
documentNumber String @unique
vendorId String
status String
issueDate DateTime
taxPercent Float @default(0)
freightAmount Float @default(0)
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Restrict)
lines PurchaseOrderLine[]
receipts PurchaseReceipt[]
revisions PurchaseOrderRevision[]
}
model PurchaseOrderLine {
id String @id @default(cuid())
purchaseOrderId String
itemId String
salesOrderId String?
salesOrderLineId String?
description String
quantity Int
unitOfMeasure String
unitCost Float
position Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
salesOrder SalesOrder? @relation(fields: [salesOrderId], references: [id], onDelete: SetNull)
salesOrderLine SalesOrderLine? @relation(fields: [salesOrderLineId], references: [id], onDelete: SetNull)
receiptLines PurchaseReceiptLine[]
@@index([purchaseOrderId, position])
@@index([salesOrderId, position])
@@index([salesOrderLineId, position])
}
model PurchaseReceipt {
id String @id @default(cuid())
receiptNumber String @unique
purchaseOrderId String
warehouseId String
locationId String
receivedAt DateTime
notes String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict)
location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
lines PurchaseReceiptLine[]
@@index([purchaseOrderId, createdAt])
@@index([warehouseId, createdAt])
@@index([locationId, createdAt])
}
model PurchaseReceiptLine {
id String @id @default(cuid())
purchaseReceiptId String
purchaseOrderLineId String
quantity Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
purchaseReceipt PurchaseReceipt @relation(fields: [purchaseReceiptId], references: [id], onDelete: Cascade)
purchaseOrderLine PurchaseOrderLine @relation(fields: [purchaseOrderLineId], references: [id], onDelete: Restrict)
@@index([purchaseReceiptId])
@@index([purchaseOrderLineId])
}
model PurchaseOrderRevision {
id String @id @default(cuid())
purchaseOrderId String
revisionNumber Int
reason String
snapshot String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@unique([purchaseOrderId, revisionNumber])
@@index([purchaseOrderId, createdAt])
}
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])
}

134
server/src/app.ts Normal file
View File

@@ -0,0 +1,134 @@
import "express-async-errors";
import cors from "cors";
import express from "express";
import helmet from "helmet";
import path from "node:path";
import pinoHttp from "pino-http";
import { env } from "./config/env.js";
import { paths } from "./config/paths.js";
import { verifyToken } from "./lib/auth.js";
import { getActiveAuthSession, touchAuthSession } from "./lib/auth-sessions.js";
import { getCurrentUserById } from "./lib/current-user.js";
import { fail, ok } from "./lib/http.js";
import { recordSupportLog } from "./lib/support-log.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";
import { filesRouter } from "./modules/files/router.js";
import { ganttRouter } from "./modules/gantt/router.js";
import { inventoryRouter } from "./modules/inventory/router.js";
import { manufacturingRouter } from "./modules/manufacturing/router.js";
import { projectsRouter } from "./modules/projects/router.js";
import { purchasingRouter } from "./modules/purchasing/router.js";
import { salesRouter } from "./modules/sales/router.js";
import { shippingRouter } from "./modules/shipping/router.js";
import { settingsRouter } from "./modules/settings/router.js";
export function createApp() {
const app = express();
app.use(helmet({ contentSecurityPolicy: false }));
app.use(cors({ origin: env.CLIENT_ORIGIN, credentials: true }));
app.use(express.json({ limit: "2mb" }));
app.use(express.urlencoded({ extended: true }));
app.use(pinoHttp());
app.use(async (request, _response, next) => {
const authHeader = request.header("authorization");
if (!authHeader?.startsWith("Bearer ")) {
return next();
}
try {
const token = authHeader.slice("Bearer ".length);
const payload = verifyToken(token);
const session = await getActiveAuthSession(payload.sid, payload.sub);
if (!session) {
request.authUser = undefined;
request.authSessionId = undefined;
return next();
}
const authUser = await getCurrentUserById(payload.sub);
if (!authUser) {
request.authUser = undefined;
request.authSessionId = undefined;
return next();
}
request.authUser = authUser;
request.authSessionId = session.id;
void touchAuthSession(session.id).catch(() => undefined);
} catch {
request.authUser = undefined;
request.authSessionId = undefined;
}
next();
});
app.use((request, response, next) => {
response.on("finish", () => {
if (response.locals.supportLogRecorded || response.statusCode < 400 || request.path === "/api/v1/health") {
return;
}
recordSupportLog({
level: response.statusCode >= 500 ? "ERROR" : "WARN",
source: "http-response",
message: `${request.method} ${request.originalUrl} returned ${response.statusCode}.`,
context: {
method: request.method,
path: request.originalUrl,
statusCode: response.statusCode,
actorId: request.authUser?.id ?? null,
ip: request.ip,
},
});
});
next();
});
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);
app.use("/api/v1/inventory", inventoryRouter);
app.use("/api/v1/manufacturing", manufacturingRouter);
app.use("/api/v1/projects", projectsRouter);
app.use("/api/v1/purchasing", purchasingRouter);
app.use("/api/v1/sales", salesRouter);
app.use("/api/v1/shipping", shippingRouter);
app.use("/api/v1/gantt", ganttRouter);
app.use("/api/v1/documents", documentsRouter);
if (env.NODE_ENV === "production") {
app.use(express.static(paths.clientDistDir));
app.get("*", (_request, response) => {
response.sendFile(path.join(paths.clientDistDir, "index.html"));
});
}
app.use((error: Error, request: express.Request, response: express.Response, _next: express.NextFunction) => {
response.locals.supportLogRecorded = true;
recordSupportLog({
level: "ERROR",
source: "express-error",
message: error.message || "Unexpected server error.",
context: {
method: request.method,
path: request.originalUrl,
actorId: request.authUser?.id ?? null,
stack: error.stack ?? null,
},
});
return fail(response, 500, "INTERNAL_ERROR", error.message || "Unexpected server error.");
});
return app;
}

19
server/src/config/env.ts Normal file
View File

@@ -0,0 +1,19 @@
import { config } from "dotenv";
import { z } from "zod";
config({ path: ".env" });
const schema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().default(3000),
JWT_SECRET: z.string().min(8).default("change-me"),
DATABASE_URL: z.string().default("file:../../data/prisma/app.db"),
DATA_DIR: z.string().default("./data"),
CLIENT_ORIGIN: z.string().default("http://localhost:5173"),
ADMIN_EMAIL: z.string().email().default("admin@mrp.local"),
ADMIN_PASSWORD: z.string().min(8).default("ChangeMe123!"),
PUPPETEER_EXECUTABLE_PATH: z.string().optional()
});
export const env = schema.parse(process.env);

View File

@@ -0,0 +1,14 @@
import path from "node:path";
import { env } from "./env.js";
const projectRoot = process.cwd();
export const paths = {
projectRoot,
dataDir: path.resolve(projectRoot, env.DATA_DIR),
uploadsDir: path.resolve(projectRoot, env.DATA_DIR, "uploads"),
prismaDir: path.resolve(projectRoot, env.DATA_DIR, "prisma"),
clientDistDir: path.resolve(projectRoot, "client", "dist"),
};

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,100 @@
import { prisma } from "./prisma.js";
const SESSION_DURATION_MS = 12 * 60 * 60 * 1000;
const SESSION_RETENTION_DAYS = 30;
export interface AuthSessionContext {
id: string;
userId: string;
expiresAt: Date;
}
export function getSessionExpiryDate(now = new Date()) {
return new Date(now.getTime() + SESSION_DURATION_MS);
}
export function getSessionRetentionCutoff(now = new Date()) {
return new Date(now.getTime() - SESSION_RETENTION_DAYS * 24 * 60 * 60 * 1000);
}
export async function createAuthSession(input: { userId: string; ipAddress?: string | null; userAgent?: string | null }) {
return prisma.authSession.create({
data: {
userId: input.userId,
expiresAt: getSessionExpiryDate(),
ipAddress: input.ipAddress ?? null,
userAgent: input.userAgent ?? null,
},
});
}
export async function getActiveAuthSession(sessionId: string, userId: string): Promise<AuthSessionContext | null> {
const session = await prisma.authSession.findFirst({
where: {
id: sessionId,
userId,
revokedAt: null,
expiresAt: {
gt: new Date(),
},
},
select: {
id: true,
userId: true,
expiresAt: true,
},
});
if (!session) {
return null;
}
return session;
}
export async function touchAuthSession(sessionId: string) {
await prisma.authSession.update({
where: { id: sessionId },
data: {
lastSeenAt: new Date(),
},
});
}
export async function revokeAuthSession(sessionId: string, input: { revokedById?: string | null; reason: string }) {
return prisma.authSession.updateMany({
where: {
id: sessionId,
revokedAt: null,
},
data: {
revokedAt: new Date(),
revokedById: input.revokedById ?? null,
revokedReason: input.reason,
},
});
}
export async function pruneOldAuthSessions() {
const cutoff = getSessionRetentionCutoff();
const result = await prisma.authSession.deleteMany({
where: {
OR: [
{
revokedAt: {
lt: cutoff,
},
},
{
revokedAt: null,
expiresAt: {
lt: cutoff,
},
},
],
},
});
return result.count;
}

28
server/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,28 @@
import type { AuthUser } from "@mrp/shared";
import jwt from "jsonwebtoken";
import { env } from "../config/env.js";
interface AuthTokenPayload {
sub: string;
sid: string;
email: string;
permissions: string[];
}
export function signToken(user: AuthUser, sessionId: string) {
return jwt.sign(
{
sub: user.id,
sid: sessionId,
email: user.email,
permissions: user.permissions,
} satisfies AuthTokenPayload,
env.JWT_SECRET,
{ expiresIn: "12h" }
);
}
export function verifyToken(token: string) {
return jwt.verify(token, env.JWT_SECRET) as AuthTokenPayload;
}

125
server/src/lib/bootstrap.ts Normal file
View File

@@ -0,0 +1,125 @@
import { defaultAdminPermissions, permissions, type PermissionKey } from "@mrp/shared";
import { env } from "../config/env.js";
import { prisma } from "./prisma.js";
import { hashPassword } from "./password.js";
import { ensureDataDirectories } from "./storage.js";
const permissionDescriptions: Record<PermissionKey, string> = {
[permissions.adminManage]: "Full administrative access",
[permissions.companyRead]: "View company settings",
[permissions.companyWrite]: "Update company settings",
[permissions.crmRead]: "View CRM records",
[permissions.crmWrite]: "Manage CRM records",
[permissions.inventoryRead]: "View inventory items and BOMs",
[permissions.inventoryWrite]: "Manage inventory items and BOMs",
[permissions.manufacturingRead]: "View manufacturing work orders and execution data",
[permissions.manufacturingWrite]: "Manage manufacturing work orders and execution data",
[permissions.filesRead]: "View attached files",
[permissions.filesWrite]: "Upload and manage attached files",
[permissions.ganttRead]: "View gantt timelines",
[permissions.salesRead]: "View sales data",
[permissions.salesWrite]: "Manage quotes and sales orders",
[permissions.projectsRead]: "View projects and program records",
[permissions.projectsWrite]: "Manage projects and program records",
"purchasing.read": "View purchasing data",
"purchasing.write": "Manage purchase orders",
[permissions.shippingRead]: "View shipping data",
[permissions.shippingWrite]: "Manage shipments",
};
export async function bootstrapAppData() {
await ensureDataDirectories();
for (const permissionKey of defaultAdminPermissions) {
await prisma.permission.upsert({
where: { key: permissionKey },
update: {},
create: {
key: permissionKey,
description: permissionDescriptions[permissionKey],
},
});
}
const adminRole = await prisma.role.upsert({
where: { name: "Administrator" },
update: { description: "Full system access" },
create: {
name: "Administrator",
description: "Full system access",
},
});
const allPermissions = await prisma.permission.findMany({
where: {
key: {
in: defaultAdminPermissions,
},
},
});
for (const permission of allPermissions) {
await prisma.rolePermission.upsert({
where: {
roleId_permissionId: {
roleId: adminRole.id,
permissionId: permission.id,
},
},
update: {},
create: {
roleId: adminRole.id,
permissionId: permission.id,
},
});
}
const adminUser = await prisma.user.upsert({
where: { email: env.ADMIN_EMAIL },
update: {},
create: {
email: env.ADMIN_EMAIL,
firstName: "System",
lastName: "Administrator",
passwordHash: await hashPassword(env.ADMIN_PASSWORD),
},
});
await prisma.userRole.upsert({
where: {
userId_roleId: {
userId: adminUser.id,
roleId: adminRole.id,
},
},
update: {},
create: {
userId: adminUser.id,
roleId: adminRole.id,
},
});
const existingProfile = await prisma.companyProfile.findFirst({
where: { isActive: true },
});
if (!existingProfile) {
await prisma.companyProfile.create({
data: {
companyName: "MRP Codex Manufacturing",
legalName: "MRP Codex Manufacturing LLC",
email: "operations@example.com",
phone: "+1 (555) 010-2000",
website: "https://example.com",
taxId: "99-9999999",
addressLine1: "100 Foundry Lane",
addressLine2: "Suite 200",
city: "Chicago",
state: "IL",
postalCode: "60601",
country: "USA",
},
});
}
}

View File

@@ -0,0 +1,50 @@
import type { AuthUser, PermissionKey } from "@mrp/shared";
import { prisma } from "./prisma.js";
export async function getCurrentUserById(userId: string): Promise<AuthUser | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
userRoles: {
include: {
role: {
include: {
rolePermissions: {
include: {
permission: true,
},
},
},
},
},
},
},
});
if (!user) {
return null;
}
if (!user.isActive) {
return null;
}
const permissionKeys = new Set<PermissionKey>();
const roleNames = user.userRoles.map(({ role }) => {
for (const rolePermission of role.rolePermissions) {
permissionKeys.add(rolePermission.permission.key as PermissionKey);
}
return role.name;
});
return {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
roles: roleNames,
permissions: [...permissionKeys],
};
}

20
server/src/lib/http.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { ApiResponse } from "@mrp/shared";
import type { Response } from "express";
export function ok<T>(response: Response, data: T, status = 200) {
const body: ApiResponse<T> = { ok: true, data };
return response.status(status).json(body);
}
export function fail(response: Response, status: number, code: string, message: string) {
const body: ApiResponse<never> = {
ok: false,
error: {
code,
message,
},
};
return response.status(status).json(body);
}

View File

@@ -0,0 +1,10 @@
import bcrypt from "bcryptjs";
export async function hashPassword(password: string) {
return bcrypt.hash(password, 10);
}
export async function verifyPassword(password: string, hash: string) {
return bcrypt.compare(password, hash);
}

27
server/src/lib/pdf.ts Normal file
View File

@@ -0,0 +1,27 @@
import puppeteer from "puppeteer";
import { env } from "../config/env.js";
export async function renderPdf(html: string) {
const browser = await puppeteer.launch({
executablePath: env.PUPPETEER_EXECUTABLE_PATH,
headless: true,
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
});
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle0" });
const pdf = await page.pdf({
format: "A4",
printBackground: true,
preferCSSPageSize: true,
});
// Normalize Puppeteer's Uint8Array output to a Node Buffer so Express sends a valid PDF payload.
return Buffer.from(pdf);
} finally {
await browser.close();
}
}

4
server/src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,4 @@
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();

30
server/src/lib/rbac.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { PermissionKey } from "@mrp/shared";
import type { NextFunction, Request, Response } from "express";
import { fail } from "./http.js";
export function requireAuth(request: Request, response: Response, next: NextFunction) {
if (!request.authUser) {
return fail(response, 401, "UNAUTHORIZED", "Authentication is required.");
}
next();
}
export function requirePermissions(requiredPermissions: PermissionKey[]) {
return (request: Request, response: Response, next: NextFunction) => {
if (!request.authUser) {
return fail(response, 401, "UNAUTHORIZED", "Authentication is required.");
}
const available = new Set(request.authUser.permissions);
const hasAll = requiredPermissions.every((permission) => available.has(permission));
if (!hasAll) {
return fail(response, 403, "FORBIDDEN", "You do not have access to this resource.");
}
next();
};
}

View File

@@ -0,0 +1,19 @@
import type { StartupValidationReportDto } from "@mrp/shared";
let latestStartupReport: StartupValidationReportDto = {
status: "WARN",
generatedAt: new Date(0).toISOString(),
durationMs: 0,
passCount: 0,
warnCount: 0,
failCount: 0,
checks: [],
};
export function setLatestStartupReport(report: StartupValidationReportDto) {
latestStartupReport = report;
}
export function getLatestStartupReport() {
return latestStartupReport;
}

View File

@@ -0,0 +1,183 @@
import type { StartupValidationCheckDto, StartupValidationReportDto } from "@mrp/shared";
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { env } from "../config/env.js";
import { paths } from "../config/paths.js";
import { prisma } from "./prisma.js";
async function pathExists(targetPath: string) {
try {
await fs.access(targetPath);
return true;
} catch {
return false;
}
}
async function canWritePath(targetPath: string) {
try {
await fs.access(targetPath, fsConstants.W_OK);
return true;
} catch {
return false;
}
}
export async function collectStartupValidationReport(): Promise<StartupValidationReportDto> {
const startedAt = Date.now();
const checks: StartupValidationCheckDto[] = [];
const dataDirExists = await pathExists(paths.dataDir);
const uploadsDirExists = await pathExists(paths.uploadsDir);
const prismaDirExists = await pathExists(paths.prismaDir);
const databaseFilePath = path.join(paths.prismaDir, "app.db");
const databaseFileExists = await pathExists(databaseFilePath);
const clientBundlePath = path.join(paths.clientDistDir, "index.html");
const clientBundleExists = await pathExists(clientBundlePath);
const puppeteerPath = env.PUPPETEER_EXECUTABLE_PATH || "/usr/bin/chromium";
const puppeteerExists = await pathExists(puppeteerPath);
const dataDirWritable = dataDirExists && (await canWritePath(paths.dataDir));
const uploadsDirWritable = uploadsDirExists && (await canWritePath(paths.uploadsDir));
checks.push({
id: "data-dir",
label: "Data directory",
status: dataDirExists ? "PASS" : "FAIL",
message: dataDirExists ? `Data directory available at ${paths.dataDir}.` : `Data directory is missing: ${paths.dataDir}.`,
});
checks.push({
id: "uploads-dir",
label: "Uploads directory",
status: uploadsDirExists ? "PASS" : "FAIL",
message: uploadsDirExists ? `Uploads directory available at ${paths.uploadsDir}.` : `Uploads directory is missing: ${paths.uploadsDir}.`,
});
checks.push({
id: "prisma-dir",
label: "Prisma directory",
status: prismaDirExists ? "PASS" : "FAIL",
message: prismaDirExists ? `Prisma data directory available at ${paths.prismaDir}.` : `Prisma data directory is missing: ${paths.prismaDir}.`,
});
checks.push({
id: "database-file",
label: "Database file",
status: databaseFileExists ? "PASS" : env.NODE_ENV === "production" ? "FAIL" : "WARN",
message: databaseFileExists ? `SQLite database file found at ${databaseFilePath}.` : `SQLite database file is missing: ${databaseFilePath}.`,
});
checks.push({
id: "data-dir-write",
label: "Data directory writable",
status: dataDirWritable ? "PASS" : "FAIL",
message: dataDirWritable ? `Application can write to ${paths.dataDir}.` : `Application cannot write to ${paths.dataDir}.`,
});
checks.push({
id: "uploads-dir-write",
label: "Uploads directory writable",
status: uploadsDirWritable ? "PASS" : "FAIL",
message: uploadsDirWritable ? `Application can write to ${paths.uploadsDir}.` : `Application cannot write to ${paths.uploadsDir}.`,
});
try {
await prisma.$queryRawUnsafe("SELECT 1");
checks.push({
id: "database-connection",
label: "Database connection",
status: "PASS",
message: "SQLite connection check succeeded.",
});
} catch (error) {
checks.push({
id: "database-connection",
label: "Database connection",
status: "FAIL",
message: error instanceof Error ? error.message : "SQLite connection check failed.",
});
}
if (env.NODE_ENV === "production") {
checks.push({
id: "client-dist",
label: "Client bundle",
status: clientBundleExists ? "PASS" : "FAIL",
message: clientBundleExists ? `Client bundle found at ${paths.clientDistDir}.` : `Production client bundle is missing from ${paths.clientDistDir}.`,
});
} else {
checks.push({
id: "client-dist",
label: "Client bundle",
status: "PASS",
message: "Client bundle check skipped outside production mode.",
});
}
checks.push({
id: "puppeteer-runtime",
label: "PDF runtime",
status: puppeteerExists ? "PASS" : env.NODE_ENV === "production" ? "FAIL" : "WARN",
message: puppeteerExists
? `Chromium runtime available at ${puppeteerPath}.`
: `Chromium runtime was not found at ${puppeteerPath}.`,
});
checks.push({
id: "client-origin",
label: "Client origin",
status: env.NODE_ENV === "production" && env.CLIENT_ORIGIN.includes("localhost") ? "WARN" : "PASS",
message:
env.NODE_ENV === "production" && env.CLIENT_ORIGIN.includes("localhost")
? `Production CLIENT_ORIGIN still points to localhost: ${env.CLIENT_ORIGIN}.`
: `Client origin is configured as ${env.CLIENT_ORIGIN}.`,
});
checks.push({
id: "jwt-secret",
label: "JWT secret",
status: env.NODE_ENV === "production" && env.JWT_SECRET === "change-me" ? "WARN" : "PASS",
message:
env.NODE_ENV === "production" && env.JWT_SECRET === "change-me"
? "Production is still using the default JWT secret."
: "JWT secret is not using the default production value.",
});
checks.push({
id: "admin-password",
label: "Bootstrap admin password",
status: env.NODE_ENV === "production" && env.ADMIN_PASSWORD === "ChangeMe123!" ? "WARN" : "PASS",
message:
env.NODE_ENV === "production" && env.ADMIN_PASSWORD === "ChangeMe123!"
? "Production is still using the default bootstrap admin password."
: "Bootstrap admin credentials are not using the default production password.",
});
const status = checks.some((check) => check.status === "FAIL")
? "FAIL"
: checks.some((check) => check.status === "WARN")
? "WARN"
: "PASS";
return {
status,
generatedAt: new Date().toISOString(),
durationMs: Date.now() - startedAt,
passCount: checks.filter((check) => check.status === "PASS").length,
warnCount: checks.filter((check) => check.status === "WARN").length,
failCount: checks.filter((check) => check.status === "FAIL").length,
checks,
};
}
export async function assertStartupReadiness() {
const report = await collectStartupValidationReport();
if (report.status === "FAIL") {
const failedChecks = report.checks.filter((check) => check.status === "FAIL").map((check) => `${check.label}: ${check.message}`);
throw new Error(`Startup validation failed. ${failedChecks.join(" | ")}`);
}
return report;
}

27
server/src/lib/storage.ts Normal file
View File

@@ -0,0 +1,27 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { paths } from "../config/paths.js";
export async function ensureDataDirectories() {
await fs.mkdir(paths.uploadsDir, { recursive: true });
await fs.mkdir(paths.prismaDir, { recursive: true });
}
export async function writeUpload(buffer: Buffer, originalName: string) {
const extension = path.extname(originalName);
const storedName = `${Date.now()}-${randomUUID()}${extension}`;
const relativePath = path.join("uploads", storedName);
const absolutePath = path.join(paths.dataDir, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, buffer);
return {
storedName,
relativePath: relativePath.replaceAll("\\", "/"),
absolutePath,
};
}

View File

@@ -0,0 +1,139 @@
import type { SupportLogEntryDto, SupportLogFiltersDto, SupportLogListDto, SupportLogSummaryDto } from "@mrp/shared";
import { randomUUID } from "node:crypto";
const SUPPORT_LOG_LIMIT = 500;
const SUPPORT_LOG_RETENTION_DAYS = 14;
const supportLogs: SupportLogEntryDto[] = [];
function serializeContext(context?: Record<string, unknown>) {
if (!context) {
return "{}";
}
try {
return JSON.stringify(context);
} catch {
return JSON.stringify({ serializationError: "Unable to serialize support log context." });
}
}
function getRetentionCutoff(now = new Date()) {
return new Date(now.getTime() - SUPPORT_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000);
}
function pruneSupportLogs(now = new Date()) {
const cutoff = getRetentionCutoff(now).getTime();
const retained = supportLogs.filter((entry) => new Date(entry.createdAt).getTime() >= cutoff);
supportLogs.length = 0;
supportLogs.push(...retained.slice(0, SUPPORT_LOG_LIMIT));
}
function normalizeFilters(filters?: SupportLogFiltersDto): SupportLogFiltersDto {
return {
level: filters?.level,
source: filters?.source?.trim() || undefined,
query: filters?.query?.trim() || undefined,
start: filters?.start,
end: filters?.end,
limit: filters?.limit,
};
}
function filterSupportLogs(filters?: SupportLogFiltersDto) {
pruneSupportLogs();
const normalized = normalizeFilters(filters);
const startMs = normalized.start ? new Date(normalized.start).getTime() : null;
const endMs = normalized.end ? new Date(normalized.end).getTime() : null;
const query = normalized.query?.toLowerCase();
const limit = Math.max(0, Math.min(normalized.limit ?? 100, SUPPORT_LOG_LIMIT));
return supportLogs
.filter((entry) => {
if (normalized.level && entry.level !== normalized.level) {
return false;
}
if (normalized.source && entry.source !== normalized.source) {
return false;
}
const createdAtMs = new Date(entry.createdAt).getTime();
if (startMs != null && createdAtMs < startMs) {
return false;
}
if (endMs != null && createdAtMs > endMs) {
return false;
}
if (!query) {
return true;
}
return [entry.source, entry.message, entry.contextJson].some((value) => value.toLowerCase().includes(query));
})
.slice(0, limit);
}
function buildSupportLogSummary(entries: SupportLogEntryDto[], totalCount: number, availableSources: string[]): SupportLogSummaryDto {
return {
totalCount,
filteredCount: entries.length,
sourceCount: availableSources.length,
retentionDays: SUPPORT_LOG_RETENTION_DAYS,
oldestEntryAt: entries.length > 0 ? entries[entries.length - 1]?.createdAt ?? null : null,
newestEntryAt: entries.length > 0 ? entries[0]?.createdAt ?? null : null,
levelCounts: {
INFO: entries.filter((entry) => entry.level === "INFO").length,
WARN: entries.filter((entry) => entry.level === "WARN").length,
ERROR: entries.filter((entry) => entry.level === "ERROR").length,
},
};
}
export function recordSupportLog(entry: {
level: SupportLogEntryDto["level"];
source: string;
message: string;
context?: Record<string, unknown>;
}) {
pruneSupportLogs();
supportLogs.unshift({
id: randomUUID(),
level: entry.level,
source: entry.source,
message: entry.message,
contextJson: serializeContext(entry.context),
createdAt: new Date().toISOString(),
});
if (supportLogs.length > SUPPORT_LOG_LIMIT) {
supportLogs.length = SUPPORT_LOG_LIMIT;
}
}
export function listSupportLogs(filters?: SupportLogFiltersDto): SupportLogListDto {
pruneSupportLogs();
const normalized = normalizeFilters(filters);
const availableSources = [...new Set(supportLogs.map((entry) => entry.source))].sort();
const entries = filterSupportLogs(normalized);
return {
entries,
summary: buildSupportLogSummary(entries, supportLogs.length, availableSources),
availableSources,
filters: normalized,
};
}
export function getSupportLogCount() {
pruneSupportLogs();
return supportLogs.length;
}
export function getSupportLogRetentionDays() {
return SUPPORT_LOG_RETENTION_DAYS;
}

View File

@@ -0,0 +1,173 @@
import { permissions } from "@mrp/shared";
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import {
createAdminRole,
listAdminAuthSessions,
createAdminUser,
getBackupGuidance,
getAdminDiagnostics,
getSupportLogs,
getSupportSnapshot,
listAdminPermissions,
listAdminRoles,
listAdminUsers,
revokeAdminAuthSession,
updateAdminRole,
updateAdminUser,
} from "./service.js";
export const adminRouter = Router();
const roleSchema = z.object({
name: z.string().trim().min(1).max(120),
description: z.string(),
permissionKeys: z.array(z.string().trim().min(1)),
});
const userSchema = z.object({
email: z.string().email(),
firstName: z.string().trim().min(1).max(120),
lastName: z.string().trim().min(1).max(120),
isActive: z.boolean(),
roleIds: z.array(z.string().trim().min(1)),
password: z.string().min(8).nullable(),
});
const supportLogQuerySchema = z.object({
level: z.enum(["INFO", "WARN", "ERROR"]).optional(),
source: z.string().trim().min(1).optional(),
query: z.string().trim().optional(),
start: z.string().datetime().optional(),
end: z.string().datetime().optional(),
limit: z.coerce.number().int().min(1).max(500).optional(),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
adminRouter.get("/diagnostics", requirePermissions([permissions.adminManage]), async (_request, response) => {
return ok(response, await getAdminDiagnostics());
});
adminRouter.get("/backup-guidance", requirePermissions([permissions.adminManage]), async (_request, response) => {
return ok(response, getBackupGuidance());
});
adminRouter.get("/support-snapshot", requirePermissions([permissions.adminManage]), async (_request, response) => {
const parsed = supportLogQuerySchema.safeParse(_request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Support snapshot filters are invalid.");
}
return ok(response, await getSupportSnapshot(parsed.data));
});
adminRouter.get("/support-logs", requirePermissions([permissions.adminManage]), async (request, response) => {
const parsed = supportLogQuerySchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Support log filters are invalid.");
}
return ok(response, getSupportLogs(parsed.data));
});
adminRouter.get("/permissions", requirePermissions([permissions.adminManage]), async (_request, response) => {
return ok(response, await listAdminPermissions());
});
adminRouter.get("/roles", requirePermissions([permissions.adminManage]), async (_request, response) => {
return ok(response, await listAdminRoles());
});
adminRouter.post("/roles", requirePermissions([permissions.adminManage]), async (request, response) => {
const parsed = roleSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Role payload is invalid.");
}
const result = await createAdminRole(parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.role, 201);
});
adminRouter.put("/roles/:roleId", requirePermissions([permissions.adminManage]), async (request, response) => {
const roleId = getRouteParam(request.params.roleId);
if (!roleId) {
return fail(response, 400, "INVALID_INPUT", "Role id is invalid.");
}
const parsed = roleSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Role payload is invalid.");
}
const result = await updateAdminRole(roleId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.role);
});
adminRouter.get("/users", requirePermissions([permissions.adminManage]), async (_request, response) => {
return ok(response, await listAdminUsers());
});
adminRouter.get("/sessions", requirePermissions([permissions.adminManage]), async (request, response) => {
return ok(response, await listAdminAuthSessions(request.authSessionId));
});
adminRouter.post("/sessions/:sessionId/revoke", requirePermissions([permissions.adminManage]), async (request, response) => {
const sessionId = getRouteParam(request.params.sessionId);
if (!sessionId) {
return fail(response, 400, "INVALID_INPUT", "Session id is invalid.");
}
const result = await revokeAdminAuthSession(sessionId, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, { success: true as const });
});
adminRouter.post("/users", requirePermissions([permissions.adminManage]), async (request, response) => {
const parsed = userSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "User payload is invalid.");
}
const result = await createAdminUser(parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.user, 201);
});
adminRouter.put("/users/:userId", requirePermissions([permissions.adminManage]), async (request, response) => {
const userId = getRouteParam(request.params.userId);
if (!userId) {
return fail(response, 400, "INVALID_INPUT", "User id is invalid.");
}
const parsed = userSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "User payload is invalid.");
}
const result = await updateAdminUser(userId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.user);
});

View File

@@ -0,0 +1,902 @@
import type {
AdminDiagnosticsDto,
AdminAuthSessionDto,
BackupGuidanceDto,
AdminPermissionOptionDto,
AdminRoleDto,
AdminRoleInput,
AdminUserDto,
AdminUserInput,
SupportSnapshotDto,
AuditEventDto,
SupportLogEntryDto,
SupportLogFiltersDto,
SupportLogListDto,
} from "@mrp/shared";
import { env } from "../../config/env.js";
import { paths } from "../../config/paths.js";
import { logAuditEvent } from "../../lib/audit.js";
import { hashPassword } from "../../lib/password.js";
import { prisma } from "../../lib/prisma.js";
import { getLatestStartupReport } from "../../lib/startup-state.js";
import { getSupportLogCount, getSupportLogRetentionDays, listSupportLogs } from "../../lib/support-log.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(),
};
}
function mapSupportLogEntry(record: SupportLogEntryDto): SupportLogEntryDto {
return { ...record };
}
function mapSupportLogList(record: SupportLogListDto): SupportLogListDto {
return {
entries: record.entries.map(mapSupportLogEntry),
summary: record.summary,
availableSources: record.availableSources,
filters: record.filters,
};
}
function mapRole(record: {
id: string;
name: string;
description: string;
createdAt: Date;
updatedAt: Date;
rolePermissions: Array<{
permission: {
key: string;
};
}>;
_count: {
userRoles: number;
};
}): AdminRoleDto {
return {
id: record.id,
name: record.name,
description: record.description,
permissionKeys: record.rolePermissions.map((rolePermission) => rolePermission.permission.key).sort(),
userCount: record._count.userRoles,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
function mapUser(record: {
id: string;
email: string;
firstName: string;
lastName: string;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
userRoles: Array<{
role: {
id: string;
name: string;
rolePermissions: Array<{
permission: {
key: string;
};
}>;
};
}>;
}): AdminUserDto {
const permissionKeys = new Set<string>();
for (const userRole of record.userRoles) {
for (const rolePermission of userRole.role.rolePermissions) {
permissionKeys.add(rolePermission.permission.key);
}
}
return {
id: record.id,
email: record.email,
firstName: record.firstName,
lastName: record.lastName,
isActive: record.isActive,
roleIds: record.userRoles.map((userRole) => userRole.role.id),
roleNames: record.userRoles.map((userRole) => userRole.role.name),
permissionKeys: [...permissionKeys].sort(),
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
function mapAuthSession(
record: {
id: string;
userId: string;
expiresAt: Date;
lastSeenAt: Date;
revokedAt: Date | null;
revokedReason: string | null;
ipAddress: string | null;
userAgent: string | null;
createdAt: Date;
user: {
email: string;
firstName: string;
lastName: string;
};
revokedBy: {
firstName: string;
lastName: string;
} | null;
},
reviewContext: {
reviewState: "NORMAL" | "REVIEW";
reviewReasons: string[];
},
currentSessionId?: string
): AdminAuthSessionDto {
const now = Date.now();
const status = record.revokedAt ? "REVOKED" : record.expiresAt.getTime() <= now ? "EXPIRED" : "ACTIVE";
return {
id: record.id,
userId: record.userId,
userEmail: record.user.email,
userName: `${record.user.firstName} ${record.user.lastName}`.trim(),
status,
reviewState: reviewContext.reviewState,
reviewReasons: reviewContext.reviewReasons,
isCurrent: record.id === currentSessionId,
createdAt: record.createdAt.toISOString(),
lastSeenAt: record.lastSeenAt.toISOString(),
expiresAt: record.expiresAt.toISOString(),
revokedAt: record.revokedAt?.toISOString() ?? null,
revokedReason: record.revokedReason,
revokedByName: record.revokedBy ? `${record.revokedBy.firstName} ${record.revokedBy.lastName}`.trim() : null,
ipAddress: record.ipAddress,
userAgent: record.userAgent,
};
}
async function validatePermissionKeys(permissionKeys: string[]) {
const uniquePermissionKeys = [...new Set(permissionKeys)];
const permissions = await prisma.permission.findMany({
where: {
key: {
in: uniquePermissionKeys,
},
},
select: {
id: true,
key: true,
},
});
if (permissions.length !== uniquePermissionKeys.length) {
return { ok: false as const, reason: "One or more selected permissions are invalid." };
}
return { ok: true as const, permissions };
}
async function validateRoleIds(roleIds: string[]) {
const uniqueRoleIds = [...new Set(roleIds)];
const roles = await prisma.role.findMany({
where: {
id: {
in: uniqueRoleIds,
},
},
select: {
id: true,
name: true,
},
});
if (roles.length !== uniqueRoleIds.length) {
return { ok: false as const, reason: "One or more selected roles are invalid." };
}
return { ok: true as const, roles };
}
export async function listAdminPermissions(): Promise<AdminPermissionOptionDto[]> {
const permissions = await prisma.permission.findMany({
orderBy: [{ key: "asc" }],
});
return permissions.map((permission) => ({
key: permission.key,
description: permission.description,
}));
}
export async function listAdminRoles(): Promise<AdminRoleDto[]> {
const roles = await prisma.role.findMany({
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
_count: {
select: {
userRoles: true,
},
},
},
orderBy: [{ name: "asc" }],
});
return roles.map(mapRole);
}
export async function createAdminRole(payload: AdminRoleInput, actorId?: string | null) {
const validatedPermissions = await validatePermissionKeys(payload.permissionKeys);
if (!validatedPermissions.ok) {
return { ok: false as const, reason: validatedPermissions.reason };
}
const role = await prisma.role.create({
data: {
name: payload.name.trim(),
description: payload.description,
rolePermissions: {
create: validatedPermissions.permissions.map((permission) => ({
permissionId: permission.id,
})),
},
},
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
_count: {
select: {
userRoles: true,
},
},
},
});
await logAuditEvent({
actorId,
entityType: "role",
entityId: role.id,
action: "created",
summary: `Created role ${role.name}.`,
metadata: {
name: role.name,
permissionKeys: role.rolePermissions.map((rolePermission) => rolePermission.permission.key),
},
});
return { ok: true as const, role: mapRole(role) };
}
export async function updateAdminRole(roleId: string, payload: AdminRoleInput, actorId?: string | null) {
const existingRole = await prisma.role.findUnique({
where: { id: roleId },
select: { id: true, name: true },
});
if (!existingRole) {
return { ok: false as const, reason: "Role was not found." };
}
const validatedPermissions = await validatePermissionKeys(payload.permissionKeys);
if (!validatedPermissions.ok) {
return { ok: false as const, reason: validatedPermissions.reason };
}
const role = await prisma.role.update({
where: { id: roleId },
data: {
name: payload.name.trim(),
description: payload.description,
rolePermissions: {
deleteMany: {},
create: validatedPermissions.permissions.map((permission) => ({
permissionId: permission.id,
})),
},
},
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
_count: {
select: {
userRoles: true,
},
},
},
});
await logAuditEvent({
actorId,
entityType: "role",
entityId: role.id,
action: "updated",
summary: `Updated role ${role.name}.`,
metadata: {
previousName: existingRole.name,
name: role.name,
permissionKeys: role.rolePermissions.map((rolePermission) => rolePermission.permission.key),
},
});
return { ok: true as const, role: mapRole(role) };
}
export async function listAdminUsers(): Promise<AdminUserDto[]> {
const users = await prisma.user.findMany({
include: {
userRoles: {
include: {
role: {
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
},
},
},
},
},
orderBy: [{ firstName: "asc" }, { lastName: "asc" }, { email: "asc" }],
});
return users.map(mapUser);
}
export async function listAdminAuthSessions(currentSessionId?: string | null): Promise<AdminAuthSessionDto[]> {
const sessions = await prisma.authSession.findMany({
include: {
user: {
select: {
email: true,
firstName: true,
lastName: true,
},
},
revokedBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ revokedAt: "asc" }, { lastSeenAt: "desc" }, { createdAt: "desc" }],
take: 200,
});
const now = Date.now();
const activeSessionsByUser = new Map<
string,
Array<{
id: string;
ipAddress: string | null;
userAgent: string | null;
lastSeenAt: Date;
}>
>();
for (const session of sessions) {
const isActive = !session.revokedAt && session.expiresAt.getTime() > now;
if (!isActive) {
continue;
}
const existing = activeSessionsByUser.get(session.userId) ?? [];
existing.push({
id: session.id,
ipAddress: session.ipAddress,
userAgent: session.userAgent,
lastSeenAt: session.lastSeenAt,
});
activeSessionsByUser.set(session.userId, existing);
}
return sessions.map((session) => {
const reviewReasons: string[] = [];
const activeUserSessions = activeSessionsByUser.get(session.userId) ?? [];
const isActive = !session.revokedAt && session.expiresAt.getTime() > now;
const staleThresholdMs = 7 * 24 * 60 * 60 * 1000;
if (isActive && activeUserSessions.length > 1) {
reviewReasons.push("Multiple active sessions");
}
if (isActive) {
const distinctIps = new Set(activeUserSessions.map((entry) => entry.ipAddress).filter(Boolean));
if (distinctIps.size > 1) {
reviewReasons.push("Multiple active IP addresses");
}
if (now - session.lastSeenAt.getTime() > staleThresholdMs) {
reviewReasons.push("Stale active session");
}
}
return mapAuthSession(
session,
{
reviewState: reviewReasons.length > 0 ? "REVIEW" : "NORMAL",
reviewReasons,
},
currentSessionId ?? undefined
);
});
}
export async function revokeAdminAuthSession(sessionId: string, actorId?: string | null) {
const existingSession = await prisma.authSession.findUnique({
where: { id: sessionId },
include: {
user: {
select: {
email: true,
firstName: true,
lastName: true,
},
},
},
});
if (!existingSession) {
return { ok: false as const, reason: "Session was not found." };
}
if (existingSession.revokedAt) {
return { ok: false as const, reason: "Session is already revoked." };
}
await prisma.authSession.update({
where: { id: sessionId },
data: {
revokedAt: new Date(),
revokedById: actorId ?? null,
revokedReason: "Revoked by administrator.",
},
});
await logAuditEvent({
actorId,
entityType: "auth-session",
entityId: existingSession.id,
action: "revoked",
summary: `Revoked session for ${existingSession.user.email}.`,
metadata: {
userId: existingSession.userId,
userEmail: existingSession.user.email,
},
});
return { ok: true as const };
}
export async function createAdminUser(payload: AdminUserInput, actorId?: string | null) {
if (!payload.password || payload.password.trim().length < 8) {
return { ok: false as const, reason: "A password with at least 8 characters is required for new users." };
}
const validatedRoles = await validateRoleIds(payload.roleIds);
if (!validatedRoles.ok) {
return { ok: false as const, reason: validatedRoles.reason };
}
const user = await prisma.user.create({
data: {
email: payload.email.trim().toLowerCase(),
firstName: payload.firstName.trim(),
lastName: payload.lastName.trim(),
isActive: payload.isActive,
passwordHash: await hashPassword(payload.password.trim()),
userRoles: {
create: validatedRoles.roles.map((role) => ({
roleId: role.id,
assignedBy: actorId ?? null,
})),
},
},
include: {
userRoles: {
include: {
role: {
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
},
},
},
},
},
});
await logAuditEvent({
actorId,
entityType: "user",
entityId: user.id,
action: "created",
summary: `Created user account for ${user.email}.`,
metadata: {
email: user.email,
isActive: user.isActive,
roleNames: user.userRoles.map((userRole) => userRole.role.name),
},
});
return { ok: true as const, user: mapUser(user) };
}
export async function updateAdminUser(userId: string, payload: AdminUserInput, actorId?: string | null) {
const existingUser = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
},
});
if (!existingUser) {
return { ok: false as const, reason: "User was not found." };
}
const validatedRoles = await validateRoleIds(payload.roleIds);
if (!validatedRoles.ok) {
return { ok: false as const, reason: validatedRoles.reason };
}
const data = {
email: payload.email.trim().toLowerCase(),
firstName: payload.firstName.trim(),
lastName: payload.lastName.trim(),
isActive: payload.isActive,
...(payload.password?.trim()
? {
passwordHash: await hashPassword(payload.password.trim()),
}
: {}),
userRoles: {
deleteMany: {},
create: validatedRoles.roles.map((role) => ({
roleId: role.id,
assignedBy: actorId ?? null,
})),
},
};
const user = await prisma.user.update({
where: { id: userId },
data,
include: {
userRoles: {
include: {
role: {
include: {
rolePermissions: {
include: {
permission: {
select: {
key: true,
},
},
},
},
},
},
},
},
},
});
await logAuditEvent({
actorId,
entityType: "user",
entityId: user.id,
action: "updated",
summary: `Updated user account for ${user.email}.`,
metadata: {
previousEmail: existingUser.email,
email: user.email,
isActive: user.isActive,
roleNames: user.userRoles.map((userRole) => userRole.role.name),
passwordReset: Boolean(payload.password?.trim()),
},
});
return { ok: true as const, user: mapUser(user) };
}
export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
const startupReport = getLatestStartupReport();
const recentSupportLogs = listSupportLogs({ limit: 50 });
const now = new Date();
const reviewSessions = await listAdminAuthSessions();
const [
companyProfile,
userCount,
activeUserCount,
activeSessionCount,
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.authSession.count({
where: {
revokedAt: null,
expiresAt: {
gt: now,
},
},
}),
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,
}),
]);
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,
activeSessionCount,
reviewSessionCount: reviewSessions.filter((session) => session.reviewState === "REVIEW").length,
roleCount,
permissionCount,
customerCount,
vendorCount,
inventoryItemCount,
warehouseCount,
workOrderCount,
projectCount,
purchaseOrderCount,
salesDocumentCount: salesQuoteCount + salesOrderCount,
shipmentCount,
attachmentCount,
auditEventCount,
supportLogCount: getSupportLogCount(),
startup: startupReport,
recentAuditEvents: recentAuditEvents.map(mapAuditEvent),
recentSupportLogs: recentSupportLogs.entries.map(mapSupportLogEntry),
};
}
export function getBackupGuidance(): BackupGuidanceDto {
return {
dataPath: paths.dataDir,
databasePath: `${paths.prismaDir}/app.db`,
uploadsPath: paths.uploadsDir,
recommendedBackupTarget: "/mnt/user/backups/mrp-codex",
backupSteps: [
{
id: "stop-app",
label: "Stop writes before copying data",
detail: "Stop the container or application process before copying the data directory so SQLite and attachments stay consistent.",
},
{
id: "copy-data",
label: "Back up the full data directory",
detail: `Copy the full data directory at ${paths.dataDir}, not just the SQLite file, so uploads and attachments are preserved with the database.`,
},
{
id: "retain-metadata",
label: "Keep timestamps and structure",
detail: "Preserve directory structure, filenames, and timestamps during backup so support recovery remains straightforward.",
},
{
id: "record-build",
label: "Record image/version context",
detail: "Capture the deployed image tag or commit alongside the backup so schema and runtime expectations are clear during restore.",
},
],
restoreSteps: [
{
id: "stop-target",
label: "Stop the target app before restore",
detail: "Do not restore into a running instance. Stop the target container or process before replacing the data directory.",
},
{
id: "replace-data",
label: "Restore the full data directory",
detail: `Replace the target data directory with the backed-up copy so ${paths.prismaDir}/app.db and uploads come back together.`,
},
{
id: "start-and-migrate",
label: "Start the app and let migrations run",
detail: "Restart the application after restore and allow the normal startup migration flow to complete before validation.",
},
{
id: "validate-core",
label: "Validate login, files, and PDFs",
detail: "Confirm admin login, attachment access, and PDF generation after restore to verify the operational surface is healthy.",
},
],
verificationChecklist: [
{
id: "backup-size-check",
label: "Confirm backup contains data and uploads",
detail: "Verify the backup archive or copied directory includes the SQLite database and uploads tree rather than only one of them.",
evidence: "Directory listing or archive manifest showing prisma/app.db and uploads/ content.",
},
{
id: "timestamp-check",
label: "Check backup freshness",
detail: "Confirm the backup timestamp matches the expected backup window and is newer than the last major data-entry period you need to protect.",
evidence: "Backup timestamp recorded in your scheduler, NAS share, or copied folder metadata.",
},
{
id: "snapshot-export",
label: "Capture a support snapshot with the backup",
detail: "Export the support snapshot from diagnostics when taking a formal backup so the runtime state and active-user footprint are recorded alongside it.",
evidence: "JSON support snapshot stored with the backup set or support ticket.",
},
{
id: "app-stop-check",
label: "Verify writes were stopped before copy",
detail: "Use a controlled maintenance stop or container stop before backup to reduce the chance of a partial SQLite copy.",
evidence: "Maintenance log entry, Docker stop event, or operator note recorded with the backup.",
},
],
restoreDrillSteps: [
{
id: "prepare-drill-target",
label: "Prepare isolated restore target",
detail: "Restore into an isolated container or duplicate environment instead of the live production instance.",
expectedOutcome: "A clean target environment is ready to receive the backed-up data directory without impacting production.",
},
{
id: "load-backed-up-data",
label: "Load the full backup set",
detail: `Restore the full backed-up data directory so ${paths.prismaDir}/app.db and uploads are returned together.`,
expectedOutcome: "The restore target contains both database and file assets with the original directory structure intact.",
},
{
id: "boot-restored-app",
label: "Start the restored application",
detail: "Launch the restored app and allow startup validation plus migrations to complete normally.",
expectedOutcome: "The application starts without startup-validation failures and the diagnostics page loads.",
},
{
id: "run-functional-checks",
label: "Run post-restore functional checks",
detail: "Verify login, one attachment download, one PDF render, and one representative transactional detail page such as inventory, purchasing, or shipping.",
expectedOutcome: "Core operational flows work in the restored environment and file/PDF dependencies remain valid.",
},
{
id: "record-drill-results",
label: "Record restore-drill results",
detail: "Capture the drill date, backup source used, startup status, and any gaps discovered so future recovery work improves over time.",
expectedOutcome: "A dated restore-drill record exists for support and disaster-recovery review.",
},
],
};
}
export async function getSupportSnapshot(filters?: SupportLogFiltersDto): Promise<SupportSnapshotDto> {
const diagnostics = await getAdminDiagnostics();
const backupGuidance = getBackupGuidance();
const supportLogs = listSupportLogs({ limit: 200, ...filters });
const [users, roles] = await Promise.all([
prisma.user.findMany({
where: { isActive: true },
select: { email: true },
orderBy: [{ email: "asc" }],
}),
prisma.role.count(),
]);
return {
generatedAt: new Date().toISOString(),
diagnostics,
userCount: diagnostics.userCount,
roleCount: roles,
activeUserEmails: users.map((user) => user.email),
backupGuidance,
supportLogs: mapSupportLogList(supportLogs),
};
}
export function getSupportLogs(filters?: SupportLogFiltersDto) {
return mapSupportLogList(listSupportLogs(filters));
}
export function getSupportLogRetentionPolicy() {
return {
retentionDays: getSupportLogRetentionDays(),
};
}

View File

@@ -0,0 +1,41 @@
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requireAuth } from "../../lib/rbac.js";
import { login, logout } from "./service.js";
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export const authRouter = Router();
authRouter.post("/login", async (request, response) => {
const parsed = loginSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Please provide a valid email and password.");
}
const result = await login(parsed.data, {
ipAddress: request.ip,
userAgent: request.header("user-agent"),
});
if (!result) {
return fail(response, 401, "INVALID_CREDENTIALS", "Email or password is incorrect.");
}
return ok(response, result);
});
authRouter.get("/me", requireAuth, async (request, response) => ok(response, request.authUser));
authRouter.post("/logout", requireAuth, async (request, response) => {
if (!request.authSessionId || !request.authUser) {
return fail(response, 401, "UNAUTHORIZED", "Authentication is required.");
}
await logout(request.authSessionId, request.authUser.id);
return ok(response, { success: true as const });
});

View File

@@ -0,0 +1,50 @@
import type { LoginRequest, LoginResponse } from "@mrp/shared";
import { signToken } from "../../lib/auth.js";
import { createAuthSession, revokeAuthSession } from "../../lib/auth-sessions.js";
import { getCurrentUserById } from "../../lib/current-user.js";
import { verifyPassword } from "../../lib/password.js";
import { prisma } from "../../lib/prisma.js";
export async function login(
payload: LoginRequest,
context?: {
ipAddress?: string | null;
userAgent?: string | null;
}
): Promise<LoginResponse | null> {
const user = await prisma.user.findUnique({
where: { email: payload.email.toLowerCase() },
});
if (!user?.isActive) {
return null;
}
if (!(await verifyPassword(payload.password, user.passwordHash))) {
return null;
}
const authUser = await getCurrentUserById(user.id);
if (!authUser) {
return null;
}
const session = await createAuthSession({
userId: user.id,
ipAddress: context?.ipAddress ?? null,
userAgent: context?.userAgent ?? null,
});
return {
token: signToken(authUser, session.id),
user: authUser,
};
}
export async function logout(sessionId: string, actorId?: string | null) {
await revokeAuthSession(sessionId, {
revokedById: actorId ?? null,
reason: actorId ? "User signed out." : "Session signed out.",
});
}

View File

@@ -0,0 +1,288 @@
import { permissions } from "@mrp/shared";
import { crmContactEntryTypes, crmContactRoles, crmLifecycleStages, crmRecordStatuses } from "@mrp/shared/dist/crm/types.js";
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import {
createCustomerContactEntry,
createCustomerContact,
createCustomer,
createVendorContactEntry,
createVendorContact,
createVendor,
getCustomerById,
getVendorById,
listCustomers,
listCustomerHierarchyOptions,
listVendors,
updateCustomer,
updateVendor,
} from "./service.js";
const crmRecordSchema = z.object({
name: z.string().trim().min(1),
email: z.string().trim().email(),
phone: z.string().trim().min(1),
addressLine1: z.string().trim().min(1),
addressLine2: z.string(),
city: z.string().trim().min(1),
state: z.string().trim().min(1),
postalCode: z.string().trim().min(1),
country: z.string().trim().min(1),
status: z.enum(crmRecordStatuses),
lifecycleStage: z.enum(crmLifecycleStages).optional(),
notes: z.string(),
isReseller: z.boolean().optional(),
resellerDiscountPercent: z.number().min(0).max(100).nullable().optional(),
parentCustomerId: z.string().nullable().optional(),
paymentTerms: z.string().nullable().optional(),
currencyCode: z.string().max(8).nullable().optional(),
taxExempt: z.boolean().optional(),
creditHold: z.boolean().optional(),
preferredAccount: z.boolean().optional(),
strategicAccount: z.boolean().optional(),
requiresApproval: z.boolean().optional(),
blockedAccount: z.boolean().optional(),
});
const crmListQuerySchema = z.object({
q: z.string().optional(),
state: z.string().optional(),
status: z.enum(crmRecordStatuses).optional(),
lifecycleStage: z.enum(crmLifecycleStages).optional(),
flag: z.enum(["PREFERRED", "STRATEGIC", "REQUIRES_APPROVAL", "BLOCKED"]).optional(),
});
const crmContactEntrySchema = z.object({
type: z.enum(crmContactEntryTypes),
summary: z.string().trim().min(1).max(160),
body: z.string().trim().min(1).max(4000),
contactAt: z.string().datetime(),
});
const crmContactSchema = z.object({
fullName: z.string().trim().min(1).max(160),
role: z.enum(crmContactRoles),
email: z.string().trim().email(),
phone: z.string().trim().min(1).max(64),
isPrimary: z.boolean(),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
export const crmRouter = Router();
crmRouter.get("/customers", requirePermissions([permissions.crmRead]), async (_request, response) => {
const parsed = crmListQuerySchema.safeParse(_request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "CRM filters are invalid.");
}
return ok(
response,
await listCustomers({
query: parsed.data.q,
status: parsed.data.status,
state: parsed.data.state,
lifecycleStage: parsed.data.lifecycleStage,
flag: parsed.data.flag,
})
);
});
crmRouter.get("/customers/hierarchy-options", requirePermissions([permissions.crmRead]), async (request, response) => {
const excludeCustomerId = getRouteParam(request.query.excludeCustomerId);
return ok(response, await listCustomerHierarchyOptions(excludeCustomerId ?? undefined));
});
crmRouter.get("/customers/:customerId", requirePermissions([permissions.crmRead]), async (request, response) => {
const customerId = getRouteParam(request.params.customerId);
if (!customerId) {
return fail(response, 400, "INVALID_INPUT", "Customer id is invalid.");
}
const customer = await getCustomerById(customerId);
if (!customer) {
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
}
return ok(response, customer);
});
crmRouter.post("/customers", requirePermissions([permissions.crmWrite]), async (request, response) => {
const parsed = crmRecordSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Customer payload is invalid.");
}
const customer = await createCustomer(parsed.data, request.authUser?.id);
if (!customer) {
return fail(response, 400, "INVALID_INPUT", "Customer reseller relationship is invalid.");
}
return ok(response, customer, 201);
});
crmRouter.put("/customers/:customerId", requirePermissions([permissions.crmWrite]), async (request, response) => {
const customerId = getRouteParam(request.params.customerId);
if (!customerId) {
return fail(response, 400, "INVALID_INPUT", "Customer id is invalid.");
}
const parsed = crmRecordSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Customer payload is invalid.");
}
const existingCustomer = await getCustomerById(customerId);
if (!existingCustomer) {
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
}
const customer = await updateCustomer(customerId, parsed.data, request.authUser?.id);
if (!customer) {
return fail(response, 400, "INVALID_INPUT", "Customer reseller relationship is invalid.");
}
return ok(response, customer);
});
crmRouter.post("/customers/:customerId/contact-history", requirePermissions([permissions.crmWrite]), async (request, response) => {
const customerId = getRouteParam(request.params.customerId);
if (!customerId) {
return fail(response, 400, "INVALID_INPUT", "Customer id is invalid.");
}
const parsed = crmContactEntrySchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Contact history entry is invalid.");
}
const entry = await createCustomerContactEntry(customerId, parsed.data, request.authUser?.id);
if (!entry) {
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
}
return ok(response, entry, 201);
});
crmRouter.post("/customers/:customerId/contacts", requirePermissions([permissions.crmWrite]), async (request, response) => {
const customerId = getRouteParam(request.params.customerId);
if (!customerId) {
return fail(response, 400, "INVALID_INPUT", "Customer id is invalid.");
}
const parsed = crmContactSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "CRM contact is invalid.");
}
const contact = await createCustomerContact(customerId, parsed.data, request.authUser?.id);
if (!contact) {
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
}
return ok(response, contact, 201);
});
crmRouter.get("/vendors", requirePermissions([permissions.crmRead]), async (_request, response) => {
const parsed = crmListQuerySchema.safeParse(_request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "CRM filters are invalid.");
}
return ok(
response,
await listVendors({
query: parsed.data.q,
status: parsed.data.status,
state: parsed.data.state,
lifecycleStage: parsed.data.lifecycleStage,
flag: parsed.data.flag,
})
);
});
crmRouter.get("/vendors/:vendorId", requirePermissions([permissions.crmRead]), async (request, response) => {
const vendorId = getRouteParam(request.params.vendorId);
if (!vendorId) {
return fail(response, 400, "INVALID_INPUT", "Vendor id is invalid.");
}
const vendor = await getVendorById(vendorId);
if (!vendor) {
return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found.");
}
return ok(response, vendor);
});
crmRouter.post("/vendors", requirePermissions([permissions.crmWrite]), async (request, response) => {
const parsed = crmRecordSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Vendor payload is invalid.");
}
return ok(response, await createVendor(parsed.data, request.authUser?.id), 201);
});
crmRouter.put("/vendors/:vendorId", requirePermissions([permissions.crmWrite]), async (request, response) => {
const vendorId = getRouteParam(request.params.vendorId);
if (!vendorId) {
return fail(response, 400, "INVALID_INPUT", "Vendor id is invalid.");
}
const parsed = crmRecordSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Vendor payload is invalid.");
}
const vendor = await updateVendor(vendorId, parsed.data, request.authUser?.id);
if (!vendor) {
return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found.");
}
return ok(response, vendor);
});
crmRouter.post("/vendors/:vendorId/contact-history", requirePermissions([permissions.crmWrite]), async (request, response) => {
const vendorId = getRouteParam(request.params.vendorId);
if (!vendorId) {
return fail(response, 400, "INVALID_INPUT", "Vendor id is invalid.");
}
const parsed = crmContactEntrySchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Contact history entry is invalid.");
}
const entry = await createVendorContactEntry(vendorId, parsed.data, request.authUser?.id);
if (!entry) {
return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found.");
}
return ok(response, entry, 201);
});
crmRouter.post("/vendors/:vendorId/contacts", requirePermissions([permissions.crmWrite]), async (request, response) => {
const vendorId = getRouteParam(request.params.vendorId);
if (!vendorId) {
return fail(response, 400, "INVALID_INPUT", "Vendor id is invalid.");
}
const parsed = crmContactSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "CRM contact is invalid.");
}
const contact = await createVendorContact(vendorId, parsed.data, request.authUser?.id);
if (!contact) {
return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found.");
}
return ok(response, contact, 201);
});

View File

@@ -0,0 +1,955 @@
import type {
CrmContactDto,
CrmContactInput,
CrmContactRole,
CrmContactEntryDto,
CrmContactEntryInput,
CrmContactEntryType,
CrmCustomerChildDto,
CrmRecordDetailDto,
CrmRecordInput,
CrmLifecycleStage,
CrmRecordRollupsDto,
CrmRecordStatus,
CrmRecordSummaryDto,
} from "@mrp/shared/dist/crm/types.js";
import type { Customer, Vendor } from "@prisma/client";
import { logAuditEvent } from "../../lib/audit.js";
import { prisma } from "../../lib/prisma.js";
function mapSummary(record: Customer | Vendor): CrmRecordSummaryDto {
return {
id: record.id,
name: record.name,
email: record.email,
phone: record.phone,
city: record.city,
state: record.state,
country: record.country,
status: record.status as CrmRecordStatus,
lifecycleStage: record.lifecycleStage as CrmLifecycleStage,
preferredAccount: record.preferredAccount,
strategicAccount: record.strategicAccount,
requiresApproval: record.requiresApproval,
blockedAccount: record.blockedAccount,
updatedAt: record.updatedAt.toISOString(),
};
}
function mapDetail(record: Customer | Vendor): CrmRecordDetailDto {
return {
...mapSummary(record),
addressLine1: record.addressLine1,
addressLine2: record.addressLine2,
postalCode: record.postalCode,
notes: record.notes,
createdAt: record.createdAt.toISOString(),
contactHistory: [],
};
}
type CustomerSummaryRecord = Customer & {
parentCustomer: Pick<Customer, "id" | "name"> | null;
_count: {
contactEntries: number;
contacts: number;
childCustomers: number;
};
contactEntries: Array<Pick<ContactEntryWithAuthor, "contactAt">>;
};
type CustomerDetailedRecord = Customer & {
parentCustomer: Pick<Customer, "id" | "name"> | null;
childCustomers: Pick<Customer, "id" | "name" | "status">[];
contactEntries: ContactEntryWithAuthor[];
contacts: ContactRecord[];
};
type VendorDetailedRecord = Vendor & {
contactEntries: ContactEntryWithAuthor[];
contacts: ContactRecord[];
};
type VendorSummaryRecord = Vendor & {
_count: {
contactEntries: number;
contacts: number;
};
contactEntries: Array<Pick<ContactEntryWithAuthor, "contactAt">>;
};
type ContactRecord = {
id: string;
fullName: string;
role: string;
email: string;
phone: string;
isPrimary: boolean;
createdAt: Date;
};
function mapCustomerChild(record: Pick<Customer, "id" | "name" | "status">): CrmCustomerChildDto {
return {
id: record.id,
name: record.name,
status: record.status as CrmRecordStatus,
};
}
function mapRollups(input: {
lastContactAt?: Date | null;
contactHistoryCount: number;
contactCount: number;
attachmentCount: number;
childCustomerCount?: number;
}): CrmRecordRollupsDto {
return {
lastContactAt: input.lastContactAt ? input.lastContactAt.toISOString() : null,
contactHistoryCount: input.contactHistoryCount,
contactCount: input.contactCount,
attachmentCount: input.attachmentCount,
childCustomerCount: input.childCustomerCount,
};
}
function mapCustomerSummary(record: CustomerSummaryRecord, attachmentCount: number): CrmRecordSummaryDto {
return {
...mapSummary(record),
isReseller: record.isReseller,
parentCustomerId: record.parentCustomer?.id ?? null,
parentCustomerName: record.parentCustomer?.name ?? null,
rollups: mapRollups({
lastContactAt: record.contactEntries[0]?.contactAt ?? null,
contactHistoryCount: record._count.contactEntries,
contactCount: record._count.contacts,
attachmentCount,
childCustomerCount: record._count.childCustomers,
}),
};
}
function mapCustomerDetail(record: CustomerDetailedRecord, attachmentCount: number): CrmRecordDetailDto {
return {
...mapDetailedRecord(record),
isReseller: record.isReseller,
resellerDiscountPercent: record.resellerDiscountPercent,
parentCustomerId: record.parentCustomer?.id ?? null,
parentCustomerName: record.parentCustomer?.name ?? null,
childCustomers: record.childCustomers.map(mapCustomerChild),
paymentTerms: record.paymentTerms,
currencyCode: record.currencyCode,
taxExempt: record.taxExempt,
creditHold: record.creditHold,
lifecycleStage: record.lifecycleStage as CrmLifecycleStage,
preferredAccount: record.preferredAccount,
strategicAccount: record.strategicAccount,
requiresApproval: record.requiresApproval,
blockedAccount: record.blockedAccount,
contacts: record.contacts.map(mapCrmContact),
rollups: mapRollups({
lastContactAt: record.contactEntries[0]?.contactAt ?? null,
contactHistoryCount: record.contactEntries.length,
contactCount: record.contacts.length,
attachmentCount,
childCustomerCount: record.childCustomers.length,
}),
};
}
function mapCrmContact(record: ContactRecord): CrmContactDto {
return {
id: record.id,
fullName: record.fullName,
role: record.role as CrmContactRole,
email: record.email,
phone: record.phone,
isPrimary: record.isPrimary,
createdAt: record.createdAt.toISOString(),
};
}
function mapVendorSummary(record: VendorSummaryRecord, attachmentCount: number): CrmRecordSummaryDto {
return {
...mapSummary(record),
rollups: mapRollups({
lastContactAt: record.contactEntries[0]?.contactAt ?? null,
contactHistoryCount: record._count.contactEntries,
contactCount: record._count.contacts,
attachmentCount,
}),
};
}
function mapVendorDetail(record: VendorDetailedRecord, attachmentCount: number): CrmRecordDetailDto {
return {
...mapDetailedRecord(record),
paymentTerms: record.paymentTerms,
currencyCode: record.currencyCode,
taxExempt: record.taxExempt,
creditHold: record.creditHold,
lifecycleStage: record.lifecycleStage as CrmLifecycleStage,
preferredAccount: record.preferredAccount,
strategicAccount: record.strategicAccount,
requiresApproval: record.requiresApproval,
blockedAccount: record.blockedAccount,
contacts: record.contacts.map(mapCrmContact),
rollups: mapRollups({
lastContactAt: record.contactEntries[0]?.contactAt ?? null,
contactHistoryCount: record.contactEntries.length,
contactCount: record.contacts.length,
attachmentCount,
}),
};
}
type ContactEntryWithAuthor = {
id: string;
type: string;
summary: string;
body: string;
contactAt: Date;
createdAt: Date;
createdBy: {
id: string;
firstName: string;
lastName: string;
email: string;
} | null;
};
type DetailedRecord = (Customer | Vendor) & {
contactEntries: ContactEntryWithAuthor[];
};
function mapContactEntry(entry: ContactEntryWithAuthor): CrmContactEntryDto {
return {
id: entry.id,
type: entry.type as CrmContactEntryType,
summary: entry.summary,
body: entry.body,
contactAt: entry.contactAt.toISOString(),
createdAt: entry.createdAt.toISOString(),
createdBy: entry.createdBy
? {
id: entry.createdBy.id,
name: `${entry.createdBy.firstName} ${entry.createdBy.lastName}`.trim(),
email: entry.createdBy.email,
}
: {
id: null,
name: "System",
email: null,
},
};
}
function mapDetailedRecord(record: DetailedRecord): CrmRecordDetailDto {
return {
...mapDetail(record),
contactHistory: record.contactEntries
.slice()
.sort((left, right) => right.contactAt.getTime() - left.contactAt.getTime())
.map(mapContactEntry),
};
}
interface CrmListFilters {
query?: string;
status?: CrmRecordStatus;
lifecycleStage?: CrmLifecycleStage;
state?: string;
flag?: "PREFERRED" | "STRATEGIC" | "REQUIRES_APPROVAL" | "BLOCKED";
}
async function getAttachmentCountMap(ownerType: string, ownerIds: string[]) {
if (ownerIds.length === 0) {
return new Map<string, number>();
}
const groupedAttachments = await prisma.fileAttachment.groupBy({
by: ["ownerId"],
where: {
ownerType,
ownerId: {
in: ownerIds,
},
},
_count: {
_all: true,
},
});
return new Map(groupedAttachments.map((entry) => [entry.ownerId, entry._count._all]));
}
function buildWhereClause(filters: CrmListFilters) {
const trimmedQuery = filters.query?.trim();
const trimmedState = filters.state?.trim();
const flagFilter =
filters.flag === "PREFERRED"
? { preferredAccount: true }
: filters.flag === "STRATEGIC"
? { strategicAccount: true }
: filters.flag === "REQUIRES_APPROVAL"
? { requiresApproval: true }
: filters.flag === "BLOCKED"
? { blockedAccount: true }
: {};
return {
...(filters.status ? { status: filters.status } : {}),
...(filters.lifecycleStage ? { lifecycleStage: filters.lifecycleStage } : {}),
...flagFilter,
...(trimmedState ? { state: { contains: trimmedState } } : {}),
...(trimmedQuery
? {
OR: [
{ name: { contains: trimmedQuery } },
{ email: { contains: trimmedQuery } },
{ phone: { contains: trimmedQuery } },
{ city: { contains: trimmedQuery } },
{ state: { contains: trimmedQuery } },
{ postalCode: { contains: trimmedQuery } },
{ country: { contains: trimmedQuery } },
],
}
: {}),
};
}
export async function listCustomers(filters: CrmListFilters = {}) {
const customers = await prisma.customer.findMany({
where: buildWhereClause(filters),
include: {
parentCustomer: {
select: {
id: true,
name: true,
},
},
contactEntries: {
select: {
contactAt: true,
},
orderBy: {
contactAt: "desc",
},
take: 1,
},
_count: {
select: {
contactEntries: true,
contacts: true,
childCustomers: true,
},
},
},
orderBy: { name: "asc" },
});
const attachmentCountMap = await getAttachmentCountMap("crm-customer", customers.map((customer) => customer.id));
return customers.map((customer) => mapCustomerSummary(customer, attachmentCountMap.get(customer.id) ?? 0));
}
export async function getCustomerById(customerId: string) {
const customer = await prisma.customer.findUnique({
where: { id: customerId },
include: {
parentCustomer: {
select: {
id: true,
name: true,
},
},
childCustomers: {
select: {
id: true,
name: true,
status: true,
},
orderBy: {
name: "asc",
},
},
contacts: {
orderBy: [{ isPrimary: "desc" }, { fullName: "asc" }],
},
contactEntries: {
include: {
createdBy: true,
},
orderBy: [{ contactAt: "desc" }, { createdAt: "desc" }],
},
},
});
if (!customer) {
return null;
}
const attachmentCount = await prisma.fileAttachment.count({
where: {
ownerType: "crm-customer",
ownerId: customerId,
},
});
return mapCustomerDetail(customer, attachmentCount);
}
export async function createCustomer(payload: CrmRecordInput, actorId?: string | null) {
if (payload.parentCustomerId) {
const parentCustomer = await prisma.customer.findUnique({
where: { id: payload.parentCustomerId },
});
if (!parentCustomer) {
return null;
}
}
const customer = await prisma.customer.create({
data: {
name: payload.name,
email: payload.email,
phone: payload.phone,
addressLine1: payload.addressLine1,
addressLine2: payload.addressLine2,
city: payload.city,
state: payload.state,
postalCode: payload.postalCode,
country: payload.country,
status: payload.status,
notes: payload.notes,
lifecycleStage: payload.lifecycleStage ?? "ACTIVE",
isReseller: payload.isReseller ?? false,
resellerDiscountPercent: payload.resellerDiscountPercent ?? 0,
parentCustomerId: payload.parentCustomerId ?? null,
paymentTerms: payload.paymentTerms ?? null,
currencyCode: payload.currencyCode ?? "USD",
taxExempt: payload.taxExempt ?? false,
creditHold: payload.creditHold ?? false,
preferredAccount: payload.preferredAccount ?? false,
strategicAccount: payload.strategicAccount ?? false,
requiresApproval: payload.requiresApproval ?? false,
blockedAccount: payload.blockedAccount ?? false,
},
});
await logAuditEvent({
actorId,
entityType: "crm-customer",
entityId: customer.id,
action: "created",
summary: `Created customer ${customer.name}.`,
metadata: {
name: customer.name,
status: customer.status,
lifecycleStage: customer.lifecycleStage,
isReseller: customer.isReseller,
},
});
return {
...mapDetail(customer),
isReseller: customer.isReseller,
resellerDiscountPercent: customer.resellerDiscountPercent,
parentCustomerId: customer.parentCustomerId,
parentCustomerName: null,
childCustomers: [],
paymentTerms: customer.paymentTerms,
currencyCode: customer.currencyCode,
taxExempt: customer.taxExempt,
creditHold: customer.creditHold,
lifecycleStage: customer.lifecycleStage as CrmLifecycleStage,
preferredAccount: customer.preferredAccount,
strategicAccount: customer.strategicAccount,
requiresApproval: customer.requiresApproval,
blockedAccount: customer.blockedAccount,
contacts: [],
rollups: mapRollups({
lastContactAt: null,
contactHistoryCount: 0,
contactCount: 0,
attachmentCount: 0,
childCustomerCount: 0,
}),
};
}
export async function updateCustomer(customerId: string, payload: CrmRecordInput, actorId?: string | null) {
const existingCustomer = await prisma.customer.findUnique({
where: { id: customerId },
});
if (!existingCustomer) {
return null;
}
if (payload.parentCustomerId === customerId) {
return null;
}
if (payload.parentCustomerId) {
const parentCustomer = await prisma.customer.findUnique({
where: { id: payload.parentCustomerId },
});
if (!parentCustomer) {
return null;
}
}
const customer = await prisma.customer.update({
where: { id: customerId },
data: {
name: payload.name,
email: payload.email,
phone: payload.phone,
addressLine1: payload.addressLine1,
addressLine2: payload.addressLine2,
city: payload.city,
state: payload.state,
postalCode: payload.postalCode,
country: payload.country,
status: payload.status,
notes: payload.notes,
lifecycleStage: payload.lifecycleStage ?? "ACTIVE",
isReseller: payload.isReseller ?? false,
resellerDiscountPercent: payload.resellerDiscountPercent ?? 0,
parentCustomerId: payload.parentCustomerId ?? null,
paymentTerms: payload.paymentTerms ?? null,
currencyCode: payload.currencyCode ?? "USD",
taxExempt: payload.taxExempt ?? false,
creditHold: payload.creditHold ?? false,
preferredAccount: payload.preferredAccount ?? false,
strategicAccount: payload.strategicAccount ?? false,
requiresApproval: payload.requiresApproval ?? false,
blockedAccount: payload.blockedAccount ?? false,
},
});
await logAuditEvent({
actorId,
entityType: "crm-customer",
entityId: customer.id,
action: "updated",
summary: `Updated customer ${customer.name}.`,
metadata: {
name: customer.name,
status: customer.status,
lifecycleStage: customer.lifecycleStage,
isReseller: customer.isReseller,
},
});
return {
...mapDetail(customer),
isReseller: customer.isReseller,
resellerDiscountPercent: customer.resellerDiscountPercent,
parentCustomerId: customer.parentCustomerId,
parentCustomerName: null,
childCustomers: [],
paymentTerms: customer.paymentTerms,
currencyCode: customer.currencyCode,
taxExempt: customer.taxExempt,
creditHold: customer.creditHold,
lifecycleStage: customer.lifecycleStage as CrmLifecycleStage,
preferredAccount: customer.preferredAccount,
strategicAccount: customer.strategicAccount,
requiresApproval: customer.requiresApproval,
blockedAccount: customer.blockedAccount,
contacts: [],
rollups: mapRollups({
lastContactAt: null,
contactHistoryCount: 0,
contactCount: 0,
attachmentCount: 0,
childCustomerCount: 0,
}),
};
}
export async function listVendors(filters: CrmListFilters = {}) {
const vendors = await prisma.vendor.findMany({
where: buildWhereClause(filters),
include: {
contactEntries: {
select: {
contactAt: true,
},
orderBy: {
contactAt: "desc",
},
take: 1,
},
_count: {
select: {
contactEntries: true,
contacts: true,
},
},
},
orderBy: { name: "asc" },
});
const attachmentCountMap = await getAttachmentCountMap("crm-vendor", vendors.map((vendor) => vendor.id));
return vendors.map((vendor) => mapVendorSummary(vendor, attachmentCountMap.get(vendor.id) ?? 0));
}
export async function listCustomerHierarchyOptions(excludeCustomerId?: string) {
const customers = await prisma.customer.findMany({
where: excludeCustomerId
? {
isReseller: true,
id: {
not: excludeCustomerId,
},
}
: {
isReseller: true,
},
orderBy: {
name: "asc",
},
select: {
id: true,
name: true,
status: true,
isReseller: true,
},
});
return customers.map((customer) => ({
id: customer.id,
name: customer.name,
status: customer.status as CrmRecordStatus,
isReseller: customer.isReseller,
}));
}
export async function getVendorById(vendorId: string) {
const vendor = await prisma.vendor.findUnique({
where: { id: vendorId },
include: {
contacts: {
orderBy: [{ isPrimary: "desc" }, { fullName: "asc" }],
},
contactEntries: {
include: {
createdBy: true,
},
orderBy: [{ contactAt: "desc" }, { createdAt: "desc" }],
},
},
});
if (!vendor) {
return null;
}
const attachmentCount = await prisma.fileAttachment.count({
where: {
ownerType: "crm-vendor",
ownerId: vendorId,
},
});
return mapVendorDetail(vendor, attachmentCount);
}
export async function createVendor(payload: CrmRecordInput, actorId?: string | null) {
const vendor = await prisma.vendor.create({
data: {
name: payload.name,
email: payload.email,
phone: payload.phone,
addressLine1: payload.addressLine1,
addressLine2: payload.addressLine2,
city: payload.city,
state: payload.state,
postalCode: payload.postalCode,
country: payload.country,
status: payload.status,
lifecycleStage: payload.lifecycleStage ?? "ACTIVE",
notes: payload.notes,
paymentTerms: payload.paymentTerms ?? null,
currencyCode: payload.currencyCode ?? "USD",
taxExempt: payload.taxExempt ?? false,
creditHold: payload.creditHold ?? false,
preferredAccount: payload.preferredAccount ?? false,
strategicAccount: payload.strategicAccount ?? false,
requiresApproval: payload.requiresApproval ?? false,
blockedAccount: payload.blockedAccount ?? false,
},
});
await logAuditEvent({
actorId,
entityType: "crm-vendor",
entityId: vendor.id,
action: "created",
summary: `Created vendor ${vendor.name}.`,
metadata: {
name: vendor.name,
status: vendor.status,
lifecycleStage: vendor.lifecycleStage,
},
});
return {
...mapDetail(vendor),
paymentTerms: vendor.paymentTerms,
currencyCode: vendor.currencyCode,
taxExempt: vendor.taxExempt,
creditHold: vendor.creditHold,
lifecycleStage: vendor.lifecycleStage as CrmLifecycleStage,
preferredAccount: vendor.preferredAccount,
strategicAccount: vendor.strategicAccount,
requiresApproval: vendor.requiresApproval,
blockedAccount: vendor.blockedAccount,
contacts: [],
rollups: mapRollups({
lastContactAt: null,
contactHistoryCount: 0,
contactCount: 0,
attachmentCount: 0,
}),
};
}
export async function updateVendor(vendorId: string, payload: CrmRecordInput, actorId?: string | null) {
const existingVendor = await prisma.vendor.findUnique({
where: { id: vendorId },
});
if (!existingVendor) {
return null;
}
const vendor = await prisma.vendor.update({
where: { id: vendorId },
data: {
name: payload.name,
email: payload.email,
phone: payload.phone,
addressLine1: payload.addressLine1,
addressLine2: payload.addressLine2,
city: payload.city,
state: payload.state,
postalCode: payload.postalCode,
country: payload.country,
status: payload.status,
lifecycleStage: payload.lifecycleStage ?? "ACTIVE",
notes: payload.notes,
paymentTerms: payload.paymentTerms ?? null,
currencyCode: payload.currencyCode ?? "USD",
taxExempt: payload.taxExempt ?? false,
creditHold: payload.creditHold ?? false,
preferredAccount: payload.preferredAccount ?? false,
strategicAccount: payload.strategicAccount ?? false,
requiresApproval: payload.requiresApproval ?? false,
blockedAccount: payload.blockedAccount ?? false,
},
});
await logAuditEvent({
actorId,
entityType: "crm-vendor",
entityId: vendor.id,
action: "updated",
summary: `Updated vendor ${vendor.name}.`,
metadata: {
name: vendor.name,
status: vendor.status,
lifecycleStage: vendor.lifecycleStage,
},
});
return {
...mapDetail(vendor),
paymentTerms: vendor.paymentTerms,
currencyCode: vendor.currencyCode,
taxExempt: vendor.taxExempt,
creditHold: vendor.creditHold,
lifecycleStage: vendor.lifecycleStage as CrmLifecycleStage,
preferredAccount: vendor.preferredAccount,
strategicAccount: vendor.strategicAccount,
requiresApproval: vendor.requiresApproval,
blockedAccount: vendor.blockedAccount,
contacts: [],
rollups: mapRollups({
lastContactAt: null,
contactHistoryCount: 0,
contactCount: 0,
attachmentCount: 0,
}),
};
}
export async function createCustomerContactEntry(customerId: string, payload: CrmContactEntryInput, createdById?: string) {
const existingCustomer = await prisma.customer.findUnique({
where: { id: customerId },
});
if (!existingCustomer) {
return null;
}
const entry = await prisma.crmContactEntry.create({
data: {
type: payload.type,
summary: payload.summary,
body: payload.body,
contactAt: new Date(payload.contactAt),
customerId,
createdById,
},
include: {
createdBy: true,
},
});
await logAuditEvent({
actorId: createdById,
entityType: "crm-customer",
entityId: customerId,
action: "contact-entry.created",
summary: `Added ${payload.type.toLowerCase()} contact history for customer ${existingCustomer.name}.`,
metadata: {
type: payload.type,
summary: payload.summary,
contactAt: payload.contactAt,
},
});
return mapContactEntry(entry);
}
export async function createVendorContactEntry(vendorId: string, payload: CrmContactEntryInput, createdById?: string) {
const existingVendor = await prisma.vendor.findUnique({
where: { id: vendorId },
});
if (!existingVendor) {
return null;
}
const entry = await prisma.crmContactEntry.create({
data: {
type: payload.type,
summary: payload.summary,
body: payload.body,
contactAt: new Date(payload.contactAt),
vendorId,
createdById,
},
include: {
createdBy: true,
},
});
await logAuditEvent({
actorId: createdById,
entityType: "crm-vendor",
entityId: vendorId,
action: "contact-entry.created",
summary: `Added ${payload.type.toLowerCase()} contact history for vendor ${existingVendor.name}.`,
metadata: {
type: payload.type,
summary: payload.summary,
contactAt: payload.contactAt,
},
});
return mapContactEntry(entry);
}
export async function createCustomerContact(customerId: string, payload: CrmContactInput, actorId?: string | null) {
const existingCustomer = await prisma.customer.findUnique({
where: { id: customerId },
});
if (!existingCustomer) {
return null;
}
if (payload.isPrimary) {
await prisma.crmContact.updateMany({
where: { customerId, isPrimary: true },
data: { isPrimary: false },
});
}
const contact = await prisma.crmContact.create({
data: {
fullName: payload.fullName,
role: payload.role,
email: payload.email,
phone: payload.phone,
isPrimary: payload.isPrimary,
customerId,
},
});
await logAuditEvent({
actorId,
entityType: "crm-customer",
entityId: customerId,
action: "contact.created",
summary: `Added contact ${contact.fullName} to customer ${existingCustomer.name}.`,
metadata: {
fullName: contact.fullName,
role: contact.role,
email: contact.email,
isPrimary: contact.isPrimary,
},
});
return mapCrmContact(contact);
}
export async function createVendorContact(vendorId: string, payload: CrmContactInput, actorId?: string | null) {
const existingVendor = await prisma.vendor.findUnique({
where: { id: vendorId },
});
if (!existingVendor) {
return null;
}
if (payload.isPrimary) {
await prisma.crmContact.updateMany({
where: { vendorId, isPrimary: true },
data: { isPrimary: false },
});
}
const contact = await prisma.crmContact.create({
data: {
fullName: payload.fullName,
role: payload.role,
email: payload.email,
phone: payload.phone,
isPrimary: payload.isPrimary,
vendorId,
},
});
await logAuditEvent({
actorId,
entityType: "crm-vendor",
entityId: vendorId,
action: "contact.created",
summary: `Added contact ${contact.fullName} to vendor ${existingVendor.name}.`,
metadata: {
fullName: contact.fullName,
role: contact.role,
email: contact.email,
isPrimary: contact.isPrimary,
},
});
return mapCrmContact(contact);
}

View File

@@ -0,0 +1,718 @@
import { permissions } from "@mrp/shared";
import { Router } from "express";
import { renderPdf } from "../../lib/pdf.js";
import { requirePermissions } from "../../lib/rbac.js";
import { getPurchaseOrderPdfData } from "../purchasing/service.js";
import { getSalesDocumentPdfData } from "../sales/service.js";
import { getShipmentDocumentData, getShipmentPackingSlipData } from "../shipping/service.js";
import { getActiveCompanyProfile } from "../settings/service.js";
export const documentsRouter = Router();
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function formatDate(value: string | null | undefined) {
return value ? new Date(value).toLocaleDateString() : "N/A";
}
function buildAddressLines(record: {
name: string;
addressLine1: string;
addressLine2: string;
city: string;
state: string;
postalCode: string;
country: string;
}) {
return [
record.name,
record.addressLine1,
record.addressLine2,
`${record.city}, ${record.state} ${record.postalCode}`.trim(),
record.country,
].filter((line) => line.trim().length > 0);
}
function renderCommercialDocumentPdf(options: {
company: Awaited<ReturnType<typeof getActiveCompanyProfile>>;
title: string;
documentNumber: string;
issueDate: string;
status: string;
partyTitle: string;
partyLines: string[];
partyMeta: Array<{ label: string; value: string }>;
documentMeta: Array<{ label: string; value: string }>;
rows: string;
totalsRows: string;
notes: string;
}) {
const { company } = options;
return renderPdf(`
<html>
<head>
<style>
@page { margin: 16mm; }
body { font-family: ${company.theme.fontFamily}, Arial, sans-serif; color: #1b1f29; font-size: 12px; }
.page { display: flex; flex-direction: column; gap: 16px; }
.header { display: flex; justify-content: space-between; gap: 24px; border-bottom: 2px solid ${company.theme.primaryColor}; padding-bottom: 16px; }
.brand h1 { margin: 0; font-size: 24px; color: ${company.theme.primaryColor}; }
.brand p { margin: 6px 0 0; color: #5a6a85; line-height: 1.45; }
.document-meta { min-width: 280px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px 18px; }
.label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; }
.value { margin-top: 4px; font-size: 13px; font-weight: 600; }
.grid { display: grid; grid-template-columns: 1.05fr 0.95fr; gap: 16px; }
.card { border: 1px solid #d7deeb; border-radius: 14px; padding: 14px 16px; }
.card-title { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; margin-bottom: 8px; }
.stack { display: flex; flex-direction: column; gap: 4px; }
table { width: 100%; border-collapse: collapse; }
thead th { text-align: left; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; background: #f4f7fb; padding: 10px 12px; border-bottom: 1px solid #d7deeb; }
tbody td { padding: 12px; border-bottom: 1px solid #e6ebf3; vertical-align: top; }
.number { text-align: right; white-space: nowrap; }
.item-name { font-weight: 600; }
.item-desc { margin-top: 4px; color: #5a6a85; font-size: 11px; }
.summary { margin-left: auto; width: 320px; border: 1px solid #d7deeb; border-radius: 14px; overflow: hidden; }
.summary table tbody td { padding: 10px 12px; }
.summary table tbody tr:last-child td { font-size: 14px; font-weight: 700; background: #f4f7fb; }
.notes { border: 1px solid #d7deeb; border-radius: 14px; padding: 14px 16px; min-height: 72px; white-space: pre-line; }
</style>
</head>
<body>
<div class="page">
<div class="header">
<div class="brand">
<h1>${escapeHtml(company.companyName)}</h1>
<p>${escapeHtml(company.addressLine1)}${company.addressLine2 ? `<br/>${escapeHtml(company.addressLine2)}` : ""}<br/>${escapeHtml(company.city)}, ${escapeHtml(company.state)} ${escapeHtml(company.postalCode)}<br/>${escapeHtml(company.country)}</p>
</div>
<div class="document-meta">
<div><div class="label">Document</div><div class="value">${escapeHtml(options.title)}</div></div>
<div><div class="label">Number</div><div class="value">${escapeHtml(options.documentNumber)}</div></div>
<div><div class="label">Issue Date</div><div class="value">${escapeHtml(formatDate(options.issueDate))}</div></div>
<div><div class="label">Status</div><div class="value">${escapeHtml(options.status)}</div></div>
${options.documentMeta.map((entry) => `<div><div class="label">${escapeHtml(entry.label)}</div><div class="value">${escapeHtml(entry.value)}</div></div>`).join("")}
</div>
</div>
<div class="grid">
<div class="card">
<div class="card-title">${escapeHtml(options.partyTitle)}</div>
<div class="stack">${options.partyLines.map((line) => `<div>${escapeHtml(line)}</div>`).join("")}</div>
</div>
<div class="card">
<div class="card-title">Contact</div>
<div class="stack">${options.partyMeta.map((entry) => `<div><strong>${escapeHtml(entry.label)}:</strong> ${escapeHtml(entry.value)}</div>`).join("")}</div>
</div>
</div>
<table>
<thead>
<tr>
<th style="width: 16%;">SKU</th>
<th>Description</th>
<th style="width: 9%;" class="number">Qty</th>
<th style="width: 9%;" class="number">UOM</th>
<th style="width: 13%;" class="number">Unit</th>
<th style="width: 14%;" class="number">Line Total</th>
</tr>
</thead>
<tbody>${options.rows}</tbody>
</table>
<div class="summary">
<table>
<tbody>${options.totalsRows}</tbody>
</table>
</div>
<div class="notes"><div class="card-title">Notes</div>${escapeHtml(options.notes || "No notes recorded for this document.")}</div>
</div>
</body>
</html>
`);
}
function buildShippingLabelPdf(options: {
company: Awaited<ReturnType<typeof getActiveCompanyProfile>>;
shipment: Awaited<ReturnType<typeof getShipmentDocumentData>>;
}) {
const { company, shipment } = options;
if (!shipment) {
throw new Error("Shipment data is required.");
}
const shipToLines = buildAddressLines(shipment.customer);
const topLine = shipment.lines[0];
return renderPdf(`
<html>
<head>
<style>
@page { size: 4in 6in; margin: 8mm; }
body { font-family: ${company.theme.fontFamily}, Arial, sans-serif; color: #111827; font-size: 11px; }
.label { border: 2px solid #111827; border-radius: 12px; padding: 12px; display: flex; flex-direction: column; gap: 12px; min-height: calc(6in - 16mm); box-sizing: border-box; }
.row { display: flex; justify-content: space-between; gap: 12px; }
.muted { font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; color: #4b5563; }
.brand { border-bottom: 2px solid ${company.theme.primaryColor}; padding-bottom: 10px; }
.brand h1 { margin: 0; font-size: 18px; color: ${company.theme.primaryColor}; }
.block { border: 1px solid #d1d5db; border-radius: 10px; padding: 10px; }
.stack { display: flex; flex-direction: column; gap: 4px; }
.barcode { border: 2px solid #111827; border-radius: 10px; padding: 8px; text-align: center; font-family: monospace; font-size: 18px; letter-spacing: 0.18em; }
.strong { font-weight: 700; }
.big { font-size: 16px; font-weight: 700; }
</style>
</head>
<body>
<div class="label">
<div class="brand">
<div class="row">
<div>
<div class="muted">From</div>
<h1>${escapeHtml(company.companyName)}</h1>
</div>
<div style="text-align:right;">
<div class="muted">Shipment</div>
<div class="big">${escapeHtml(shipment.shipmentNumber)}</div>
</div>
</div>
</div>
<div class="block">
<div class="muted">Ship To</div>
<div class="stack" style="margin-top:8px;">
${shipToLines.map((line) => `<div class="${line === shipment.customer.name ? "strong" : ""}">${escapeHtml(line)}</div>`).join("")}
</div>
</div>
<div class="row">
<div class="block" style="flex:1;">
<div class="muted">Service</div>
<div class="big" style="margin-top:6px;">${escapeHtml(shipment.serviceLevel || "GROUND")}</div>
</div>
<div class="block" style="width:90px;">
<div class="muted">Pkgs</div>
<div class="big" style="margin-top:6px;">${shipment.packageCount}</div>
</div>
</div>
<div class="row">
<div class="block" style="flex:1;">
<div class="muted">Sales Order</div>
<div class="strong" style="margin-top:6px;">${escapeHtml(shipment.salesOrderNumber)}</div>
</div>
<div class="block" style="width:110px;">
<div class="muted">Ship Date</div>
<div class="strong" style="margin-top:6px;">${escapeHtml(formatDate(shipment.shipDate))}</div>
</div>
</div>
<div class="block">
<div class="muted">Reference</div>
<div style="margin-top:6px;">${escapeHtml(topLine ? `${topLine.itemSku} · ${topLine.itemName}` : "Shipment record")}</div>
</div>
<div class="barcode">
*${escapeHtml(shipment.trackingNumber || shipment.shipmentNumber)}*
</div>
<div style="text-align:center; font-size:10px; color:#4b5563;">${escapeHtml(shipment.carrier || "Carrier pending")} · ${escapeHtml(shipment.trackingNumber || "Tracking pending")}</div>
</div>
</body>
</html>
`);
}
function buildBillOfLadingPdf(options: {
company: Awaited<ReturnType<typeof getActiveCompanyProfile>>;
shipment: Awaited<ReturnType<typeof getShipmentDocumentData>>;
}) {
const { company, shipment } = options;
if (!shipment) {
throw new Error("Shipment data is required.");
}
const shipperLines = [
company.companyName,
company.addressLine1,
company.addressLine2,
`${company.city}, ${company.state} ${company.postalCode}`.trim(),
company.country,
company.phone,
company.email,
].filter((line) => line.trim().length > 0);
const consigneeLines = [
shipment.customer.name,
shipment.customer.addressLine1,
shipment.customer.addressLine2,
`${shipment.customer.city}, ${shipment.customer.state} ${shipment.customer.postalCode}`.trim(),
shipment.customer.country,
shipment.customerPhone,
shipment.customerEmail,
].filter((line) => line.trim().length > 0);
const totalQuantity = shipment.lines.reduce((sum, line) => sum + line.quantity, 0);
const rows = shipment.lines.map((line) => `
<tr>
<td>${escapeHtml(line.itemSku)}</td>
<td><div class="item-name">${escapeHtml(line.itemName)}</div><div class="item-desc">${escapeHtml(line.description || "")}</div></td>
<td class="number">${line.quantity}</td>
<td class="number">${escapeHtml(line.unitOfMeasure)}</td>
</tr>
`).join("");
return renderPdf(`
<html>
<head>
<style>
@page { margin: 16mm; }
body { font-family: ${company.theme.fontFamily}, Arial, sans-serif; color: #1b1f29; font-size: 12px; }
.page { display: flex; flex-direction: column; gap: 16px; }
.header { display: flex; justify-content: space-between; gap: 24px; border-bottom: 2px solid ${company.theme.primaryColor}; padding-bottom: 16px; }
.brand h1 { margin: 0; font-size: 24px; color: ${company.theme.primaryColor}; }
.brand p { margin: 6px 0 0; color: #5a6a85; line-height: 1.45; }
.meta { min-width: 320px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px 18px; }
.label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; }
.value { margin-top: 4px; font-size: 13px; font-weight: 600; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.card { border: 1px solid #d7deeb; border-radius: 14px; padding: 14px 16px; }
.card-title { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; margin-bottom: 8px; }
.stack { display: flex; flex-direction: column; gap: 4px; }
table { width: 100%; border-collapse: collapse; }
thead th { text-align: left; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; background: #f4f7fb; padding: 10px 12px; border-bottom: 1px solid #d7deeb; }
tbody td { padding: 12px; border-bottom: 1px solid #e6ebf3; vertical-align: top; }
.number { text-align: right; white-space: nowrap; }
.item-name { font-weight: 600; }
.item-desc { margin-top: 4px; color: #5a6a85; font-size: 11px; }
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.summary-card { border: 1px solid #d7deeb; border-radius: 14px; padding: 12px 14px; }
.notes { border: 1px solid #d7deeb; border-radius: 14px; padding: 14px 16px; min-height: 72px; white-space: pre-line; }
</style>
</head>
<body>
<div class="page">
<div class="header">
<div class="brand">
<h1>${escapeHtml(company.companyName)}</h1>
<p>Bill of Lading</p>
</div>
<div class="meta">
<div><div class="label">Shipment</div><div class="value">${escapeHtml(shipment.shipmentNumber)}</div></div>
<div><div class="label">Sales Order</div><div class="value">${escapeHtml(shipment.salesOrderNumber)}</div></div>
<div><div class="label">Ship Date</div><div class="value">${escapeHtml(formatDate(shipment.shipDate))}</div></div>
<div><div class="label">Status</div><div class="value">${escapeHtml(shipment.status)}</div></div>
<div><div class="label">Carrier</div><div class="value">${escapeHtml(shipment.carrier || "Not set")}</div></div>
<div><div class="label">Tracking</div><div class="value">${escapeHtml(shipment.trackingNumber || "Not set")}</div></div>
</div>
</div>
<div class="grid">
<div class="card">
<div class="card-title">Shipper</div>
<div class="stack">${shipperLines.map((line) => `<div>${escapeHtml(line)}</div>`).join("")}</div>
</div>
<div class="card">
<div class="card-title">Consignee</div>
<div class="stack">${consigneeLines.map((line) => `<div>${escapeHtml(line)}</div>`).join("")}</div>
</div>
</div>
<div class="summary">
<div class="summary-card"><div class="label">Packages</div><div class="value">${shipment.packageCount}</div></div>
<div class="summary-card"><div class="label">Line Count</div><div class="value">${shipment.lines.length}</div></div>
<div class="summary-card"><div class="label">Total Qty</div><div class="value">${totalQuantity}</div></div>
<div class="summary-card"><div class="label">Service</div><div class="value">${escapeHtml(shipment.serviceLevel || "Not set")}</div></div>
</div>
<table>
<thead>
<tr>
<th style="width: 18%;">SKU</th>
<th>Description</th>
<th style="width: 12%;" class="number">Qty</th>
<th style="width: 10%;" class="number">UOM</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
<div class="notes"><div class="card-title">Logistics Notes</div>${escapeHtml(shipment.notes || "No shipment notes recorded.")}</div>
</div>
</body>
</html>
`);
}
documentsRouter.get("/company-profile-preview.pdf", requirePermissions([permissions.companyRead]), async (_request, response) => {
const profile = await getActiveCompanyProfile();
const pdf = await renderPdf(`
<html>
<head>
<style>
body { font-family: ${profile.theme.fontFamily}, Arial, sans-serif; color: #1b1f29; padding: 32px; }
.card { border: 1px solid #d7deeb; border-radius: 18px; overflow: hidden; }
.header { background: ${profile.theme.primaryColor}; color: white; padding: 24px 28px; }
.body { padding: 28px; background: #ffffff; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.label { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; }
.value { font-size: 16px; margin-top: 6px; }
</style>
</head>
<body>
<div class="card">
<div class="header">
<h1>${profile.companyName}</h1>
<p>Brand profile preview generated through Puppeteer</p>
</div>
<div class="body">
<div class="grid">
<div><div class="label">Legal name</div><div class="value">${profile.legalName}</div></div>
<div><div class="label">Tax ID</div><div class="value">${profile.taxId}</div></div>
<div><div class="label">Contact</div><div class="value">${profile.email}<br/>${profile.phone}</div></div>
<div><div class="label">Website</div><div class="value">${profile.website}</div></div>
<div><div class="label">Address</div><div class="value">${profile.addressLine1}<br/>${profile.addressLine2}<br/>${profile.city}, ${profile.state} ${profile.postalCode}<br/>${profile.country}</div></div>
<div><div class="label">Theme</div><div class="value">Primary ${profile.theme.primaryColor}<br/>Accent ${profile.theme.accentColor}<br/>Surface ${profile.theme.surfaceColor}</div></div>
</div>
</div>
</div>
</body>
</html>
`);
response.setHeader("Content-Type", "application/pdf");
response.setHeader("Content-Disposition", "inline; filename=company-profile-preview.pdf");
return response.send(pdf);
});
documentsRouter.get(
"/sales/quotes/:quoteId/document.pdf",
requirePermissions([permissions.salesRead]),
async (request, response) => {
const quoteId = typeof request.params.quoteId === "string" ? request.params.quoteId : null;
if (!quoteId) {
response.status(400);
return response.send("Invalid quote id.");
}
const [profile, quote] = await Promise.all([getActiveCompanyProfile(), getSalesDocumentPdfData("QUOTE", quoteId)]);
if (!quote) {
response.status(404);
return response.send("Quote was not found.");
}
const rows = quote.lines.map((line) => `
<tr>
<td>${escapeHtml(line.itemSku)}</td>
<td><div class="item-name">${escapeHtml(line.itemName)}</div><div class="item-desc">${escapeHtml(line.description || "")}</div></td>
<td class="number">${line.quantity}</td>
<td class="number">${escapeHtml(line.unitOfMeasure)}</td>
<td class="number">$${line.unitPrice.toFixed(2)}</td>
<td class="number">$${line.lineTotal.toFixed(2)}</td>
</tr>
`).join("");
const pdf = await renderCommercialDocumentPdf({
company: profile,
title: "Sales Quote",
documentNumber: quote.documentNumber,
issueDate: quote.issueDate,
status: quote.status,
partyTitle: "Bill To",
partyLines: buildAddressLines(quote.customer),
partyMeta: [
{ label: "Email", value: quote.customer.email || "Not set" },
{ label: "Phone", value: quote.customer.phone || "Not set" },
],
documentMeta: [
{ label: "Expires", value: formatDate(quote.expiresAt) },
],
rows,
totalsRows: `
<tr><td>Subtotal</td><td class="number">$${quote.subtotal.toFixed(2)}</td></tr>
<tr><td>Discount (${quote.discountPercent.toFixed(2)}%)</td><td class="number">-$${quote.discountAmount.toFixed(2)}</td></tr>
<tr><td>Tax (${quote.taxPercent.toFixed(2)}%)</td><td class="number">$${quote.taxAmount.toFixed(2)}</td></tr>
<tr><td>Freight</td><td class="number">$${quote.freightAmount.toFixed(2)}</td></tr>
<tr><td>Total</td><td class="number">$${quote.total.toFixed(2)}</td></tr>
`,
notes: quote.notes,
});
response.setHeader("Content-Type", "application/pdf");
response.setHeader("Content-Disposition", `inline; filename=${quote.documentNumber.toLowerCase()}-quote.pdf`);
return response.send(pdf);
}
);
documentsRouter.get(
"/sales/orders/:orderId/document.pdf",
requirePermissions([permissions.salesRead]),
async (request, response) => {
const orderId = typeof request.params.orderId === "string" ? request.params.orderId : null;
if (!orderId) {
response.status(400);
return response.send("Invalid sales order id.");
}
const [profile, order] = await Promise.all([getActiveCompanyProfile(), getSalesDocumentPdfData("ORDER", orderId)]);
if (!order) {
response.status(404);
return response.send("Sales order was not found.");
}
const rows = order.lines.map((line) => `
<tr>
<td>${escapeHtml(line.itemSku)}</td>
<td><div class="item-name">${escapeHtml(line.itemName)}</div><div class="item-desc">${escapeHtml(line.description || "")}</div></td>
<td class="number">${line.quantity}</td>
<td class="number">${escapeHtml(line.unitOfMeasure)}</td>
<td class="number">$${line.unitPrice.toFixed(2)}</td>
<td class="number">$${line.lineTotal.toFixed(2)}</td>
</tr>
`).join("");
const pdf = await renderCommercialDocumentPdf({
company: profile,
title: "Sales Order",
documentNumber: order.documentNumber,
issueDate: order.issueDate,
status: order.status,
partyTitle: "Bill To",
partyLines: buildAddressLines(order.customer),
partyMeta: [
{ label: "Email", value: order.customer.email || "Not set" },
{ label: "Phone", value: order.customer.phone || "Not set" },
],
documentMeta: [],
rows,
totalsRows: `
<tr><td>Subtotal</td><td class="number">$${order.subtotal.toFixed(2)}</td></tr>
<tr><td>Discount (${order.discountPercent.toFixed(2)}%)</td><td class="number">-$${order.discountAmount.toFixed(2)}</td></tr>
<tr><td>Tax (${order.taxPercent.toFixed(2)}%)</td><td class="number">$${order.taxAmount.toFixed(2)}</td></tr>
<tr><td>Freight</td><td class="number">$${order.freightAmount.toFixed(2)}</td></tr>
<tr><td>Total</td><td class="number">$${order.total.toFixed(2)}</td></tr>
`,
notes: order.notes,
});
response.setHeader("Content-Type", "application/pdf");
response.setHeader("Content-Disposition", `inline; filename=${order.documentNumber.toLowerCase()}-sales-order.pdf`);
return response.send(pdf);
}
);
documentsRouter.get(
"/purchasing/orders/:orderId/document.pdf",
requirePermissions([permissions.purchasingRead]),
async (request, response) => {
const orderId = typeof request.params.orderId === "string" ? request.params.orderId : null;
if (!orderId) {
response.status(400);
return response.send("Invalid purchase order id.");
}
const [profile, order] = await Promise.all([getActiveCompanyProfile(), getPurchaseOrderPdfData(orderId)]);
if (!order) {
response.status(404);
return response.send("Purchase order was not found.");
}
const rows = order.lines.map((line) => `
<tr>
<td>${escapeHtml(line.itemSku)}</td>
<td><div class="item-name">${escapeHtml(line.itemName)}</div><div class="item-desc">${escapeHtml(line.description || "")}</div></td>
<td class="number">${line.quantity}</td>
<td class="number">${escapeHtml(line.unitOfMeasure)}</td>
<td class="number">$${line.unitCost.toFixed(2)}</td>
<td class="number">$${line.lineTotal.toFixed(2)}</td>
</tr>
`).join("");
const pdf = await renderCommercialDocumentPdf({
company: profile,
title: "Purchase Order",
documentNumber: order.documentNumber,
issueDate: order.issueDate,
status: order.status,
partyTitle: "Vendor",
partyLines: buildAddressLines(order.vendor),
partyMeta: [
{ label: "Email", value: order.vendor.email || "Not set" },
{ label: "Phone", value: order.vendor.phone || "Not set" },
{ label: "Terms", value: order.vendor.paymentTerms || "Not set" },
{ label: "Currency", value: order.vendor.currencyCode || "USD" },
],
documentMeta: [],
rows,
totalsRows: `
<tr><td>Subtotal</td><td class="number">$${order.subtotal.toFixed(2)}</td></tr>
<tr><td>Tax (${order.taxPercent.toFixed(2)}%)</td><td class="number">$${order.taxAmount.toFixed(2)}</td></tr>
<tr><td>Freight</td><td class="number">$${order.freightAmount.toFixed(2)}</td></tr>
<tr><td>Total</td><td class="number">$${order.total.toFixed(2)}</td></tr>
`,
notes: order.notes,
});
response.setHeader("Content-Type", "application/pdf");
response.setHeader("Content-Disposition", `inline; filename=${order.documentNumber.toLowerCase()}-purchase-order.pdf`);
return response.send(pdf);
}
);
documentsRouter.get(
"/shipping/shipments/:shipmentId/packing-slip.pdf",
requirePermissions([permissions.shippingRead]),
async (request, response) => {
const shipmentId = typeof request.params.shipmentId === "string" ? request.params.shipmentId : null;
if (!shipmentId) {
response.status(400);
return response.send("Invalid shipment id.");
}
const [profile, shipment] = await Promise.all([getActiveCompanyProfile(), getShipmentPackingSlipData(shipmentId)]);
if (!shipment) {
response.status(404);
return response.send("Shipment was not found.");
}
const shipToLines = [
shipment.customer.name,
shipment.customer.addressLine1,
shipment.customer.addressLine2,
`${shipment.customer.city}, ${shipment.customer.state} ${shipment.customer.postalCode}`.trim(),
shipment.customer.country,
].filter((line) => line.trim().length > 0);
const rows = shipment.lines
.map(
(line) => `
<tr>
<td>${escapeHtml(line.itemSku)}</td>
<td>
<div class="item-name">${escapeHtml(line.itemName)}</div>
<div class="item-desc">${escapeHtml(line.description || "")}</div>
</td>
<td class="qty">${line.quantity}</td>
<td class="qty">${escapeHtml(line.unitOfMeasure)}</td>
</tr>
`
)
.join("");
const pdf = await renderPdf(`
<html>
<head>
<style>
@page { margin: 18mm; }
body { font-family: ${profile.theme.fontFamily}, Arial, sans-serif; color: #1b1f29; font-size: 12px; }
.page { display: flex; flex-direction: column; gap: 18px; }
.header { display: flex; justify-content: space-between; gap: 24px; border-bottom: 2px solid ${profile.theme.primaryColor}; padding-bottom: 16px; }
.brand h1 { margin: 0; font-size: 24px; color: ${profile.theme.primaryColor}; }
.brand p { margin: 6px 0 0; color: #5a6a85; }
.meta { min-width: 280px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px 18px; }
.label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; }
.value { margin-top: 4px; font-size: 13px; font-weight: 600; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.card { border: 1px solid #d7deeb; border-radius: 14px; padding: 14px 16px; }
.card-title { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; margin-bottom: 8px; }
.stack { display: flex; flex-direction: column; gap: 4px; }
table { width: 100%; border-collapse: collapse; }
thead th { text-align: left; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; background: #f4f7fb; padding: 10px 12px; border-bottom: 1px solid #d7deeb; }
tbody td { padding: 12px; border-bottom: 1px solid #e6ebf3; vertical-align: top; }
.qty { text-align: right; white-space: nowrap; }
.item-name { font-weight: 600; }
.item-desc { margin-top: 4px; color: #5a6a85; font-size: 11px; }
.footer-note { border: 1px solid #d7deeb; border-radius: 14px; padding: 14px 16px; min-height: 72px; white-space: pre-line; }
</style>
</head>
<body>
<div class="page">
<div class="header">
<div class="brand">
<h1>${escapeHtml(profile.companyName)}</h1>
<p>${escapeHtml(profile.addressLine1)}${profile.addressLine2 ? `<br/>${escapeHtml(profile.addressLine2)}` : ""}<br/>${escapeHtml(profile.city)}, ${escapeHtml(profile.state)} ${escapeHtml(profile.postalCode)}<br/>${escapeHtml(profile.country)}</p>
</div>
<div class="meta">
<div><div class="label">Document</div><div class="value">Packing Slip</div></div>
<div><div class="label">Shipment</div><div class="value">${escapeHtml(shipment.shipmentNumber)}</div></div>
<div><div class="label">Sales Order</div><div class="value">${escapeHtml(shipment.salesOrderNumber)}</div></div>
<div><div class="label">Ship Date</div><div class="value">${shipment.shipDate ? escapeHtml(new Date(shipment.shipDate).toLocaleDateString()) : "Pending"}</div></div>
<div><div class="label">Carrier</div><div class="value">${escapeHtml(shipment.carrier || "Not set")}</div></div>
<div><div class="label">Tracking</div><div class="value">${escapeHtml(shipment.trackingNumber || "Not set")}</div></div>
</div>
</div>
<div class="grid">
<div class="card">
<div class="card-title">Ship To</div>
<div class="stack">${shipToLines.map((line) => `<div>${escapeHtml(line)}</div>`).join("")}</div>
</div>
<div class="card">
<div class="card-title">Shipment Info</div>
<div class="stack">
<div><strong>Status:</strong> ${escapeHtml(shipment.status)}</div>
<div><strong>Service:</strong> ${escapeHtml(shipment.serviceLevel || "Not set")}</div>
<div><strong>Packages:</strong> ${shipment.packageCount}</div>
</div>
</div>
</div>
<table>
<thead>
<tr>
<th style="width: 20%;">SKU</th>
<th>Description</th>
<th style="width: 12%;" class="qty">Qty</th>
<th style="width: 10%;" class="qty">UOM</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
<div class="footer-note"><div class="card-title">Notes</div>${escapeHtml(shipment.notes || "No shipment notes recorded.")}</div>
</div>
</body>
</html>
`);
response.setHeader("Content-Type", "application/pdf");
response.setHeader("Content-Disposition", `inline; filename=${shipment.shipmentNumber.toLowerCase()}-packing-slip.pdf`);
return response.send(pdf);
}
);
documentsRouter.get(
"/shipping/shipments/:shipmentId/shipping-label.pdf",
requirePermissions([permissions.shippingRead]),
async (request, response) => {
const shipmentId = typeof request.params.shipmentId === "string" ? request.params.shipmentId : null;
if (!shipmentId) {
response.status(400);
return response.send("Invalid shipment id.");
}
const [profile, shipment] = await Promise.all([getActiveCompanyProfile(), getShipmentDocumentData(shipmentId)]);
if (!shipment) {
response.status(404);
return response.send("Shipment was not found.");
}
const pdf = await buildShippingLabelPdf({ company: profile, shipment });
response.setHeader("Content-Type", "application/pdf");
response.setHeader("Content-Disposition", `inline; filename=${shipment.shipmentNumber.toLowerCase()}-label.pdf`);
return response.send(pdf);
}
);
documentsRouter.get(
"/shipping/shipments/:shipmentId/bill-of-lading.pdf",
requirePermissions([permissions.shippingRead]),
async (request, response) => {
const shipmentId = typeof request.params.shipmentId === "string" ? request.params.shipmentId : null;
if (!shipmentId) {
response.status(400);
return response.send("Invalid shipment id.");
}
const [profile, shipment] = await Promise.all([getActiveCompanyProfile(), getShipmentDocumentData(shipmentId)]);
if (!shipment) {
response.status(404);
return response.send("Shipment was not found.");
}
const pdf = await buildBillOfLadingPdf({ company: profile, shipment });
response.setHeader("Content-Type", "application/pdf");
response.setHeader("Content-Disposition", `inline; filename=${shipment.shipmentNumber.toLowerCase()}-bill-of-lading.pdf`);
return response.send(pdf);
}
);

View File

@@ -0,0 +1,77 @@
import { permissions } from "@mrp/shared";
import { Router } from "express";
import multer from "multer";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import { createAttachment, deleteAttachment, getAttachmentContent, getAttachmentMetadata, listAttachmentsByOwner } from "./service.js";
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024,
},
});
const uploadSchema = z.object({
ownerType: z.string().min(1),
ownerId: z.string().min(1),
});
const listSchema = z.object({
ownerType: z.string().min(1),
ownerId: z.string().min(1),
});
export const filesRouter = Router();
filesRouter.get("/", requirePermissions([permissions.filesRead]), async (request, response) => {
const parsed = listSchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "ownerType and ownerId are required.");
}
return ok(response, await listAttachmentsByOwner(parsed.data.ownerType, parsed.data.ownerId));
});
filesRouter.post(
"/upload",
requirePermissions([permissions.filesWrite]),
upload.single("file"),
async (request, response) => {
const parsed = uploadSchema.safeParse(request.body);
if (!parsed.success || !request.file) {
return fail(response, 400, "INVALID_UPLOAD", "A file, ownerType, and ownerId are required.");
}
return ok(
response,
await createAttachment({
buffer: request.file.buffer,
originalName: request.file.originalname,
mimeType: request.file.mimetype,
sizeBytes: request.file.size,
ownerType: parsed.data.ownerType,
ownerId: parsed.data.ownerId,
createdById: request.authUser?.id,
}),
201
);
}
);
filesRouter.get("/:id", requirePermissions([permissions.filesRead]), async (request, response) => {
return ok(response, await getAttachmentMetadata(String(request.params.id)));
});
filesRouter.get("/:id/content", requirePermissions([permissions.filesRead]), async (request, response) => {
const { file, content } = await getAttachmentContent(String(request.params.id));
response.setHeader("Content-Type", file.mimeType);
response.setHeader("Content-Disposition", `inline; filename="${file.originalName}"`);
return response.send(content);
});
filesRouter.delete("/:id", requirePermissions([permissions.filesWrite]), async (request, response) => {
return ok(response, await deleteAttachment(String(request.params.id)));
});

View File

@@ -0,0 +1,100 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { FileAttachmentDto } from "@mrp/shared";
import { paths } from "../../config/paths.js";
import { prisma } from "../../lib/prisma.js";
import { writeUpload } from "../../lib/storage.js";
type FileRecord = Awaited<ReturnType<typeof prisma.fileAttachment.create>>;
function mapFile(file: FileRecord): FileAttachmentDto {
return {
id: file.id,
originalName: file.originalName,
mimeType: file.mimeType,
sizeBytes: file.sizeBytes,
relativePath: file.relativePath,
ownerType: file.ownerType,
ownerId: file.ownerId,
createdAt: file.createdAt.toISOString(),
};
}
export async function createAttachment(options: {
buffer: Buffer;
originalName: string;
mimeType: string;
sizeBytes: number;
ownerType: string;
ownerId: string;
createdById?: string;
}) {
const saved = await writeUpload(options.buffer, options.originalName);
const file = await prisma.fileAttachment.create({
data: {
originalName: options.originalName,
storedName: saved.storedName,
mimeType: options.mimeType,
sizeBytes: options.sizeBytes,
relativePath: saved.relativePath,
ownerType: options.ownerType,
ownerId: options.ownerId,
createdById: options.createdById,
},
});
return mapFile(file);
}
export async function getAttachmentMetadata(id: string) {
return mapFile(
await prisma.fileAttachment.findUniqueOrThrow({
where: { id },
})
);
}
export async function listAttachmentsByOwner(ownerType: string, ownerId: string) {
const files = await prisma.fileAttachment.findMany({
where: {
ownerType,
ownerId,
},
orderBy: [{ createdAt: "desc" }, { originalName: "asc" }],
});
return files.map(mapFile);
}
export async function getAttachmentContent(id: string) {
const file = await prisma.fileAttachment.findUniqueOrThrow({
where: { id },
});
return {
file,
content: await fs.readFile(path.join(paths.dataDir, file.relativePath)),
};
}
export async function deleteAttachment(id: string) {
const file = await prisma.fileAttachment.findUniqueOrThrow({
where: { id },
});
try {
await fs.unlink(path.join(paths.dataDir, file.relativePath));
} catch (error: unknown) {
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
throw error;
}
}
await prisma.fileAttachment.delete({
where: { id },
});
return mapFile(file);
}

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 { getPlanningTimeline } from "./service.js";
export const ganttRouter = Router();
ganttRouter.get("/timeline", requirePermissions([permissions.ganttRead]), async (_request, response) => {
return ok(response, await getPlanningTimeline());
});

View File

@@ -0,0 +1,460 @@
import type { GanttLinkDto, GanttTaskDto, PlanningTimelineDto } from "@mrp/shared";
import { prisma } from "../../lib/prisma.js";
const DAY_MS = 24 * 60 * 60 * 1000;
function clampProgress(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
}
function addDays(value: Date, days: number) {
return new Date(value.getTime() + days * DAY_MS);
}
function startOfDay(value: Date) {
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
}
function endOfDay(value: Date) {
return new Date(value.getFullYear(), value.getMonth(), value.getDate(), 23, 59, 59, 999);
}
function projectProgressFromStatus(status: string) {
switch (status) {
case "COMPLETE":
return 100;
case "AT_RISK":
return 45;
case "ACTIVE":
return 60;
case "ON_HOLD":
return 20;
default:
return 10;
}
}
function workOrderProgress(quantity: number, completedQuantity: number, status: string) {
if (status === "COMPLETE") {
return 100;
}
if (quantity <= 0) {
return 0;
}
return clampProgress((completedQuantity / quantity) * 100);
}
function buildOwnerLabel(ownerName: string | null, customerName: string | null) {
if (ownerName && customerName) {
return `${ownerName}${customerName}`;
}
return ownerName ?? customerName ?? null;
}
export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
const now = new Date();
const planningProjects = await prisma.project.findMany({
where: {
status: {
not: "COMPLETE",
},
},
include: {
customer: {
select: {
name: true,
},
},
owner: {
select: {
firstName: true,
lastName: true,
},
},
workOrders: {
where: {
status: {
notIn: ["COMPLETE", "CANCELLED"],
},
},
select: {
id: true,
workOrderNumber: true,
status: true,
quantity: true,
completedQuantity: true,
dueDate: true,
createdAt: true,
operations: {
select: {
id: true,
sequence: true,
plannedStart: true,
plannedEnd: true,
plannedMinutes: true,
station: {
select: {
code: true,
name: true,
},
},
},
orderBy: [{ sequence: "asc" }],
},
item: {
select: {
sku: true,
name: true,
},
},
},
orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }],
},
},
orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }],
});
const standaloneWorkOrders = await prisma.workOrder.findMany({
where: {
projectId: null,
status: {
notIn: ["COMPLETE", "CANCELLED"],
},
},
include: {
item: {
select: {
sku: true,
name: true,
},
},
operations: {
select: {
id: true,
sequence: true,
plannedStart: true,
plannedEnd: true,
plannedMinutes: true,
station: {
select: {
code: true,
name: true,
},
},
},
orderBy: [{ sequence: "asc" }],
},
},
orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }],
});
const tasks: GanttTaskDto[] = [];
const links: GanttLinkDto[] = [];
const exceptions: PlanningTimelineDto["exceptions"] = [];
for (const project of planningProjects) {
const ownerName = project.owner ? `${project.owner.firstName} ${project.owner.lastName}`.trim() : null;
const ownerLabel = buildOwnerLabel(ownerName, project.customer.name);
const dueDates = project.workOrders.map((workOrder) => workOrder.dueDate).filter((value): value is Date => Boolean(value));
const earliestWorkStart = project.workOrders[0]?.createdAt ?? project.createdAt;
const lastDueDate = dueDates.sort((left, right) => left.getTime() - right.getTime()).at(-1) ?? project.dueDate ?? addDays(project.createdAt, 14);
const start = startOfDay(earliestWorkStart);
const end = endOfDay(lastDueDate);
tasks.push({
id: `project-${project.id}`,
text: `${project.projectNumber} - ${project.name}`,
start: start.toISOString(),
end: end.toISOString(),
progress: clampProgress(
project.workOrders.length > 0
? project.workOrders.reduce((sum, workOrder) => sum + workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status), 0) / project.workOrders.length
: projectProgressFromStatus(project.status)
),
type: "project",
status: project.status,
ownerLabel,
detailHref: `/projects/${project.id}`,
});
if (project.dueDate) {
tasks.push({
id: `project-milestone-${project.id}`,
text: `${project.projectNumber} due`,
start: startOfDay(project.dueDate).toISOString(),
end: startOfDay(project.dueDate).toISOString(),
progress: project.status === "COMPLETE" ? 100 : 0,
type: "milestone",
parentId: `project-${project.id}`,
status: project.status,
ownerLabel,
detailHref: `/projects/${project.id}`,
});
links.push({
id: `project-link-${project.id}`,
source: `project-${project.id}`,
target: `project-milestone-${project.id}`,
type: "e2e",
});
}
let previousTaskId: string | null = null;
for (const workOrder of project.workOrders) {
const workOrderStart = startOfDay(workOrder.createdAt);
const workOrderEnd = endOfDay(workOrder.dueDate ?? addDays(workOrder.createdAt, 7));
const workOrderTaskId = `work-order-${workOrder.id}`;
tasks.push({
id: workOrderTaskId,
text: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
start: workOrderStart.toISOString(),
end: workOrderEnd.toISOString(),
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
type: "task",
parentId: `project-${project.id}`,
status: workOrder.status,
ownerLabel: workOrder.item.name,
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
});
if (previousTaskId) {
links.push({
id: `sequence-${previousTaskId}-${workOrderTaskId}`,
source: previousTaskId,
target: workOrderTaskId,
type: "e2e",
});
} else {
links.push({
id: `project-start-${project.id}-${workOrder.id}`,
source: `project-${project.id}`,
target: workOrderTaskId,
type: "e2e",
});
}
previousTaskId = workOrderTaskId;
let previousOperationTaskId: string | null = null;
for (const operation of workOrder.operations) {
const operationTaskId = `work-order-operation-${operation.id}`;
tasks.push({
id: operationTaskId,
text: `${operation.station.code} - ${operation.station.name}`,
start: operation.plannedStart.toISOString(),
end: operation.plannedEnd.toISOString(),
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
type: "task",
parentId: workOrderTaskId,
status: workOrder.status,
ownerLabel: workOrder.workOrderNumber,
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
});
links.push({
id: `work-order-operation-parent-${workOrder.id}-${operation.id}`,
source: workOrderTaskId,
target: operationTaskId,
type: "e2e",
});
if (previousOperationTaskId) {
links.push({
id: `work-order-operation-sequence-${previousOperationTaskId}-${operationTaskId}`,
source: previousOperationTaskId,
target: operationTaskId,
type: "e2e",
});
}
previousOperationTaskId = operationTaskId;
}
if (workOrder.dueDate && workOrder.dueDate.getTime() < now.getTime()) {
exceptions.push({
id: `work-order-${workOrder.id}`,
kind: "WORK_ORDER",
title: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
status: workOrder.status,
dueDate: workOrder.dueDate.toISOString(),
ownerLabel: project.projectNumber,
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
});
}
}
if (project.dueDate && project.dueDate.getTime() < now.getTime()) {
exceptions.push({
id: `project-${project.id}`,
kind: "PROJECT",
title: `${project.projectNumber} - ${project.name}`,
status: project.status,
dueDate: project.dueDate.toISOString(),
ownerLabel,
detailHref: `/projects/${project.id}`,
});
} else if (project.status === "AT_RISK") {
exceptions.push({
id: `project-${project.id}`,
kind: "PROJECT",
title: `${project.projectNumber} - ${project.name}`,
status: project.status,
dueDate: project.dueDate ? project.dueDate.toISOString() : null,
ownerLabel,
detailHref: `/projects/${project.id}`,
});
}
}
if (standaloneWorkOrders.length > 0) {
const firstStandaloneWorkOrder = standaloneWorkOrders[0]!;
const bucketStart = startOfDay(
standaloneWorkOrders.reduce((earliest, workOrder) => (workOrder.createdAt < earliest ? workOrder.createdAt : earliest), firstStandaloneWorkOrder.createdAt)
);
const bucketEnd = endOfDay(
standaloneWorkOrders.reduce(
(latest, workOrder) => {
const candidate = workOrder.dueDate ?? addDays(workOrder.createdAt, 7);
return candidate > latest ? candidate : latest;
},
firstStandaloneWorkOrder.dueDate ?? addDays(firstStandaloneWorkOrder.createdAt, 7)
)
);
tasks.push({
id: "standalone-manufacturing",
text: "Standalone Manufacturing Queue",
start: bucketStart.toISOString(),
end: bucketEnd.toISOString(),
progress: clampProgress(
standaloneWorkOrders.reduce((sum, workOrder) => sum + workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status), 0) /
standaloneWorkOrders.length
),
type: "project",
status: "ACTIVE",
ownerLabel: "Manufacturing",
detailHref: "/manufacturing/work-orders",
});
let previousStandaloneTaskId: string | null = null;
for (const workOrder of standaloneWorkOrders) {
const workOrderTaskId = `work-order-${workOrder.id}`;
tasks.push({
id: workOrderTaskId,
text: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
start: startOfDay(workOrder.createdAt).toISOString(),
end: endOfDay(workOrder.dueDate ?? addDays(workOrder.createdAt, 7)).toISOString(),
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
type: "task",
parentId: "standalone-manufacturing",
status: workOrder.status,
ownerLabel: workOrder.item.name,
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
});
if (previousStandaloneTaskId) {
links.push({
id: `sequence-${previousStandaloneTaskId}-${workOrderTaskId}`,
source: previousStandaloneTaskId,
target: workOrderTaskId,
type: "e2e",
});
}
previousStandaloneTaskId = workOrderTaskId;
let previousOperationTaskId: string | null = null;
for (const operation of workOrder.operations) {
const operationTaskId = `work-order-operation-${operation.id}`;
tasks.push({
id: operationTaskId,
text: `${operation.station.code} - ${operation.station.name}`,
start: operation.plannedStart.toISOString(),
end: operation.plannedEnd.toISOString(),
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
type: "task",
parentId: workOrderTaskId,
status: workOrder.status,
ownerLabel: workOrder.workOrderNumber,
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
});
links.push({
id: `work-order-operation-parent-${workOrder.id}-${operation.id}`,
source: workOrderTaskId,
target: operationTaskId,
type: "e2e",
});
if (previousOperationTaskId) {
links.push({
id: `work-order-operation-sequence-${previousOperationTaskId}-${operationTaskId}`,
source: previousOperationTaskId,
target: operationTaskId,
type: "e2e",
});
}
previousOperationTaskId = operationTaskId;
}
if (workOrder.dueDate === null) {
exceptions.push({
id: `work-order-unscheduled-${workOrder.id}`,
kind: "WORK_ORDER",
title: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
status: workOrder.status,
dueDate: null,
ownerLabel: "No project",
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
});
} else if (workOrder.dueDate.getTime() < now.getTime()) {
exceptions.push({
id: `work-order-${workOrder.id}`,
kind: "WORK_ORDER",
title: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
status: workOrder.status,
dueDate: workOrder.dueDate.toISOString(),
ownerLabel: "No project",
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
});
}
}
}
const taskDates = tasks.flatMap((task) => [new Date(task.start), new Date(task.end)]);
const horizonStart = taskDates.length > 0 ? new Date(Math.min(...taskDates.map((date) => date.getTime()))) : startOfDay(now);
const horizonEnd = taskDates.length > 0 ? new Date(Math.max(...taskDates.map((date) => date.getTime()))) : addDays(startOfDay(now), 30);
return {
tasks,
links,
summary: {
activeProjects: planningProjects.filter((project) => project.status === "ACTIVE").length,
atRiskProjects: planningProjects.filter((project) => project.status === "AT_RISK").length,
overdueProjects: planningProjects.filter((project) => project.dueDate && project.dueDate.getTime() < now.getTime()).length,
activeWorkOrders: [...planningProjects.flatMap((project) => project.workOrders), ...standaloneWorkOrders].filter((workOrder) =>
["RELEASED", "IN_PROGRESS", "ON_HOLD"].includes(workOrder.status)
).length,
overdueWorkOrders: [...planningProjects.flatMap((project) => project.workOrders), ...standaloneWorkOrders].filter(
(workOrder) => workOrder.dueDate && workOrder.dueDate.getTime() < now.getTime()
).length,
unscheduledWorkOrders: standaloneWorkOrders.filter((workOrder) => workOrder.dueDate === null).length,
horizonStart: horizonStart.toISOString(),
horizonEnd: horizonEnd.toISOString(),
},
exceptions: exceptions
.sort((left, right) => {
if (!left.dueDate) {
return 1;
}
if (!right.dueDate) {
return -1;
}
return new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime();
})
.slice(0, 12),
};
}

View File

@@ -0,0 +1,388 @@
import { permissions } from "@mrp/shared";
import { inventoryItemStatuses, inventoryItemTypes, inventoryTransactionTypes, inventoryUnitsOfMeasure } from "@mrp/shared/dist/inventory/types.js";
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import {
createInventoryItem,
createInventoryReservation,
createInventorySkuFamily,
createInventorySkuNode,
createInventoryTransfer,
createInventoryTransaction,
createWarehouse,
getInventoryItemById,
listInventorySkuCatalog,
listInventorySkuFamilies,
listInventorySkuNodeOptions,
previewInventorySku,
getWarehouseById,
listInventoryItemOptions,
listInventoryItems,
listWarehouseLocationOptions,
listWarehouses,
updateInventoryItem,
updateWarehouse,
} from "./service.js";
const bomLineSchema = z.object({
componentItemId: z.string().trim().min(1),
quantity: z.number().int().positive(),
unitOfMeasure: z.enum(inventoryUnitsOfMeasure),
notes: z.string(),
position: z.number().int().nonnegative(),
});
const operationSchema = z.object({
stationId: z.string().trim().min(1),
setupMinutes: z.number().int().nonnegative(),
runMinutesPerUnit: z.number().int().nonnegative(),
moveMinutes: z.number().int().nonnegative(),
position: z.number().int().nonnegative(),
notes: z.string(),
});
const inventoryItemSchema = z.object({
sku: z.string().trim().min(1).max(64),
skuBuilder: z
.object({
familyId: z.string().trim().min(1),
nodeId: z.string().trim().min(1).nullable(),
})
.nullable(),
name: z.string().trim().min(1).max(160),
description: z.string(),
type: z.enum(inventoryItemTypes),
status: z.enum(inventoryItemStatuses),
unitOfMeasure: z.enum(inventoryUnitsOfMeasure),
isSellable: z.boolean(),
isPurchasable: z.boolean(),
preferredVendorId: z.string().trim().min(1).nullable(),
defaultCost: z.number().nonnegative().nullable(),
defaultPrice: z.number().nonnegative().nullable(),
notes: z.string(),
bomLines: z.array(bomLineSchema),
operations: z.array(operationSchema),
});
const inventoryListQuerySchema = z.object({
q: z.string().optional(),
status: z.enum(inventoryItemStatuses).optional(),
type: z.enum(inventoryItemTypes).optional(),
});
const inventoryTransactionSchema = z.object({
transactionType: z.enum(inventoryTransactionTypes),
quantity: z.number().int().positive(),
warehouseId: z.string().trim().min(1),
locationId: z.string().trim().min(1),
reference: z.string().max(120),
notes: z.string(),
});
const inventoryTransferSchema = z.object({
quantity: z.number().int().positive(),
fromWarehouseId: z.string().trim().min(1),
fromLocationId: z.string().trim().min(1),
toWarehouseId: z.string().trim().min(1),
toLocationId: z.string().trim().min(1),
notes: z.string(),
});
const inventoryReservationSchema = z.object({
quantity: z.number().int().positive(),
warehouseId: z.string().trim().min(1).nullable(),
locationId: z.string().trim().min(1).nullable(),
notes: z.string(),
});
const warehouseLocationSchema = z.object({
code: z.string().trim().min(1).max(64),
name: z.string().trim().min(1).max(160),
notes: z.string(),
});
const warehouseSchema = z.object({
code: z.string().trim().min(1).max(64),
name: z.string().trim().min(1).max(160),
notes: z.string(),
locations: z.array(warehouseLocationSchema),
});
const skuFamilySchema = z.object({
code: z.string().trim().min(2).max(12),
sequenceCode: z.string().trim().min(2).max(2),
name: z.string().trim().min(1).max(160),
description: z.string(),
isActive: z.boolean(),
});
const skuNodeSchema = z.object({
familyId: z.string().trim().min(1),
parentNodeId: z.string().trim().min(1).nullable(),
code: z.string().trim().min(1).max(32),
label: z.string().trim().min(1).max(160),
description: z.string(),
sortOrder: z.number().int().nonnegative(),
isActive: z.boolean(),
});
const skuPreviewQuerySchema = z.object({
familyId: z.string().trim().min(1),
nodeId: z.string().trim().min(1).nullable().optional(),
});
const skuNodeOptionsQuerySchema = z.object({
familyId: z.string().trim().min(1),
parentNodeId: z.string().trim().min(1).nullable().optional(),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
export const inventoryRouter = Router();
inventoryRouter.get("/items", requirePermissions([permissions.inventoryRead]), async (request, response) => {
const parsed = inventoryListQuerySchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Inventory filters are invalid.");
}
return ok(
response,
await listInventoryItems({
query: parsed.data.q,
status: parsed.data.status,
type: parsed.data.type,
})
);
});
inventoryRouter.get("/items/options", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
return ok(response, await listInventoryItemOptions());
});
inventoryRouter.get("/sku/families", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
return ok(response, await listInventorySkuFamilies());
});
inventoryRouter.get("/sku/catalog", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
return ok(response, await listInventorySkuCatalog());
});
inventoryRouter.get("/sku/nodes", requirePermissions([permissions.inventoryRead]), async (request, response) => {
const parsed = skuNodeOptionsQuerySchema.safeParse({
familyId: request.query.familyId,
parentNodeId: request.query.parentNodeId ?? null,
});
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "SKU node filters are invalid.");
}
return ok(response, await listInventorySkuNodeOptions(parsed.data.familyId, parsed.data.parentNodeId ?? null));
});
inventoryRouter.get("/sku/preview", requirePermissions([permissions.inventoryRead]), async (request, response) => {
const parsed = skuPreviewQuerySchema.safeParse({
familyId: request.query.familyId,
nodeId: request.query.nodeId ?? null,
});
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "SKU preview request is invalid.");
}
const preview = await previewInventorySku({
familyId: parsed.data.familyId,
nodeId: parsed.data.nodeId ?? null,
});
if (!preview) {
return fail(response, 400, "INVALID_INPUT", "SKU preview request is invalid.");
}
return ok(response, preview);
});
inventoryRouter.get("/locations/options", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
return ok(response, await listWarehouseLocationOptions());
});
inventoryRouter.get("/items/:itemId", requirePermissions([permissions.inventoryRead]), async (request, response) => {
const itemId = getRouteParam(request.params.itemId);
if (!itemId) {
return fail(response, 400, "INVALID_INPUT", "Inventory item id is invalid.");
}
const item = await getInventoryItemById(itemId);
if (!item) {
return fail(response, 404, "INVENTORY_ITEM_NOT_FOUND", "Inventory item was not found.");
}
return ok(response, item);
});
inventoryRouter.post("/items", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
const parsed = inventoryItemSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Inventory item payload is invalid.");
}
const item = await createInventoryItem(parsed.data, request.authUser?.id);
if (!item) {
return fail(response, 400, "INVALID_INPUT", "Inventory item BOM references are invalid.");
}
return ok(response, item, 201);
});
inventoryRouter.post("/sku/families", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
const parsed = skuFamilySchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "SKU family payload is invalid.");
}
const family = await createInventorySkuFamily(parsed.data);
if (!family) {
return fail(response, 400, "INVALID_INPUT", "SKU family payload is invalid.");
}
return ok(response, family, 201);
});
inventoryRouter.post("/sku/nodes", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
const parsed = skuNodeSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "SKU branch payload is invalid.");
}
const node = await createInventorySkuNode(parsed.data);
if (!node) {
return fail(response, 400, "INVALID_INPUT", "SKU branch payload is invalid.");
}
return ok(response, node, 201);
});
inventoryRouter.put("/items/:itemId", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
const itemId = getRouteParam(request.params.itemId);
if (!itemId) {
return fail(response, 400, "INVALID_INPUT", "Inventory item id is invalid.");
}
const parsed = inventoryItemSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Inventory item payload is invalid.");
}
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.");
}
return ok(response, item);
});
inventoryRouter.post("/items/:itemId/transactions", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
const itemId = getRouteParam(request.params.itemId);
if (!itemId) {
return fail(response, 400, "INVALID_INPUT", "Inventory item id is invalid.");
}
const parsed = inventoryTransactionSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Inventory transaction payload is invalid.");
}
const result = await createInventoryTransaction(itemId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.item, 201);
});
inventoryRouter.post("/items/:itemId/transfers", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
const itemId = getRouteParam(request.params.itemId);
if (!itemId) {
return fail(response, 400, "INVALID_INPUT", "Inventory item id is invalid.");
}
const parsed = inventoryTransferSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Inventory transfer payload is invalid.");
}
const result = await createInventoryTransfer(itemId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.item, 201);
});
inventoryRouter.post("/items/:itemId/reservations", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
const itemId = getRouteParam(request.params.itemId);
if (!itemId) {
return fail(response, 400, "INVALID_INPUT", "Inventory item id is invalid.");
}
const parsed = inventoryReservationSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Inventory reservation payload is invalid.");
}
const result = await createInventoryReservation(itemId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.item, 201);
});
inventoryRouter.get("/warehouses", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
return ok(response, await listWarehouses());
});
inventoryRouter.get("/warehouses/:warehouseId", requirePermissions([permissions.inventoryRead]), async (request, response) => {
const warehouseId = getRouteParam(request.params.warehouseId);
if (!warehouseId) {
return fail(response, 400, "INVALID_INPUT", "Warehouse id is invalid.");
}
const warehouse = await getWarehouseById(warehouseId);
if (!warehouse) {
return fail(response, 404, "WAREHOUSE_NOT_FOUND", "Warehouse was not found.");
}
return ok(response, warehouse);
});
inventoryRouter.post("/warehouses", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
const parsed = warehouseSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Warehouse payload is invalid.");
}
return ok(response, await createWarehouse(parsed.data, request.authUser?.id), 201);
});
inventoryRouter.put("/warehouses/:warehouseId", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
const warehouseId = getRouteParam(request.params.warehouseId);
if (!warehouseId) {
return fail(response, 400, "INVALID_INPUT", "Warehouse id is invalid.");
}
const parsed = warehouseSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Warehouse payload is invalid.");
}
const warehouse = await updateWarehouse(warehouseId, parsed.data, request.authUser?.id);
if (!warehouse) {
return fail(response, 404, "WAREHOUSE_NOT_FOUND", "Warehouse was not found.");
}
return ok(response, warehouse);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,205 @@
import { permissions } from "@mrp/shared";
import { workOrderStatuses } from "@mrp/shared/dist/manufacturing/types.js";
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import {
createManufacturingStation,
createWorkOrder,
getWorkOrderById,
issueWorkOrderMaterial,
listManufacturingItemOptions,
listManufacturingProjectOptions,
listManufacturingStations,
listWorkOrders,
recordWorkOrderCompletion,
updateWorkOrder,
updateWorkOrderStatus,
} from "./service.js";
const stationSchema = z.object({
code: z.string().trim().min(1).max(64),
name: z.string().trim().min(1).max(160),
description: z.string(),
queueDays: z.number().int().min(0).max(365),
isActive: z.boolean(),
});
const workOrderSchema = z.object({
itemId: z.string().trim().min(1),
projectId: z.string().trim().min(1).nullable(),
salesOrderId: z.string().trim().min(1).nullable(),
salesOrderLineId: z.string().trim().min(1).nullable(),
status: z.enum(workOrderStatuses),
quantity: z.number().int().positive(),
warehouseId: z.string().trim().min(1),
locationId: z.string().trim().min(1),
dueDate: z.string().datetime().nullable(),
notes: z.string(),
});
const workOrderFiltersSchema = z.object({
q: z.string().optional(),
status: z.enum(workOrderStatuses).optional(),
projectId: z.string().optional(),
itemId: z.string().optional(),
});
const statusUpdateSchema = z.object({
status: z.enum(workOrderStatuses),
});
const materialIssueSchema = z.object({
componentItemId: z.string().trim().min(1),
warehouseId: z.string().trim().min(1),
locationId: z.string().trim().min(1),
quantity: z.number().int().positive(),
notes: z.string(),
});
const completionSchema = z.object({
quantity: z.number().int().positive(),
notes: z.string(),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
export const manufacturingRouter = Router();
manufacturingRouter.get("/items/options", requirePermissions([permissions.manufacturingRead]), async (_request, response) => {
return ok(response, await listManufacturingItemOptions());
});
manufacturingRouter.get("/projects/options", requirePermissions([permissions.manufacturingRead]), async (_request, response) => {
return ok(response, await listManufacturingProjectOptions());
});
manufacturingRouter.get("/stations", requirePermissions([permissions.manufacturingRead]), async (_request, response) => {
return ok(response, await listManufacturingStations());
});
manufacturingRouter.post("/stations", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const parsed = stationSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Manufacturing station payload is invalid.");
}
return ok(response, await createManufacturingStation(parsed.data, request.authUser?.id), 201);
});
manufacturingRouter.get("/work-orders", requirePermissions([permissions.manufacturingRead]), async (request, response) => {
const parsed = workOrderFiltersSchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Work-order filters are invalid.");
}
return ok(response, await listWorkOrders(parsed.data));
});
manufacturingRouter.get("/work-orders/:workOrderId", requirePermissions([permissions.manufacturingRead]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
if (!workOrderId) {
return fail(response, 400, "INVALID_INPUT", "Work-order id is invalid.");
}
const workOrder = await getWorkOrderById(workOrderId);
if (!workOrder) {
return fail(response, 404, "WORK_ORDER_NOT_FOUND", "Work order was not found.");
}
return ok(response, workOrder);
});
manufacturingRouter.post("/work-orders", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const parsed = workOrderSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Work-order payload is invalid.");
}
const result = await createWorkOrder(parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder, 201);
});
manufacturingRouter.put("/work-orders/:workOrderId", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
if (!workOrderId) {
return fail(response, 400, "INVALID_INPUT", "Work-order id is invalid.");
}
const parsed = workOrderSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Work-order payload is invalid.");
}
const result = await updateWorkOrder(workOrderId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder);
});
manufacturingRouter.patch("/work-orders/:workOrderId/status", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
if (!workOrderId) {
return fail(response, 400, "INVALID_INPUT", "Work-order id is invalid.");
}
const parsed = statusUpdateSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Work-order status payload is invalid.");
}
const result = await updateWorkOrderStatus(workOrderId, parsed.data.status, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder);
});
manufacturingRouter.post("/work-orders/:workOrderId/issues", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
if (!workOrderId) {
return fail(response, 400, "INVALID_INPUT", "Work-order id is invalid.");
}
const parsed = materialIssueSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Material-issue payload is invalid.");
}
const result = await issueWorkOrderMaterial(workOrderId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder, 201);
});
manufacturingRouter.post("/work-orders/:workOrderId/completions", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
if (!workOrderId) {
return fail(response, 400, "INVALID_INPUT", "Work-order id is invalid.");
}
const parsed = completionSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Completion payload is invalid.");
}
const result = await recordWorkOrderCompletion(workOrderId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder, 201);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,139 @@
import { permissions, projectPriorities, projectStatuses } from "@mrp/shared";
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import {
createProject,
getProjectById,
listProjectCustomerOptions,
listProjectOrderOptions,
listProjectOwnerOptions,
listProjects,
listProjectQuoteOptions,
listProjectShipmentOptions,
updateProject,
} from "./service.js";
const projectSchema = z.object({
name: z.string().trim().min(1).max(160),
status: z.enum(projectStatuses),
priority: z.enum(projectPriorities),
customerId: z.string().trim().min(1),
salesQuoteId: z.string().trim().min(1).nullable(),
salesOrderId: z.string().trim().min(1).nullable(),
shipmentId: z.string().trim().min(1).nullable(),
ownerId: z.string().trim().min(1).nullable(),
dueDate: z.string().datetime().nullable(),
notes: z.string(),
});
const projectListQuerySchema = z.object({
q: z.string().optional(),
status: z.enum(projectStatuses).optional(),
priority: z.enum(projectPriorities).optional(),
customerId: z.string().optional(),
ownerId: z.string().optional(),
});
const projectOptionQuerySchema = z.object({
customerId: z.string().optional(),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
export const projectsRouter = Router();
projectsRouter.get("/customers/options", requirePermissions([permissions.projectsRead]), async (_request, response) => {
return ok(response, await listProjectCustomerOptions());
});
projectsRouter.get("/owners/options", requirePermissions([permissions.projectsRead]), async (_request, response) => {
return ok(response, await listProjectOwnerOptions());
});
projectsRouter.get("/quotes/options", requirePermissions([permissions.projectsRead]), async (request, response) => {
const parsed = projectOptionQuerySchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Project quote filters are invalid.");
}
return ok(response, await listProjectQuoteOptions(parsed.data.customerId));
});
projectsRouter.get("/orders/options", requirePermissions([permissions.projectsRead]), async (request, response) => {
const parsed = projectOptionQuerySchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Project order filters are invalid.");
}
return ok(response, await listProjectOrderOptions(parsed.data.customerId));
});
projectsRouter.get("/shipments/options", requirePermissions([permissions.projectsRead]), async (request, response) => {
const parsed = projectOptionQuerySchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Project shipment filters are invalid.");
}
return ok(response, await listProjectShipmentOptions(parsed.data.customerId));
});
projectsRouter.get("/", requirePermissions([permissions.projectsRead]), async (request, response) => {
const parsed = projectListQuerySchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Project filters are invalid.");
}
return ok(response, await listProjects(parsed.data));
});
projectsRouter.get("/:projectId", requirePermissions([permissions.projectsRead]), async (request, response) => {
const projectId = getRouteParam(request.params.projectId);
if (!projectId) {
return fail(response, 400, "INVALID_INPUT", "Project id is invalid.");
}
const project = await getProjectById(projectId);
if (!project) {
return fail(response, 404, "PROJECT_NOT_FOUND", "Project was not found.");
}
return ok(response, project);
});
projectsRouter.post("/", requirePermissions([permissions.projectsWrite]), async (request, response) => {
const parsed = projectSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Project payload is invalid.");
}
const result = await createProject(parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.project, 201);
});
projectsRouter.put("/:projectId", requirePermissions([permissions.projectsWrite]), async (request, response) => {
const projectId = getRouteParam(request.params.projectId);
if (!projectId) {
return fail(response, 400, "INVALID_INPUT", "Project id is invalid.");
}
const parsed = projectSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Project payload is invalid.");
}
const result = await updateProject(projectId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.project);
});

View File

@@ -0,0 +1,456 @@
import type {
ProjectCustomerOptionDto,
ProjectDetailDto,
ProjectDocumentOptionDto,
ProjectInput,
ProjectOwnerOptionDto,
ProjectPriority,
ProjectShipmentOptionDto,
ProjectStatus,
ProjectSummaryDto,
} from "@mrp/shared";
import { logAuditEvent } from "../../lib/audit.js";
import { prisma } from "../../lib/prisma.js";
const projectModel = (prisma as any).project;
type ProjectRecord = {
id: string;
projectNumber: string;
name: string;
status: string;
priority: string;
dueDate: Date | null;
notes: string;
createdAt: Date;
updatedAt: Date;
customer: {
id: string;
name: string;
email: string;
phone: string;
};
owner: {
id: string;
firstName: string;
lastName: string;
} | null;
salesQuote: {
id: string;
documentNumber: string;
} | null;
salesOrder: {
id: string;
documentNumber: string;
} | null;
shipment: {
id: string;
shipmentNumber: string;
} | null;
};
function getOwnerName(owner: ProjectRecord["owner"]) {
return owner ? `${owner.firstName} ${owner.lastName}`.trim() : null;
}
function mapProjectSummary(record: ProjectRecord): ProjectSummaryDto {
return {
id: record.id,
projectNumber: record.projectNumber,
name: record.name,
status: record.status as ProjectStatus,
priority: record.priority as ProjectPriority,
customerId: record.customer.id,
customerName: record.customer.name,
ownerId: record.owner?.id ?? null,
ownerName: getOwnerName(record.owner),
dueDate: record.dueDate ? record.dueDate.toISOString() : null,
updatedAt: record.updatedAt.toISOString(),
};
}
function mapProjectDetail(record: ProjectRecord): ProjectDetailDto {
return {
...mapProjectSummary(record),
notes: record.notes,
createdAt: record.createdAt.toISOString(),
salesQuoteId: record.salesQuote?.id ?? null,
salesQuoteNumber: record.salesQuote?.documentNumber ?? null,
salesOrderId: record.salesOrder?.id ?? null,
salesOrderNumber: record.salesOrder?.documentNumber ?? null,
shipmentId: record.shipment?.id ?? null,
shipmentNumber: record.shipment?.shipmentNumber ?? null,
customerEmail: record.customer.email,
customerPhone: record.customer.phone,
};
}
function buildInclude() {
return {
customer: {
select: {
id: true,
name: true,
email: true,
phone: true,
},
},
owner: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
salesQuote: {
select: {
id: true,
documentNumber: true,
},
},
salesOrder: {
select: {
id: true,
documentNumber: true,
},
},
shipment: {
select: {
id: true,
shipmentNumber: true,
},
},
};
}
async function nextProjectNumber() {
const next = (await projectModel.count()) + 1;
return `PRJ-${String(next).padStart(5, "0")}`;
}
async function validateProjectInput(payload: ProjectInput) {
const customer = await prisma.customer.findUnique({
where: { id: payload.customerId },
select: { id: true },
});
if (!customer) {
return { ok: false as const, reason: "Customer was not found." };
}
if (payload.ownerId) {
const owner = await prisma.user.findUnique({
where: { id: payload.ownerId },
select: { id: true, isActive: true },
});
if (!owner?.isActive) {
return { ok: false as const, reason: "Project owner was not found." };
}
}
if (payload.salesQuoteId) {
const quote = await prisma.salesQuote.findUnique({
where: { id: payload.salesQuoteId },
select: { id: true, customerId: true },
});
if (!quote) {
return { ok: false as const, reason: "Linked quote was not found." };
}
if (quote.customerId !== payload.customerId) {
return { ok: false as const, reason: "Linked quote must belong to the selected customer." };
}
}
if (payload.salesOrderId) {
const order = await prisma.salesOrder.findUnique({
where: { id: payload.salesOrderId },
select: { id: true, customerId: true },
});
if (!order) {
return { ok: false as const, reason: "Linked sales order was not found." };
}
if (order.customerId !== payload.customerId) {
return { ok: false as const, reason: "Linked sales order must belong to the selected customer." };
}
}
if (payload.shipmentId) {
const shipment = await prisma.shipment.findUnique({
where: { id: payload.shipmentId },
include: {
salesOrder: {
select: {
customerId: true,
},
},
},
});
if (!shipment) {
return { ok: false as const, reason: "Linked shipment was not found." };
}
if (shipment.salesOrder.customerId !== payload.customerId) {
return { ok: false as const, reason: "Linked shipment must belong to the selected customer." };
}
}
return { ok: true as const };
}
export async function listProjectCustomerOptions(): Promise<ProjectCustomerOptionDto[]> {
const customers = await prisma.customer.findMany({
where: {
status: {
not: "INACTIVE",
},
},
select: {
id: true,
name: true,
email: true,
},
orderBy: [{ name: "asc" }],
});
return customers;
}
export async function listProjectOwnerOptions(): Promise<ProjectOwnerOptionDto[]> {
const users = await prisma.user.findMany({
where: {
isActive: true,
},
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
orderBy: [{ firstName: "asc" }, { lastName: "asc" }],
});
return users.map((user) => ({
id: user.id,
fullName: `${user.firstName} ${user.lastName}`.trim(),
email: user.email,
}));
}
export async function listProjectQuoteOptions(customerId?: string | null): Promise<ProjectDocumentOptionDto[]> {
const quotes = await prisma.salesQuote.findMany({
where: {
...(customerId ? { customerId } : {}),
},
include: {
customer: {
select: {
name: true,
},
},
},
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
});
return quotes.map((quote) => ({
id: quote.id,
documentNumber: quote.documentNumber,
customerName: quote.customer.name,
status: quote.status,
}));
}
export async function listProjectOrderOptions(customerId?: string | null): Promise<ProjectDocumentOptionDto[]> {
const orders = await prisma.salesOrder.findMany({
where: {
...(customerId ? { customerId } : {}),
},
include: {
customer: {
select: {
name: true,
},
},
},
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
});
return orders.map((order) => ({
id: order.id,
documentNumber: order.documentNumber,
customerName: order.customer.name,
status: order.status,
}));
}
export async function listProjectShipmentOptions(customerId?: string | null): Promise<ProjectShipmentOptionDto[]> {
const shipments = await prisma.shipment.findMany({
where: {
...(customerId ? { salesOrder: { customerId } } : {}),
},
include: {
salesOrder: {
include: {
customer: {
select: {
name: true,
},
},
},
},
},
orderBy: [{ createdAt: "desc" }],
});
return shipments.map((shipment) => ({
id: shipment.id,
shipmentNumber: shipment.shipmentNumber,
salesOrderNumber: shipment.salesOrder.documentNumber,
customerName: shipment.salesOrder.customer.name,
status: shipment.status,
}));
}
export async function listProjects(filters: {
q?: string;
status?: ProjectStatus;
priority?: ProjectPriority;
customerId?: string;
ownerId?: string;
} = {}) {
const query = filters.q?.trim();
const projects = await projectModel.findMany({
where: {
...(filters.status ? { status: filters.status } : {}),
...(filters.priority ? { priority: filters.priority } : {}),
...(filters.customerId ? { customerId: filters.customerId } : {}),
...(filters.ownerId ? { ownerId: filters.ownerId } : {}),
...(query
? {
OR: [
{ projectNumber: { contains: query } },
{ name: { contains: query } },
{ customer: { name: { contains: query } } },
],
}
: {}),
},
include: buildInclude(),
orderBy: [{ dueDate: "asc" }, { updatedAt: "desc" }],
});
return projects.map((project: unknown) => mapProjectSummary(project as ProjectRecord));
}
export async function getProjectById(projectId: string) {
const project = await projectModel.findUnique({
where: { id: projectId },
include: buildInclude(),
});
return project ? mapProjectDetail(project as ProjectRecord) : null;
}
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 };
}
const projectNumber = await nextProjectNumber();
const created = await projectModel.create({
data: {
projectNumber,
name: payload.name.trim(),
status: payload.status,
priority: payload.priority,
customerId: payload.customerId,
salesQuoteId: payload.salesQuoteId,
salesOrderId: payload.salesOrderId,
shipmentId: payload.shipmentId,
ownerId: payload.ownerId,
dueDate: payload.dueDate ? new Date(payload.dueDate) : null,
notes: payload.notes,
},
select: {
id: true,
},
});
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, actorId?: string | null) {
const existing = await projectModel.findUnique({
where: { id: projectId },
select: { id: true },
});
if (!existing) {
return { ok: false as const, reason: "Project was not found." };
}
const validated = await validateProjectInput(payload);
if (!validated.ok) {
return { ok: false as const, reason: validated.reason };
}
await projectModel.update({
where: { id: projectId },
data: {
name: payload.name.trim(),
status: payload.status,
priority: payload.priority,
customerId: payload.customerId,
salesQuoteId: payload.salesQuoteId,
salesOrderId: payload.salesOrderId,
shipmentId: payload.shipmentId,
ownerId: payload.ownerId,
dueDate: payload.dueDate ? new Date(payload.dueDate) : null,
notes: payload.notes,
},
select: {
id: true,
},
});
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

@@ -0,0 +1,184 @@
import { permissions, purchaseOrderStatuses } from "@mrp/shared";
import { inventoryUnitsOfMeasure } from "@mrp/shared/dist/inventory/types.js";
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import {
createPurchaseReceipt,
createPurchaseOrder,
getPurchaseOrderById,
listPurchaseOrderRevisions,
listPurchaseOrders,
listPurchaseVendorOptions,
updatePurchaseOrder,
updatePurchaseOrderStatus,
} from "./service.js";
const purchaseLineSchema = z.object({
itemId: z.string().trim().min(1),
salesOrderId: z.string().trim().min(1).nullable().optional(),
salesOrderLineId: z.string().trim().min(1).nullable().optional(),
description: z.string(),
quantity: z.number().int().positive(),
unitOfMeasure: z.enum(inventoryUnitsOfMeasure),
unitCost: z.number().nonnegative(),
position: z.number().int().nonnegative(),
});
const purchaseOrderSchema = z.object({
vendorId: z.string().trim().min(1),
status: z.enum(purchaseOrderStatuses),
issueDate: z.string().datetime(),
taxPercent: z.number().min(0).max(100),
freightAmount: z.number().nonnegative(),
notes: z.string(),
revisionReason: z.string().optional(),
lines: z.array(purchaseLineSchema),
});
const purchaseListQuerySchema = z.object({
q: z.string().optional(),
status: z.enum(purchaseOrderStatuses).optional(),
vendorId: z.string().optional(),
});
const purchaseStatusUpdateSchema = z.object({
status: z.enum(purchaseOrderStatuses),
});
const purchaseReceiptLineSchema = z.object({
purchaseOrderLineId: z.string().trim().min(1),
quantity: z.number().int().positive(),
});
const purchaseReceiptSchema = z.object({
receivedAt: z.string().datetime(),
warehouseId: z.string().trim().min(1),
locationId: z.string().trim().min(1),
notes: z.string(),
lines: z.array(purchaseReceiptLineSchema),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
export const purchasingRouter = Router();
purchasingRouter.get("/vendors/options", requirePermissions(["purchasing.read"]), async (_request, response) => {
return ok(response, await listPurchaseVendorOptions());
});
purchasingRouter.get("/orders", requirePermissions(["purchasing.read"]), async (request, response) => {
const parsed = purchaseListQuerySchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Purchase order filters are invalid.");
}
return ok(response, await listPurchaseOrders(parsed.data));
});
purchasingRouter.get("/orders/:orderId", requirePermissions(["purchasing.read"]), async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Purchase order id is invalid.");
}
const order = await getPurchaseOrderById(orderId);
if (!order) {
return fail(response, 404, "PURCHASE_ORDER_NOT_FOUND", "Purchase order was not found.");
}
return ok(response, order);
});
purchasingRouter.get("/orders/:orderId/revisions", requirePermissions(["purchasing.read"]), async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Purchase order id is invalid.");
}
const order = await getPurchaseOrderById(orderId);
if (!order) {
return fail(response, 404, "PURCHASE_ORDER_NOT_FOUND", "Purchase order was not found.");
}
return ok(response, await listPurchaseOrderRevisions(orderId));
});
purchasingRouter.post("/orders", requirePermissions(["purchasing.write"]), async (request, response) => {
const parsed = purchaseOrderSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Purchase order payload is invalid.");
}
const result = await createPurchaseOrder(parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document, 201);
});
purchasingRouter.put("/orders/:orderId", requirePermissions(["purchasing.write"]), async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Purchase order id is invalid.");
}
const parsed = purchaseOrderSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Purchase order payload is invalid.");
}
const result = await updatePurchaseOrder(orderId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document);
});
purchasingRouter.patch("/orders/:orderId/status", requirePermissions(["purchasing.write"]), async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Purchase order id is invalid.");
}
const parsed = purchaseStatusUpdateSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Purchase order status payload is invalid.");
}
const result = await updatePurchaseOrderStatus(orderId, parsed.data.status, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document);
});
purchasingRouter.post(
"/orders/:orderId/receipts",
requirePermissions([permissions.purchasingWrite, permissions.inventoryWrite]),
async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Purchase order id is invalid.");
}
const parsed = purchaseReceiptSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Purchase receipt payload is invalid.");
}
const result = await createPurchaseReceipt(orderId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document, 201);
}
);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,322 @@
import { permissions } from "@mrp/shared";
import { salesDocumentStatuses, type SalesDocumentType } from "@mrp/shared/dist/sales/types.js";
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import { inventoryUnitsOfMeasure } from "@mrp/shared/dist/inventory/types.js";
import {
approveSalesDocument,
convertQuoteToSalesOrder,
createSalesDocument,
getSalesDocumentById,
listSalesDocumentRevisions,
listSalesCustomerOptions,
listSalesDocuments,
listSalesOrderOptions,
updateSalesDocumentStatus,
updateSalesDocument,
} from "./service.js";
import { getDemandPlanningRollup, getSalesOrderPlanningById } from "./planning.js";
const salesLineSchema = z.object({
itemId: z.string().trim().min(1),
description: z.string(),
quantity: z.number().int().positive(),
unitOfMeasure: z.enum(inventoryUnitsOfMeasure),
unitPrice: z.number().nonnegative(),
position: z.number().int().nonnegative(),
});
const quoteSchema = z.object({
customerId: z.string().trim().min(1),
status: z.enum(salesDocumentStatuses),
issueDate: z.string().datetime(),
expiresAt: z.string().datetime().nullable(),
discountPercent: z.number().min(0).max(100),
taxPercent: z.number().min(0).max(100),
freightAmount: z.number().nonnegative(),
notes: z.string(),
lines: z.array(salesLineSchema),
revisionReason: z.string().optional(),
});
const orderSchema = z.object({
customerId: z.string().trim().min(1),
status: z.enum(salesDocumentStatuses),
issueDate: z.string().datetime(),
discountPercent: z.number().min(0).max(100),
taxPercent: z.number().min(0).max(100),
freightAmount: z.number().nonnegative(),
notes: z.string(),
lines: z.array(salesLineSchema),
revisionReason: z.string().optional(),
});
const salesListQuerySchema = z.object({
q: z.string().optional(),
status: z.enum(salesDocumentStatuses).optional(),
});
const salesStatusUpdateSchema = z.object({
status: z.enum(salesDocumentStatuses),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
export const salesRouter = Router();
salesRouter.get("/customers/options", requirePermissions([permissions.salesRead]), async (_request, response) => {
return ok(response, await listSalesCustomerOptions());
});
salesRouter.get("/orders/options", requirePermissions([permissions.salesRead]), async (_request, response) => {
return ok(response, await listSalesOrderOptions());
});
salesRouter.get("/quotes", requirePermissions([permissions.salesRead]), async (request, response) => {
const parsed = salesListQuerySchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Quote filters are invalid.");
}
return ok(response, await listSalesDocuments("QUOTE", parsed.data));
});
salesRouter.get("/quotes/:quoteId", requirePermissions([permissions.salesRead]), async (request, response) => {
const quoteId = getRouteParam(request.params.quoteId);
if (!quoteId) {
return fail(response, 400, "INVALID_INPUT", "Quote id is invalid.");
}
const quote = await getSalesDocumentById("QUOTE", quoteId);
if (!quote) {
return fail(response, 404, "QUOTE_NOT_FOUND", "Quote was not found.");
}
return ok(response, quote);
});
salesRouter.post("/quotes", requirePermissions([permissions.salesWrite]), async (request, response) => {
const parsed = quoteSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Quote payload is invalid.");
}
const result = await createSalesDocument("QUOTE", parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document, 201);
});
salesRouter.put("/quotes/:quoteId", requirePermissions([permissions.salesWrite]), async (request, response) => {
const quoteId = getRouteParam(request.params.quoteId);
if (!quoteId) {
return fail(response, 400, "INVALID_INPUT", "Quote id is invalid.");
}
const parsed = quoteSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Quote payload is invalid.");
}
const result = await updateSalesDocument("QUOTE", quoteId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document);
});
salesRouter.patch("/quotes/:quoteId/status", requirePermissions([permissions.salesWrite]), async (request, response) => {
const quoteId = getRouteParam(request.params.quoteId);
if (!quoteId) {
return fail(response, 400, "INVALID_INPUT", "Quote id is invalid.");
}
const parsed = salesStatusUpdateSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Quote status payload is invalid.");
}
const result = await updateSalesDocumentStatus("QUOTE", quoteId, parsed.data.status, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document);
});
salesRouter.post("/quotes/:quoteId/approve", requirePermissions([permissions.salesWrite]), async (request, response) => {
const quoteId = getRouteParam(request.params.quoteId);
if (!quoteId) {
return fail(response, 400, "INVALID_INPUT", "Quote id is invalid.");
}
const result = await approveSalesDocument("QUOTE", quoteId, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document);
});
salesRouter.get("/quotes/:quoteId/revisions", requirePermissions([permissions.salesRead]), async (request, response) => {
const quoteId = getRouteParam(request.params.quoteId);
if (!quoteId) {
return fail(response, 400, "INVALID_INPUT", "Quote id is invalid.");
}
const quote = await getSalesDocumentById("QUOTE", quoteId);
if (!quote) {
return fail(response, 404, "QUOTE_NOT_FOUND", "Quote was not found.");
}
return ok(response, await listSalesDocumentRevisions("QUOTE", quoteId));
});
salesRouter.post("/quotes/:quoteId/convert", requirePermissions([permissions.salesWrite]), async (request, response) => {
const quoteId = getRouteParam(request.params.quoteId);
if (!quoteId) {
return fail(response, 400, "INVALID_INPUT", "Quote id is invalid.");
}
const result = await convertQuoteToSalesOrder(quoteId, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document, 201);
});
salesRouter.get("/orders", requirePermissions([permissions.salesRead]), async (request, response) => {
const parsed = salesListQuerySchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Sales order filters are invalid.");
}
return ok(response, await listSalesDocuments("ORDER", parsed.data));
});
salesRouter.get("/orders/:orderId", requirePermissions([permissions.salesRead]), async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Sales order id is invalid.");
}
const order = await getSalesDocumentById("ORDER", orderId);
if (!order) {
return fail(response, 404, "SALES_ORDER_NOT_FOUND", "Sales order was not found.");
}
return ok(response, order);
});
salesRouter.get("/orders/:orderId/planning", requirePermissions([permissions.salesRead]), async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Sales order id is invalid.");
}
const planning = await getSalesOrderPlanningById(orderId);
if (!planning) {
return fail(response, 404, "SALES_ORDER_NOT_FOUND", "Sales order was not found.");
}
return ok(response, planning);
});
salesRouter.get("/planning-rollup", requirePermissions([permissions.salesRead]), async (_request, response) => {
return ok(response, await getDemandPlanningRollup());
});
salesRouter.post("/orders", requirePermissions([permissions.salesWrite]), async (request, response) => {
const parsed = orderSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Sales order payload is invalid.");
}
const result = await createSalesDocument("ORDER", {
...parsed.data,
expiresAt: null,
}, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document, 201);
});
salesRouter.put("/orders/:orderId", requirePermissions([permissions.salesWrite]), async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Sales order id is invalid.");
}
const parsed = orderSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Sales order payload is invalid.");
}
const result = await updateSalesDocument("ORDER", orderId, {
...parsed.data,
expiresAt: null,
}, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document);
});
salesRouter.patch("/orders/:orderId/status", requirePermissions([permissions.salesWrite]), async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Sales order id is invalid.");
}
const parsed = salesStatusUpdateSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Sales order status payload is invalid.");
}
const result = await updateSalesDocumentStatus("ORDER", orderId, parsed.data.status, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document);
});
salesRouter.post("/orders/:orderId/approve", requirePermissions([permissions.salesWrite]), async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Sales order id is invalid.");
}
const result = await approveSalesDocument("ORDER", orderId, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document);
});
salesRouter.get("/orders/:orderId/revisions", requirePermissions([permissions.salesRead]), async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Sales order id is invalid.");
}
const order = await getSalesDocumentById("ORDER", orderId);
if (!order) {
return fail(response, 404, "SALES_ORDER_NOT_FOUND", "Sales order was not found.");
}
return ok(response, await listSalesDocumentRevisions("ORDER", orderId));
});

View File

@@ -0,0 +1,902 @@
import type {
SalesCustomerOptionDto,
SalesDocumentDetailDto,
SalesDocumentInput,
SalesDocumentRevisionDto,
SalesDocumentRevisionSnapshotDto,
SalesDocumentStatus,
SalesDocumentSummaryDto,
SalesDocumentType,
SalesLineInput,
} from "@mrp/shared/dist/sales/types.js";
import { logAuditEvent } from "../../lib/audit.js";
import { prisma } from "../../lib/prisma.js";
export interface SalesDocumentPdfData {
type: SalesDocumentType;
documentNumber: string;
status: SalesDocumentStatus;
issueDate: string;
expiresAt: string | null;
customer: {
name: string;
email: string;
phone: string;
addressLine1: string;
addressLine2: string;
city: string;
state: string;
postalCode: string;
country: string;
};
notes: string;
subtotal: number;
discountPercent: number;
discountAmount: number;
taxPercent: number;
taxAmount: number;
freightAmount: number;
total: number;
lines: Array<{
itemSku: string;
itemName: string;
description: string;
quantity: number;
unitOfMeasure: string;
unitPrice: number;
lineTotal: number;
}>;
}
type SalesLineRecord = {
id: string;
description: string;
quantity: number;
unitOfMeasure: string;
unitPrice: number;
position: number;
item: {
id: string;
sku: string;
name: string;
};
};
type RevisionRecord = {
id: string;
revisionNumber: number;
reason: string;
snapshot: string;
createdAt: Date;
createdBy: {
firstName: string;
lastName: string;
} | null;
};
type SalesDocumentRecord = {
id: string;
documentNumber: string;
status: string;
issueDate: Date;
expiresAt?: Date | null;
approvedAt: Date | null;
discountPercent: number;
taxPercent: number;
freightAmount: number;
notes: string;
createdAt: Date;
updatedAt: Date;
customer: {
id: string;
name: string;
email: string;
};
approvedBy: {
firstName: string;
lastName: string;
} | null;
revisions: RevisionRecord[];
lines: SalesLineRecord[];
};
type DocumentConfig = {
prefix: string;
findMany: typeof prisma.salesQuote.findMany;
findUnique: typeof prisma.salesQuote.findUnique;
create: typeof prisma.salesQuote.create;
update: typeof prisma.salesQuote.update;
count: typeof prisma.salesQuote.count;
revisionFindMany: typeof prisma.salesQuoteRevision.findMany;
revisionAggregate: typeof prisma.salesQuoteRevision.aggregate;
revisionCreate: typeof prisma.salesQuoteRevision.create;
revisionDocumentField: "quoteId" | "orderId";
};
const documentConfig: Record<SalesDocumentType, DocumentConfig> = {
QUOTE: {
prefix: "Q",
findMany: (prisma as any).salesQuote.findMany.bind((prisma as any).salesQuote),
findUnique: (prisma as any).salesQuote.findUnique.bind((prisma as any).salesQuote),
create: (prisma as any).salesQuote.create.bind((prisma as any).salesQuote),
update: (prisma as any).salesQuote.update.bind((prisma as any).salesQuote),
count: (prisma as any).salesQuote.count.bind((prisma as any).salesQuote),
revisionFindMany: (prisma as any).salesQuoteRevision.findMany.bind((prisma as any).salesQuoteRevision),
revisionAggregate: (prisma as any).salesQuoteRevision.aggregate.bind((prisma as any).salesQuoteRevision),
revisionCreate: (prisma as any).salesQuoteRevision.create.bind((prisma as any).salesQuoteRevision),
revisionDocumentField: "quoteId",
},
ORDER: {
prefix: "SO",
findMany: (prisma as any).salesOrder.findMany.bind((prisma as any).salesOrder),
findUnique: (prisma as any).salesOrder.findUnique.bind((prisma as any).salesOrder),
create: (prisma as any).salesOrder.create.bind((prisma as any).salesOrder),
update: (prisma as any).salesOrder.update.bind((prisma as any).salesOrder),
count: (prisma as any).salesOrder.count.bind((prisma as any).salesOrder),
revisionFindMany: (prisma as any).salesOrderRevision.findMany.bind((prisma as any).salesOrderRevision),
revisionAggregate: (prisma as any).salesOrderRevision.aggregate.bind((prisma as any).salesOrderRevision),
revisionCreate: (prisma as any).salesOrderRevision.create.bind((prisma as any).salesOrderRevision),
revisionDocumentField: "orderId",
},
};
function roundMoney(value: number) {
return Math.round(value * 100) / 100;
}
function calculateTotals(subtotal: number, discountPercent: number, taxPercent: number, freightAmount: number) {
const normalizedSubtotal = roundMoney(subtotal);
const normalizedDiscountPercent = Number.isFinite(discountPercent) ? discountPercent : 0;
const normalizedTaxPercent = Number.isFinite(taxPercent) ? taxPercent : 0;
const normalizedFreight = roundMoney(Number.isFinite(freightAmount) ? freightAmount : 0);
const discountAmount = roundMoney(normalizedSubtotal * (normalizedDiscountPercent / 100));
const taxableSubtotal = roundMoney(normalizedSubtotal - discountAmount);
const taxAmount = roundMoney(taxableSubtotal * (normalizedTaxPercent / 100));
const total = roundMoney(taxableSubtotal + taxAmount + normalizedFreight);
return {
subtotal: normalizedSubtotal,
discountPercent: normalizedDiscountPercent,
discountAmount,
taxPercent: normalizedTaxPercent,
taxAmount,
freightAmount: normalizedFreight,
total,
};
}
function getUserDisplayName(user: { firstName: string; lastName: string } | null) {
if (!user) {
return null;
}
return `${user.firstName} ${user.lastName}`.trim();
}
function parseRevisionSnapshot(snapshot: string): SalesDocumentRevisionSnapshotDto {
return JSON.parse(snapshot) as SalesDocumentRevisionSnapshotDto;
}
function mapRevision(record: RevisionRecord): SalesDocumentRevisionDto {
return {
id: record.id,
revisionNumber: record.revisionNumber,
reason: record.reason,
createdAt: record.createdAt.toISOString(),
createdByName: getUserDisplayName(record.createdBy),
snapshot: parseRevisionSnapshot(record.snapshot),
};
}
function normalizeLines(lines: SalesLineInput[]) {
return lines
.map((line, index) => ({
itemId: line.itemId,
description: line.description.trim(),
quantity: Number(line.quantity),
unitOfMeasure: line.unitOfMeasure,
unitPrice: Number(line.unitPrice),
position: line.position ?? (index + 1) * 10,
}))
.filter((line) => line.itemId.trim().length > 0);
}
async function validateLines(lines: SalesLineInput[]) {
const normalized = normalizeLines(lines);
if (normalized.length === 0) {
return { ok: false as const, reason: "At least one line item is required." };
}
if (normalized.some((line) => !Number.isInteger(line.quantity) || line.quantity <= 0)) {
return { ok: false as const, reason: "Line quantity must be a whole number greater than zero." };
}
if (normalized.some((line) => Number.isNaN(line.unitPrice) || line.unitPrice < 0)) {
return { ok: false as const, reason: "Unit price must be zero or greater." };
}
const itemIds = [...new Set(normalized.map((line) => line.itemId))];
const items = await prisma.inventoryItem.findMany({
where: { id: { in: itemIds } },
select: { id: true },
});
if (items.length !== itemIds.length) {
return { ok: false as const, reason: "One or more sales lines reference an invalid inventory item." };
}
return { ok: true as const, lines: normalized };
}
function mapDocument(record: SalesDocumentRecord): SalesDocumentDetailDto {
const lines = record.lines
.slice()
.sort((left, right) => left.position - right.position)
.map((line) => ({
id: line.id,
itemId: line.item.id,
itemSku: line.item.sku,
itemName: line.item.name,
description: line.description,
quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure as SalesDocumentDetailDto["lines"][number]["unitOfMeasure"],
unitPrice: line.unitPrice,
lineTotal: line.quantity * line.unitPrice,
position: line.position,
}));
const totals = calculateTotals(
lines.reduce((sum, line) => sum + line.lineTotal, 0),
record.discountPercent,
record.taxPercent,
record.freightAmount
);
const revisions = record.revisions
.slice()
.sort((left, right) => right.revisionNumber - left.revisionNumber)
.map(mapRevision);
return {
id: record.id,
documentNumber: record.documentNumber,
customerId: record.customer.id,
customerName: record.customer.name,
customerEmail: record.customer.email,
status: record.status as SalesDocumentStatus,
approvedAt: record.approvedAt ? record.approvedAt.toISOString() : null,
approvedByName: getUserDisplayName(record.approvedBy),
currentRevisionNumber: revisions[0]?.revisionNumber ?? 0,
subtotal: totals.subtotal,
discountPercent: totals.discountPercent,
discountAmount: totals.discountAmount,
taxPercent: totals.taxPercent,
taxAmount: totals.taxAmount,
freightAmount: totals.freightAmount,
total: totals.total,
issueDate: record.issueDate.toISOString(),
expiresAt: "expiresAt" in record && record.expiresAt ? record.expiresAt.toISOString() : null,
notes: record.notes,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
lineCount: lines.length,
lines,
revisions,
};
}
function buildRevisionSnapshot(document: SalesDocumentDetailDto) {
return JSON.stringify({
documentNumber: document.documentNumber,
customerId: document.customerId,
customerName: document.customerName,
status: document.status,
approvedAt: document.approvedAt,
approvedByName: document.approvedByName,
issueDate: document.issueDate,
expiresAt: document.expiresAt,
discountPercent: document.discountPercent,
discountAmount: document.discountAmount,
taxPercent: document.taxPercent,
taxAmount: document.taxAmount,
freightAmount: document.freightAmount,
subtotal: document.subtotal,
total: document.total,
notes: document.notes,
lines: document.lines.map((line) => ({
itemId: line.itemId,
itemSku: line.itemSku,
itemName: line.itemName,
description: line.description,
quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure,
unitPrice: line.unitPrice,
lineTotal: line.lineTotal,
position: line.position,
})),
});
}
async function createRevision(
type: SalesDocumentType,
documentId: string,
detail: SalesDocumentDetailDto,
reason: string,
userId?: string
) {
const aggregate = await documentConfig[type].revisionAggregate({
where: { [documentConfig[type].revisionDocumentField]: documentId },
_max: { revisionNumber: true },
});
const nextRevisionNumber = (aggregate._max.revisionNumber ?? 0) + 1;
if (type === "QUOTE") {
await prisma.salesQuoteRevision.create({
data: {
quoteId: documentId,
revisionNumber: nextRevisionNumber,
reason,
snapshot: buildRevisionSnapshot(detail),
createdById: userId ?? null,
},
});
return;
}
await prisma.salesOrderRevision.create({
data: {
orderId: documentId,
revisionNumber: nextRevisionNumber,
reason,
snapshot: buildRevisionSnapshot(detail),
createdById: userId ?? null,
},
});
}
async function getDocumentDetailOrNull(type: SalesDocumentType, documentId: string) {
const record = await documentConfig[type].findUnique({
where: { id: documentId },
include: buildInclude(),
});
return record ? mapDocument(record as SalesDocumentRecord) : null;
}
async function nextDocumentNumber(type: SalesDocumentType) {
const next = (await documentConfig[type].count()) + 1;
return `${documentConfig[type].prefix}-${String(next).padStart(5, "0")}`;
}
function buildInclude() {
return {
customer: {
select: {
id: true,
name: true,
email: true,
},
},
approvedBy: {
select: {
firstName: true,
lastName: true,
},
},
revisions: {
include: {
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ revisionNumber: "desc" as const }],
},
lines: {
include: {
item: {
select: {
id: true,
sku: true,
name: true,
},
},
},
orderBy: [{ position: "asc" as const }, { createdAt: "asc" as const }],
},
};
}
export async function listSalesCustomerOptions(): Promise<SalesCustomerOptionDto[]> {
const customers = await prisma.customer.findMany({
where: {
status: {
not: "INACTIVE",
},
},
select: {
id: true,
name: true,
email: true,
resellerDiscountPercent: true,
},
orderBy: [{ name: "asc" }],
});
return customers;
}
export async function listSalesOrderOptions() {
const orders = await prisma.salesOrder.findMany({
include: {
customer: {
select: {
name: true,
},
},
lines: {
select: {
quantity: true,
unitPrice: true,
},
},
},
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
});
return orders.map((order) => ({
id: order.id,
documentNumber: order.documentNumber,
customerName: order.customer.name,
status: order.status,
total: calculateTotals(
order.lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0),
order.discountPercent,
order.taxPercent,
order.freightAmount
).total,
}));
}
export async function listSalesDocuments(type: SalesDocumentType, filters: { q?: string; status?: SalesDocumentStatus } = {}) {
const query = filters.q?.trim();
const records = await documentConfig[type].findMany({
where: {
...(filters.status ? { status: filters.status } : {}),
...(query
? {
OR: [
{ documentNumber: { contains: query } },
{ customer: { name: { contains: query } } },
],
}
: {}),
},
include: buildInclude(),
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
});
return records.map((record: unknown) => {
const detail = mapDocument(record as SalesDocumentRecord);
const summary: SalesDocumentSummaryDto = {
id: detail.id,
documentNumber: detail.documentNumber,
customerId: detail.customerId,
customerName: detail.customerName,
status: detail.status,
approvedAt: detail.approvedAt,
approvedByName: detail.approvedByName,
currentRevisionNumber: detail.currentRevisionNumber,
subtotal: detail.subtotal,
discountPercent: detail.discountPercent,
discountAmount: detail.discountAmount,
taxPercent: detail.taxPercent,
taxAmount: detail.taxAmount,
freightAmount: detail.freightAmount,
total: detail.total,
issueDate: detail.issueDate,
updatedAt: detail.updatedAt,
lineCount: detail.lineCount,
};
return summary;
});
}
export async function getSalesDocumentById(type: SalesDocumentType, documentId: string) {
return getDocumentDetailOrNull(type, documentId);
}
export async function listSalesDocumentRevisions(type: SalesDocumentType, documentId: string) {
const revisions = await documentConfig[type].revisionFindMany({
where: { [documentConfig[type].revisionDocumentField]: documentId },
include: {
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ revisionNumber: "desc" }],
});
return revisions.map((revision: RevisionRecord) => mapRevision(revision));
}
export async function createSalesDocument(type: SalesDocumentType, payload: SalesDocumentInput, userId?: string) {
const validatedLines = await validateLines(payload.lines);
if (!validatedLines.ok) {
return { ok: false as const, reason: validatedLines.reason };
}
const customer = await prisma.customer.findUnique({
where: { id: payload.customerId },
select: { id: true },
});
if (!customer) {
return { ok: false as const, reason: "Customer was not found." };
}
const documentNumber = await nextDocumentNumber(type);
const createdId = await prisma.$transaction(async (tx) => {
const created =
type === "QUOTE"
? await tx.salesQuote.create({
data: {
documentNumber,
customerId: payload.customerId,
status: payload.status,
issueDate: new Date(payload.issueDate),
expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null,
discountPercent: payload.discountPercent,
taxPercent: payload.taxPercent,
freightAmount: payload.freightAmount,
notes: payload.notes,
lines: {
create: validatedLines.lines,
},
},
select: { id: true },
})
: await tx.salesOrder.create({
data: {
documentNumber,
customerId: payload.customerId,
status: payload.status,
issueDate: new Date(payload.issueDate),
discountPercent: payload.discountPercent,
taxPercent: payload.taxPercent,
freightAmount: payload.freightAmount,
notes: payload.notes,
lines: {
create: validatedLines.lines,
},
},
select: { id: true },
});
return created.id;
});
const detail = await getDocumentDetailOrNull(type, createdId);
if (!detail) {
return { ok: false as const, reason: "Unable to load saved document." };
}
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." };
}
export async function updateSalesDocument(type: SalesDocumentType, documentId: string, payload: SalesDocumentInput, userId?: string) {
const existing = await documentConfig[type].findUnique({
where: { id: documentId },
select: { id: true, approvedAt: true },
});
if (!existing) {
return { ok: false as const, reason: "Sales document was not found." };
}
const validatedLines = await validateLines(payload.lines);
if (!validatedLines.ok) {
return { ok: false as const, reason: validatedLines.reason };
}
const customer = await prisma.customer.findUnique({
where: { id: payload.customerId },
select: { id: true },
});
if (!customer) {
return { ok: false as const, reason: "Customer was not found." };
}
await documentConfig[type].update({
where: { id: documentId },
data: {
customerId: payload.customerId,
status: payload.status,
issueDate: new Date(payload.issueDate),
...(type === "QUOTE" ? { expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null } : {}),
discountPercent: payload.discountPercent,
taxPercent: payload.taxPercent,
freightAmount: payload.freightAmount,
notes: payload.notes,
approvedAt: payload.status === "APPROVED" ? existing.approvedAt ?? new Date() : null,
approvedById: payload.status === "APPROVED" ? userId ?? null : null,
lines: {
deleteMany: {},
create: validatedLines.lines,
},
},
select: { id: true },
});
const detail = await getDocumentDetailOrNull(type, documentId);
if (!detail) {
return { ok: false as const, reason: "Unable to load saved document." };
}
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." };
}
export async function updateSalesDocumentStatus(type: SalesDocumentType, documentId: string, status: SalesDocumentStatus, userId?: string) {
const existing = await documentConfig[type].findUnique({
where: { id: documentId },
select: { id: true, status: true, approvedAt: true },
});
if (!existing) {
return { ok: false as const, reason: "Sales document was not found." };
}
await documentConfig[type].update({
where: { id: documentId },
data: {
status,
approvedAt: status === "APPROVED" ? existing.approvedAt ?? new Date() : null,
approvedById: status === "APPROVED" ? userId ?? null : null,
},
select: { id: true },
});
const detail = await getDocumentDetailOrNull(type, documentId);
if (!detail) {
return { ok: false as const, reason: "Unable to load updated document." };
}
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." };
}
export async function approveSalesDocument(type: SalesDocumentType, documentId: string, userId?: string) {
const existing = await documentConfig[type].findUnique({
where: { id: documentId },
select: { id: true, status: true, approvedAt: true },
});
if (!existing) {
return { ok: false as const, reason: "Sales document was not found." };
}
if (existing.status === "CLOSED") {
return { ok: false as const, reason: "Closed sales documents cannot be approved." };
}
await documentConfig[type].update({
where: { id: documentId },
data: {
status: "APPROVED",
approvedAt: existing.approvedAt ?? new Date(),
approvedById: userId ?? null,
},
select: { id: true },
});
const detail = await getDocumentDetailOrNull(type, documentId);
if (!detail) {
return { ok: false as const, reason: "Unable to load approved document." };
}
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." };
}
export async function convertQuoteToSalesOrder(quoteId: string, userId?: string) {
const quote = await documentConfig.QUOTE.findUnique({
where: { id: quoteId },
include: buildInclude(),
});
if (!quote) {
return { ok: false as const, reason: "Quote was not found." };
}
const mappedQuote = mapDocument(quote as SalesDocumentRecord);
const nextOrderNumber = await nextDocumentNumber("ORDER");
const createdId = await prisma.$transaction(async (tx) => {
const created = await tx.salesOrder.create({
data: {
documentNumber: nextOrderNumber,
customerId: mappedQuote.customerId,
status: "DRAFT",
issueDate: new Date(),
discountPercent: mappedQuote.discountPercent,
taxPercent: mappedQuote.taxPercent,
freightAmount: mappedQuote.freightAmount,
notes: mappedQuote.notes ? `Converted from ${mappedQuote.documentNumber}\n\n${mappedQuote.notes}` : `Converted from ${mappedQuote.documentNumber}`,
lines: {
create: mappedQuote.lines.map((line) => ({
itemId: line.itemId,
description: line.description,
quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure,
unitPrice: line.unitPrice,
position: line.position,
})),
},
},
select: { id: true },
});
return created.id;
});
const order = await getDocumentDetailOrNull("ORDER", createdId);
if (!order) {
return { ok: false as const, reason: "Unable to load converted sales order." };
}
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." };
}
export async function getSalesDocumentPdfData(type: SalesDocumentType, documentId: string): Promise<SalesDocumentPdfData | null> {
const include = {
customer: {
select: {
name: true,
email: true,
phone: true,
addressLine1: true,
addressLine2: true,
city: true,
state: true,
postalCode: true,
country: true,
},
},
lines: {
include: {
item: {
select: {
sku: true,
name: true,
},
},
},
orderBy: [{ position: "asc" as const }, { createdAt: "asc" as const }],
},
};
const record =
type === "QUOTE"
? await prisma.salesQuote.findUnique({
where: { id: documentId },
include,
})
: await prisma.salesOrder.findUnique({
where: { id: documentId },
include,
});
if (!record) {
return null;
}
const lines = record.lines.map((line: { item: { sku: string; name: string }; description: string; quantity: number; unitOfMeasure: string; unitPrice: number }) => ({
itemSku: line.item.sku,
itemName: line.item.name,
description: line.description,
quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure,
unitPrice: line.unitPrice,
lineTotal: line.quantity * line.unitPrice,
}));
const totals = calculateTotals(
lines.reduce((sum: number, line: SalesDocumentPdfData["lines"][number]) => sum + line.lineTotal, 0),
record.discountPercent,
record.taxPercent,
record.freightAmount
);
return {
type,
documentNumber: record.documentNumber,
status: record.status as SalesDocumentStatus,
issueDate: record.issueDate.toISOString(),
expiresAt: "expiresAt" in record && record.expiresAt ? record.expiresAt.toISOString() : null,
customer: record.customer,
notes: record.notes,
subtotal: totals.subtotal,
discountPercent: totals.discountPercent,
discountAmount: totals.discountAmount,
taxPercent: totals.taxPercent,
taxAmount: totals.taxAmount,
freightAmount: totals.freightAmount,
total: totals.total,
lines,
};
}

View File

@@ -0,0 +1,44 @@
import { permissions } from "@mrp/shared";
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import { getActiveCompanyProfile, updateActiveCompanyProfile } from "./service.js";
const companySchema = z.object({
companyName: z.string().min(1),
legalName: z.string().min(1),
email: z.string().email(),
phone: z.string().min(1),
website: z.string().min(1),
taxId: z.string().min(1),
addressLine1: z.string().min(1),
addressLine2: z.string(),
city: z.string().min(1),
state: z.string().min(1),
postalCode: z.string().min(1),
country: z.string().min(1),
theme: z.object({
primaryColor: z.string().regex(/^#([A-Fa-f0-9]{6})$/),
accentColor: z.string().regex(/^#([A-Fa-f0-9]{6})$/),
surfaceColor: z.string().regex(/^#([A-Fa-f0-9]{6})$/),
fontFamily: z.string().min(1),
logoFileId: z.string().nullable(),
}),
});
export const settingsRouter = Router();
settingsRouter.get("/company-profile", requirePermissions([permissions.companyRead]), async (_request, response) => {
return ok(response, await getActiveCompanyProfile());
});
settingsRouter.put("/company-profile", requirePermissions([permissions.companyWrite]), async (request, response) => {
const parsed = companySchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Company settings payload is invalid.");
}
return ok(response, await updateActiveCompanyProfile(parsed.data, request.authUser?.id));
});

View File

@@ -0,0 +1,85 @@
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>>;
function mapCompanyProfile(profile: CompanyProfileRecord): CompanyProfileDto {
return {
id: profile.id,
companyName: profile.companyName,
legalName: profile.legalName,
email: profile.email,
phone: profile.phone,
website: profile.website,
taxId: profile.taxId,
addressLine1: profile.addressLine1,
addressLine2: profile.addressLine2,
city: profile.city,
state: profile.state,
postalCode: profile.postalCode,
country: profile.country,
theme: {
primaryColor: profile.primaryColor,
accentColor: profile.accentColor,
surfaceColor: profile.surfaceColor,
fontFamily: profile.fontFamily,
logoFileId: profile.logoFileId,
},
logoUrl: profile.logoFileId ? `/api/v1/files/${profile.logoFileId}/content` : null,
updatedAt: profile.updatedAt.toISOString(),
};
}
export async function getActiveCompanyProfile() {
return mapCompanyProfile(
await prisma.companyProfile.findFirstOrThrow({
where: { isActive: true },
})
);
}
export async function updateActiveCompanyProfile(payload: CompanyProfileInput, actorId?: string | null) {
const current = await prisma.companyProfile.findFirstOrThrow({
where: { isActive: true },
});
const profile = await prisma.companyProfile.update({
where: { id: current.id },
data: {
companyName: payload.companyName,
legalName: payload.legalName,
email: payload.email,
phone: payload.phone,
website: payload.website,
taxId: payload.taxId,
addressLine1: payload.addressLine1,
addressLine2: payload.addressLine2,
city: payload.city,
state: payload.state,
postalCode: payload.postalCode,
country: payload.country,
primaryColor: payload.theme.primaryColor,
accentColor: payload.theme.accentColor,
surfaceColor: payload.theme.surfaceColor,
fontFamily: payload.theme.fontFamily,
logoFileId: payload.theme.logoFileId,
},
});
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);
}

View File

@@ -0,0 +1,114 @@
import { permissions } from "@mrp/shared";
import { shipmentStatuses } from "@mrp/shared/dist/shipping/types.js";
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import { createShipment, getShipmentById, listShipmentOrderOptions, listShipments, updateShipment, updateShipmentStatus } from "./service.js";
const shipmentSchema = z.object({
salesOrderId: z.string().trim().min(1),
status: z.enum(shipmentStatuses),
shipDate: z.string().datetime().nullable(),
carrier: z.string(),
serviceLevel: z.string(),
trackingNumber: z.string(),
packageCount: z.number().int().positive(),
notes: z.string(),
});
const shipmentListQuerySchema = z.object({
q: z.string().optional(),
status: z.enum(shipmentStatuses).optional(),
salesOrderId: z.string().optional(),
});
const shipmentStatusUpdateSchema = z.object({
status: z.enum(shipmentStatuses),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
export const shippingRouter = Router();
shippingRouter.get("/orders/options", requirePermissions([permissions.shippingRead]), async (_request, response) => {
return ok(response, await listShipmentOrderOptions());
});
shippingRouter.get("/shipments", requirePermissions([permissions.shippingRead]), async (request, response) => {
const parsed = shipmentListQuerySchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Shipment filters are invalid.");
}
return ok(response, await listShipments(parsed.data));
});
shippingRouter.get("/shipments/:shipmentId", requirePermissions([permissions.shippingRead]), async (request, response) => {
const shipmentId = getRouteParam(request.params.shipmentId);
if (!shipmentId) {
return fail(response, 400, "INVALID_INPUT", "Shipment id is invalid.");
}
const shipment = await getShipmentById(shipmentId);
if (!shipment) {
return fail(response, 404, "SHIPMENT_NOT_FOUND", "Shipment was not found.");
}
return ok(response, shipment);
});
shippingRouter.post("/shipments", requirePermissions([permissions.shippingWrite]), async (request, response) => {
const parsed = shipmentSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Shipment payload is invalid.");
}
const result = await createShipment(parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.shipment, 201);
});
shippingRouter.put("/shipments/:shipmentId", requirePermissions([permissions.shippingWrite]), async (request, response) => {
const shipmentId = getRouteParam(request.params.shipmentId);
if (!shipmentId) {
return fail(response, 400, "INVALID_INPUT", "Shipment id is invalid.");
}
const parsed = shipmentSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Shipment payload is invalid.");
}
const result = await updateShipment(shipmentId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.shipment);
});
shippingRouter.patch("/shipments/:shipmentId/status", requirePermissions([permissions.shippingWrite]), async (request, response) => {
const shipmentId = getRouteParam(request.params.shipmentId);
if (!shipmentId) {
return fail(response, 400, "INVALID_INPUT", "Shipment id is invalid.");
}
const parsed = shipmentStatusUpdateSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Shipment status payload is invalid.");
}
const result = await updateShipmentStatus(shipmentId, parsed.data.status, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.shipment);
});

View File

@@ -0,0 +1,374 @@
import type {
ShipmentDetailDto,
ShipmentInput,
ShipmentOrderOptionDto,
ShipmentStatus,
ShipmentSummaryDto,
} from "@mrp/shared/dist/shipping/types.js";
import { logAuditEvent } from "../../lib/audit.js";
import { prisma } from "../../lib/prisma.js";
export interface ShipmentPackingSlipData {
shipmentNumber: string;
status: ShipmentStatus;
shipDate: string | null;
carrier: string;
serviceLevel: string;
trackingNumber: string;
packageCount: number;
notes: string;
salesOrderNumber: string;
customer: {
name: string;
addressLine1: string;
addressLine2: string;
city: string;
state: string;
postalCode: string;
country: string;
};
lines: Array<{
itemSku: string;
itemName: string;
description: string;
quantity: number;
unitOfMeasure: string;
}>;
}
export interface ShipmentDocumentData extends ShipmentPackingSlipData {
salesOrderId: string;
customerEmail: string;
customerPhone: string;
}
type ShipmentRecord = {
id: string;
shipmentNumber: string;
status: string;
shipDate: Date | null;
carrier: string;
serviceLevel: string;
trackingNumber: string;
packageCount: number;
notes: string;
createdAt: Date;
updatedAt: Date;
salesOrder: {
id: string;
documentNumber: string;
customer: {
name: string;
};
};
};
function mapShipment(record: ShipmentRecord): ShipmentDetailDto {
return {
id: record.id,
shipmentNumber: record.shipmentNumber,
salesOrderId: record.salesOrder.id,
salesOrderNumber: record.salesOrder.documentNumber,
customerName: record.salesOrder.customer.name,
status: record.status as ShipmentStatus,
carrier: record.carrier,
serviceLevel: record.serviceLevel,
trackingNumber: record.trackingNumber,
packageCount: record.packageCount,
shipDate: record.shipDate ? record.shipDate.toISOString() : null,
notes: record.notes,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
async function nextShipmentNumber() {
const next = (await prisma.shipment.count()) + 1;
return `SHP-${String(next).padStart(5, "0")}`;
}
export async function listShipmentOrderOptions(): Promise<ShipmentOrderOptionDto[]> {
const orders = await prisma.salesOrder.findMany({
include: {
customer: {
select: {
name: true,
},
},
lines: {
select: {
quantity: true,
unitPrice: true,
},
},
},
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
});
return orders.map((order) => ({
id: order.id,
documentNumber: order.documentNumber,
customerName: order.customer.name,
status: order.status,
total: order.lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0),
}));
}
export async function listShipments(filters: { q?: string; status?: ShipmentStatus; salesOrderId?: string } = {}) {
const query = filters.q?.trim();
const shipments = await prisma.shipment.findMany({
where: {
...(filters.status ? { status: filters.status } : {}),
...(filters.salesOrderId ? { salesOrderId: filters.salesOrderId } : {}),
...(query
? {
OR: [
{ shipmentNumber: { contains: query } },
{ trackingNumber: { contains: query } },
{ carrier: { contains: query } },
{ salesOrder: { documentNumber: { contains: query } } },
{ salesOrder: { customer: { name: { contains: query } } } },
],
}
: {}),
},
include: {
salesOrder: {
include: {
customer: {
select: {
name: true,
},
},
},
},
},
orderBy: [{ createdAt: "desc" }],
});
return shipments.map((shipment) => mapShipment(shipment));
}
export async function getShipmentById(shipmentId: string) {
const shipment = await prisma.shipment.findUnique({
where: { id: shipmentId },
include: {
salesOrder: {
include: {
customer: {
select: {
name: true,
},
},
},
},
},
});
return shipment ? mapShipment(shipment) : null;
}
export async function createShipment(payload: ShipmentInput, actorId?: string | null) {
const order = await prisma.salesOrder.findUnique({
where: { id: payload.salesOrderId },
select: { id: true },
});
if (!order) {
return { ok: false as const, reason: "Sales order was not found." };
}
const shipmentNumber = await nextShipmentNumber();
const shipment = await prisma.shipment.create({
data: {
shipmentNumber,
salesOrderId: payload.salesOrderId,
status: payload.status,
shipDate: payload.shipDate ? new Date(payload.shipDate) : null,
carrier: payload.carrier.trim(),
serviceLevel: payload.serviceLevel.trim(),
trackingNumber: payload.trackingNumber.trim(),
packageCount: payload.packageCount,
notes: payload.notes,
},
select: { id: true },
});
const detail = await getShipmentById(shipment.id);
if (detail) {
await logAuditEvent({
actorId,
entityType: "shipment",
entityId: shipment.id,
action: "created",
summary: `Created shipment ${detail.shipmentNumber}.`,
metadata: {
shipmentNumber: detail.shipmentNumber,
salesOrderId: detail.salesOrderId,
status: detail.status,
carrier: detail.carrier,
},
});
}
return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load saved shipment." };
}
export async function updateShipment(shipmentId: string, payload: ShipmentInput, actorId?: string | null) {
const existing = await prisma.shipment.findUnique({
where: { id: shipmentId },
select: { id: true },
});
if (!existing) {
return { ok: false as const, reason: "Shipment was not found." };
}
const order = await prisma.salesOrder.findUnique({
where: { id: payload.salesOrderId },
select: { id: true },
});
if (!order) {
return { ok: false as const, reason: "Sales order was not found." };
}
await prisma.shipment.update({
where: { id: shipmentId },
data: {
salesOrderId: payload.salesOrderId,
status: payload.status,
shipDate: payload.shipDate ? new Date(payload.shipDate) : null,
carrier: payload.carrier.trim(),
serviceLevel: payload.serviceLevel.trim(),
trackingNumber: payload.trackingNumber.trim(),
packageCount: payload.packageCount,
notes: payload.notes,
},
select: { id: true },
});
const detail = await getShipmentById(shipmentId);
if (detail) {
await logAuditEvent({
actorId,
entityType: "shipment",
entityId: shipmentId,
action: "updated",
summary: `Updated shipment ${detail.shipmentNumber}.`,
metadata: {
shipmentNumber: detail.shipmentNumber,
salesOrderId: detail.salesOrderId,
status: detail.status,
carrier: detail.carrier,
},
});
}
return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load saved shipment." };
}
export async function updateShipmentStatus(shipmentId: string, status: ShipmentStatus, actorId?: string | null) {
const existing = await prisma.shipment.findUnique({
where: { id: shipmentId },
select: { id: true },
});
if (!existing) {
return { ok: false as const, reason: "Shipment was not found." };
}
await prisma.shipment.update({
where: { id: shipmentId },
data: { status },
select: { id: true },
});
const detail = await getShipmentById(shipmentId);
if (detail) {
await logAuditEvent({
actorId,
entityType: "shipment",
entityId: shipmentId,
action: "status.updated",
summary: `Updated shipment ${detail.shipmentNumber} to ${status}.`,
metadata: {
shipmentNumber: detail.shipmentNumber,
status,
},
});
}
return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load updated shipment." };
}
export async function getShipmentPackingSlipData(shipmentId: string): Promise<ShipmentPackingSlipData | null> {
const shipment = await getShipmentDocumentData(shipmentId);
if (!shipment) {
return null;
}
return shipment;
}
export async function getShipmentDocumentData(shipmentId: string): Promise<ShipmentDocumentData | null> {
const shipment = await prisma.shipment.findUnique({
where: { id: shipmentId },
include: {
salesOrder: {
include: {
customer: {
select: {
name: true,
email: true,
phone: true,
addressLine1: true,
addressLine2: true,
city: true,
state: true,
postalCode: true,
country: true,
},
},
lines: {
include: {
item: {
select: {
sku: true,
name: true,
},
},
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
},
},
},
});
if (!shipment) {
return null;
}
return {
salesOrderId: shipment.salesOrder.id,
shipmentNumber: shipment.shipmentNumber,
status: shipment.status as ShipmentStatus,
shipDate: shipment.shipDate ? shipment.shipDate.toISOString() : null,
carrier: shipment.carrier,
serviceLevel: shipment.serviceLevel,
trackingNumber: shipment.trackingNumber,
packageCount: shipment.packageCount,
notes: shipment.notes,
salesOrderNumber: shipment.salesOrder.documentNumber,
customerEmail: shipment.salesOrder.customer.email,
customerPhone: shipment.salesOrder.customer.phone,
customer: shipment.salesOrder.customer,
lines: shipment.salesOrder.lines.map((line) => ({
itemSku: line.item.sku,
itemName: line.item.name,
description: line.description,
quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure,
})),
};
}

69
server/src/server.ts Normal file
View File

@@ -0,0 +1,69 @@
import { createApp } from "./app.js";
import { env } from "./config/env.js";
import { pruneOldAuthSessions } from "./lib/auth-sessions.js";
import { bootstrapAppData } from "./lib/bootstrap.js";
import { prisma } from "./lib/prisma.js";
import { setLatestStartupReport } from "./lib/startup-state.js";
import { assertStartupReadiness } from "./lib/startup-validation.js";
import { recordSupportLog } from "./lib/support-log.js";
async function start() {
await bootstrapAppData();
const prunedSessionCount = await pruneOldAuthSessions();
const startupReport = await assertStartupReadiness();
setLatestStartupReport(startupReport);
recordSupportLog({
level: startupReport.status === "PASS" ? "INFO" : startupReport.status === "WARN" ? "WARN" : "ERROR",
source: "startup-validation",
message: `Startup validation completed with status ${startupReport.status}.`,
context: {
generatedAt: startupReport.generatedAt,
durationMs: startupReport.durationMs,
passCount: startupReport.passCount,
warnCount: startupReport.warnCount,
failCount: startupReport.failCount,
prunedSessionCount,
},
});
for (const check of startupReport.checks.filter((entry) => entry.status !== "PASS")) {
console.warn(`[startup:${check.status.toLowerCase()}] ${check.label}: ${check.message}`);
recordSupportLog({
level: check.status === "WARN" ? "WARN" : "ERROR",
source: "startup-check",
message: `${check.label}: ${check.message}`,
context: {
checkId: check.id,
status: check.status,
},
});
}
const app = createApp();
const server = app.listen(env.PORT, () => {
console.log(`MRP server listening on port ${env.PORT}`);
});
const shutdown = async () => {
server.close();
await prisma.$disconnect();
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}
start().catch(async (error) => {
console.error(error);
recordSupportLog({
level: "ERROR",
source: "server-startup",
message: error instanceof Error ? error.message : "Server startup failed.",
context: {
stack: error instanceof Error ? error.stack ?? null : null,
},
});
await prisma.$disconnect();
process.exit(1);
});

12
server/src/types/express.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
import type { AuthUser } from "@mrp/shared";
declare global {
namespace Express {
interface Request {
authUser?: AuthUser;
authSessionId?: string;
}
}
}
export {};

33
server/tests/auth.test.ts Normal file
View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { permissions } from "@mrp/shared";
import { requirePermissions } from "../src/lib/rbac.js";
describe("rbac", () => {
it("allows requests with all required permissions", () => {
const middleware = requirePermissions([permissions.companyRead]);
const request = {
authUser: {
id: "1",
email: "admin@example.com",
firstName: "Admin",
lastName: "User",
roles: ["Administrator"],
permissions: [permissions.companyRead],
},
};
const response = {
status: () => response,
json: (body: unknown) => body,
};
let nextCalled = false;
middleware(request as never, response as never, () => {
nextCalled = true;
});
expect(nextCalled).toBe(true);
});
});

View File

@@ -0,0 +1,166 @@
import { describe, expect, it } from "vitest";
import { buildSalesOrderPlanning, type SalesOrderPlanningSnapshot } from "../src/modules/sales/planning.js";
describe("sales order planning", () => {
it("nets stock and open supply before cascading build demand into child components", () => {
const snapshot: SalesOrderPlanningSnapshot = {
orderId: "order-1",
documentNumber: "SO-00001",
status: "APPROVED",
lines: [
{
id: "line-1",
itemId: "assembly-1",
itemSku: "ASSY-100",
itemName: "Assembly",
quantity: 5,
unitOfMeasure: "EA",
},
],
itemsById: {
"assembly-1": {
id: "assembly-1",
sku: "ASSY-100",
name: "Assembly",
type: "ASSEMBLY",
unitOfMeasure: "EA",
isPurchasable: false,
bomLines: [
{
componentItemId: "mfg-1",
quantity: 2,
unitOfMeasure: "EA",
},
{
componentItemId: "buy-1",
quantity: 3,
unitOfMeasure: "EA",
},
],
},
"mfg-1": {
id: "mfg-1",
sku: "MFG-200",
name: "Manufactured Child",
type: "MANUFACTURED",
unitOfMeasure: "EA",
isPurchasable: false,
bomLines: [
{
componentItemId: "buy-2",
quantity: 4,
unitOfMeasure: "EA",
},
],
},
"buy-1": {
id: "buy-1",
sku: "BUY-300",
name: "Purchased Child",
type: "PURCHASED",
unitOfMeasure: "EA",
isPurchasable: true,
bomLines: [],
},
"buy-2": {
id: "buy-2",
sku: "BUY-400",
name: "Raw Material",
type: "PURCHASED",
unitOfMeasure: "EA",
isPurchasable: true,
bomLines: [],
},
},
supplyByItemId: {
"assembly-1": {
onHandQuantity: 1,
reservedQuantity: 0,
availableQuantity: 1,
openWorkOrderSupply: 1,
openPurchaseSupply: 0,
},
"mfg-1": {
onHandQuantity: 2,
reservedQuantity: 0,
availableQuantity: 2,
openWorkOrderSupply: 1,
openPurchaseSupply: 0,
},
"buy-1": {
onHandQuantity: 4,
reservedQuantity: 1,
availableQuantity: 3,
openWorkOrderSupply: 0,
openPurchaseSupply: 5,
},
"buy-2": {
onHandQuantity: 1,
reservedQuantity: 0,
availableQuantity: 1,
openWorkOrderSupply: 0,
openPurchaseSupply: 2,
},
},
orderLinkedSupplyByItemId: {
"assembly-1": {
linkedWorkOrderSupply: 1,
linkedPurchaseSupply: 0,
},
"mfg-1": {
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 0,
},
"buy-1": {
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 0,
},
"buy-2": {
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 3,
},
},
lineLinkedSupplyByLineId: {
"line-1": {
"buy-1": {
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 2,
},
},
},
};
const planning = buildSalesOrderPlanning(snapshot);
expect(planning.summary.totalBuildQuantity).toBe(3);
expect(planning.summary.totalPurchaseQuantity).toBe(0);
const assembly = planning.items.find((item) => item.itemId === "assembly-1");
const manufacturedChild = planning.items.find((item) => item.itemId === "mfg-1");
const purchasedChild = planning.items.find((item) => item.itemId === "buy-1");
const rawMaterial = planning.items.find((item) => item.itemId === "buy-2");
expect(assembly?.linkedWorkOrderSupply).toBe(1);
expect(assembly?.recommendedBuildQuantity).toBe(2);
expect(assembly?.supplyFromStock).toBe(1);
expect(assembly?.supplyFromOpenWorkOrders).toBe(1);
expect(manufacturedChild?.grossDemand).toBe(4);
expect(manufacturedChild?.recommendedBuildQuantity).toBe(1);
expect(manufacturedChild?.supplyFromStock).toBe(2);
expect(manufacturedChild?.supplyFromOpenWorkOrders).toBe(1);
expect(purchasedChild?.grossDemand).toBe(6);
expect(purchasedChild?.linkedPurchaseSupply).toBe(2);
expect(purchasedChild?.recommendedPurchaseQuantity).toBe(0);
expect(purchasedChild?.supplyFromStock).toBe(3);
expect(purchasedChild?.supplyFromOpenPurchaseOrders).toBe(1);
expect(rawMaterial?.grossDemand).toBe(4);
expect(rawMaterial?.linkedPurchaseSupply).toBe(3);
expect(rawMaterial?.recommendedPurchaseQuantity).toBe(0);
expect(rawMaterial?.supplyFromStock).toBe(1);
expect(rawMaterial?.supplyFromOpenPurchaseOrders).toBe(0);
});
});

13
server/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": [
"node"
]
},
"include": [
"src"
]
}

8
server/vitest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});