init
This commit is contained in:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user