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

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
.git
.gitignore
node_modules
client/node_modules
server/node_modules
shared/node_modules
client/dist
server/dist
data
coverage
*.log

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
PORT=3000
JWT_SECRET=change-me
DATABASE_URL="file:../../data/prisma/app.db"
DATA_DIR="./data"
CLIENT_ORIGIN="http://localhost:5173"

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
node_modules
dist
build
.vite
.turbo
coverage
.tsbuildinfo
*.tsbuildinfo
.env
.env.*
!.env.example
data
uploads
client/dist
server/dist
server/prisma/dev.db
server/prisma/dev.db-journal
client/src/**/*.js
client/src/**/*.d.ts
server/tests/**/*.js
client/tailwind.config.js
client/tailwind.config.d.ts
client/vite.config.js
client/vite.config.d.ts
*.log

81
Dockerfile Normal file
View File

@@ -0,0 +1,81 @@
FROM node:22-bookworm-slim AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json ./
COPY client/package.json client/package.json
COPY server/package.json server/package.json
COPY shared/package.json shared/package.json
RUN npm ci
FROM deps AS build
COPY . .
RUN npm run prisma:generate
RUN npm run build
FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV DATA_DIR=/app/data
ENV DATABASE_URL=file:../../data/prisma/app.db
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
RUN apt-get update && apt-get install -y --no-install-recommends \
chromium \
ca-certificates \
fonts-liberation \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libc6 \
libcairo2 \
libcups2 \
libdbus-1-3 \
libexpat1 \
libfontconfig1 \
libgbm1 \
libgcc1 \
libglib2.0-0 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libstdc++6 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcomposite1 \
libxcursor1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxi6 \
libxrandr2 \
libxrender1 \
libxss1 \
libxtst6 \
xdg-utils \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
COPY client/package.json client/package.json
COPY server/package.json server/package.json
COPY shared/package.json shared/package.json
RUN npm ci --omit=dev
COPY --from=build /app/client/dist /app/client/dist
COPY --from=build /app/server/dist /app/server/dist
COPY --from=build /app/shared/dist /app/shared/dist
COPY --from=build /app/server/prisma /app/server/prisma
COPY --from=build /app/docker-entrypoint.sh /app/docker-entrypoint.sh
COPY README.md INSTRUCTIONS.md STRUCTURE.md ./
RUN chmod +x /app/docker-entrypoint.sh
VOLUME ["/app/data"]
EXPOSE 3000
ENTRYPOINT ["/app/docker-entrypoint.sh"]

37
INSTRUCTIONS.md Normal file
View File

@@ -0,0 +1,37 @@
# Development Instructions
## Current milestone
This repository implements the platform foundation milestone:
- workspace scaffolding
- local auth and RBAC
- company settings and branding
- file attachment storage
- Dockerized single-container deployment
- Puppeteer PDF pipeline foundation
## Workflow
1. Update the roadmap before starting large features.
2. Keep backend and frontend modules grouped by domain.
3. Add Prisma models and migrations for all persisted schema changes.
4. Keep uploaded files on disk under `/app/data/uploads`; never store blobs in SQLite.
5. Reuse shared DTOs and permission keys from the `shared` package.
## Operational notes
- Run `npm run prisma:generate` after schema changes.
- Run `npm run prisma:migrate` during development to create versioned migrations.
- Use `npm run prisma:deploy` in production environments.
- Prefer Node 22 locally when running Prisma migration commands to match the Docker runtime.
- Branding defaults live in the frontend theme token layer and are overridden by the persisted company profile.
- Back up the whole `/app/data` volume to capture both the database and attachments.
## Next roadmap candidates
- CRM entity detail pages and search
- inventory and BOM management
- sales orders, purchase orders, and document templates
- shipping workflows and printable logistics documents
- manufacturing gantt scheduling with live project data

56
README.md Normal file
View File

