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

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