Initial MRP foundation scaffold
This commit is contained in:
27
server/src/lib/auth.ts
Normal file
27
server/src/lib/auth.ts
Normal 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
164
server/src/lib/bootstrap.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
47
server/src/lib/current-user.ts
Normal file
47
server/src/lib/current-user.ts
Normal 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
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);
|
||||
}
|
||||
|
||||
25
server/src/lib/pdf.ts
Normal file
25
server/src/lib/pdf.ts
Normal 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
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();
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user