admin services
This commit is contained in:
@@ -25,6 +25,7 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a
|
||||
- planning gantt timelines backed by live project and manufacturing schedule data
|
||||
- admin diagnostics with runtime footprint, record counts, and persisted audit-trail visibility
|
||||
- admin user management with account creation, activation, role assignment, and role-permission editing
|
||||
- CRM/shipping audit coverage and startup validation surfaced through the admin diagnostics workflow
|
||||
- Puppeteer PDF foundation
|
||||
- single-container Docker deployment
|
||||
|
||||
@@ -121,8 +122,8 @@ If implementation changes invalidate those docs, update them in the same change
|
||||
|
||||
Near-term priorities are:
|
||||
|
||||
1. CRM/shipping audit coverage and richer startup validation
|
||||
2. Backup/restore workflow documentation and support-oriented admin tooling
|
||||
1. Backup/restore workflow documentation and support-oriented admin tooling
|
||||
2. Deeper startup diagnostics and support export helpers
|
||||
|
||||
When adding new modules, preserve the ability to extend the system without refactoring the existing app shell.
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ This file is the running release and change log for MRP Codex. Keep it updated w
|
||||
|
||||
### Added
|
||||
|
||||
- CRM customer/vendor changes and shipping mutations now feed the shared audit trail
|
||||
- Startup validation now runs during server boot and surfaces readiness checks in admin diagnostics
|
||||
- Admin user-management screen with account creation, activation control, role assignment, and role-permission editing
|
||||
- Route-level lazy loading and vendor chunking across the client so major operational modules no longer ship in the initial bundle
|
||||
- Persisted audit events for core settings, inventory, purchasing, sales, project, and manufacturing write actions
|
||||
@@ -43,7 +45,8 @@ This file is the running release and change log for MRP Codex. Keep it updated w
|
||||
- Vendor CRM detail now exposes purchasing activity and can launch pre-seeded purchase orders
|
||||
- The client entry bundle now stays lighter by loading major modules on demand instead of importing all route pages eagerly in `main.tsx`
|
||||
- Company settings now acts as the staging area for admin surfaces while user administration lives on its own dedicated page instead of inside the company-profile form
|
||||
- Roadmap and project docs now treat CRM/shipping audit coverage and richer startup validation as the next active priority after the user-management slice
|
||||
- Admin diagnostics now includes startup-readiness status alongside runtime footprint and recent audit activity
|
||||
- Roadmap and project docs now treat backup/restore workflow documentation and support-oriented admin tooling as the next active priority after the CRM/shipping audit and startup-validation slice
|
||||
|
||||
## 2026-03-15
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ This repository implements the platform foundation milestone:
|
||||
- planning gantt timelines backed by live project and manufacturing schedule data
|
||||
- admin diagnostics with runtime footprint, storage visibility, record counts, and recent audit activity
|
||||
- admin user management with account creation, activation, role assignment, and role-permission editing
|
||||
- CRM/shipping audit coverage and startup validation surfaced through diagnostics
|
||||
- Dockerized single-container deployment
|
||||
- Puppeteer PDF pipeline foundation
|
||||
|
||||
@@ -64,5 +65,5 @@ This repository implements the platform foundation milestone:
|
||||
|
||||
## Next roadmap candidates
|
||||
|
||||
- CRM/shipping audit coverage and richer startup validation
|
||||
- backup/restore workflow depth and support-oriented admin tooling
|
||||
- deeper startup diagnostics and support export helpers
|
||||
|
||||
12
README.md
12
README.md
@@ -28,6 +28,7 @@ Current foundation scope includes:
|
||||
- planning gantt timelines with live project and manufacturing schedule data
|
||||
- admin diagnostics with runtime footprint, record counts, and recent audit-trail visibility
|
||||
- admin user management with account creation, activation, role assignment, and role-permission editing
|
||||
- CRM and shipping audit coverage plus startup validation surfaced through the admin diagnostics page
|
||||
- route-level code-splitting and vendor chunking for lighter initial client loads
|
||||
- file storage and PDF rendering
|
||||
|
||||
@@ -49,14 +50,14 @@ Current completed foundation areas:
|
||||
|
||||
Near-term priorities:
|
||||
|
||||
1. CRM/shipping audit coverage and richer startup validation
|
||||
2. Backup/restore workflow documentation and support-oriented admin tooling
|
||||
1. Backup/restore workflow documentation and support-oriented admin tooling
|
||||
2. Deeper startup diagnostics and support export helpers
|
||||
|
||||
Revisit / deferred items:
|
||||
|
||||
- local Windows Prisma migration reliability
|
||||
- deeper support diagnostics and startup validation
|
||||
- backup/restore workflow depth and support tooling
|
||||
- deeper startup diagnostics and support export helpers
|
||||
|
||||
Dashboard direction:
|
||||
|
||||
@@ -348,13 +349,14 @@ The current admin operations slice supports:
|
||||
- persisted audit events for core settings, inventory, purchasing, sales, project, and manufacturing write actions
|
||||
- an admin diagnostics page for runtime footprint, data/storage path visibility, key record counts, and recent audit activity
|
||||
- a dedicated user-management page for account creation, activation, role assignment, password reset-style updates, and role-permission administration
|
||||
- CRM customer/vendor changes and shipping mutations now flow into the shared audit trail
|
||||
- startup validation now checks storage paths, database connectivity, client bundle readiness, Chromium availability, and risky production defaults during server boot
|
||||
- operator-facing review of recent high-impact changes without direct database access
|
||||
|
||||
Current follow-up direction:
|
||||
|
||||
- deeper audit coverage across CRM and shipping mutations
|
||||
- richer environment validation and startup diagnostics
|
||||
- backup/restore workflow guidance and support-oriented admin tooling
|
||||
- deeper startup diagnostics and support export helpers
|
||||
|
||||
## UI Notes
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
|
||||
- Audit trail coverage across core settings, inventory, purchasing, project, sales, and manufacturing write flows
|
||||
- Admin diagnostics screen with runtime footprint, record counts, storage-path visibility, and recent audit activity
|
||||
- Dedicated user-management screen for account creation, activation, role assignment, and role-permission editing
|
||||
- CRM customer/vendor changes and shipping mutations covered by the shared audit trail
|
||||
- Startup validation during server boot with checks for storage paths, database connectivity, client bundle readiness, Chromium availability, and risky production defaults
|
||||
- Route-level frontend code-splitting and vendor chunking to keep the initial client payload lighter
|
||||
- SKU-searchable BOM component selection for inventory-scale datasets
|
||||
- Theme persistence fixes and denser responsive workspace layouts
|
||||
@@ -255,6 +257,8 @@ Foundation slice shipped:
|
||||
- Audit trail coverage across core write flows for settings, inventory, sales, purchasing, projects, and manufacturing
|
||||
- Admin diagnostics screen for runtime footprint, storage visibility, key record counts, and recent audit activity
|
||||
- Expanded role-management UI with account creation, activation, role assignment, and permission administration
|
||||
- CRM customer/vendor changes and shipping mutations covered by the shared audit trail
|
||||
- Startup validation during server boot with checks for storage paths, database connectivity, client bundle readiness, Chromium availability, and risky production defaults
|
||||
|
||||
- Expanded role management UI
|
||||
- Permission assignment administration
|
||||
@@ -289,5 +293,5 @@ QOL subfeatures:
|
||||
|
||||
## Near-term priority order
|
||||
|
||||
1. CRM/shipping audit coverage plus richer startup validation
|
||||
2. Backup/restore workflow documentation and support-oriented admin tooling
|
||||
1. Backup/restore workflow documentation and support-oriented admin tooling
|
||||
2. Deeper startup diagnostics and support export helpers
|
||||
|
||||
@@ -78,6 +78,13 @@ export function AdminDiagnosticsPage() {
|
||||
["Shipments", diagnostics.shipmentCount.toString()],
|
||||
];
|
||||
|
||||
const startupStatusTone =
|
||||
diagnostics.startupStatus === "PASS"
|
||||
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-200"
|
||||
: diagnostics.startupStatus === "WARN"
|
||||
? "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-200"
|
||||
: "bg-rose-100 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||
@@ -108,6 +115,29 @@ export function AdminDiagnosticsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Startup Validation</p>
|
||||
<h3 className="mt-2 text-lg font-bold text-text">Boot-time readiness checks</h3>
|
||||
</div>
|
||||
<span className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${startupStatusTone}`}>
|
||||
{diagnostics.startupStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-3 xl:grid-cols-2">
|
||||
{diagnostics.startupChecks.map((check) => (
|
||||
<div key={check.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-text">{check.label}</p>
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{check.status}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted">{check.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">System Footprint</p>
|
||||
<div className="mt-5 grid gap-3 xl:grid-cols-2">
|
||||
|
||||
19
server/src/lib/startup-state.ts
Normal file
19
server/src/lib/startup-state.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { StartupValidationCheckDto } from "@mrp/shared";
|
||||
|
||||
interface StartupValidationReport {
|
||||
status: "PASS" | "WARN" | "FAIL";
|
||||
checks: StartupValidationCheckDto[];
|
||||
}
|
||||
|
||||
let latestStartupReport: StartupValidationReport = {
|
||||
status: "WARN",
|
||||
checks: [],
|
||||
};
|
||||
|
||||
export function setLatestStartupReport(report: StartupValidationReport) {
|
||||
latestStartupReport = report;
|
||||
}
|
||||
|
||||
export function getLatestStartupReport() {
|
||||
return latestStartupReport;
|
||||
}
|
||||
138
server/src/lib/startup-validation.ts
Normal file
138
server/src/lib/startup-validation.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { StartupValidationCheckDto } from "@mrp/shared";
|
||||
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";
|
||||
|
||||
interface StartupValidationReport {
|
||||
status: "PASS" | "WARN" | "FAIL";
|
||||
checks: StartupValidationCheckDto[];
|
||||
}
|
||||
|
||||
async function pathExists(targetPath: string) {
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectStartupValidationReport(): Promise<StartupValidationReport> {
|
||||
const checks: StartupValidationCheckDto[] = [];
|
||||
|
||||
checks.push({
|
||||
id: "data-dir",
|
||||
label: "Data directory",
|
||||
status: (await pathExists(paths.dataDir)) ? "PASS" : "FAIL",
|
||||
message: (await pathExists(paths.dataDir)) ? `Data directory available at ${paths.dataDir}.` : `Data directory is missing: ${paths.dataDir}.`,
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "uploads-dir",
|
||||
label: "Uploads directory",
|
||||
status: (await pathExists(paths.uploadsDir)) ? "PASS" : "FAIL",
|
||||
message: (await pathExists(paths.uploadsDir))
|
||||
? `Uploads directory available at ${paths.uploadsDir}.`
|
||||
: `Uploads directory is missing: ${paths.uploadsDir}.`,
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "prisma-dir",
|
||||
label: "Prisma directory",
|
||||
status: (await pathExists(paths.prismaDir)) ? "PASS" : "FAIL",
|
||||
message: (await pathExists(paths.prismaDir))
|
||||
? `Prisma data directory available at ${paths.prismaDir}.`
|
||||
: `Prisma data directory is missing: ${paths.prismaDir}.`,
|
||||
});
|
||||
|
||||
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: (await pathExists(path.join(paths.clientDistDir, "index.html"))) ? "PASS" : "FAIL",
|
||||
message: (await pathExists(path.join(paths.clientDistDir, "index.html")))
|
||||
? `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.",
|
||||
});
|
||||
}
|
||||
|
||||
const puppeteerPath = env.PUPPETEER_EXECUTABLE_PATH || "/usr/bin/chromium";
|
||||
const puppeteerExists = await pathExists(puppeteerPath);
|
||||
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: "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,
|
||||
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;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ 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";
|
||||
|
||||
function mapAuditEvent(record: {
|
||||
id: string;
|
||||
@@ -464,6 +465,7 @@ export async function updateAdminUser(userId: string, payload: AdminUserInput, a
|
||||
}
|
||||
|
||||
export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
|
||||
const startupReport = getLatestStartupReport();
|
||||
const [
|
||||
companyProfile,
|
||||
userCount,
|
||||
@@ -540,6 +542,8 @@ export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
|
||||
shipmentCount,
|
||||
attachmentCount,
|
||||
auditEventCount,
|
||||
startupStatus: startupReport.status,
|
||||
startupChecks: startupReport.checks,
|
||||
recentAuditEvents: recentAuditEvents.map(mapAuditEvent),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ crmRouter.post("/customers", requirePermissions([permissions.crmWrite]), async (
|
||||
return fail(response, 400, "INVALID_INPUT", "Customer payload is invalid.");
|
||||
}
|
||||
|
||||
const customer = await createCustomer(parsed.data);
|
||||
const customer = await createCustomer(parsed.data, request.authUser?.id);
|
||||
if (!customer) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Customer reseller relationship is invalid.");
|
||||
}
|
||||
@@ -143,7 +143,7 @@ crmRouter.put("/customers/:customerId", requirePermissions([permissions.crmWrite
|
||||
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found.");
|
||||
}
|
||||
|
||||
const customer = await updateCustomer(customerId, parsed.data);
|
||||
const customer = await updateCustomer(customerId, parsed.data, request.authUser?.id);
|
||||
if (!customer) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Customer reseller relationship is invalid.");
|
||||
}
|
||||
@@ -181,7 +181,7 @@ crmRouter.post("/customers/:customerId/contacts", requirePermissions([permission
|
||||
return fail(response, 400, "INVALID_INPUT", "CRM contact is invalid.");
|
||||
}
|
||||
|
||||
const contact = await createCustomerContact(customerId, parsed.data);
|
||||
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.");
|
||||
}
|
||||
@@ -227,7 +227,7 @@ crmRouter.post("/vendors", requirePermissions([permissions.crmWrite]), async (re
|
||||
return fail(response, 400, "INVALID_INPUT", "Vendor payload is invalid.");
|
||||
}
|
||||
|
||||
return ok(response, await createVendor(parsed.data), 201);
|
||||
return ok(response, await createVendor(parsed.data, request.authUser?.id), 201);
|
||||
});
|
||||
|
||||
crmRouter.put("/vendors/:vendorId", requirePermissions([permissions.crmWrite]), async (request, response) => {
|
||||
@@ -241,7 +241,7 @@ crmRouter.put("/vendors/:vendorId", requirePermissions([permissions.crmWrite]),
|
||||
return fail(response, 400, "INVALID_INPUT", "Vendor payload is invalid.");
|
||||
}
|
||||
|
||||
const vendor = await updateVendor(vendorId, parsed.data);
|
||||
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.");
|
||||
}
|
||||
@@ -279,7 +279,7 @@ crmRouter.post("/vendors/:vendorId/contacts", requirePermissions([permissions.cr
|
||||
return fail(response, 400, "INVALID_INPUT", "CRM contact is invalid.");
|
||||
}
|
||||
|
||||
const contact = await createVendorContact(vendorId, parsed.data);
|
||||
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.");
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
} 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 {
|
||||
@@ -397,7 +398,7 @@ export async function getCustomerById(customerId: string) {
|
||||
return mapCustomerDetail(customer, attachmentCount);
|
||||
}
|
||||
|
||||
export async function createCustomer(payload: CrmRecordInput) {
|
||||
export async function createCustomer(payload: CrmRecordInput, actorId?: string | null) {
|
||||
if (payload.parentCustomerId) {
|
||||
const parentCustomer = await prisma.customer.findUnique({
|
||||
where: { id: payload.parentCustomerId },
|
||||
@@ -436,6 +437,20 @@ export async function createCustomer(payload: CrmRecordInput) {
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
@@ -463,7 +478,7 @@ export async function createCustomer(payload: CrmRecordInput) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateCustomer(customerId: string, payload: CrmRecordInput) {
|
||||
export async function updateCustomer(customerId: string, payload: CrmRecordInput, actorId?: string | null) {
|
||||
const existingCustomer = await prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
});
|
||||
@@ -515,6 +530,20 @@ export async function updateCustomer(customerId: string, payload: CrmRecordInput
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
@@ -630,7 +659,7 @@ export async function getVendorById(vendorId: string) {
|
||||
return mapVendorDetail(vendor, attachmentCount);
|
||||
}
|
||||
|
||||
export async function createVendor(payload: CrmRecordInput) {
|
||||
export async function createVendor(payload: CrmRecordInput, actorId?: string | null) {
|
||||
const vendor = await prisma.vendor.create({
|
||||
data: {
|
||||
name: payload.name,
|
||||
@@ -656,6 +685,19 @@ export async function createVendor(payload: CrmRecordInput) {
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
@@ -677,7 +719,7 @@ export async function createVendor(payload: CrmRecordInput) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateVendor(vendorId: string, payload: CrmRecordInput) {
|
||||
export async function updateVendor(vendorId: string, payload: CrmRecordInput, actorId?: string | null) {
|
||||
const existingVendor = await prisma.vendor.findUnique({
|
||||
where: { id: vendorId },
|
||||
});
|
||||
@@ -712,6 +754,19 @@ export async function updateVendor(vendorId: string, payload: CrmRecordInput) {
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
@@ -756,6 +811,19 @@ export async function createCustomerContactEntry(customerId: string, payload: Cr
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -782,10 +850,23 @@ export async function createVendorContactEntry(vendorId: string, payload: CrmCon
|
||||
},
|
||||
});
|
||||
|
||||
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) {
|
||||
export async function createCustomerContact(customerId: string, payload: CrmContactInput, actorId?: string | null) {
|
||||
const existingCustomer = await prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
});
|
||||
@@ -812,10 +893,24 @@ export async function createCustomerContact(customerId: string, payload: CrmCont
|
||||
},
|
||||
});
|
||||
|
||||
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) {
|
||||
export async function createVendorContact(vendorId: string, payload: CrmContactInput, actorId?: string | null) {
|
||||
const existingVendor = await prisma.vendor.findUnique({
|
||||
where: { id: vendorId },
|
||||
});
|
||||
@@ -842,5 +937,19 @@ export async function createVendorContact(vendorId: string, payload: CrmContactI
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ shippingRouter.post("/shipments", requirePermissions([permissions.shippingWrite]
|
||||
return fail(response, 400, "INVALID_INPUT", "Shipment payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await createShipment(parsed.data);
|
||||
const result = await createShipment(parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
@@ -86,7 +86,7 @@ shippingRouter.put("/shipments/:shipmentId", requirePermissions([permissions.shi
|
||||
return fail(response, 400, "INVALID_INPUT", "Shipment payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updateShipment(shipmentId, parsed.data);
|
||||
const result = await updateShipment(shipmentId, parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
@@ -105,7 +105,7 @@ shippingRouter.patch("/shipments/:shipmentId/status", requirePermissions([permis
|
||||
return fail(response, 400, "INVALID_INPUT", "Shipment status payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updateShipmentStatus(shipmentId, parsed.data.status);
|
||||
const result = await updateShipmentStatus(shipmentId, parsed.data.status, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ShipmentSummaryDto,
|
||||
} from "@mrp/shared/dist/shipping/types.js";
|
||||
|
||||
import { logAuditEvent } from "../../lib/audit.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
export interface ShipmentPackingSlipData {
|
||||
@@ -168,7 +169,7 @@ export async function getShipmentById(shipmentId: string) {
|
||||
return shipment ? mapShipment(shipment) : null;
|
||||
}
|
||||
|
||||
export async function createShipment(payload: ShipmentInput) {
|
||||
export async function createShipment(payload: ShipmentInput, actorId?: string | null) {
|
||||
const order = await prisma.salesOrder.findUnique({
|
||||
where: { id: payload.salesOrderId },
|
||||
select: { id: true },
|
||||
@@ -195,10 +196,25 @@ export async function createShipment(payload: ShipmentInput) {
|
||||
});
|
||||
|
||||
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) {
|
||||
export async function updateShipment(shipmentId: string, payload: ShipmentInput, actorId?: string | null) {
|
||||
const existing = await prisma.shipment.findUnique({
|
||||
where: { id: shipmentId },
|
||||
select: { id: true },
|
||||
@@ -233,10 +249,25 @@ export async function updateShipment(shipmentId: string, payload: ShipmentInput)
|
||||
});
|
||||
|
||||
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) {
|
||||
export async function updateShipmentStatus(shipmentId: string, status: ShipmentStatus, actorId?: string | null) {
|
||||
const existing = await prisma.shipment.findUnique({
|
||||
where: { id: shipmentId },
|
||||
select: { id: true },
|
||||
@@ -253,6 +284,19 @@ export async function updateShipmentStatus(shipmentId: string, status: ShipmentS
|
||||
});
|
||||
|
||||
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." };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,17 @@ import { createApp } from "./app.js";
|
||||
import { env } from "./config/env.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";
|
||||
|
||||
async function start() {
|
||||
await bootstrapAppData();
|
||||
const startupReport = await assertStartupReadiness();
|
||||
setLatestStartupReport(startupReport);
|
||||
|
||||
for (const check of startupReport.checks.filter((entry) => entry.status !== "PASS")) {
|
||||
console.warn(`[startup:${check.status.toLowerCase()}] ${check.label}: ${check.message}`);
|
||||
}
|
||||
|
||||
const app = createApp();
|
||||
const server = app.listen(env.PORT, () => {
|
||||
@@ -25,4 +33,3 @@ start().catch(async (error) => {
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -53,6 +53,13 @@ export interface AdminUserInput {
|
||||
password: string | null;
|
||||
}
|
||||
|
||||
export interface StartupValidationCheckDto {
|
||||
id: string;
|
||||
label: string;
|
||||
status: "PASS" | "WARN" | "FAIL";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AdminDiagnosticsDto {
|
||||
serverTime: string;
|
||||
nodeVersion: string;
|
||||
@@ -76,5 +83,7 @@ export interface AdminDiagnosticsDto {
|
||||
shipmentCount: number;
|
||||
attachmentCount: number;
|
||||
auditEventCount: number;
|
||||
startupStatus: "PASS" | "WARN" | "FAIL";
|
||||
startupChecks: StartupValidationCheckDto[];
|
||||
recentAuditEvents: AuditEventDto[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user