Initial MRP foundation scaffold

This commit is contained in:
2026-03-14 14:44:40 -05:00
commit ee833ed074
77 changed files with 10218 additions and 0 deletions

41
server/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "@mrp/server",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/server.js",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"lint": "tsc -p tsconfig.json --noEmit",
"prisma:generate": "dotenv -e ../.env -- prisma generate",
"prisma:migrate": "dotenv -e ../.env -- prisma migrate dev --name foundation",
"prisma:deploy": "dotenv -e ../.env -- prisma migrate deploy"
},
"dependencies": {
"@mrp/shared": "0.1.0",
"@prisma/client": "^6.16.2",
"bcryptjs": "^3.0.3",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^4.22.1",
"express-async-errors": "^3.1.1",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.1.1",
"pino-http": "^11.0.0",
"prisma": "^6.16.2",
"puppeteer": "^24.39.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.0.0",
"@types/node": "^24.5.2",
"dotenv-cli": "^8.0.0",
"tsx": "^4.20.5"
}
}

View File

@@ -0,0 +1,139 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Role" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Permission" (
"id" TEXT NOT NULL PRIMARY KEY,
"key" TEXT NOT NULL,
"description" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "UserRole" (
"userId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"assignedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"assignedBy" TEXT,
PRIMARY KEY ("userId", "roleId"),
CONSTRAINT "UserRole_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "RolePermission" (
"roleId" TEXT NOT NULL,
"permissionId" TEXT NOT NULL,
"grantedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("roleId", "permissionId"),
CONSTRAINT "RolePermission_permissionId_fkey" FOREIGN KEY ("permissionId") REFERENCES "Permission" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "RolePermission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "CompanyProfile" (
"id" TEXT NOT NULL PRIMARY KEY,
"companyName" TEXT NOT NULL,
"legalName" TEXT NOT NULL,
"email" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"website" TEXT NOT NULL,
"taxId" TEXT NOT NULL,
"addressLine1" TEXT NOT NULL,
"addressLine2" TEXT NOT NULL,
"city" TEXT NOT NULL,
"state" TEXT NOT NULL,
"postalCode" TEXT NOT NULL,
"country" TEXT NOT NULL,
"primaryColor" TEXT NOT NULL DEFAULT '#185ADB',
"accentColor" TEXT NOT NULL DEFAULT '#00A6A6',
"surfaceColor" TEXT NOT NULL DEFAULT '#F4F7FB',
"fontFamily" TEXT NOT NULL DEFAULT 'Manrope',
"logoFileId" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "CompanyProfile_logoFileId_fkey" FOREIGN KEY ("logoFileId") REFERENCES "FileAttachment" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "FileAttachment" (
"id" TEXT NOT NULL PRIMARY KEY,
"originalName" TEXT NOT NULL,
"storedName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"sizeBytes" INTEGER NOT NULL,
"relativePath" TEXT NOT NULL,
"ownerType" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Customer" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"addressLine1" TEXT NOT NULL,
"addressLine2" TEXT NOT NULL,
"city" TEXT NOT NULL,
"state" TEXT NOT NULL,
"postalCode" TEXT NOT NULL,
"country" TEXT NOT NULL,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Vendor" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"addressLine1" TEXT NOT NULL,
"addressLine2" TEXT NOT NULL,
"city" TEXT NOT NULL,
"state" TEXT NOT NULL,
"postalCode" TEXT NOT NULL,
"country" TEXT NOT NULL,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Permission_key_key" ON "Permission"("key");
-- CreateIndex
CREATE UNIQUE INDEX "CompanyProfile_logoFileId_key" ON "CompanyProfile"("logoFileId");

View File

@@ -0,0 +1,2 @@
provider = "sqlite"

133
server/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,133 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
firstName String
lastName String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userRoles UserRole[]
}
model Role {
id String @id @default(cuid())
name String @unique
description String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userRoles UserRole[]
rolePermissions RolePermission[]
}
model Permission {
id String @id @default(cuid())
key String @unique
description String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
rolePermissions RolePermission[]
}
model UserRole {
userId String
roleId String
assignedAt DateTime @default(now())
assignedBy String?
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, roleId])
}
model RolePermission {
roleId String
permissionId String
grantedAt DateTime @default(now())
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@id([roleId, permissionId])
}
model CompanyProfile {
id String @id @default(cuid())
companyName String
legalName String
email String
phone String
website String
taxId String
addressLine1 String
addressLine2 String
city String
state String
postalCode String
country String
primaryColor String @default("#185ADB")
accentColor String @default("#00A6A6")
surfaceColor String @default("#F4F7FB")
fontFamily String @default("Manrope")
logoFileId String? @unique
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
logoFile FileAttachment? @relation("CompanyLogo", fields: [logoFileId], references: [id], onDelete: SetNull)
}
model FileAttachment {
id String @id @default(cuid())
originalName String
storedName String
mimeType String
sizeBytes Int
relativePath String
ownerType String
ownerId String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
companyLogoFor CompanyProfile? @relation("CompanyLogo")
}
model Customer {
id String @id @default(cuid())
name String
email String
phone String
addressLine1 String
addressLine2 String
city String
state String
postalCode String
country String
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Vendor {
id String @id @default(cuid())
name String
email String
phone String
addressLine1 String
addressLine2 String
city String
state String
postalCode String
country String
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

68
server/src/app.ts Normal file
View File

@@ -0,0 +1,68 @@
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 { getCurrentUserById } from "./lib/current-user.js";
import { fail, ok } from "./lib/http.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 { 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 authUser = await getCurrentUserById(payload.sub);
request.authUser = authUser ?? undefined;
} catch {
request.authUser = undefined;
}
next();
});
app.get("/api/v1/health", (_request, response) => ok(response, { status: "ok" }));
app.use("/api/v1/auth", authRouter);
app.use("/api/v1", settingsRouter);
app.use("/api/v1/files", filesRouter);
app.use("/api/v1/crm", crmRouter);
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) => {
return fail(response, 500, "INTERNAL_ERROR", error.message || "Unexpected server error.");
});
return app;
}