@@ -0,0 +1,56 @@
# MRP Codex
Foundation release for a modular Manufacturing Resource Planning platform built with React, Express, Prisma, SQLite, and a single-container Docker deployment.
## Workspace
- `client`: React, Vite, Tailwind frontend
- `server`: Express API, Prisma, auth/RBAC, file storage, PDF rendering
- `shared`: shared TypeScript contracts and constants
## Local development
1. Use Node.js 22 for local development if you want Prisma migration commands to behave the same way as Docker.
2. Install dependencies with `npm.cmd install`.
3. Copy [`.env.example`](D:\CODING\mrp-codex\.env.example) to `.env` and adjust values if needed.
4. Generate Prisma client with `npm run prisma:generate`.
5. Apply committed migrations with `npm run prisma:deploy`.
6. Start the workspace with `npm run dev`.
The frontend runs through Vite in development and is served statically by the API in production.
Seeded admin credentials for first login:
- email: `admin@mrp.local`
- password: `ChangeMe123!`
## Docker
Build and run:
```bash
docker build -t mrp-codex .
docker run -p 3000:3000 -v mrp_data:/app/data mrp-codex
```
The container startup script runs `npx prisma migrate deploy` automatically before launching the server.
## Persistence and backup
- SQLite database path: `/app/data/prisma/app.db`
- Uploaded files: `/app/data/uploads`
- Backup the entire mounted `/app/data` volume to preserve both records and attachments.
## Branding
Brand colors and typography are configured through the Company Settings page and the frontend theme token layer. Update runtime branding in-app, or adjust defaults in the theme config if you need a new baseline brand.
## Migrations
- Create a local migration: `npm run prisma:migrate`
- Apply committed migrations in production: `npm run prisma:deploy`
- If Prisma migration commands fail on a local Node 24 Windows environment, use Node 22 or Docker for migration execution. The committed migration files in `server/prisma/migrations` remain the source of truth.
## PDF generation
Puppeteer is used by the backend to render HTML templates into professional PDFs. The Docker image includes Chromium runtime dependencies required for headless execution.

39
STRUCTURE.md Normal file
View File

@@ -0,0 +1,39 @@
# Project Structure
## Top-level layout
- `client/`: frontend application
- `server/`: backend application
- `shared/`: shared TypeScript contracts, permissions, and utility types
- `Dockerfile`: production container build
- `docker-entrypoint.sh`: migration-aware startup script
## Frontend rules
- Organize code by domain under `src/modules`.
- Keep app-shell concerns in `src/app`.
- Keep reusable UI primitives in `src/components`.
- Theme state and brand tokens belong in `src/theme`.
- PDF screen components must remain separate from API-rendered document templates.
## Backend rules
- Organize domain modules under `src/modules/<domain>`.
- Keep HTTP routers thin; place business logic in services.
- Centralize Prisma access, auth middleware, and file storage utilities in `src/lib`.
- Store persistence-related constants under `src/config`.
- Serve the built frontend from the API layer in production.
## Shared package rules
- Place cross-app DTOs, permission keys, enums, and document interfaces in `shared/src`.
- Keep shared code free of runtime framework dependencies.
## Adding a new domain
1. Add backend routes, service, and repository/module files under `server/src/modules/<domain>`.
2. Add Prisma models and a migration if the module needs persistence.
3. Add permission keys in `shared/src/auth`.
4. Add frontend route/module under `client/src/modules/<domain>`.
5. Register navigation and route guards through the app shell without refactoring existing modules.

13
client/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MRP Codex</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

32
client/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@mrp/client",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run",
"lint": "tsc -b --pretty false"
},
"dependencies": {
"@mrp/shared": "0.1.0",
"@svar-ui/react-gantt": "^2.5.2",
"@tanstack/react-query": "^5.90.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.3"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.4.27",
"jsdom": "^28.1.0",
"postcss": "^8.5.8",
"tailwindcss": "^3.4.17",
"vite": "^8.0.0"
}
}

View File

@@ -0,0 +1,7 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,70 @@
import type { AuthUser } from "@mrp/shared";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { api } from "../lib/api";
interface AuthContextValue {
token: string | null;
user: AuthUser | null;
isReady: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
const tokenKey = "mrp.auth.token";
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [token, setToken] = useState<string | null>(() => window.localStorage.getItem(tokenKey));
const [user, setUser] = useState<AuthUser | null>(null);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
if (!token) {
setUser(null);
setIsReady(true);
return;
}
api.me(token)
.then((nextUser) => {
setUser(nextUser);
})
.catch(() => {
window.localStorage.removeItem(tokenKey);
setToken(null);
})
.finally(() => setIsReady(true));
}, [token]);
const value = useMemo<AuthContextValue>(
() => ({
token,
user,
isReady,
async login(email, password) {
const result = await api.login({ email, password });
setToken(result.token);
setUser(result.user);
window.localStorage.setItem(tokenKey, result.token);
},
logout() {
window.localStorage.removeItem(tokenKey);
setToken(null);
setUser(null);
},
}),
[isReady, token, user]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}

View File

