init
This commit is contained in:
134
server/src/app.ts
Normal file
134
server/src/app.ts
Normal 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
19
server/src/config/env.ts
Normal 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);
|
||||
|
||||
14
server/src/config/paths.ts
Normal file
14
server/src/config/paths.ts
Normal 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
27
server/src/lib/audit.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import { prisma } from "./prisma.js";
|
||||
|
||||
type AuditClient = Prisma.TransactionClient | typeof prisma;
|
||||
|
||||
interface LogAuditEventInput {
|
||||
actorId?: string | null;
|
||||
entityType: string;
|
||||
entityId?: string | null;
|
||||
action: string;
|
||||
summary: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function logAuditEvent(input: LogAuditEventInput, client: AuditClient = prisma) {
|
||||
await client.auditEvent.create({
|
||||
data: {
|
||||
actorId: input.actorId ?? null,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId ?? null,
|
||||
action: input.action,
|
||||
summary: input.summary,
|
||||
metadataJson: JSON.stringify(input.metadata ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
100
server/src/lib/auth-sessions.ts
Normal file
100
server/src/lib/auth-sessions.ts
Normal 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
28
server/src/lib/auth.ts
Normal 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
125
server/src/lib/bootstrap.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
50
server/src/lib/current-user.ts
Normal file
50
server/src/lib/current-user.ts
Normal 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
20
server/src/lib/http.ts
Normal 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);
|
||||
}
|
||||
|
||||
10
server/src/lib/password.ts
Normal file
10
server/src/lib/password.ts
Normal 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
27
server/src/lib/pdf.ts
Normal 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
4
server/src/lib/prisma.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
|
||||
30
server/src/lib/rbac.ts
Normal file
30
server/src/lib/rbac.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
|
||||
19
server/src/lib/startup-state.ts
Normal file
19
server/src/lib/startup-state.ts
Normal 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;
|
||||
}
|
||||
183
server/src/lib/startup-validation.ts
Normal file
183
server/src/lib/startup-validation.ts
Normal 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
27
server/src/lib/storage.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
139
server/src/lib/support-log.ts
Normal file
139
server/src/lib/support-log.ts
Normal 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;
|
||||
}
|
||||
173
server/src/modules/admin/router.ts
Normal file
173
server/src/modules/admin/router.ts
Normal 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);
|
||||
});
|
||||
902
server/src/modules/admin/service.ts
Normal file
902
server/src/modules/admin/service.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
41
server/src/modules/auth/router.ts
Normal file
41
server/src/modules/auth/router.ts
Normal 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 });
|
||||
});
|
||||
50
server/src/modules/auth/service.ts
Normal file
50
server/src/modules/auth/service.ts
Normal 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.",
|
||||
});
|
||||
}
|
||||
288
server/src/modules/crm/router.ts
Normal file
288
server/src/modules/crm/router.ts
Normal 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);
|
||||
});
|
||||
955
server/src/modules/crm/service.ts
Normal file
955
server/src/modules/crm/service.ts
Normal 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);
|
||||
}
|
||||
718
server/src/modules/documents/router.ts
Normal file
718
server/src/modules/documents/router.ts
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
);
|
||||
77
server/src/modules/files/router.ts
Normal file
77
server/src/modules/files/router.ts
Normal 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)));
|
||||
});
|
||||
100
server/src/modules/files/service.ts
Normal file
100
server/src/modules/files/service.ts
Normal 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);
|
||||
}
|
||||
12
server/src/modules/gantt/router.ts
Normal file
12
server/src/modules/gantt/router.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import { Router } from "express";
|
||||
|
||||
import { ok } from "../../lib/http.js";
|
||||
import { requirePermissions } from "../../lib/rbac.js";
|
||||
import { getPlanningTimeline } from "./service.js";
|
||||
|
||||
export const ganttRouter = Router();
|
||||
|
||||
ganttRouter.get("/timeline", requirePermissions([permissions.ganttRead]), async (_request, response) => {
|
||||
return ok(response, await getPlanningTimeline());
|
||||
});
|
||||
460
server/src/modules/gantt/service.ts
Normal file
460
server/src/modules/gantt/service.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
388
server/src/modules/inventory/router.ts
Normal file
388
server/src/modules/inventory/router.ts
Normal 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);
|
||||
});
|
||||
1899
server/src/modules/inventory/service.ts
Normal file
1899
server/src/modules/inventory/service.ts
Normal file
File diff suppressed because it is too large
Load Diff
205
server/src/modules/manufacturing/router.ts
Normal file
205
server/src/modules/manufacturing/router.ts
Normal 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);
|
||||
});
|
||||
1196
server/src/modules/manufacturing/service.ts
Normal file
1196
server/src/modules/manufacturing/service.ts
Normal file
File diff suppressed because it is too large
Load Diff
139
server/src/modules/projects/router.ts
Normal file
139
server/src/modules/projects/router.ts
Normal 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);
|
||||
});
|
||||
456
server/src/modules/projects/service.ts
Normal file
456
server/src/modules/projects/service.ts
Normal 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." };
|
||||
}
|
||||
184
server/src/modules/purchasing/router.ts
Normal file
184
server/src/modules/purchasing/router.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
1047
server/src/modules/purchasing/service.ts
Normal file
1047
server/src/modules/purchasing/service.ts
Normal file
File diff suppressed because it is too large
Load Diff
1205
server/src/modules/sales/planning.ts
Normal file
1205
server/src/modules/sales/planning.ts
Normal file
File diff suppressed because it is too large
Load Diff
322
server/src/modules/sales/router.ts
Normal file
322
server/src/modules/sales/router.ts
Normal 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));
|
||||
});
|
||||
902
server/src/modules/sales/service.ts
Normal file
902
server/src/modules/sales/service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
44
server/src/modules/settings/router.ts
Normal file
44
server/src/modules/settings/router.ts
Normal 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));
|
||||
});
|
||||
85
server/src/modules/settings/service.ts
Normal file
85
server/src/modules/settings/service.ts
Normal 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);
|
||||
}
|
||||
114
server/src/modules/shipping/router.ts
Normal file
114
server/src/modules/shipping/router.ts
Normal 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);
|
||||
});
|
||||
374
server/src/modules/shipping/service.ts
Normal file
374
server/src/modules/shipping/service.ts
Normal 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
69
server/src/server.ts
Normal 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
12
server/src/types/express.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { AuthUser } from "@mrp/shared";
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
authUser?: AuthUser;
|
||||
authSessionId?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user