admin services

This commit is contained in:
2026-03-15 14:57:41 -05:00
parent 3197e68749
commit 28b23bc355
15 changed files with 401 additions and 30 deletions

View File

@@ -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 - planning gantt timelines backed by live project and manufacturing schedule data
- admin diagnostics with runtime footprint, record counts, and persisted audit-trail visibility - 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 - 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 - Puppeteer PDF foundation
- single-container Docker deployment - single-container Docker deployment
@@ -121,8 +122,8 @@ If implementation changes invalidate those docs, update them in the same change
Near-term priorities are: Near-term priorities are:
1. CRM/shipping audit coverage and richer startup validation 1. Backup/restore workflow documentation and support-oriented admin tooling
2. 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. When adding new modules, preserve the ability to extend the system without refactoring the existing app shell.

View File

@@ -6,6 +6,8 @@ This file is the running release and change log for MRP Codex. Keep it updated w
### Added ### 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 - 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 - 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 - 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 - 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` - 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 - 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 ## 2026-03-15

View File

@@ -29,6 +29,7 @@ This repository implements the platform foundation milestone:
- planning gantt timelines backed by live project and manufacturing schedule data - 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 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 - 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 - Dockerized single-container deployment
- Puppeteer PDF pipeline foundation - Puppeteer PDF pipeline foundation
@@ -64,5 +65,5 @@ This repository implements the platform foundation milestone:
## Next roadmap candidates ## Next roadmap candidates
- CRM/shipping audit coverage and richer startup validation
- backup/restore workflow depth and support-oriented admin tooling - backup/restore workflow depth and support-oriented admin tooling
- deeper startup diagnostics and support export helpers

View File

@@ -28,6 +28,7 @@ Current foundation scope includes:
- planning gantt timelines with live project and manufacturing schedule data - planning gantt timelines with live project and manufacturing schedule data
- admin diagnostics with runtime footprint, record counts, and recent audit-trail visibility - 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 - 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 - route-level code-splitting and vendor chunking for lighter initial client loads
- file storage and PDF rendering - file storage and PDF rendering
@@ -49,14 +50,14 @@ Current completed foundation areas:
Near-term priorities: Near-term priorities:
1. CRM/shipping audit coverage and richer startup validation 1. Backup/restore workflow documentation and support-oriented admin tooling
2. Backup/restore workflow documentation and support-oriented admin tooling 2. Deeper startup diagnostics and support export helpers
Revisit / deferred items: Revisit / deferred items:
- local Windows Prisma migration reliability - local Windows Prisma migration reliability
- deeper support diagnostics and startup validation
- backup/restore workflow depth and support tooling - backup/restore workflow depth and support tooling
- deeper startup diagnostics and support export helpers
Dashboard direction: 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 - 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 - 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 - 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 - operator-facing review of recent high-impact changes without direct database access
Current follow-up direction: 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 - backup/restore workflow guidance and support-oriented admin tooling
- deeper startup diagnostics and support export helpers
## UI Notes ## UI Notes

View File

@@ -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 - 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 - 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 - 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 - Route-level frontend code-splitting and vendor chunking to keep the initial client payload lighter
- SKU-searchable BOM component selection for inventory-scale datasets - SKU-searchable BOM component selection for inventory-scale datasets
- Theme persistence fixes and denser responsive workspace layouts - 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 - 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 - 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 - 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 - Expanded role management UI
- Permission assignment administration - Permission assignment administration
@@ -289,5 +293,5 @@ QOL subfeatures:
## Near-term priority order ## Near-term priority order
1. CRM/shipping audit coverage plus richer startup validation 1. Backup/restore workflow documentation and support-oriented admin tooling
2. Backup/restore workflow documentation and support-oriented admin tooling 2. Deeper startup diagnostics and support export helpers

View File

@@ -78,6 +78,13 @@ export function AdminDiagnosticsPage() {
["Shipments", diagnostics.shipmentCount.toString()], ["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 ( return (
<div className="space-y-6"> <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"> <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> </div>
</section> </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"> <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> <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"> <div className="mt-5 grid gap-3 xl:grid-cols-2">

View 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;
}

View 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;
}