@@ -0,0 +1,81 @@
import { NavLink, Outlet } from "react-router-dom";
import { useAuth } from "../auth/AuthProvider";
import { ThemeToggle } from "./ThemeToggle";
const links = [
{ to: "/", label: "Overview" },
{ to: "/settings/company", label: "Company Settings" },
{ to: "/crm/customers", label: "Customers" },
{ to: "/crm/vendors", label: "Vendors" },
{ to: "/planning/gantt", label: "Gantt" },
];
export function AppShell() {
const { user, logout } = useAuth();
return (
<div className="min-h-screen px-4 py-6 md:px-8">
<div className="mx-auto flex max-w-7xl gap-6">
<aside className="hidden w-72 shrink-0 flex-col rounded-[28px] border border-line/70 bg-surface/90 p-6 shadow-panel backdrop-blur md:flex">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">MRP Codex</div>
<h1 className="mt-3 text-2xl font-extrabold text-text">Manufacturing foundation</h1>
<p className="mt-2 text-sm text-muted">Single-tenant platform shell with branding, auth, file storage, and planning foundations.</p>
</div>
<nav className="mt-8 space-y-2">
{links.map((link) => (
<NavLink
key={link.to}
to={link.to}
className={({ isActive }) =>
`block rounded-2xl px-4 py-3 text-sm font-semibold transition ${
isActive ? "bg-brand text-white" : "text-text hover:bg-page"
}`
}
>
{link.label}
</NavLink>
))}
</nav>
<div className="mt-auto rounded-2xl border border-line/70 bg-page/70 p-4">
<p className="text-sm font-semibold text-text">{user?.firstName} {user?.lastName}</p>
<p className="text-xs text-muted">{user?.email}</p>
<button
type="button"
onClick={logout}
className="mt-4 rounded-xl bg-text px-4 py-2 text-sm font-semibold text-page"
>
Sign out
</button>
</div>
</aside>
<main className="min-w-0 flex-1">
<div className="mb-6 flex items-center justify-between rounded-[28px] border border-line/70 bg-surface/90 px-6 py-5 shadow-panel backdrop-blur">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted">Operations Command</p>
<h2 className="text-2xl font-bold text-text">Foundation Console</h2>
</div>
<ThemeToggle />
</div>
<nav className="mb-6 flex gap-3 overflow-x-auto rounded-[24px] border border-line/70 bg-surface/85 p-3 shadow-panel backdrop-blur md:hidden">
{links.map((link) => (
<NavLink
key={link.to}
to={link.to}
className={({ isActive }) =>
`whitespace-nowrap rounded-2xl px-4 py-2 text-sm font-semibold transition ${
isActive ? "bg-brand text-white" : "bg-page/70 text-text"
}`
}
>
{link.label}
</NavLink>
))}
</nav>
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import type { PermissionKey } from "@mrp/shared";
import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "../auth/AuthProvider";
export function ProtectedRoute({ requiredPermissions = [] }: { requiredPermissions?: PermissionKey[] }) {
const { isReady, token, user } = useAuth();
if (!isReady) {
return <div className="p-10 text-center text-muted">Loading workspace...</div>;
}
if (!token || !user) {
return <Navigate to="/login" replace />;
}
const permissionSet = new Set(user.permissions);
const allowed = requiredPermissions.every((permission) => permissionSet.has(permission));
return allowed ? <Outlet /> : <Navigate to="/" replace />;
}

View File

@@ -0,0 +1,16 @@
import { useTheme } from "../theme/ThemeProvider";
export function ThemeToggle() {
const { mode, toggleMode } = useTheme();
return (
<button
type="button"
onClick={toggleMode}
className="rounded-full border border-line/70 bg-surface px-4 py-2 text-sm font-semibold text-text transition hover:border-brand/60"
>
{mode === "light" ? "Dark mode" : "Light mode"}
</button>
);
}

47
client/src/index.css Normal file
View File

@@ -0,0 +1,47 @@
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap");
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--font-family: "Manrope";
--color-brand: 24 90 219;
--color-accent: 0 166 166;
--color-surface: 244 247 251;
--color-page: 248 250 252;
--color-text: 15 23 42;
--color-muted: 90 106 133;
--color-line: 215 222 235;
}
.dark {
--color-brand: 63 140 255;
--color-accent: 34 211 238;
--color-surface: 30 41 59;
--color-page: 2 6 23;
--color-text: 226 232 240;
--color-muted: 148 163 184;
--color-line: 51 65 85;
}
html,
body,
#root {
min-height: 100%;
}
body {
background:
radial-gradient(circle at top left, rgb(var(--color-brand) / 0.18), transparent 32%),
radial-gradient(circle at top right, rgb(var(--color-accent) / 0.16), transparent 25%),
rgb(var(--color-page));
color: rgb(var(--color-text));
font-family: var(--font-family), sans-serif;
}
.gantt-theme .wx-bar,
.gantt-theme .wx-task {
fill: rgb(var(--color-brand));
}

100
client/src/lib/api.ts Normal file
View File