19
server/src/config/env.ts Normal file
View File

@@ -0,0 +1,19 @@
import { config } from "dotenv";
import { z } from "zod";
config({ path: ".env" });
const schema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().default(3000),
JWT_SECRET: z.string().min(8).default("change-me"),
DATABASE_URL: z.string().default("file:../../data/prisma/app.db"),
DATA_DIR: z.string().default("./data"),
CLIENT_ORIGIN: z.string().default("http://localhost:5173"),
ADMIN_EMAIL: z.string().email().default("admin@mrp.local"),
ADMIN_PASSWORD: z.string().min(8).default("ChangeMe123!"),
PUPPETEER_EXECUTABLE_PATH: z.string().optional()
});
export const env = schema.parse(process.env);

View File

@@ -0,0 +1,14 @@
import path from "node:path";
import { env } from "./env.js";
const projectRoot = process.cwd();
export const paths = {
projectRoot,
dataDir: path.resolve(projectRoot, env.DATA_DIR),
uploadsDir: path.resolve(projectRoot, env.DATA_DIR, "uploads"),
prismaDir: path.resolve(projectRoot, env.DATA_DIR, "prisma"),
clientDistDir: path.resolve(projectRoot, "client", "dist"),
};

27
server/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { AuthUser } from "@mrp/shared";
import jwt from "jsonwebtoken";
import { env } from "../config/env.js";
interface AuthTokenPayload {
sub: string;
email: string;
permissions: string[];
}
export function signToken(user: AuthUser) {
return jwt.sign(
{
sub: user.id,
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;
}

164
server/src/lib/bootstrap.ts Normal file
View File

@@ -0,0 +1,164 @@
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.filesRead]: "View attached files",
[permissions.filesWrite]: "Upload and manage attached files",
[permissions.ganttRead]: "View gantt timelines",
[permissions.salesRead]: "View sales data",
[permissions.shippingRead]: "View shipping data",
};
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",
},
});
}
if ((await prisma.customer.count()) === 0) {
await prisma.customer.createMany({
data: [
{
name: "Acme Components",
email: "buyer@acme.example",
phone: "555-0101",
addressLine1: "1 Industrial Road",
addressLine2: "",
city: "Detroit",
state: "MI",
postalCode: "48201",
country: "USA",
notes: "Priority account",
},
{
name: "Northwind Fabrication",
email: "ops@northwind.example",
phone: "555-0120",
addressLine1: "42 Assembly Ave",
addressLine2: "",
city: "Milwaukee",
state: "WI",
postalCode: "53202",
country: "USA",
notes: "Requires ASN notice",
},
],
});
}
if ((await prisma.vendor.count()) === 0) {
await prisma.vendor.create({
data: {
name: "SteelSource Supply",
email: "sales@steelsource.example",
phone: "555-0142",
addressLine1: "77 Mill Street",
addressLine2: "",
city: "Gary",
state: "IN",
postalCode: "46402",
country: "USA",
notes: "Lead time 5 business days",
},
});
}
}