View File

@@ -14,6 +14,7 @@ import { paths } from "../../config/paths.js";
import { logAuditEvent } from "../../lib/audit.js"; import { logAuditEvent } from "../../lib/audit.js";
import { hashPassword } from "../../lib/password.js"; import { hashPassword } from "../../lib/password.js";
import { prisma } from "../../lib/prisma.js"; import { prisma } from "../../lib/prisma.js";
import { getLatestStartupReport } from "../../lib/startup-state.js";
function mapAuditEvent(record: { function mapAuditEvent(record: {
id: string; id: string;
@@ -464,6 +465,7 @@ export async function updateAdminUser(userId: string, payload: AdminUserInput, a
} }
export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> { export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
const startupReport = getLatestStartupReport();
const [ const [
companyProfile, companyProfile,
userCount, userCount,
@@ -540,6 +542,8 @@ export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
shipmentCount, shipmentCount,
attachmentCount, attachmentCount,
auditEventCount, auditEventCount,
startupStatus: startupReport.status,
startupChecks: startupReport.checks,
recentAuditEvents: recentAuditEvents.map(mapAuditEvent), recentAuditEvents: recentAuditEvents.map(mapAuditEvent),
}; };
} }

View File

@@ -119,7 +119,7 @@ crmRouter.post("/customers", requirePermissions([permissions.crmWrite]), async (
return fail(response, 400, "INVALID_INPUT", "Customer payload is invalid."); 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) { if (!customer) {
return fail(response, 400, "INVALID_INPUT", "Customer reseller relationship is invalid."); 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."); 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) { if (!customer) {
return fail(response, 400, "INVALID_INPUT", "Customer reseller relationship is invalid."); 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."); 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) { if (!contact) {
return fail(response, 404, "CRM_CUSTOMER_NOT_FOUND", "Customer record was not found."); 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 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) => { 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."); 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) { if (!vendor) {
return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found."); 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."); 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) { if (!contact) {
return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found."); return fail(response, 404, "CRM_VENDOR_NOT_FOUND", "Vendor record was not found.");
} }

View File

@@ -15,6 +15,7 @@ import type {
} from "@mrp/shared/dist/crm/types.js"; } from "@mrp/shared/dist/crm/types.js";
import type { Customer, Vendor } from "@prisma/client"; import type { Customer, Vendor } from "@prisma/client";
import { logAuditEvent } from "../../lib/audit.js";
import { prisma } from "../../lib/prisma.js"; import { prisma } from "../../lib/prisma.js";
function mapSummary(record: Customer | Vendor): CrmRecordSummaryDto { function mapSummary(record: Customer | Vendor): CrmRecordSummaryDto {
@@ -397,7 +398,7 @@ export async function getCustomerById(customerId: string) {
return mapCustomerDetail(customer, attachmentCount); return mapCustomerDetail(customer, attachmentCount);
} }
export async function createCustomer(payload: CrmRecordInput) { export async function createCustomer(payload: CrmRecordInput, actorId?: string | null) {
if (payload.parentCustomerId) { if (payload.parentCustomerId) {
const parentCustomer = await prisma.customer.findUnique({ const parentCustomer = await prisma.customer.findUnique({
where: { id: payload.parentCustomerId }, 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 { return {
...mapDetail(customer), ...mapDetail(customer),
isReseller: customer.isReseller, 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({ const existingCustomer = await prisma.customer.findUnique({
where: { id: customerId }, 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 { return {
...mapDetail(customer), ...mapDetail(customer),
isReseller: customer.isReseller, isReseller: customer.isReseller,
@@ -630,7 +659,7 @@ export async function getVendorById(vendorId: string) {
return mapVendorDetail(vendor, attachmentCount); 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({ const vendor = await prisma.vendor.create({
data: { data: {
name: payload.name, 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 { return {
...mapDetail(vendor), ...mapDetail(vendor),
paymentTerms: vendor.paymentTerms, 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({ const existingVendor = await prisma.vendor.findUnique({
where: { id: vendorId }, 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 { return {
...mapDetail(vendor), ...mapDetail(vendor),
paymentTerms: vendor.paymentTerms, 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); 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); 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({ const existingCustomer = await prisma.customer.findUnique({
where: { id: customerId }, 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); 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({ const existingVendor = await prisma.vendor.findUnique({
where: { id: vendorId }, 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); return mapCrmContact(contact);
} }

View File

@@ -67,7 +67,7 @@ shippingRouter.post("/shipments", requirePermissions([permissions.shippingWrite]
return fail(response, 400, "INVALID_INPUT", "Shipment payload is invalid."); 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) { if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason); 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."); 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) { if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason); 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."); 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) { if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason); return fail(response, 400, "INVALID_INPUT", result.reason);
} }

View File

@@ -6,6 +6,7 @@ import type {
ShipmentSummaryDto, ShipmentSummaryDto,
} from "@mrp/shared/dist/shipping/types.js"; } from "@mrp/shared/dist/shipping/types.js";
import { logAuditEvent } from "../../lib/audit.js";
import { prisma } from "../../lib/prisma.js"; import { prisma } from "../../lib/prisma.js";
export interface ShipmentPackingSlipData { export interface ShipmentPackingSlipData {
@@ -168,7 +169,7 @@ export async function getShipmentById(shipmentId: string) {
return shipment ? mapShipment(shipment) : null; 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({ const order = await prisma.salesOrder.findUnique({
where: { id: payload.salesOrderId }, where: { id: payload.salesOrderId },
select: { id: true }, select: { id: true },
@@ -195,10 +196,25 @@ export async function createShipment(payload: ShipmentInput) {
}); });
const detail = await getShipmentById(shipment.id); 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." }; 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({ const existing = await prisma.shipment.findUnique({
where: { id: shipmentId }, where: { id: shipmentId },
select: { id: true }, select: { id: true },
@@ -233,10 +249,25 @@ export async function updateShipment(shipmentId: string, payload: ShipmentInput)
}); });
const detail = await getShipmentById(shipmentId); 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." }; 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({ const existing = await prisma.shipment.findUnique({
where: { id: shipmentId }, where: { id: shipmentId },
select: { id: true }, select: { id: true },
@@ -253,6 +284,19 @@ export async function updateShipmentStatus(shipmentId: string, status: ShipmentS
}); });
const detail = await getShipmentById(shipmentId); 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." }; return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load updated shipment." };
} }

View File

@@ -2,9 +2,17 @@ import { createApp } from "./app.js";
import { env } from "./config/env.js"; import { env } from "./config/env.js";
import { bootstrapAppData } from "./lib/bootstrap.js"; import { bootstrapAppData } from "./lib/bootstrap.js";
import { prisma } from "./lib/prisma.js"; import { prisma } from "./lib/prisma.js";
import { setLatestStartupReport } from "./lib/startup-state.js";
import { assertStartupReadiness } from "./lib/startup-validation.js";
async function start() { async function start() {
await bootstrapAppData(); 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 app = createApp();
const server = app.listen(env.PORT, () => { const server = app.listen(env.PORT, () => {
@@ -25,4 +33,3 @@ start().catch(async (error) => {
await prisma.$disconnect(); await prisma.$disconnect();
process.exit(1); process.exit(1);
}); });

View File

@@ -53,6 +53,13 @@ export interface AdminUserInput {
password: string | null; password: string | null;
} }
export interface StartupValidationCheckDto {
id: string;
label: string;
status: "PASS" | "WARN" | "FAIL";
message: string;
}
export interface AdminDiagnosticsDto { export interface AdminDiagnosticsDto {
serverTime: string; serverTime: string;
nodeVersion: string; nodeVersion: string;
@@ -76,5 +83,7 @@ export interface AdminDiagnosticsDto {
shipmentCount: number; shipmentCount: number;
attachmentCount: number; attachmentCount: number;
auditEventCount: number; auditEventCount: number;
startupStatus: "PASS" | "WARN" | "FAIL";
startupChecks: StartupValidationCheckDto[];
recentAuditEvents: AuditEventDto[]; recentAuditEvents: AuditEventDto[];
} }