@@ -0,0 +1,100 @@
import type {
ApiResponse,
CompanyProfileDto,
CompanyProfileInput,
FileAttachmentDto,
GanttLinkDto,
GanttTaskDto,
LoginRequest,
LoginResponse,
} from "@mrp/shared";
export class ApiError extends Error {
constructor(message: string, public readonly code: string) {
super(message);
}
}
async function request<T>(input: string, init?: RequestInit, token?: string): Promise<T> {
const response = await fetch(input, {
...init,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init?.headers ?? {}),
},
});
const json = (await response.json()) as ApiResponse<T>;
if (!json.ok) {
throw new ApiError(json.error.message, json.error.code);
}
return json.data;
}
export const api = {
login(payload: LoginRequest) {
return request<LoginResponse>("/api/v1/auth/login", {
method: "POST",
body: JSON.stringify(payload),
});
},
me(token: string) {
return request<LoginResponse["user"]>("/api/v1/auth/me", undefined, token);
},
getCompanyProfile(token: string) {
return request<CompanyProfileDto>("/api/v1/company-profile", undefined, token);
},
updateCompanyProfile(token: string, payload: CompanyProfileInput) {
return request<CompanyProfileDto>(
"/api/v1/company-profile",
{
method: "PUT",
body: JSON.stringify(payload),
},
token
);
},
async uploadFile(token: string, file: File, ownerType: string, ownerId: string) {
const formData = new FormData();
formData.append("file", file);
formData.append("ownerType", ownerType);
formData.append("ownerId", ownerId);
const response = await fetch("/api/v1/files/upload", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
const json = (await response.json()) as ApiResponse<FileAttachmentDto>;
if (!json.ok) {
throw new ApiError(json.error.message, json.error.code);
}
return json.data;
},
getCustomers(token: string) {
return request<Array<Record<string, string>>>("/api/v1/crm/customers", undefined, token);
},
getVendors(token: string) {
return request<Array<Record<string, string>>>("/api/v1/crm/vendors", undefined, token);
},
getGanttDemo(token: string) {
return request<{ tasks: GanttTaskDto[]; links: GanttLinkDto[] }>("/api/v1/gantt/demo", undefined, token);
},
async getCompanyProfilePreviewPdf(token: string) {
const response = await fetch("/api/v1/documents/company-profile-preview.pdf", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new ApiError("Unable to render company profile preview PDF.", "PDF_PREVIEW_FAILED");
}
return response.blob();
},
};

63
client/src/main.tsx Normal file
View File

@@ -0,0 +1,63 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import ReactDOM from "react-dom/client";
import { createBrowserRouter, Navigate, RouterProvider } from "react-router-dom";
import { permissions } from "@mrp/shared";
import { AppShell } from "./components/AppShell";
import { ProtectedRoute } from "./components/ProtectedRoute";
import { AuthProvider } from "./auth/AuthProvider";
import { DashboardPage } from "./modules/dashboard/DashboardPage";
import { LoginPage } from "./modules/login/LoginPage";
import { CompanySettingsPage } from "./modules/settings/CompanySettingsPage";
import { CustomersPage } from "./modules/crm/CustomersPage";
import { VendorsPage } from "./modules/crm/VendorsPage";
import { GanttPage } from "./modules/gantt/GanttPage";
import { ThemeProvider } from "./theme/ThemeProvider";
import "./index.css";
const queryClient = new QueryClient();
const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
{
element: <ProtectedRoute />,
children: [
{
element: <AppShell />,
children: [
{ path: "/", element: <DashboardPage /> },
{
element: <ProtectedRoute requiredPermissions={[permissions.companyRead]} />,
children: [{ path: "/settings/company", element: <CompanySettingsPage /> }],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.crmRead]} />,
children: [
{ path: "/crm/customers", element: <CustomersPage /> },
{ path: "/crm/vendors", element: <VendorsPage /> },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.ganttRead]} />,
children: [{ path: "/planning/gantt", element: <GanttPage /> }],
},
],
},
],
},
{ path: "*", element: <Navigate to="/" replace /> },
]);
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</QueryClientProvider>
</ThemeProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,46 @@
import { useEffect, useState } from "react";
import { useAuth } from "../../auth/AuthProvider";
import { api } from "../../lib/api";
export function CustomersPage() {
const { token } = useAuth();
const [customers, setCustomers] = useState<Array<Record<string, string>>>([]);
useEffect(() => {
if (!token) {
return;
}
api.getCustomers(token).then(setCustomers);
}, [token]);
return (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM</p>
<h3 className="mt-3 text-2xl font-bold text-text">Customers</h3>
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr>
<th className="px-4 py-3">Name</th>
<th className="px-4 py-3">Email</th>
<th className="px-4 py-3">Phone</th>
<th className="px-4 py-3">Location</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{customers.map((customer) => (
<tr key={customer.id}>
<td className="px-4 py-3 font-semibold text-text">{customer.name}</td>
<td className="px-4 py-3 text-muted">{customer.email}</td>
<td className="px-4 py-3 text-muted">{customer.phone}</td>
<td className="px-4 py-3 text-muted">{customer.city}, {customer.state}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}

View File

@@ -0,0 +1,34 @@
import { useEffect, useState } from "react";
import { useAuth } from "../../auth/AuthProvider";
import { api } from "../../lib/api";
export function VendorsPage() {
const { token } = useAuth();
const [vendors, setVendors] = useState<Array<Record<string, string>>>([]);
useEffect(() => {
if (!token) {
return;
}
api.getVendors(token).then(setVendors);
}, [token]);
return (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CRM</p>
<h3 className="mt-3 text-2xl font-bold text-text">Vendors</h3>
<div className="mt-6 grid gap-4 md:grid-cols-2">
{vendors.map((vendor) => (
<article key={vendor.id} className="rounded-2xl border border-line/70 bg-page/70 p-5">
<h4 className="text-lg font-bold text-text">{vendor.name}</h4>
<p className="mt-2 text-sm text-muted">{vendor.email}</p>
<p className="text-sm text-muted">{vendor.phone}</p>
<p className="mt-3 text-sm text-muted">{vendor.city}, {vendor.state}</p>
</article>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,38 @@
import { Link } from "react-router-dom";
export function DashboardPage() {
return (
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel backdrop-blur">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Foundation Status</p>
<h3 className="mt-3 text-3xl font-bold text-text">Platform primitives are online.</h3>
<p className="mt-4 max-w-2xl text-sm leading-7 text-muted">
Authentication, RBAC, runtime branding, attachment storage, Docker deployment, and a planning visualization wrapper are now structured for future domain expansion.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Link className="rounded-2xl bg-brand px-5 py-3 text-sm font-semibold text-white" to="/settings/company">
Manage company profile
</Link>
<Link className="rounded-2xl border border-line/70 px-5 py-3 text-sm font-semibold text-text" to="/planning/gantt">
Open gantt preview
</Link>
</div>
</section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel backdrop-blur">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Roadmap</p>
<div className="mt-5 space-y-4">
{[
"CRM reference entities are seeded and available via protected APIs.",
"Company Settings drives runtime brand tokens and PDF identity.",
"The next module phase can add BOMs, orders, and shipping documents without app-shell refactors.",
].map((item) => (
<div key={item} className="rounded-2xl border border-line/70 bg-page/70 px-4 py-4 text-sm text-text">
{item}
</div>
))}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { useEffect, useState } from "react";
import { Gantt } from "@svar-ui/react-gantt";
import "@svar-ui/react-gantt/style.css";
import type { GanttLinkDto, GanttTaskDto } from "@mrp/shared";
import { useAuth } from "../../auth/AuthProvider";
import { api } from "../../lib/api";
export function GanttPage() {
const { token } = useAuth();
const [tasks, setTasks] = useState<GanttTaskDto[]>([]);
const [links, setLinks] = useState<GanttLinkDto[]>([]);
useEffect(() => {
if (!token) {
return;
}
api.getGanttDemo(token).then((data) => {
setTasks(data.tasks);
setLinks(data.links);
});
}, [token]);
return (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planning</p>
<h3 className="mt-3 text-2xl font-bold text-text">SVAR Gantt Preview</h3>
<p className="mt-2 text-sm text-muted">Theme-aware integration wrapper prepared for future manufacturing schedules and task dependencies.</p>
<div className="gantt-theme mt-6 overflow-hidden rounded-2xl border border-line/70 bg-page/70 p-4">
<Gantt
tasks={tasks.map((task) => ({
...task,
start: new Date(task.start),
end: new Date(task.end),
}))}
links={links}
/>
</div>
</section>
);
}

View File

@@ -0,0 +1,76 @@
import { useState } from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
export function LoginPage() {
const { login, token } = useAuth();
const [email, setEmail] = useState("admin@mrp.local");
const [password, setPassword] = useState("ChangeMe123!");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
if (token) {
return <Navigate to="/" replace />;
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setIsSubmitting(true);
try {
await login(email, password);
} catch (submissionError) {
setError(submissionError instanceof Error ? submissionError.message : "Unable to sign in.");
} finally {
setIsSubmitting(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center px-4 py-8">
<div className="grid w-full max-w-5xl overflow-hidden rounded-[32px] border border-line/70 bg-surface/90 shadow-panel backdrop-blur lg:grid-cols-[1.2fr_0.8fr]">
<section className="bg-brand px-8 py-12 text-white md:px-12">
<p className="text-xs font-semibold uppercase tracking-[0.26em] text-white/75">MRP Codex</p>
<h1 className="mt-6 text-4xl font-extrabold">A streamlined manufacturing operating system.</h1>
<p className="mt-5 max-w-xl text-base text-white/82">
This foundation release establishes authentication, company settings, brand theming, file persistence, and planning scaffolding.
</p>
</section>
<section className="px-8 py-12 md:px-12">
<h2 className="text-2xl font-bold text-text">Sign in</h2>
<p className="mt-2 text-sm text-muted">Use the seeded admin account to access the initial platform shell.</p>
<form className="mt-8 space-y-5" onSubmit={handleSubmit}>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Email</span>
<input
value={email}
onChange={(event) => setEmail(event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Password</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
/>
</label>
{error ? <div className="rounded-2xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-2xl bg-text px-4 py-3 text-sm font-semibold text-page transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
>
{isSubmitting ? "Signing in..." : "Enter workspace"}
</button>
</form>
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,177 @@
import type { CompanyProfileInput } from "@mrp/shared";
import { useEffect, useState } from "react";
import { useAuth } from "../../auth/AuthProvider";
import { api } from "../../lib/api";
import { useTheme } from "../../theme/ThemeProvider";
export function CompanySettingsPage() {
const { token } = useAuth();
const { applyBrandProfile } = useTheme();
const [form, setForm] = useState<CompanyProfileInput | null>(null);
const [companyId, setCompanyId] = useState<string | null>(null);
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [status, setStatus] = useState<string>("Loading company profile...");
useEffect(() => {
if (!token) {
return;
}
api.getCompanyProfile(token).then((profile) => {
setCompanyId(profile.id);
setLogoUrl(profile.logoUrl);
setForm({
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: profile.theme,
});
applyBrandProfile(profile);
setStatus("Company profile loaded.");
});
}, [applyBrandProfile, token]);
if (!form || !token) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-8 text-sm text-muted shadow-panel">{status}</div>;
}
async function handleSave(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token || !form) {
return;
}
const profile = await api.updateCompanyProfile(token, form);
applyBrandProfile(profile);
setLogoUrl(profile.logoUrl);
setStatus("Company settings saved.");
}
async function handleLogoUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file || !companyId || !token) {
return;
}
const attachment = await api.uploadFile(token, file, "company-profile", companyId);
setForm((current) =>
current
? {
...current,
theme: {
...current.theme,
logoFileId: attachment.id,
},
}
: current
);
setLogoUrl(`/api/v1/files/${attachment.id}/content`);
setStatus("Logo uploaded. Save to persist it on the profile.");
}
async function handlePdfPreview() {
if (!token) {
return;
}
const blob = await api.getCompanyProfilePreviewPdf(token);
const objectUrl = window.URL.createObjectURL(blob);
window.open(objectUrl, "_blank", "noopener,noreferrer");
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 60_000);
}
function updateField<Key extends keyof CompanyProfileInput>(key: Key, value: CompanyProfileInput[Key]) {
setForm((current) => (current ? { ...current, [key]: value } : current));
}
return (
<form className="space-y-6" onSubmit={handleSave}>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel backdrop-blur">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Company Profile</p>
<h3 className="mt-3 text-2xl font-bold text-text">Branding and legal identity</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">Every internal document and PDF template will inherit its company identity from this profile.</p>
</div>
<div className="rounded-3xl border border-dashed border-line/70 bg-page/80 p-4">
{logoUrl ? <img alt="Company logo" className="h-20 w-20 rounded-2xl object-cover" src={logoUrl} /> : <div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-brand text-sm font-bold text-white">LOGO</div>}
<label className="mt-3 block cursor-pointer text-sm font-semibold text-brand">
Upload logo
<input className="hidden" type="file" accept="image/*" onChange={handleLogoUpload} />
</label>
</div>
</div>
<div className="mt-8 grid gap-5 md:grid-cols-2">
{[
["companyName", "Company name"],
["legalName", "Legal name"],
["email", "Email"],
["phone", "Phone"],
["website", "Website"],
["taxId", "Tax ID"],
["addressLine1", "Address line 1"],
["addressLine2", "Address line 2"],
["city", "City"],
["state", "State"],
["postalCode", "Postal code"],
["country", "Country"],
].map(([key, label]) => (
<label key={key} className="block">
<span className="mb-2 block text-sm font-semibold text-text">{label}</span>
<input
value={String(form[key as keyof CompanyProfileInput])}
onChange={(event) => updateField(key as keyof CompanyProfileInput, event.target.value as never)}
className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand"
/>
</label>
))}
</div>
</section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel backdrop-blur">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Theme</p>
<div className="mt-6 grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Primary color</span>
<input type="color" value={form.theme.primaryColor} onChange={(event) => updateField("theme", { ...form.theme, primaryColor: event.target.value })} className="h-12 w-full rounded-2xl border border-line/70 bg-page p-2" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Accent color</span>
<input type="color" value={form.theme.accentColor} onChange={(event) => updateField("theme", { ...form.theme, accentColor: event.target.value })} className="h-12 w-full rounded-2xl border border-line/70 bg-page p-2" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Surface color</span>
<input type="color" value={form.theme.surfaceColor} onChange={(event) => updateField("theme", { ...form.theme, surfaceColor: event.target.value })} className="h-12 w-full rounded-2xl border border-line/70 bg-page p-2" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Font family</span>
<input value={form.theme.fontFamily} onChange={(event) => updateField("theme", { ...form.theme, fontFamily: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-page px-4 py-3 text-text outline-none transition focus:border-brand" />
</label>
</div>
<div className="mt-6 flex items-center justify-between rounded-2xl border border-line/70 bg-page/70 px-4 py-4">
<span className="text-sm text-muted">{status}</span>
<div className="flex gap-3">
<button
type="button"
onClick={handlePdfPreview}
className="rounded-2xl border border-line/70 px-4 py-3 text-sm font-semibold text-text"
>
Preview PDF
</button>
<button type="submit" className="rounded-2xl bg-brand px-5 py-3 text-sm font-semibold text-white">
Save changes
</button>
</div>
</div>
</section>
</form>
);
}

View File

@@ -0,0 +1,2 @@
import "@testing-library/jest-dom/vitest";

View File

@@ -0,0 +1,20 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ThemeProvider } from "../theme/ThemeProvider";
import { ThemeToggle } from "../components/ThemeToggle";
describe("ThemeToggle", () => {
it("toggles the html dark class", () => {
render(
<ThemeProvider>
<ThemeToggle />
</ThemeProvider>
);
fireEvent.click(screen.getByRole("button"));
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
});

View File

@@ -0,0 +1,58 @@
import type { CompanyProfileDto } from "@mrp/shared";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { hexToRgbTriplet } from "./utils";
type ThemeMode = "light" | "dark";
interface ThemeContextValue {
mode: ThemeMode;
toggleMode: () => void;
applyBrandProfile: (profile: CompanyProfileDto | null) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
const storageKey = "mrp.theme.mode";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [mode, setMode] = useState<ThemeMode>(() => {
const stored = window.localStorage.getItem(storageKey);
return stored === "dark" ? "dark" : "light";
});
useEffect(() => {
document.documentElement.classList.toggle("dark", mode === "dark");
window.localStorage.setItem(storageKey, mode);
}, [mode]);
const applyBrandProfile = (profile: CompanyProfileDto | null) => {
if (!profile) {
return;
}
document.documentElement.style.setProperty("--color-brand", hexToRgbTriplet(profile.theme.primaryColor));
document.documentElement.style.setProperty("--color-accent", hexToRgbTriplet(profile.theme.accentColor));
document.documentElement.style.setProperty("--color-surface", hexToRgbTriplet(profile.theme.surfaceColor));
document.documentElement.style.setProperty("--font-family", profile.theme.fontFamily);
};
const value = useMemo(
() => ({
mode,
toggleMode: () => setMode((current) => (current === "light" ? "dark" : "light")),
applyBrandProfile,
}),
[mode]
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within ThemeProvider");
}
return context;
}

View File

@@ -0,0 +1,9 @@
export function hexToRgbTriplet(hex: string) {
const normalized = hex.replace("#", "");
const numeric = Number.parseInt(normalized, 16);
const r = (numeric >> 16) & 255;
const g = (numeric >> 8) & 255;
const b = numeric & 255;
return `${r} ${g} ${b}`;
}

2
client/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />

27
client/tailwind.config.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
brand: "rgb(var(--color-brand) / <alpha-value>)",
accent: "rgb(var(--color-accent) / <alpha-value>)",
surface: "rgb(var(--color-surface) / <alpha-value>)",
page: "rgb(var(--color-page) / <alpha-value>)",
text: "rgb(var(--color-text) / <alpha-value>)",
muted: "rgb(var(--color-muted) / <alpha-value>)",
line: "rgb(var(--color-line) / <alpha-value>)",
},
fontFamily: {
sans: ["var(--font-family)", "ui-sans-serif", "system-ui"],
},
boxShadow: {
panel: "0 24px 60px rgba(15, 23, 42, 0.14)",
},
},
},
plugins: [],
} satisfies Config;

23
client/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"moduleResolution": "Bundler",
"types": [
"vite/client"
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"src"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

12
client/tsconfig.node.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler"
},
"include": [
"vite.config.ts",
"tailwind.config.ts"
]
}

19
client/vite.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import react from "@vitejs/plugin-react";
import path from "node:path";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
server: {
port: 5173,
proxy: {
"/api": "http://localhost:3000",
},
},
});

11
client/vitest.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./src/tests/setup.ts"],
},
});

11
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
mkdir -p /app/data/prisma /app/data/uploads
echo "Applying Prisma migrations..."
npx prisma migrate deploy --schema /app/server/prisma/schema.prisma
echo "Starting MRP Codex..."
exec node /app/server/dist/server.js

7448
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "mrp-codex",
"private": true,
"version": "0.1.0",
"workspaces": [
"client",
"server",
"shared"
],
"scripts": {
"dev": "concurrently \"npm:dev -w shared\" \"npm:dev -w server\" \"npm:dev -w client\"",
"build": "npm run build -w shared && npm run build -w server && npm run build -w client",
"test": "npm run test -w shared && npm run test -w server && npm run test -w client",
"lint": "npm run lint -w shared && npm run lint -w server && npm run lint -w client",
"prisma:generate": "npm run prisma:generate -w server",
"prisma:migrate": "npm run prisma:migrate -w server",
"prisma:deploy": "npm run prisma:deploy -w server"
},
"devDependencies": {
"concurrently": "^9.2.1",
"typescript": "^5.9.3",
"vite": "^8.0.0",
"vitest": "^4.1.0"
}
}

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",
},
});

14
shared/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "@mrp/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"dev": "tsc -w -p tsconfig.json",
"build": "tsc -p tsconfig.json",
"test": "vitest run --passWithNoTests",
"lint": "tsc -p tsconfig.json --noEmit"
}
}

View File

@@ -0,0 +1,17 @@
export const permissions = {
adminManage: "admin.manage",
companyRead: "company.read",
companyWrite: "company.write",
crmRead: "crm.read",
crmWrite: "crm.write",
filesRead: "files.read",
filesWrite: "files.write",
ganttRead: "gantt.read",
salesRead: "sales.read",
shippingRead: "shipping.read",
} as const;
export type PermissionKey = (typeof permissions)[keyof typeof permissions];
export const defaultAdminPermissions: PermissionKey[] = Object.values(permissions);

21
shared/src/auth/types.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { PermissionKey } from "./permissions";
export interface AuthUser {
id: string;
email: string;
firstName: string;
lastName: string;
roles: string[];
permissions: PermissionKey[];
}
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponse {
token: string;
user: AuthUser;
}

15
shared/src/common/api.ts Normal file
View File

@@ -0,0 +1,15 @@
export interface ApiSuccess<T> {
ok: true;
data: T;
}
export interface ApiError {
ok: false;
error: {
message: string;
code: string;
};
}
export type ApiResponse<T> = ApiSuccess<T> | ApiError;

View File

@@ -0,0 +1,29 @@
export interface BrandTheme {
primaryColor: string;
accentColor: string;
surfaceColor: string;
fontFamily: string;
logoFileId: string | null;
}
export interface CompanyProfileDto {
id: string;
companyName: string;
legalName: string;
email: string;
phone: string;
website: string;
taxId: string;
addressLine1: string;
addressLine2: string;
city: string;
state: string;
postalCode: string;
country: string;
theme: BrandTheme;
logoUrl: string | null;
updatedAt: string;
}
export type CompanyProfileInput = Omit<CompanyProfileDto, "id" | "logoUrl" | "updatedAt">;

11
shared/src/files/types.ts Normal file
View File

@@ -0,0 +1,11 @@
export interface FileAttachmentDto {
id: string;
originalName: string;
mimeType: string;
sizeBytes: number;
relativePath: string;
ownerType: string;
ownerId: string;
createdAt: string;
}

16
shared/src/gantt/types.ts Normal file
View File

@@ -0,0 +1,16 @@
export interface GanttTaskDto {
id: string;
text: string;
start: string;
end: string;
progress: number;
type: "task" | "project" | "milestone";
}
export interface GanttLinkDto {
id: string;
source: string;
target: string;
type: string;
}

6
shared/src/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export * from "./auth/permissions";
export * from "./auth/types";
export * from "./common/api";
export * from "./company/types";
export * from "./files/types";
export * from "./gantt/types";

12
shared/tsconfig.json Normal file
View File

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

16
tsconfig.base.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": "."
}
}