Initial MRP foundation scaffold
This commit is contained in:
41
server/package.json
Normal file
41
server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
139
server/prisma/migrations/20260314193000_foundation/migration.sql
Normal file
139
server/prisma/migrations/20260314193000_foundation/migration.sql
Normal 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");
|
||||
2
server/prisma/migrations/migration_lock.toml
Normal file
2
server/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
provider = "sqlite"
|
||||
|
||||
133
server/prisma/schema.prisma
Normal file
133
server/prisma/schema.prisma
Normal 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
68
server/src/app.ts
Normal 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
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/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,
|
||||
};
|
||||
}
|
||||
|
||||
30
server/src/modules/auth/router.ts
Normal file
30
server/src/modules/auth/router.ts
Normal 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));
|
||||
|
||||
31
server/src/modules/auth/service.ts
Normal file
31
server/src/modules/auth/service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
17
server/src/modules/crm/router.ts
Normal file
17
server/src/modules/crm/router.ts
Normal 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());
|
||||
});
|
||||
|
||||
14
server/src/modules/crm/service.ts
Normal file
14
server/src/modules/crm/service.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
|
||||
50
server/src/modules/documents/router.ts
Normal file
50
server/src/modules/documents/router.ts
Normal 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);
|
||||
});
|
||||
|
||||
59
server/src/modules/files/router.ts
Normal file
59
server/src/modules/files/router.ts
Normal 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);
|
||||
});
|
||||
69
server/src/modules/files/service.ts
Normal file
69
server/src/modules/files/service.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
|
||||
23
server/src/modules/gantt/router.ts
Normal file
23
server/src/modules/gantt/router.ts
Normal 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" }
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
45
server/src/modules/settings/router.ts
Normal file
45
server/src/modules/settings/router.ts
Normal 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));
|
||||
});
|
||||
|
||||
72
server/src/modules/settings/service.ts
Normal file
72
server/src/modules/settings/service.ts
Normal 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
28
server/src/server.ts
Normal 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
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
33
server/tests/auth.test.ts
Normal file
33
server/tests/auth.test.ts
Normal 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
13
server/tsconfig.json
Normal 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
8
server/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user