View File

@@ -0,0 +1,47 @@
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;
}
const permissionKeys = new Set<PermissionKey>();
const roleNames = user.userRoles.map(({ role }) => {
for (const rolePermission of role.rolePermissions) {
permissionKeys.add(rolePermission.permission.key as PermissionKey);
}
return role.name;
});
return {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
roles: roleNames,
permissions: [...permissionKeys],
};
}

20
server/src/lib/http.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { ApiResponse } from "@mrp/shared";
import type { Response } from "express";
export function ok<T>(response: Response, data: T, status = 200) {
const body: ApiResponse<T> = { ok: true, data };
return response.status(status).json(body);
}
export function fail(response: Response, status: number, code: string, message: string) {
const body: ApiResponse<never> = {
ok: false,
error: {
code,
message,
},
};
return response.status(status).json(body);
}

View File

@@ -0,0 +1,10 @@
import bcrypt from "bcryptjs";
export async function hashPassword(password: string) {
return bcrypt.hash(password, 10);
}
export async function verifyPassword(password: string, hash: string) {
return bcrypt.compare(password, hash);
}

25
server/src/lib/pdf.ts Normal file
View File

@@ -0,0 +1,25 @@
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" });
return await page.pdf({
format: "A4",
printBackground: true,
preferCSSPageSize: true,
});
} finally {
await browser.close();
}
}

4
server/src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,4 @@
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();

30
server/src/lib/rbac.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { PermissionKey } from "@mrp/shared";
import type { NextFunction, Request, Response } from "express";
import { fail } from "./http.js";
export function requireAuth(request: Request, response: Response, next: NextFunction) {
if (!request.authUser) {
return fail(response, 401, "UNAUTHORIZED", "Authentication is required.");
}
next();
}
export function requirePermissions(requiredPermissions: PermissionKey[]) {
return (request: Request, response: Response, next: NextFunction) => {
if (!request.authUser) {
return fail(response, 401, "UNAUTHORIZED", "Authentication is required.");
}
const available = new Set(request.authUser.permissions);
const hasAll = requiredPermissions.every((permission) => available.has(permission));
if (!hasAll) {
return fail(response, 403, "FORBIDDEN", "You do not have access to this resource.");
}
next();
};
}

27
server/src/lib/storage.ts Normal file
View File

@@ -0,0 +1,27 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { paths } from "../config/paths.js";
export async function ensureDataDirectories() {
await fs.mkdir(paths.uploadsDir, { recursive: true });
await fs.mkdir(paths.prismaDir, { recursive: true });
}
export async function writeUpload(buffer: Buffer, originalName: string) {
const extension = path.extname(originalName);
const storedName = `${Date.now()}-${randomUUID()}${extension}`;
const relativePath = path.join("uploads", storedName);
const absolutePath = path.join(paths.dataDir, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, buffer);
return {
storedName,
relativePath: relativePath.replaceAll("\\", "/"),
absolutePath,
};
}

View File

@@ -0,0 +1,30 @@
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requireAuth } from "../../lib/rbac.js";
import { login } 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);
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));

View File

@@ -0,0 +1,31 @@
import type { LoginRequest, LoginResponse } from "@mrp/shared";
import { signToken } from "../../lib/auth.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): 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;
}
return {
token: signToken(authUser),
user: authUser,
};
}

View File

