Initial MRP foundation scaffold
This commit is contained in:
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user