Initial MRP foundation scaffold
This commit is contained in:
12
.dockerignore
Normal file
12
.dockerignore
Normal 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
5
.env.example
Normal 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
25
.gitignore
vendored
Normal 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
81
Dockerfile
Normal 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
37
INSTRUCTIONS.md
Normal 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
56
README.md
Normal 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
39
STRUCTURE.md
Normal 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
13
client/index.html
Normal 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
32
client/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
client/postcss.config.cjs
Normal file
7
client/postcss.config.cjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
70
client/src/auth/AuthProvider.tsx
Normal file
70
client/src/auth/AuthProvider.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
81
client/src/components/AppShell.tsx
Normal file
81
client/src/components/AppShell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
client/src/components/ProtectedRoute.tsx
Normal file
22
client/src/components/ProtectedRoute.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
|
|
||||||
16
client/src/components/ThemeToggle.tsx
Normal file
16
client/src/components/ThemeToggle.tsx
Normal 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
47
client/src/index.css
Normal 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
100
client/src/lib/api.ts
Normal 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
63
client/src/main.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
|
||||||
46
client/src/modules/crm/CustomersPage.tsx
Normal file
46
client/src/modules/crm/CustomersPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
34
client/src/modules/crm/VendorsPage.tsx
Normal file
34
client/src/modules/crm/VendorsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
38
client/src/modules/dashboard/DashboardPage.tsx
Normal file
38
client/src/modules/dashboard/DashboardPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
43
client/src/modules/gantt/GanttPage.tsx
Normal file
43
client/src/modules/gantt/GanttPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
client/src/modules/login/LoginPage.tsx
Normal file
76
client/src/modules/login/LoginPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
177
client/src/modules/settings/CompanySettingsPage.tsx
Normal file
177
client/src/modules/settings/CompanySettingsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
client/src/tests/setup.ts
Normal file
2
client/src/tests/setup.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
20
client/src/tests/theme.test.tsx
Normal file
20
client/src/tests/theme.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
58
client/src/theme/ThemeProvider.tsx
Normal file
58
client/src/theme/ThemeProvider.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
9
client/src/theme/utils.ts
Normal file
9
client/src/theme/utils.ts
Normal 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
2
client/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
27
client/tailwind.config.ts
Normal file
27
client/tailwind.config.ts
Normal 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
23
client/tsconfig.json
Normal 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
12
client/tsconfig.node.json
Normal 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
19
client/vite.config.ts
Normal 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
11
client/vitest.config.ts
Normal 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
11
docker-entrypoint.sh
Normal 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
7448
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal 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
41
server/package.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "@mrp/server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"test": "vitest run",
|
||||||
|
"lint": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"prisma:generate": "dotenv -e ../.env -- prisma generate",
|
||||||
|
"prisma:migrate": "dotenv -e ../.env -- prisma migrate dev --name foundation",
|
||||||
|
"prisma:deploy": "dotenv -e ../.env -- prisma migrate deploy"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mrp/shared": "0.1.0",
|
||||||
|
"@prisma/client": "^6.16.2",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"express": "^4.22.1",
|
||||||
|
"express-async-errors": "^3.1.1",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"multer": "^2.1.1",
|
||||||
|
"pino-http": "^11.0.0",
|
||||||
|
"prisma": "^6.16.2",
|
||||||
|
"puppeteer": "^24.39.1",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^5.0.3",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
|
"@types/node": "^24.5.2",
|
||||||
|
"dotenv-cli": "^8.0.0",
|
||||||
|
"tsx": "^4.20.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
139
server/prisma/migrations/20260314193000_foundation/migration.sql
Normal file
139
server/prisma/migrations/20260314193000_foundation/migration.sql
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"firstName" TEXT NOT NULL,
|
||||||
|
"lastName" TEXT NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Role" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Permission" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "UserRole" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"roleId" TEXT NOT NULL,
|
||||||
|
"assignedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"assignedBy" TEXT,
|
||||||
|
|
||||||
|
PRIMARY KEY ("userId", "roleId"),
|
||||||
|
CONSTRAINT "UserRole_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RolePermission" (
|
||||||
|
"roleId" TEXT NOT NULL,
|
||||||
|
"permissionId" TEXT NOT NULL,
|
||||||
|
"grantedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
PRIMARY KEY ("roleId", "permissionId"),
|
||||||
|
CONSTRAINT "RolePermission_permissionId_fkey" FOREIGN KEY ("permissionId") REFERENCES "Permission" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "RolePermission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CompanyProfile" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"companyName" TEXT NOT NULL,
|
||||||
|
"legalName" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"phone" TEXT NOT NULL,
|
||||||
|
"website" TEXT NOT NULL,
|
||||||
|
"taxId" TEXT NOT NULL,
|
||||||
|
"addressLine1" TEXT NOT NULL,
|
||||||
|
"addressLine2" TEXT NOT NULL,
|
||||||
|
"city" TEXT NOT NULL,
|
||||||
|
"state" TEXT NOT NULL,
|
||||||
|
"postalCode" TEXT NOT NULL,
|
||||||
|
"country" TEXT NOT NULL,
|
||||||
|
"primaryColor" TEXT NOT NULL DEFAULT '#185ADB',
|
||||||
|
"accentColor" TEXT NOT NULL DEFAULT '#00A6A6',
|
||||||
|
"surfaceColor" TEXT NOT NULL DEFAULT '#F4F7FB',
|
||||||
|
"fontFamily" TEXT NOT NULL DEFAULT 'Manrope',
|
||||||
|
"logoFileId" TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "CompanyProfile_logoFileId_fkey" FOREIGN KEY ("logoFileId") REFERENCES "FileAttachment" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FileAttachment" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"originalName" TEXT NOT NULL,
|
||||||
|
"storedName" TEXT NOT NULL,
|
||||||
|
"mimeType" TEXT NOT NULL,
|
||||||
|
"sizeBytes" INTEGER NOT NULL,
|
||||||
|
"relativePath" TEXT NOT NULL,
|
||||||
|
"ownerType" TEXT NOT NULL,
|
||||||
|
"ownerId" TEXT NOT NULL,
|
||||||
|
"createdById" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Customer" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"phone" TEXT NOT NULL,
|
||||||
|
"addressLine1" TEXT NOT NULL,
|
||||||
|
"addressLine2" TEXT NOT NULL,
|
||||||
|
"city" TEXT NOT NULL,
|
||||||
|
"state" TEXT NOT NULL,
|
||||||
|
"postalCode" TEXT NOT NULL,
|
||||||
|
"country" TEXT NOT NULL,
|
||||||
|
"notes" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Vendor" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"phone" TEXT NOT NULL,
|
||||||
|
"addressLine1" TEXT NOT NULL,
|
||||||
|
"addressLine2" TEXT NOT NULL,
|
||||||
|
"city" TEXT NOT NULL,
|
||||||
|
"state" TEXT NOT NULL,
|
||||||
|
"postalCode" TEXT NOT NULL,
|
||||||
|
"country" TEXT NOT NULL,
|
||||||
|
"notes" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Permission_key_key" ON "Permission"("key");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CompanyProfile_logoFileId_key" ON "CompanyProfile"("logoFileId");
|
||||||
2
server/prisma/migrations/migration_lock.toml
Normal file
2
server/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
provider = "sqlite"
|
||||||
|
|
||||||
133
server/prisma/schema.prisma
Normal file
133
server/prisma/schema.prisma
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
firstName String
|
||||||
|
lastName String
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
userRoles UserRole[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Role {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
description String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
userRoles UserRole[]
|
||||||
|
rolePermissions RolePermission[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Permission {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
key String @unique
|
||||||
|
description String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
rolePermissions RolePermission[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserRole {
|
||||||
|
userId String
|
||||||
|
roleId String
|
||||||
|
assignedAt DateTime @default(now())
|
||||||
|
assignedBy String?
|
||||||
|
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([userId, roleId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RolePermission {
|
||||||
|
roleId String
|
||||||
|
permissionId String
|
||||||
|
grantedAt DateTime @default(now())
|
||||||
|
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
|
||||||
|
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([roleId, permissionId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CompanyProfile {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
companyName String
|
||||||
|
legalName String
|
||||||
|
email String
|
||||||
|
phone String
|
||||||
|
website String
|
||||||
|
taxId String
|
||||||
|
addressLine1 String
|
||||||
|
addressLine2 String
|
||||||
|
city String
|
||||||
|
state String
|
||||||
|
postalCode String
|
||||||
|
country String
|
||||||
|
primaryColor String @default("#185ADB")
|
||||||
|
accentColor String @default("#00A6A6")
|
||||||
|
surfaceColor String @default("#F4F7FB")
|
||||||
|
fontFamily String @default("Manrope")
|
||||||
|
logoFileId String? @unique
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
logoFile FileAttachment? @relation("CompanyLogo", fields: [logoFileId], references: [id], onDelete: SetNull)
|
||||||
|
}
|
||||||
|
|
||||||
|
model FileAttachment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
originalName String
|
||||||
|
storedName String
|
||||||
|
mimeType String
|
||||||
|
sizeBytes Int
|
||||||
|
relativePath String
|
||||||
|
ownerType String
|
||||||
|
ownerId String
|
||||||
|
createdById String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
companyLogoFor CompanyProfile? @relation("CompanyLogo")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Customer {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
email String
|
||||||
|
phone String
|
||||||
|
addressLine1 String
|
||||||
|
addressLine2 String
|
||||||
|
city String
|
||||||
|
state String
|
||||||
|
postalCode String
|
||||||
|
country String
|
||||||
|
notes String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Vendor {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
email String
|
||||||
|
phone String
|
||||||
|
addressLine1 String
|
||||||
|
addressLine2 String
|
||||||
|
city String
|
||||||
|
state String
|
||||||
|
postalCode String
|
||||||
|
country String
|
||||||
|
notes String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
68
server/src/app.ts
Normal file
68
server/src/app.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import "express-async-errors";
|
||||||
|
|
||||||
|
import cors from "cors";
|
||||||
|
import express from "express";
|
||||||
|
import helmet from "helmet";
|
||||||
|
import path from "node:path";
|
||||||
|
import pinoHttp from "pino-http";
|
||||||
|
|
||||||
|
import { env } from "./config/env.js";
|
||||||
|
import { paths } from "./config/paths.js";
|
||||||
|
import { verifyToken } from "./lib/auth.js";
|
||||||
|
import { getCurrentUserById } from "./lib/current-user.js";
|
||||||
|
import { fail, ok } from "./lib/http.js";
|
||||||
|
import { authRouter } from "./modules/auth/router.js";
|
||||||
|
import { crmRouter } from "./modules/crm/router.js";
|
||||||
|
import { documentsRouter } from "./modules/documents/router.js";
|
||||||
|
import { filesRouter } from "./modules/files/router.js";
|
||||||
|
import { ganttRouter } from "./modules/gantt/router.js";
|
||||||
|
import { settingsRouter } from "./modules/settings/router.js";
|
||||||
|
|
||||||
|
export function createApp() {
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(helmet({ contentSecurityPolicy: false }));
|
||||||
|
app.use(cors({ origin: env.CLIENT_ORIGIN, credentials: true }));
|
||||||
|
app.use(express.json({ limit: "2mb" }));
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
app.use(pinoHttp());
|
||||||
|
|
||||||
|
app.use(async (request, _response, next) => {
|
||||||
|
const authHeader = request.header("authorization");
|
||||||
|
if (!authHeader?.startsWith("Bearer ")) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = authHeader.slice("Bearer ".length);
|
||||||
|
const payload = verifyToken(token);
|
||||||
|
const authUser = await getCurrentUserById(payload.sub);
|
||||||
|
request.authUser = authUser ?? undefined;
|
||||||
|
} catch {
|
||||||
|
request.authUser = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/health", (_request, response) => ok(response, { status: "ok" }));
|
||||||
|
app.use("/api/v1/auth", authRouter);
|
||||||
|
app.use("/api/v1", settingsRouter);
|
||||||
|
app.use("/api/v1/files", filesRouter);
|
||||||
|
app.use("/api/v1/crm", crmRouter);
|
||||||
|
app.use("/api/v1/gantt", ganttRouter);
|
||||||
|
app.use("/api/v1/documents", documentsRouter);
|
||||||
|
|
||||||
|
if (env.NODE_ENV === "production") {
|
||||||
|
app.use(express.static(paths.clientDistDir));
|
||||||
|
app.get("*", (_request, response) => {
|
||||||
|
response.sendFile(path.join(paths.clientDistDir, "index.html"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use((error: Error, _request: express.Request, response: express.Response, _next: express.NextFunction) => {
|
||||||
|
return fail(response, 500, "INTERNAL_ERROR", error.message || "Unexpected server error.");
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
19
server/src/config/env.ts
Normal file
19
server/src/config/env.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { config } from "dotenv";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
config({ path: ".env" });
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||||
|
PORT: z.coerce.number().default(3000),
|
||||||
|
JWT_SECRET: z.string().min(8).default("change-me"),
|
||||||
|
DATABASE_URL: z.string().default("file:../../data/prisma/app.db"),
|
||||||
|
DATA_DIR: z.string().default("./data"),
|
||||||
|
CLIENT_ORIGIN: z.string().default("http://localhost:5173"),
|
||||||
|
ADMIN_EMAIL: z.string().email().default("admin@mrp.local"),
|
||||||
|
ADMIN_PASSWORD: z.string().min(8).default("ChangeMe123!"),
|
||||||
|
PUPPETEER_EXECUTABLE_PATH: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const env = schema.parse(process.env);
|
||||||
|
|
||||||
14
server/src/config/paths.ts
Normal file
14
server/src/config/paths.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { env } from "./env.js";
|
||||||
|
|
||||||
|
const projectRoot = process.cwd();
|
||||||
|
|
||||||
|
export const paths = {
|
||||||
|
projectRoot,
|
||||||
|
dataDir: path.resolve(projectRoot, env.DATA_DIR),
|
||||||
|
uploadsDir: path.resolve(projectRoot, env.DATA_DIR, "uploads"),
|
||||||
|
prismaDir: path.resolve(projectRoot, env.DATA_DIR, "prisma"),
|
||||||
|
clientDistDir: path.resolve(projectRoot, "client", "dist"),
|
||||||
|
};
|
||||||
|
|
||||||
27
server/src/lib/auth.ts
Normal file
27
server/src/lib/auth.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { AuthUser } from "@mrp/shared";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
|
import { env } from "../config/env.js";
|
||||||
|
|
||||||
|
interface AuthTokenPayload {
|
||||||
|
sub: string;
|
||||||
|
email: string;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signToken(user: AuthUser) {
|
||||||
|
return jwt.sign(
|
||||||
|
{
|
||||||
|
sub: user.id,
|
||||||
|
email: user.email,
|
||||||
|
permissions: user.permissions,
|
||||||
|
} satisfies AuthTokenPayload,
|
||||||
|
env.JWT_SECRET,
|
||||||
|
{ expiresIn: "12h" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyToken(token: string) {
|
||||||
|
return jwt.verify(token, env.JWT_SECRET) as AuthTokenPayload;
|
||||||
|
}
|
||||||
|
|
||||||
164
server/src/lib/bootstrap.ts
Normal file
164
server/src/lib/bootstrap.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { defaultAdminPermissions, permissions, type PermissionKey } from "@mrp/shared";
|
||||||
|
|
||||||
|
import { env } from "../config/env.js";
|
||||||
|
import { prisma } from "./prisma.js";
|
||||||
|
import { hashPassword } from "./password.js";
|
||||||
|
import { ensureDataDirectories } from "./storage.js";
|
||||||
|
|
||||||
|
const permissionDescriptions: Record<PermissionKey, string> = {
|
||||||
|
[permissions.adminManage]: "Full administrative access",
|
||||||
|
[permissions.companyRead]: "View company settings",
|
||||||
|
[permissions.companyWrite]: "Update company settings",
|
||||||
|
[permissions.crmRead]: "View CRM records",
|
||||||
|
[permissions.crmWrite]: "Manage CRM records",
|
||||||
|
[permissions.filesRead]: "View attached files",
|
||||||
|
[permissions.filesWrite]: "Upload and manage attached files",
|
||||||
|
[permissions.ganttRead]: "View gantt timelines",
|
||||||
|
[permissions.salesRead]: "View sales data",
|
||||||
|
[permissions.shippingRead]: "View shipping data",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function bootstrapAppData() {
|
||||||
|
await ensureDataDirectories();
|
||||||
|
|
||||||
|
for (const permissionKey of defaultAdminPermissions) {
|
||||||
|
await prisma.permission.upsert({
|
||||||
|
where: { key: permissionKey },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
key: permissionKey,
|
||||||
|
description: permissionDescriptions[permissionKey],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminRole = await prisma.role.upsert({
|
||||||
|
where: { name: "Administrator" },
|
||||||
|
update: { description: "Full system access" },
|
||||||
|
create: {
|
||||||
|
name: "Administrator",
|
||||||
|
description: "Full system access",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const allPermissions = await prisma.permission.findMany({
|
||||||
|
where: {
|
||||||
|
key: {
|
||||||
|
in: defaultAdminPermissions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const permission of allPermissions) {
|
||||||
|
await prisma.rolePermission.upsert({
|
||||||
|
where: {
|
||||||
|
roleId_permissionId: {
|
||||||
|
roleId: adminRole.id,
|
||||||
|
permissionId: permission.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
roleId: adminRole.id,
|
||||||
|
permissionId: permission.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminUser = await prisma.user.upsert({
|
||||||
|
where: { email: env.ADMIN_EMAIL },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: env.ADMIN_EMAIL,
|
||||||
|
firstName: "System",
|
||||||
|
lastName: "Administrator",
|
||||||
|
passwordHash: await hashPassword(env.ADMIN_PASSWORD),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.userRole.upsert({
|
||||||
|
where: {
|
||||||
|
userId_roleId: {
|
||||||
|
userId: adminUser.id,
|
||||||
|
roleId: adminRole.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
userId: adminUser.id,
|
||||||
|
roleId: adminRole.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingProfile = await prisma.companyProfile.findFirst({
|
||||||
|
where: { isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingProfile) {
|
||||||
|
await prisma.companyProfile.create({
|
||||||
|
data: {
|
||||||
|
companyName: "MRP Codex Manufacturing",
|
||||||
|
legalName: "MRP Codex Manufacturing LLC",
|
||||||
|
email: "operations@example.com",
|
||||||
|
phone: "+1 (555) 010-2000",
|
||||||
|
website: "https://example.com",
|
||||||
|
taxId: "99-9999999",
|
||||||
|
addressLine1: "100 Foundry Lane",
|
||||||
|
addressLine2: "Suite 200",
|
||||||
|
city: "Chicago",
|
||||||
|
state: "IL",
|
||||||
|
postalCode: "60601",
|
||||||
|
country: "USA",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((await prisma.customer.count()) === 0) {
|
||||||
|
await prisma.customer.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
name: "Acme Components",
|
||||||
|
email: "buyer@acme.example",
|
||||||
|
phone: "555-0101",
|
||||||
|
addressLine1: "1 Industrial Road",
|
||||||
|
addressLine2: "",
|
||||||
|
city: "Detroit",
|
||||||
|
state: "MI",
|
||||||
|
postalCode: "48201",
|
||||||
|
country: "USA",
|
||||||
|
notes: "Priority account",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Northwind Fabrication",
|
||||||
|
email: "ops@northwind.example",
|
||||||
|
phone: "555-0120",
|
||||||
|
addressLine1: "42 Assembly Ave",
|
||||||
|
addressLine2: "",
|
||||||
|
city: "Milwaukee",
|
||||||
|
state: "WI",
|
||||||
|
postalCode: "53202",
|
||||||
|
country: "USA",
|
||||||
|
notes: "Requires ASN notice",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((await prisma.vendor.count()) === 0) {
|
||||||
|
await prisma.vendor.create({
|
||||||
|
data: {
|
||||||
|
name: "SteelSource Supply",
|
||||||
|
email: "sales@steelsource.example",
|
||||||
|
phone: "555-0142",
|
||||||
|
addressLine1: "77 Mill Street",
|
||||||
|
addressLine2: "",
|
||||||
|
city: "Gary",
|
||||||
|
state: "IN",
|
||||||
|
postalCode: "46402",
|
||||||
|
country: "USA",
|
||||||
|
notes: "Lead time 5 business days",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
47
server/src/lib/current-user.ts
Normal file
47
server/src/lib/current-user.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { AuthUser, PermissionKey } from "@mrp/shared";
|
||||||
|
|
||||||
|
import { prisma } from "./prisma.js";
|
||||||
|
|
||||||
|
export async function getCurrentUserById(userId: string): Promise<AuthUser | null> {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: {
|
||||||
|
userRoles: {
|
||||||
|
include: {
|
||||||
|
role: {
|
||||||
|
include: {
|
||||||
|
rolePermissions: {
|
||||||
|
include: {
|
||||||
|
permission: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionKeys = new Set<PermissionKey>();
|
||||||
|
const roleNames = user.userRoles.map(({ role }) => {
|
||||||
|
for (const rolePermission of role.rolePermissions) {
|
||||||
|
permissionKeys.add(rolePermission.permission.key as PermissionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return role.name;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
roles: roleNames,
|
||||||
|
permissions: [...permissionKeys],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
20
server/src/lib/http.ts
Normal file
20
server/src/lib/http.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ApiResponse } from "@mrp/shared";
|
||||||
|
import type { Response } from "express";
|
||||||
|
|
||||||
|
export function ok<T>(response: Response, data: T, status = 200) {
|
||||||
|
const body: ApiResponse<T> = { ok: true, data };
|
||||||
|
return response.status(status).json(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fail(response: Response, status: number, code: string, message: string) {
|
||||||
|
const body: ApiResponse<never> = {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return response.status(status).json(body);
|
||||||
|
}
|
||||||
|
|
||||||
10
server/src/lib/password.ts
Normal file
10
server/src/lib/password.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
export async function hashPassword(password: string) {
|
||||||
|
return bcrypt.hash(password, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(password: string, hash: string) {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
|
|
||||||
25
server/src/lib/pdf.ts
Normal file
25
server/src/lib/pdf.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
|
||||||
|
import { env } from "../config/env.js";
|
||||||
|
|
||||||
|
export async function renderPdf(html: string) {
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
executablePath: env.PUPPETEER_EXECUTABLE_PATH,
|
||||||
|
headless: true,
|
||||||
|
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setContent(html, { waitUntil: "networkidle0" });
|
||||||
|
|
||||||
|
return await page.pdf({
|
||||||
|
format: "A4",
|
||||||
|
printBackground: true,
|
||||||
|
preferCSSPageSize: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
4
server/src/lib/prisma.ts
Normal file
4
server/src/lib/prisma.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
export const prisma = new PrismaClient();
|
||||||
|
|
||||||
30
server/src/lib/rbac.ts
Normal file
30
server/src/lib/rbac.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { PermissionKey } from "@mrp/shared";
|
||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
|
||||||
|
import { fail } from "./http.js";
|
||||||
|
|
||||||
|
export function requireAuth(request: Request, response: Response, next: NextFunction) {
|
||||||
|
if (!request.authUser) {
|
||||||
|
return fail(response, 401, "UNAUTHORIZED", "Authentication is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requirePermissions(requiredPermissions: PermissionKey[]) {
|
||||||
|
return (request: Request, response: Response, next: NextFunction) => {
|
||||||
|
if (!request.authUser) {
|
||||||
|
return fail(response, 401, "UNAUTHORIZED", "Authentication is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const available = new Set(request.authUser.permissions);
|
||||||
|
const hasAll = requiredPermissions.every((permission) => available.has(permission));
|
||||||
|
|
||||||
|
if (!hasAll) {
|
||||||
|
return fail(response, 403, "FORBIDDEN", "You do not have access to this resource.");
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
27
server/src/lib/storage.ts
Normal file
27
server/src/lib/storage.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { paths } from "../config/paths.js";
|
||||||
|
|
||||||
|
export async function ensureDataDirectories() {
|
||||||
|
await fs.mkdir(paths.uploadsDir, { recursive: true });
|
||||||
|
await fs.mkdir(paths.prismaDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeUpload(buffer: Buffer, originalName: string) {
|
||||||
|
const extension = path.extname(originalName);
|
||||||
|
const storedName = `${Date.now()}-${randomUUID()}${extension}`;
|
||||||
|
const relativePath = path.join("uploads", storedName);
|
||||||
|
const absolutePath = path.join(paths.dataDir, relativePath);
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||||
|
await fs.writeFile(absolutePath, buffer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
storedName,
|
||||||
|
relativePath: relativePath.replaceAll("\\", "/"),
|
||||||
|
absolutePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
30
server/src/modules/auth/router.ts
Normal file
30
server/src/modules/auth/router.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { fail, ok } from "../../lib/http.js";
|
||||||
|
import { requireAuth } from "../../lib/rbac.js";
|
||||||
|
import { login } from "./service.js";
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const authRouter = Router();
|
||||||
|
|
||||||
|
authRouter.post("/login", async (request, response) => {
|
||||||
|
const parsed = loginSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return fail(response, 400, "INVALID_INPUT", "Please provide a valid email and password.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await login(parsed.data);
|
||||||
|
if (!result) {
|
||||||
|
return fail(response, 401, "INVALID_CREDENTIALS", "Email or password is incorrect.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(response, result);
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.get("/me", requireAuth, async (request, response) => ok(response, request.authUser));
|
||||||
|
|
||||||
31
server/src/modules/auth/service.ts
Normal file
31
server/src/modules/auth/service.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { LoginRequest, LoginResponse } from "@mrp/shared";
|
||||||
|
|
||||||
|
import { signToken } from "../../lib/auth.js";
|
||||||
|
import { getCurrentUserById } from "../../lib/current-user.js";
|
||||||
|
import { verifyPassword } from "../../lib/password.js";
|
||||||
|
import { prisma } from "../../lib/prisma.js";
|
||||||
|
|
||||||
|
export async function login(payload: LoginRequest): Promise<LoginResponse | null> {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: payload.email.toLowerCase() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user?.isActive) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await verifyPassword(payload.password, user.passwordHash))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authUser = await getCurrentUserById(user.id);
|
||||||
|
if (!authUser) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: signToken(authUser),
|
||||||
|
user: authUser,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
17
server/src/modules/crm/router.ts
Normal file
17
server/src/modules/crm/router.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import { Router } from "express";
|
||||||
|
|
||||||
|
import { ok } from "../../lib/http.js";
|
||||||
|
import { requirePermissions } from "../../lib/rbac.js";
|
||||||
|
import { listCustomers, listVendors } from "./service.js";
|
||||||
|
|
||||||
|
export const crmRouter = Router();
|
||||||
|
|
||||||
|
crmRouter.get("/customers", requirePermissions([permissions.crmRead]), async (_request, response) => {
|
||||||
|
return ok(response, await listCustomers());
|
||||||
|
});
|
||||||
|
|
||||||
|
crmRouter.get("/vendors", requirePermissions([permissions.crmRead]), async (_request, response) => {
|
||||||
|
return ok(response, await listVendors());
|
||||||
|
});
|
||||||
|
|
||||||
14
server/src/modules/crm/service.ts
Normal file
14
server/src/modules/crm/service.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { prisma } from "../../lib/prisma.js";
|
||||||
|
|
||||||
|
export async function listCustomers() {
|
||||||
|
return prisma.customer.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listVendors() {
|
||||||
|
return prisma.vendor.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
50
server/src/modules/documents/router.ts
Normal file
50
server/src/modules/documents/router.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import { Router } from "express";
|
||||||
|
|
||||||
|
import { renderPdf } from "../../lib/pdf.js";
|
||||||
|
import { requirePermissions } from "../../lib/rbac.js";
|
||||||
|
import { getActiveCompanyProfile } from "../settings/service.js";
|
||||||
|
|
||||||
|
export const documentsRouter = Router();
|
||||||
|
|
||||||
|
documentsRouter.get("/company-profile-preview.pdf", requirePermissions([permissions.companyRead]), async (_request, response) => {
|
||||||
|
const profile = await getActiveCompanyProfile();
|
||||||
|
const pdf = await renderPdf(`
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: ${profile.theme.fontFamily}, Arial, sans-serif; color: #1b1f29; padding: 32px; }
|
||||||
|
.card { border: 1px solid #d7deeb; border-radius: 18px; overflow: hidden; }
|
||||||
|
.header { background: ${profile.theme.primaryColor}; color: white; padding: 24px 28px; }
|
||||||
|
.body { padding: 28px; background: #ffffff; }
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
|
.label { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; }
|
||||||
|
.value { font-size: 16px; margin-top: 6px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="header">
|
||||||
|
<h1>${profile.companyName}</h1>
|
||||||
|
<p>Brand profile preview generated through Puppeteer</p>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<div class="grid">
|
||||||
|
<div><div class="label">Legal name</div><div class="value">${profile.legalName}</div></div>
|
||||||
|
<div><div class="label">Tax ID</div><div class="value">${profile.taxId}</div></div>
|
||||||
|
<div><div class="label">Contact</div><div class="value">${profile.email}<br/>${profile.phone}</div></div>
|
||||||
|
<div><div class="label">Website</div><div class="value">${profile.website}</div></div>
|
||||||
|
<div><div class="label">Address</div><div class="value">${profile.addressLine1}<br/>${profile.addressLine2}<br/>${profile.city}, ${profile.state} ${profile.postalCode}<br/>${profile.country}</div></div>
|
||||||
|
<div><div class="label">Theme</div><div class="value">Primary ${profile.theme.primaryColor}<br/>Accent ${profile.theme.accentColor}<br/>Surface ${profile.theme.surfaceColor}</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
|
||||||
|
response.setHeader("Content-Type", "application/pdf");
|
||||||
|
response.setHeader("Content-Disposition", "inline; filename=company-profile-preview.pdf");
|
||||||
|
return response.send(pdf);
|
||||||
|
});
|
||||||
|
|
||||||
59
server/src/modules/files/router.ts
Normal file
59
server/src/modules/files/router.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import { Router } from "express";
|
||||||
|
import multer from "multer";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { fail, ok } from "../../lib/http.js";
|
||||||
|
import { requirePermissions } from "../../lib/rbac.js";
|
||||||
|
import { createAttachment, getAttachmentContent, getAttachmentMetadata } from "./service.js";
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: {
|
||||||
|
fileSize: 10 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadSchema = z.object({
|
||||||
|
ownerType: z.string().min(1),
|
||||||
|
ownerId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const filesRouter = Router();
|
||||||
|
|
||||||
|
filesRouter.post(
|
||||||
|
"/upload",
|
||||||
|
requirePermissions([permissions.filesWrite]),
|
||||||
|
upload.single("file"),
|
||||||
|
async (request, response) => {
|
||||||
|
const parsed = uploadSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success || !request.file) {
|
||||||
|
return fail(response, 400, "INVALID_UPLOAD", "A file, ownerType, and ownerId are required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(
|
||||||
|
response,
|
||||||
|
await createAttachment({
|
||||||
|
buffer: request.file.buffer,
|
||||||
|
originalName: request.file.originalname,
|
||||||
|
mimeType: request.file.mimetype,
|
||||||
|
sizeBytes: request.file.size,
|
||||||
|
ownerType: parsed.data.ownerType,
|
||||||
|
ownerId: parsed.data.ownerId,
|
||||||
|
createdById: request.authUser?.id,
|
||||||
|
}),
|
||||||
|
201
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
filesRouter.get("/:id", requirePermissions([permissions.filesRead]), async (request, response) => {
|
||||||
|
return ok(response, await getAttachmentMetadata(String(request.params.id)));
|
||||||
|
});
|
||||||
|
|
||||||
|
filesRouter.get("/:id/content", requirePermissions([permissions.filesRead]), async (request, response) => {
|
||||||
|
const { file, content } = await getAttachmentContent(String(request.params.id));
|
||||||
|
response.setHeader("Content-Type", file.mimeType);
|
||||||
|
response.setHeader("Content-Disposition", `inline; filename="${file.originalName}"`);
|
||||||
|
return response.send(content);
|
||||||
|
});
|
||||||
69
server/src/modules/files/service.ts
Normal file
69
server/src/modules/files/service.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import type { FileAttachmentDto } from "@mrp/shared";
|
||||||
|
|
||||||
|
import { paths } from "../../config/paths.js";
|
||||||
|
import { prisma } from "../../lib/prisma.js";
|
||||||
|
import { writeUpload } from "../../lib/storage.js";
|
||||||
|
|
||||||
|
type FileRecord = Awaited<ReturnType<typeof prisma.fileAttachment.create>>;
|
||||||
|
|
||||||
|
function mapFile(file: FileRecord): FileAttachmentDto {
|
||||||
|
return {
|
||||||
|
id: file.id,
|
||||||
|
originalName: file.originalName,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
sizeBytes: file.sizeBytes,
|
||||||
|
relativePath: file.relativePath,
|
||||||
|
ownerType: file.ownerType,
|
||||||
|
ownerId: file.ownerId,
|
||||||
|
createdAt: file.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAttachment(options: {
|
||||||
|
buffer: Buffer;
|
||||||
|
originalName: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
ownerType: string;
|
||||||
|
ownerId: string;
|
||||||
|
createdById?: string;
|
||||||
|
}) {
|
||||||
|
const saved = await writeUpload(options.buffer, options.originalName);
|
||||||
|
const file = await prisma.fileAttachment.create({
|
||||||
|
data: {
|
||||||
|
originalName: options.originalName,
|
||||||
|
storedName: saved.storedName,
|
||||||
|
mimeType: options.mimeType,
|
||||||
|
sizeBytes: options.sizeBytes,
|
||||||
|
relativePath: saved.relativePath,
|
||||||
|
ownerType: options.ownerType,
|
||||||
|
ownerId: options.ownerId,
|
||||||
|
createdById: options.createdById,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAttachmentMetadata(id: string) {
|
||||||
|
return mapFile(
|
||||||
|
await prisma.fileAttachment.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAttachmentContent(id: string) {
|
||||||
|
const file = await prisma.fileAttachment.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
content: await fs.readFile(path.join(paths.dataDir, file.relativePath)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
23
server/src/modules/gantt/router.ts
Normal file
23
server/src/modules/gantt/router.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import { Router } from "express";
|
||||||
|
|
||||||
|
import { ok } from "../../lib/http.js";
|
||||||
|
import { requirePermissions } from "../../lib/rbac.js";
|
||||||
|
|
||||||
|
export const ganttRouter = Router();
|
||||||
|
|
||||||
|
ganttRouter.get("/demo", requirePermissions([permissions.ganttRead]), (_request, response) => {
|
||||||
|
return ok(response, {
|
||||||
|
tasks: [
|
||||||
|
{ id: "project-1", text: "Machine Assembly Program", start: "2026-03-16", end: "2026-03-28", progress: 35, type: "project" },
|
||||||
|
{ id: "task-1", text: "Frame fabrication", start: "2026-03-16", end: "2026-03-19", progress: 80, type: "task" },
|
||||||
|
{ id: "task-2", text: "Electrical install", start: "2026-03-20", end: "2026-03-25", progress: 20, type: "task" },
|
||||||
|
{ id: "milestone-1", text: "Factory acceptance", start: "2026-03-28", end: "2026-03-28", progress: 0, type: "milestone" }
|
||||||
|
],
|
||||||
|
links: [
|
||||||
|
{ id: "link-1", source: "task-1", target: "task-2", type: "e2e" },
|
||||||
|
{ id: "link-2", source: "task-2", target: "milestone-1", type: "e2e" }
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
45
server/src/modules/settings/router.ts
Normal file
45
server/src/modules/settings/router.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { fail, ok } from "../../lib/http.js";
|
||||||
|
import { requirePermissions } from "../../lib/rbac.js";
|
||||||
|
import { getActiveCompanyProfile, updateActiveCompanyProfile } from "./service.js";
|
||||||
|
|
||||||
|
const companySchema = z.object({
|
||||||
|
companyName: z.string().min(1),
|
||||||
|
legalName: z.string().min(1),
|
||||||
|
email: z.string().email(),
|
||||||
|
phone: z.string().min(1),
|
||||||
|
website: z.string().min(1),
|
||||||
|
taxId: z.string().min(1),
|
||||||
|
addressLine1: z.string().min(1),
|
||||||
|
addressLine2: z.string(),
|
||||||
|
city: z.string().min(1),
|
||||||
|
state: z.string().min(1),
|
||||||
|
postalCode: z.string().min(1),
|
||||||
|
country: z.string().min(1),
|
||||||
|
theme: z.object({
|
||||||
|
primaryColor: z.string().regex(/^#([A-Fa-f0-9]{6})$/),
|
||||||
|
accentColor: z.string().regex(/^#([A-Fa-f0-9]{6})$/),
|
||||||
|
surfaceColor: z.string().regex(/^#([A-Fa-f0-9]{6})$/),
|
||||||
|
fontFamily: z.string().min(1),
|
||||||
|
logoFileId: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const settingsRouter = Router();
|
||||||
|
|
||||||
|
settingsRouter.get("/company-profile", requirePermissions([permissions.companyRead]), async (_request, response) => {
|
||||||
|
return ok(response, await getActiveCompanyProfile());
|
||||||
|
});
|
||||||
|
|
||||||
|
settingsRouter.put("/company-profile", requirePermissions([permissions.companyWrite]), async (request, response) => {
|
||||||
|
const parsed = companySchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return fail(response, 400, "INVALID_INPUT", "Company settings payload is invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(response, await updateActiveCompanyProfile(parsed.data));
|
||||||
|
});
|
||||||
|
|
||||||
72
server/src/modules/settings/service.ts
Normal file
72
server/src/modules/settings/service.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { CompanyProfileDto, CompanyProfileInput } from "@mrp/shared";
|
||||||
|
|
||||||
|
import { prisma } from "../../lib/prisma.js";
|
||||||
|
|
||||||
|
type CompanyProfileRecord = Awaited<ReturnType<typeof prisma.companyProfile.findFirstOrThrow>>;
|
||||||
|
|
||||||
|
function mapCompanyProfile(profile: CompanyProfileRecord): CompanyProfileDto {
|
||||||
|
return {
|
||||||
|
id: profile.id,
|
||||||
|
companyName: profile.companyName,
|
||||||
|
legalName: profile.legalName,
|
||||||
|
email: profile.email,
|
||||||
|
phone: profile.phone,
|
||||||
|
website: profile.website,
|
||||||
|
taxId: profile.taxId,
|
||||||
|
addressLine1: profile.addressLine1,
|
||||||
|
addressLine2: profile.addressLine2,
|
||||||
|
city: profile.city,
|
||||||
|
state: profile.state,
|
||||||
|
postalCode: profile.postalCode,
|
||||||
|
country: profile.country,
|
||||||
|
theme: {
|
||||||
|
primaryColor: profile.primaryColor,
|
||||||
|
accentColor: profile.accentColor,
|
||||||
|
surfaceColor: profile.surfaceColor,
|
||||||
|
fontFamily: profile.fontFamily,
|
||||||
|
logoFileId: profile.logoFileId,
|
||||||
|
},
|
||||||
|
logoUrl: profile.logoFileId ? `/api/v1/files/${profile.logoFileId}/content` : null,
|
||||||
|
updatedAt: profile.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveCompanyProfile() {
|
||||||
|
return mapCompanyProfile(
|
||||||
|
await prisma.companyProfile.findFirstOrThrow({
|
||||||
|
where: { isActive: true },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateActiveCompanyProfile(payload: CompanyProfileInput) {
|
||||||
|
const current = await prisma.companyProfile.findFirstOrThrow({
|
||||||
|
where: { isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile = await prisma.companyProfile.update({
|
||||||
|
where: { id: current.id },
|
||||||
|
data: {
|
||||||
|
companyName: payload.companyName,
|
||||||
|
legalName: payload.legalName,
|
||||||
|
email: payload.email,
|
||||||
|
phone: payload.phone,
|
||||||
|
website: payload.website,
|
||||||
|
taxId: payload.taxId,
|
||||||
|
addressLine1: payload.addressLine1,
|
||||||
|
addressLine2: payload.addressLine2,
|
||||||
|
city: payload.city,
|
||||||
|
state: payload.state,
|
||||||
|
postalCode: payload.postalCode,
|
||||||
|
country: payload.country,
|
||||||
|
primaryColor: payload.theme.primaryColor,
|
||||||
|
accentColor: payload.theme.accentColor,
|
||||||
|
surfaceColor: payload.theme.surfaceColor,
|
||||||
|
fontFamily: payload.theme.fontFamily,
|
||||||
|
logoFileId: payload.theme.logoFileId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapCompanyProfile(profile);
|
||||||
|
}
|
||||||
|
|
||||||
28
server/src/server.ts
Normal file
28
server/src/server.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { createApp } from "./app.js";
|
||||||
|
import { env } from "./config/env.js";
|
||||||
|
import { bootstrapAppData } from "./lib/bootstrap.js";
|
||||||
|
import { prisma } from "./lib/prisma.js";
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
await bootstrapAppData();
|
||||||
|
|
||||||
|
const app = createApp();
|
||||||
|
const server = app.listen(env.PORT, () => {
|
||||||
|
console.log(`MRP server listening on port ${env.PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const shutdown = async () => {
|
||||||
|
server.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
start().catch(async (error) => {
|
||||||
|
console.error(error);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
12
server/src/types/express.d.ts
vendored
Normal file
12
server/src/types/express.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { AuthUser } from "@mrp/shared";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
authUser?: AuthUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
33
server/tests/auth.test.ts
Normal file
33
server/tests/auth.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { permissions } from "@mrp/shared";
|
||||||
|
|
||||||
|
import { requirePermissions } from "../src/lib/rbac.js";
|
||||||
|
|
||||||
|
describe("rbac", () => {
|
||||||
|
it("allows requests with all required permissions", () => {
|
||||||
|
const middleware = requirePermissions([permissions.companyRead]);
|
||||||
|
const request = {
|
||||||
|
authUser: {
|
||||||
|
id: "1",
|
||||||
|
email: "admin@example.com",
|
||||||
|
firstName: "Admin",
|
||||||
|
lastName: "User",
|
||||||
|
roles: ["Administrator"],
|
||||||
|
permissions: [permissions.companyRead],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const response = {
|
||||||
|
status: () => response,
|
||||||
|
json: (body: unknown) => body,
|
||||||
|
};
|
||||||
|
let nextCalled = false;
|
||||||
|
|
||||||
|
middleware(request as never, response as never, () => {
|
||||||
|
nextCalled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(nextCalled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
13
server/tsconfig.json
Normal file
13
server/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
8
server/vitest.config.ts
Normal file
8
server/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
14
shared/package.json
Normal file
14
shared/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
shared/src/auth/permissions.ts
Normal file
17
shared/src/auth/permissions.ts
Normal 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
21
shared/src/auth/types.ts
Normal 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
15
shared/src/common/api.ts
Normal 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;
|
||||||
|
|
||||||
29
shared/src/company/types.ts
Normal file
29
shared/src/company/types.ts
Normal 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
11
shared/src/files/types.ts
Normal 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
16
shared/src/gantt/types.ts
Normal 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
6
shared/src/index.ts
Normal 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
12
shared/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
16
tsconfig.base.json
Normal file
16
tsconfig.base.json
Normal 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": "."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user