@@ -0,0 +1,17 @@
import { permissions } from "@mrp/shared";
import { Router } from "express";
import { ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import { listCustomers, listVendors } from "./service.js";
export const crmRouter = Router();
crmRouter.get("/customers", requirePermissions([permissions.crmRead]), async (_request, response) => {
return ok(response, await listCustomers());
});
crmRouter.get("/vendors", requirePermissions([permissions.crmRead]), async (_request, response) => {
return ok(response, await listVendors());
});

View File

@@ -0,0 +1,14 @@
import { prisma } from "../../lib/prisma.js";
export async function listCustomers() {
return prisma.customer.findMany({
orderBy: { name: "asc" },
});
}
export async function listVendors() {
return prisma.vendor.findMany({
orderBy: { name: "asc" },
});
}

View File

@@ -0,0 +1,50 @@
import { permissions } from "@mrp/shared";
import { Router } from "express";
import { renderPdf } from "../../lib/pdf.js";
import { requirePermissions } from "../../lib/rbac.js";
import { getActiveCompanyProfile } from "../settings/service.js";
export const documentsRouter = Router();
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);
});

View File

@@ -0,0 +1,59 @@
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, getAttachmentContent, getAttachmentMetadata } 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),
});
export const filesRouter = Router();
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);
});

View File

@@ -0,0 +1,69 @@
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 getAttachmentContent(id: string) {
const file = await prisma.fileAttachment.findUniqueOrThrow({
where: { id },
});
return {
file,
content: await fs.readFile(path.join(paths.dataDir, file.relativePath)),
};
}

View File

@@ -0,0 +1,23 @@
import { permissions } from "@mrp/shared";
import { Router } from "express";
import { ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
export const ganttRouter = Router();
ganttRouter.get("/demo", requirePermissions([permissions.ganttRead]), (_request, response) => {
return ok(response, {
tasks: [
{ id: "project-1", text: "Machine Assembly Program", start: "2026-03-16", end: "2026-03-28", progress: 35, type: "project" },
{ id: "task-1", text: "Frame fabrication", start: "2026-03-16", end: "2026-03-19", progress: 80, type: "task" },
{ id: "task-2", text: "Electrical install", start: "2026-03-20", end: "2026-03-25", progress: 20, type: "task" },
{ id: "milestone-1", text: "Factory acceptance", start: "2026-03-28", end: "2026-03-28", progress: 0, type: "milestone" }
],
links: [
{ id: "link-1", source: "task-1", target: "task-2", type: "e2e" },
{ id: "link-2", source: "task-2", target: "milestone-1", type: "e2e" }
],
});
});

View File

@@ -0,0 +1,45 @@
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));
});

View File

@@ -0,0 +1,72 @@
import type { CompanyProfileDto, CompanyProfileInput } from "@mrp/shared";
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) {
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,
},
});
return mapCompanyProfile(profile);
}

28
server/src/server.ts Normal file
View File

@@ -0,0 +1,28 @@
import { createApp } from "./app.js";
import { env } from "./config/env.js";
import { bootstrapAppData } from "./lib/bootstrap.js";
import { prisma } from "./lib/prisma.js";
async function start() {
await bootstrapAppData();
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);
await prisma.$disconnect();
process.exit(1);
});

12
server/src/types/express.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
import type { AuthUser } from "@mrp/shared";
declare global {
namespace Express {
interface Request {
authUser?: AuthUser;
}
}
}
export {};

33
server/tests/auth.test.ts Normal file
View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { permissions } from "@mrp/shared";
import { requirePermissions } from "../src/lib/rbac.js";
describe("rbac", () => {
it("allows requests with all required permissions", () => {
const middleware = requirePermissions([permissions.companyRead]);
const request = {
authUser: {
id: "1",
email: "admin@example.com",
firstName: "Admin",
lastName: "User",
roles: ["Administrator"],
permissions: [permissions.companyRead],
},
};
const response = {
status: () => response,
json: (body: unknown) => body,
};
let nextCalled = false;
middleware(request as never, response as never, () => {
nextCalled = true;
});
expect(nextCalled).toBe(true);
});
});

13
server/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": [
"node"
]
},
"include": [
"src"
]
}

8
server/vitest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});