feat: initial workflow portal - multi-tenant architecture

- Backend: Node.js + Express + TypeScript + Prisma + PostgreSQL
- Frontend: React + TypeScript + Tailwind CSS + Vite
- Multi-tenant with role-based access (SystemAdmin, Technician, Customer)
- Workflow editor with drag-and-drop steps
- JWT authentication with refresh tokens
- German UI throughout
This commit is contained in:
root
2026-03-20 22:48:04 +00:00
commit d58d53fa3b
48 changed files with 5688 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
DATABASE_URL=postgresql://workflow:workflow@localhost:5432/workflow_portal
JWT_SECRET=change-me-in-production
JWT_REFRESH_SECRET=change-me-too
PORT=4000
CORS_ORIGIN=http://localhost:3000
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.js.map
+34
View File
@@ -0,0 +1,34 @@
{
"name": "workflow-portal-backend",
"version": "1.0.0",
"scripts": {
"dev": "nodemon --exec ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:migrate": "npx prisma migrate deploy",
"db:seed": "ts-node prisma/seed.ts",
"db:generate": "npx prisma generate"
},
"dependencies": {
"@prisma/client": "^6.0.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.0",
"express": "^4.21.0",
"express-rate-limit": "^7.4.0",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.0.0",
"nodemon": "^3.1.0",
"prisma": "^6.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.6.0"
}
}
+108
View File
@@ -0,0 +1,108 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Tenant {
id String @id @default(uuid())
name String
slug String @unique
logo String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
workflows Workflow[]
categories Category[]
}
model User {
id String @id @default(uuid())
email String @unique
passwordHash String
firstName String
lastName String
role Role
tenantId String?
tenant Tenant? @relation(fields: [tenantId], references: [id])
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdWorkflows Workflow[] @relation("CreatedBy")
updatedWorkflows Workflow[] @relation("UpdatedBy")
}
enum Role {
SYSTEM_ADMIN
TECHNICIAN
CUSTOMER_ADMIN
CUSTOMER_USER
}
model Category {
id String @id @default(uuid())
name String
icon String?
tenantId String?
tenant Tenant? @relation(fields: [tenantId], references: [id])
workflows Workflow[]
createdAt DateTime @default(now())
}
model Workflow {
id String @id @default(uuid())
title String
description String?
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
categoryId String?
category Category? @relation(fields: [categoryId], references: [id])
status WorkflowStatus @default(DRAFT)
version Int @default(1)
isTemplate Boolean @default(false)
tags String[]
createdById String
createdBy User @relation("CreatedBy", fields: [createdById], references: [id])
updatedById String?
updatedBy User? @relation("UpdatedBy", fields: [updatedById], references: [id])
steps WorkflowStep[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId])
@@index([status])
}
enum WorkflowStatus {
DRAFT
REVIEW
PUBLISHED
ARCHIVED
}
model WorkflowStep {
id String @id @default(uuid())
workflowId String
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
order Int
title String
content String
type StepType @default(INSTRUCTION)
isRequired Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([workflowId])
}
enum StepType {
INSTRUCTION
CHECKLIST
WARNING
INFO
COMMAND
}
+212
View File
@@ -0,0 +1,212 @@
import { PrismaClient, Role, WorkflowStatus, StepType } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
console.log('Seeding database...');
// Create default tenant "Systemhaus"
const systemhausTenant = await prisma.tenant.upsert({
where: { slug: 'systemhaus' },
update: {},
create: {
name: 'Systemhaus',
slug: 'systemhaus',
active: true,
},
});
console.log('Created tenant:', systemhausTenant.name);
// Create sample tenant "Musterkunde GmbH"
const musterkundeTenant = await prisma.tenant.upsert({
where: { slug: 'musterkunde' },
update: {},
create: {
name: 'Musterkunde GmbH',
slug: 'musterkunde',
active: true,
},
});
console.log('Created tenant:', musterkundeTenant.name);
// Create system admin user
const adminPasswordHash = await bcrypt.hash('admin123', 12);
const systemAdmin = await prisma.user.upsert({
where: { email: 'admin@systemhaus.de' },
update: {},
create: {
email: 'admin@systemhaus.de',
passwordHash: adminPasswordHash,
firstName: 'System',
lastName: 'Administrator',
role: Role.SYSTEM_ADMIN,
tenantId: systemhausTenant.id,
active: true,
},
});
console.log('Created user:', systemAdmin.email, '(SYSTEM_ADMIN)');
// Create technician user
const technicianPasswordHash = await bcrypt.hash('admin123', 12);
const technician = await prisma.user.upsert({
where: { email: 'techniker@systemhaus.de' },
update: {},
create: {
email: 'techniker@systemhaus.de',
passwordHash: technicianPasswordHash,
firstName: 'Max',
lastName: 'Techniker',
role: Role.TECHNICIAN,
tenantId: systemhausTenant.id,
active: true,
},
});
console.log('Created user:', technician.email, '(TECHNICIAN)');
// Create customer admin user
const customerAdminPasswordHash = await bcrypt.hash('admin123', 12);
const customerAdmin = await prisma.user.upsert({
where: { email: 'admin@musterkunde.de' },
update: {},
create: {
email: 'admin@musterkunde.de',
passwordHash: customerAdminPasswordHash,
firstName: 'Maria',
lastName: 'Mustermann',
role: Role.CUSTOMER_ADMIN,
tenantId: musterkundeTenant.id,
active: true,
},
});
console.log('Created user:', customerAdmin.email, '(CUSTOMER_ADMIN)');
// Create customer user
const customerUserPasswordHash = await bcrypt.hash('admin123', 12);
const customerUser = await prisma.user.upsert({
where: { email: 'user@musterkunde.de' },
update: {},
create: {
email: 'user@musterkunde.de',
passwordHash: customerUserPasswordHash,
firstName: 'Hans',
lastName: 'Benutzer',
role: Role.CUSTOMER_USER,
tenantId: musterkundeTenant.id,
active: true,
},
});
console.log('Created user:', customerUser.email, '(CUSTOMER_USER)');
// Create categories (global, no tenant)
const categories = [
{ name: 'Benutzerverwaltung', icon: 'users' },
{ name: 'Netzwerk', icon: 'network' },
{ name: 'VPN', icon: 'shield' },
{ name: 'Backup', icon: 'database' },
{ name: 'Server', icon: 'server' },
];
const createdCategories: Record<string, string> = {};
for (const cat of categories) {
const existing = await prisma.category.findFirst({
where: { name: cat.name, tenantId: null },
});
if (existing) {
createdCategories[cat.name] = existing.id;
console.log('Category already exists:', cat.name);
} else {
const created = await prisma.category.create({
data: {
name: cat.name,
icon: cat.icon,
tenantId: null,
},
});
createdCategories[cat.name] = created.id;
console.log('Created category:', cat.name);
}
}
// Create sample workflow: "Neuen Benutzer anlegen"
const existingWorkflow = await prisma.workflow.findFirst({
where: {
title: 'Neuen Benutzer anlegen',
tenantId: musterkundeTenant.id,
},
});
if (!existingWorkflow) {
const workflow = await prisma.workflow.create({
data: {
title: 'Neuen Benutzer anlegen',
description: 'Schritt-fuer-Schritt Anleitung zum Anlegen eines neuen Benutzers im Active Directory und allen relevanten Systemen.',
tenantId: musterkundeTenant.id,
categoryId: createdCategories['Benutzerverwaltung'],
status: WorkflowStatus.PUBLISHED,
version: 1,
isTemplate: false,
tags: ['benutzer', 'onboarding', 'active-directory'],
createdById: systemAdmin.id,
steps: {
create: [
{
order: 0,
title: 'Anforderung pruefen',
content: 'Pruefen Sie die Benutzeranforderung auf Vollstaendigkeit:\n- Vor- und Nachname\n- Abteilung\n- Vorgesetzter\n- Benoetigte Berechtigungen\n- Startdatum',
type: StepType.CHECKLIST,
isRequired: true,
},
{
order: 1,
title: 'Active Directory Benutzer erstellen',
content: 'Oeffnen Sie die Active Directory Benutzer und Computer Konsole und erstellen Sie einen neuen Benutzer:\n\n```powershell\nNew-ADUser -Name "Vorname Nachname" -GivenName "Vorname" -Surname "Nachname" -SamAccountName "v.nachname" -UserPrincipalName "v.nachname@domain.de" -Path "OU=Users,DC=domain,DC=de" -AccountPassword (ConvertTo-SecureString "TempPasswort123!" -AsPlainText -Force) -Enabled $true -ChangePasswordAtLogon $true\n```',
type: StepType.COMMAND,
isRequired: true,
},
{
order: 2,
title: 'Gruppenmitgliedschaften konfigurieren',
content: 'Fuegen Sie den Benutzer den entsprechenden Sicherheitsgruppen hinzu basierend auf Abteilung und Rolle:\n- Abteilungsgruppe\n- VPN-Zugang (falls erforderlich)\n- Drucker-Gruppen\n- Anwendungs-Gruppen',
type: StepType.INSTRUCTION,
isRequired: true,
},
{
order: 3,
title: 'E-Mail Postfach einrichten',
content: 'WICHTIG: Warten Sie nach der AD-Erstellung mindestens 15 Minuten auf die Exchange-Synchronisation. Pruefen Sie anschliessend, ob das Postfach automatisch erstellt wurde. Falls nicht, erstellen Sie es manuell im Exchange Admin Center.',
type: StepType.WARNING,
isRequired: true,
},
{
order: 4,
title: 'Dokumentation und Uebergabe',
content: 'Dokumentieren Sie die Zugangsdaten und informieren Sie den Vorgesetzten:\n- Benutzername\n- Initiales Passwort (sicher uebermitteln!)\n- E-Mail Adresse\n- VPN-Zugangsdaten (falls zutreffend)\n\nHinweis: Verwenden Sie einen sicheren Kanal fuer die Passwort-Uebermittlung.',
type: StepType.INFO,
isRequired: true,
},
],
},
},
});
console.log('Created workflow:', workflow.title, 'with 5 steps');
} else {
console.log('Workflow already exists:', existingWorkflow.title);
}
console.log('\nSeed complete!');
console.log('\nTest Accounts:');
console.log(' System Admin: admin@systemhaus.de / admin123');
console.log(' Technician: techniker@systemhaus.de / admin123');
console.log(' Customer Admin: admin@musterkunde.de / admin123');
console.log(' Customer User: user@musterkunde.de / admin123');
}
main()
.catch((e) => {
console.error('Seed error:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+108
View File
@@ -0,0 +1,108 @@
import dotenv from 'dotenv';
dotenv.config();
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import { AppError } from './lib/errors';
import authRoutes from './routes/auth.routes';
import tenantRoutes from './routes/tenant.routes';
import userRoutes from './routes/user.routes';
import workflowRoutes from './routes/workflow.routes';
import categoryRoutes from './routes/category.routes';
const app = express();
const PORT = parseInt(process.env.PORT || '4000', 10);
// Security middleware
app.use(helmet());
// CORS
app.use(cors({
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
credentials: true,
}));
// Body parsing
app.use(express.json({ limit: '10mb' }));
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests', message: 'Please try again later' },
});
app.use('/api/', limiter);
// Stricter rate limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many login attempts', message: 'Please try again later' },
});
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/refresh', authLimiter);
// Health check
app.get('/api/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/tenants', tenantRoutes);
app.use('/api/users', userRoutes);
app.use('/api/workflows', workflowRoutes);
app.use('/api/categories', categoryRoutes);
// 404 handler
app.use((_req: Request, res: Response) => {
res.status(404).json({ error: 'Not found', message: 'The requested endpoint does not exist' });
});
// Global error handler
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.error('Error:', err);
if (err instanceof AppError) {
res.status(err.statusCode).json({
error: err.message,
...(err.statusCode >= 500 ? {} : { message: err.message }),
});
return;
}
// Prisma known errors
if ((err as any).code === 'P2002') {
res.status(409).json({
error: 'Conflict',
message: 'A record with this unique constraint already exists',
});
return;
}
if ((err as any).code === 'P2025') {
res.status(404).json({
error: 'Not found',
message: 'The requested record was not found',
});
return;
}
res.status(500).json({
error: 'Internal server error',
message: process.env.NODE_ENV === 'development' ? err.message : 'An unexpected error occurred',
});
});
app.listen(PORT, () => {
console.log(`Workflow Portal API running on port ${PORT}`);
});
export default app;
+35
View File
@@ -0,0 +1,35 @@
export class AppError extends Error {
public statusCode: number;
public isOperational: boolean;
constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
Object.setPrototypeOf(this, AppError.prototype);
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 404);
}
}
export class ValidationError extends AppError {
constructor(message: string) {
super(message, 400);
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401);
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, 403);
}
}
+5
View File
@@ -0,0 +1,5 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export default prisma;
+96
View File
@@ -0,0 +1,96 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { Role } from '@prisma/client';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
interface JwtPayload {
id: string;
email: string;
role: Role;
tenantId: string | null;
firstName: string;
lastName: string;
}
export function authenticate(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ error: 'Authentication required', message: 'No valid token provided' });
return;
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload;
req.user = {
id: decoded.id,
email: decoded.email,
role: decoded.role,
tenantId: decoded.tenantId,
firstName: decoded.firstName,
lastName: decoded.lastName,
};
next();
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
res.status(401).json({ error: 'Token expired', message: 'Please refresh your token' });
return;
}
res.status(401).json({ error: 'Invalid token', message: 'Token verification failed' });
}
}
export function authorize(...roles: Role[]) {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
if (!roles.includes(req.user.role)) {
res.status(403).json({
error: 'Forbidden',
message: `Role ${req.user.role} is not authorized for this action`,
});
return;
}
next();
};
}
export function tenantGuard(req: Request, res: Response, next: NextFunction): void {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
// SYSTEM_ADMIN and TECHNICIAN can access all tenants
if (req.user.role === 'SYSTEM_ADMIN' || req.user.role === 'TECHNICIAN') {
next();
return;
}
// For tenant-scoped users, check that the requested tenantId matches their own
const requestedTenantId = req.params.tenantId || req.body?.tenantId || req.query.tenantId;
if (requestedTenantId && requestedTenantId !== req.user.tenantId) {
res.status(403).json({
error: 'Forbidden',
message: 'You can only access data within your own tenant',
});
return;
}
next();
}
export function getTenantFilter(user: JwtPayload): { tenantId: string } | {} {
if (user.role === 'SYSTEM_ADMIN' || user.role === 'TECHNICIAN') {
return {};
}
return { tenantId: user.tenantId! };
}
+187
View File
@@ -0,0 +1,187 @@
import { Router, Request, Response, NextFunction } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { z } from 'zod';
import prisma from '../lib/prisma';
import { authenticate } from '../middleware/auth';
import { AppError } from '../lib/errors';
const router = Router();
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'change-me-too';
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
// Store for invalidated refresh tokens (in production, use Redis)
const invalidatedTokens = new Set<string>();
const loginSchema = z.object({
email: z.string().email('Invalid email format'),
password: z.string().min(1, 'Password is required'),
});
const refreshSchema = z.object({
refreshToken: z.string().min(1, 'Refresh token is required'),
});
function generateTokens(user: { id: string; email: string; role: string; tenantId: string | null; firstName: string; lastName: string }) {
const payload = {
id: user.id,
email: user.email,
role: user.role,
tenantId: user.tenantId,
firstName: user.firstName,
lastName: user.lastName,
};
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRY });
const refreshToken = jwt.sign({ id: user.id, type: 'refresh' }, JWT_REFRESH_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRY });
return { accessToken, refreshToken };
}
// POST /api/auth/login
router.post('/login', async (req: Request, res: Response, next: NextFunction) => {
try {
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
error: 'Validation error',
details: parsed.error.flatten().fieldErrors,
});
return;
}
const { email, password } = parsed.data;
const user = await prisma.user.findUnique({
where: { email },
include: { tenant: true },
});
if (!user || !user.active) {
res.status(401).json({ error: 'Invalid credentials' });
return;
}
const validPassword = await bcrypt.compare(password, user.passwordHash);
if (!validPassword) {
res.status(401).json({ error: 'Invalid credentials' });
return;
}
const tokens = generateTokens(user);
res.json({
...tokens,
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
tenantId: user.tenantId,
tenant: user.tenant ? { id: user.tenant.id, name: user.tenant.name, slug: user.tenant.slug } : null,
},
});
} catch (err) {
next(err);
}
});
// POST /api/auth/refresh
router.post('/refresh', async (req: Request, res: Response, next: NextFunction) => {
try {
const parsed = refreshSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
error: 'Validation error',
details: parsed.error.flatten().fieldErrors,
});
return;
}
const { refreshToken } = parsed.data;
if (invalidatedTokens.has(refreshToken)) {
res.status(401).json({ error: 'Token has been invalidated' });
return;
}
let decoded: any;
try {
decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET);
} catch {
res.status(401).json({ error: 'Invalid refresh token' });
return;
}
if (decoded.type !== 'refresh') {
res.status(401).json({ error: 'Invalid token type' });
return;
}
const user = await prisma.user.findUnique({
where: { id: decoded.id },
});
if (!user || !user.active) {
res.status(401).json({ error: 'User not found or inactive' });
return;
}
// Invalidate old refresh token
invalidatedTokens.add(refreshToken);
const tokens = generateTokens(user);
res.json(tokens);
} catch (err) {
next(err);
}
});
// POST /api/auth/logout
router.post('/logout', authenticate, async (req: Request, res: Response, next: NextFunction) => {
try {
const refreshToken = req.body.refreshToken;
if (refreshToken) {
invalidatedTokens.add(refreshToken);
}
res.json({ message: 'Logged out successfully' });
} catch (err) {
next(err);
}
});
// GET /api/auth/me
router.get('/me', authenticate, async (req: Request, res: Response, next: NextFunction) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
include: { tenant: true },
});
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
res.json({
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
tenantId: user.tenantId,
tenant: user.tenant ? { id: user.tenant.id, name: user.tenant.name, slug: user.tenant.slug, logo: user.tenant.logo } : null,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
});
} catch (err) {
next(err);
}
});
export default router;
+223
View File
@@ -0,0 +1,223 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import prisma from '../lib/prisma';
import { authenticate, authorize } from '../middleware/auth';
const router = Router();
const createCategorySchema = z.object({
name: z.string().min(1, 'Name is required').max(255),
icon: z.string().max(100).optional().nullable(),
tenantId: z.string().uuid().optional().nullable(),
});
const updateCategorySchema = z.object({
name: z.string().min(1).max(255).optional(),
icon: z.string().max(100).optional().nullable(),
});
router.use(authenticate);
// GET /api/categories
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user!;
let where: any = {};
if (user.role === 'CUSTOMER_ADMIN' || user.role === 'CUSTOMER_USER') {
// Customer users see global categories (tenantId is null) + their tenant's categories
where = {
OR: [
{ tenantId: null },
{ tenantId: user.tenantId },
],
};
}
// Allow filtering by tenantId for system users
if ((user.role === 'SYSTEM_ADMIN' || user.role === 'TECHNICIAN') && req.query.tenantId) {
where = {
OR: [
{ tenantId: null },
{ tenantId: req.query.tenantId as string },
],
};
}
const categories = await prisma.category.findMany({
where,
include: {
_count: { select: { workflows: true } },
tenant: { select: { id: true, name: true, slug: true } },
},
orderBy: { name: 'asc' },
});
res.json({ data: categories });
} catch (err) {
next(err);
}
});
// GET /api/categories/:id
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user!;
const { id } = req.params;
const category = await prisma.category.findUnique({
where: { id },
include: {
_count: { select: { workflows: true } },
tenant: { select: { id: true, name: true, slug: true } },
},
});
if (!category) {
res.status(404).json({ error: 'Category not found' });
return;
}
// Tenant isolation: customer users can only see global or their tenant's categories
if (
(user.role === 'CUSTOMER_ADMIN' || user.role === 'CUSTOMER_USER') &&
category.tenantId !== null &&
category.tenantId !== user.tenantId
) {
res.status(403).json({ error: 'Forbidden', message: 'You cannot access this category' });
return;
}
res.json({ data: category });
} catch (err) {
next(err);
}
});
// POST /api/categories
router.post('/', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user!;
const parsed = createCategorySchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
return;
}
const data = parsed.data;
// Customer admins can only create tenant-scoped categories in their own tenant
if (user.role === 'CUSTOMER_ADMIN') {
data.tenantId = user.tenantId;
}
// Only SYSTEM_ADMIN can create global categories (tenantId = null)
if (data.tenantId === null && user.role !== 'SYSTEM_ADMIN') {
res.status(403).json({ error: 'Forbidden', message: 'Only system admins can create global categories' });
return;
}
const category = await prisma.category.create({
data: {
name: data.name,
icon: data.icon ?? null,
tenantId: data.tenantId ?? null,
},
include: {
tenant: { select: { id: true, name: true, slug: true } },
},
});
res.status(201).json({ data: category });
} catch (err) {
next(err);
}
});
// PUT /api/categories/:id
router.put('/:id', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user!;
const { id } = req.params;
const parsed = updateCategorySchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
return;
}
const category = await prisma.category.findUnique({ where: { id } });
if (!category) {
res.status(404).json({ error: 'Category not found' });
return;
}
// Tenant isolation
if (user.role === 'CUSTOMER_ADMIN' && category.tenantId !== user.tenantId) {
res.status(403).json({ error: 'Forbidden', message: 'You can only edit your tenant categories' });
return;
}
// Only SYSTEM_ADMIN can edit global categories
if (category.tenantId === null && user.role !== 'SYSTEM_ADMIN') {
res.status(403).json({ error: 'Forbidden', message: 'Only system admins can edit global categories' });
return;
}
const updated = await prisma.category.update({
where: { id },
data: parsed.data,
include: {
tenant: { select: { id: true, name: true, slug: true } },
},
});
res.json({ data: updated });
} catch (err) {
next(err);
}
});
// DELETE /api/categories/:id
router.delete('/:id', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user!;
const { id } = req.params;
const category = await prisma.category.findUnique({
where: { id },
include: { _count: { select: { workflows: true } } },
});
if (!category) {
res.status(404).json({ error: 'Category not found' });
return;
}
if (user.role === 'CUSTOMER_ADMIN' && category.tenantId !== user.tenantId) {
res.status(403).json({ error: 'Forbidden', message: 'You can only delete your tenant categories' });
return;
}
if (category.tenantId === null && user.role !== 'SYSTEM_ADMIN') {
res.status(403).json({ error: 'Forbidden', message: 'Only system admins can delete global categories' });
return;
}
if (category._count.workflows > 0) {
res.status(409).json({
error: 'Conflict',
message: `Cannot delete category with ${category._count.workflows} workflow(s). Reassign or delete them first.`,
});
return;
}
await prisma.category.delete({ where: { id } });
res.json({ message: 'Category deleted successfully' });
} catch (err) {
next(err);
}
});
export default router;
+168
View File
@@ -0,0 +1,168 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import prisma from '../lib/prisma';
import { authenticate, authorize } from '../middleware/auth';
const router = Router();
const createTenantSchema = z.object({
name: z.string().min(1, 'Name is required').max(255),
slug: z.string().min(1, 'Slug is required').max(100).regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens'),
logo: z.string().url().optional().nullable(),
active: z.boolean().optional(),
});
const updateTenantSchema = z.object({
name: z.string().min(1).max(255).optional(),
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens').optional(),
logo: z.string().url().optional().nullable(),
active: z.boolean().optional(),
});
// All tenant routes require authentication
router.use(authenticate);
// GET /api/tenants
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user!;
let where: any = {};
if (user.role === 'CUSTOMER_ADMIN' || user.role === 'CUSTOMER_USER') {
// Customer users can only see their own tenant
where = { id: user.tenantId };
}
const tenants = await prisma.tenant.findMany({
where,
orderBy: { name: 'asc' },
include: {
_count: {
select: { users: true, workflows: true },
},
},
});
res.json({ data: tenants });
} catch (err) {
next(err);
}
});
// GET /api/tenants/:id
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user!;
const { id } = req.params;
// Customer users can only view their own tenant
if ((user.role === 'CUSTOMER_ADMIN' || user.role === 'CUSTOMER_USER') && id !== user.tenantId) {
res.status(403).json({ error: 'Forbidden', message: 'You can only access your own tenant' });
return;
}
const tenant = await prisma.tenant.findUnique({
where: { id },
include: {
_count: {
select: { users: true, workflows: true },
},
},
});
if (!tenant) {
res.status(404).json({ error: 'Tenant not found' });
return;
}
res.json({ data: tenant });
} catch (err) {
next(err);
}
});
// POST /api/tenants (SYSTEM_ADMIN only)
router.post('/', authorize('SYSTEM_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
try {
const parsed = createTenantSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
return;
}
const existing = await prisma.tenant.findUnique({ where: { slug: parsed.data.slug } });
if (existing) {
res.status(409).json({ error: 'Conflict', message: 'A tenant with this slug already exists' });
return;
}
const tenant = await prisma.tenant.create({
data: parsed.data,
});
res.status(201).json({ data: tenant });
} catch (err) {
next(err);
}
});
// PUT /api/tenants/:id (SYSTEM_ADMIN only)
router.put('/:id', authorize('SYSTEM_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const parsed = updateTenantSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
return;
}
const tenant = await prisma.tenant.findUnique({ where: { id } });
if (!tenant) {
res.status(404).json({ error: 'Tenant not found' });
return;
}
if (parsed.data.slug && parsed.data.slug !== tenant.slug) {
const existing = await prisma.tenant.findUnique({ where: { slug: parsed.data.slug } });
if (existing) {
res.status(409).json({ error: 'Conflict', message: 'A tenant with this slug already exists' });
return;
}
}
const updated = await prisma.tenant.update({
where: { id },
data: parsed.data,
});
res.json({ data: updated });
} catch (err) {
next(err);
}
});
// DELETE /api/tenants/:id (SYSTEM_ADMIN only)
router.delete('/:id', authorize('SYSTEM_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const tenant = await prisma.tenant.findUnique({ where: { id } });
if (!tenant) {
res.status(404).json({ error: 'Tenant not found' });
return;
}
// Soft delete by deactivating
await prisma.tenant.update({
where: { id },
data: { active: false },
});
res.json({ message: 'Tenant deactivated successfully' });
} catch (err) {
next(err);
}
});
export default router;
+333
View File
@@ -0,0 +1,333 @@
import { Router, Request, Response, NextFunction } from 'express';
import bcrypt from 'bcryptjs';
import { z } from 'zod';
import prisma from '../lib/prisma';
import { authenticate, authorize } from '../middleware/auth';
import { Role } from '@prisma/client';
const router = Router();
const createUserSchema = z.object({
email: z.string().email('Invalid email format'),
password: z.string().min(6, 'Password must be at least 6 characters'),
firstName: z.string().min(1, 'First name is required').max(100),
lastName: z.string().min(1, 'Last name is required').max(100),
role: z.nativeEnum(Role),
tenantId: z.string().uuid().optional().nullable(),
active: z.boolean().optional(),
});
const updateUserSchema = z.object({
email: z.string().email().optional(),
firstName: z.string().min(1).max(100).optional(),
lastName: z.string().min(1).max(100).optional(),
role: z.nativeEnum(Role).optional(),
tenantId: z.string().uuid().optional().nullable(),
active: z.boolean().optional(),
});
const changePasswordSchema = z.object({
currentPassword: z.string().min(1, 'Current password is required'),
newPassword: z.string().min(6, 'New password must be at least 6 characters'),
});
router.use(authenticate);
// GET /api/users
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user!;
let where: any = {};
if (user.role === 'CUSTOMER_ADMIN') {
// Customer admins see users in their tenant only
where = { tenantId: user.tenantId };
} else if (user.role === 'CUSTOMER_USER') {
// Customer users can only see themselves
where = { id: user.id };
}
// SYSTEM_ADMIN and TECHNICIAN see all users
// Optional tenant filter via query param
if ((user.role === 'SYSTEM_ADMIN' || user.role === 'TECHNICIAN') && req.query.tenantId) {
where.tenantId = req.query.tenantId as string;
}
const users = await prisma.user.findMany({
where,
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
tenantId: true,
active: true,
createdAt: true,
updatedAt: true,
tenant: {
select: { id: true, name: true, slug: true },
},
},
orderBy: { lastName: 'asc' },
});
res.json({ data: users });
} catch (err) {
next(err);
}
});
// GET /api/users/:id
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const currentUser = req.user!;
const { id } = req.params;
const targetUser = await prisma.user.findUnique({
where: { id },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
tenantId: true,
active: true,
createdAt: true,
updatedAt: true,
tenant: {
select: { id: true, name: true, slug: true },
},
},
});
if (!targetUser) {
res.status(404).json({ error: 'User not found' });
return;
}
// Access control
if (currentUser.role === 'CUSTOMER_ADMIN' && targetUser.tenantId !== currentUser.tenantId) {
res.status(403).json({ error: 'Forbidden', message: 'You can only view users in your tenant' });
return;
}
if (currentUser.role === 'CUSTOMER_USER' && targetUser.id !== currentUser.id) {
res.status(403).json({ error: 'Forbidden', message: 'You can only view your own profile' });
return;
}
res.json({ data: targetUser });
} catch (err) {
next(err);
}
});
// POST /api/users
router.post('/', authorize('SYSTEM_ADMIN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
try {
const currentUser = req.user!;
const parsed = createUserSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
return;
}
const data = parsed.data;
// CUSTOMER_ADMIN can only create users in their own tenant with limited roles
if (currentUser.role === 'CUSTOMER_ADMIN') {
if (data.role === 'SYSTEM_ADMIN' || data.role === 'TECHNICIAN') {
res.status(403).json({ error: 'Forbidden', message: 'You cannot create users with this role' });
return;
}
data.tenantId = currentUser.tenantId;
}
// Validate that tenant-scoped roles have a tenantId
if ((data.role === 'CUSTOMER_ADMIN' || data.role === 'CUSTOMER_USER') && !data.tenantId) {
res.status(400).json({ error: 'Validation error', message: 'Customer roles require a tenantId' });
return;
}
// Check email uniqueness
const existing = await prisma.user.findUnique({ where: { email: data.email } });
if (existing) {
res.status(409).json({ error: 'Conflict', message: 'A user with this email already exists' });
return;
}
const passwordHash = await bcrypt.hash(data.password, 12);
const user = await prisma.user.create({
data: {
email: data.email,
passwordHash,
firstName: data.firstName,
lastName: data.lastName,
role: data.role,
tenantId: data.tenantId ?? null,
active: data.active ?? true,
},
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
tenantId: true,
active: true,
createdAt: true,
},
});
res.status(201).json({ data: user });
} catch (err) {
next(err);
}
});
// PUT /api/users/:id
router.put('/:id', authorize('SYSTEM_ADMIN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
try {
const currentUser = req.user!;
const { id } = req.params;
const parsed = updateUserSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
return;
}
const targetUser = await prisma.user.findUnique({ where: { id } });
if (!targetUser) {
res.status(404).json({ error: 'User not found' });
return;
}
// CUSTOMER_ADMIN can only edit users in their tenant
if (currentUser.role === 'CUSTOMER_ADMIN') {
if (targetUser.tenantId !== currentUser.tenantId) {
res.status(403).json({ error: 'Forbidden', message: 'You can only edit users in your tenant' });
return;
}
// Cannot change role to system-level roles
if (parsed.data.role && (parsed.data.role === 'SYSTEM_ADMIN' || parsed.data.role === 'TECHNICIAN')) {
res.status(403).json({ error: 'Forbidden', message: 'You cannot assign this role' });
return;
}
// Cannot change tenantId
delete parsed.data.tenantId;
}
// Check email uniqueness if changing
if (parsed.data.email && parsed.data.email !== targetUser.email) {
const existing = await prisma.user.findUnique({ where: { email: parsed.data.email } });
if (existing) {
res.status(409).json({ error: 'Conflict', message: 'A user with this email already exists' });
return;
}
}
const updated = await prisma.user.update({
where: { id },
data: parsed.data,
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
tenantId: true,
active: true,
createdAt: true,
updatedAt: true,
},
});
res.json({ data: updated });
} catch (err) {
next(err);
}
});
// DELETE /api/users/:id
router.delete('/:id', authorize('SYSTEM_ADMIN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
try {
const currentUser = req.user!;
const { id } = req.params;
if (id === currentUser.id) {
res.status(400).json({ error: 'Bad request', message: 'You cannot delete your own account' });
return;
}
const targetUser = await prisma.user.findUnique({ where: { id } });
if (!targetUser) {
res.status(404).json({ error: 'User not found' });
return;
}
if (currentUser.role === 'CUSTOMER_ADMIN' && targetUser.tenantId !== currentUser.tenantId) {
res.status(403).json({ error: 'Forbidden', message: 'You can only delete users in your tenant' });
return;
}
// Soft delete
await prisma.user.update({
where: { id },
data: { active: false },
});
res.json({ message: 'User deactivated successfully' });
} catch (err) {
next(err);
}
});
// PATCH /api/users/:id/password
router.patch('/:id/password', async (req: Request, res: Response, next: NextFunction) => {
try {
const currentUser = req.user!;
const { id } = req.params;
// Users can only change their own password (admins can reset via update)
if (id !== currentUser.id && currentUser.role !== 'SYSTEM_ADMIN') {
res.status(403).json({ error: 'Forbidden', message: 'You can only change your own password' });
return;
}
const parsed = changePasswordSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
return;
}
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
// Verify current password (skip for SYSTEM_ADMIN resetting another user's password)
if (id === currentUser.id) {
const validPassword = await bcrypt.compare(parsed.data.currentPassword, user.passwordHash);
if (!validPassword) {
res.status(400).json({ error: 'Invalid current password' });
return;
}
}
const passwordHash = await bcrypt.hash(parsed.data.newPassword, 12);
await prisma.user.update({
where: { id },
data: { passwordHash },
});
res.json({ message: 'Password changed successfully' });
} catch (err) {
next(err);
}
});
export default router;
+630
View File
@@ -0,0 +1,630 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import prisma from '../lib/prisma';
import { authenticate, authorize } from '../middleware/auth';
import { WorkflowStatus, StepType } from '@prisma/client';
const router = Router();
const createWorkflowSchema = z.object({
title: z.string().min(1, 'Title is required').max(500),
description: z.string().optional().nullable(),
tenantId: z.string().uuid('Invalid tenant ID'),
categoryId: z.string().uuid().optional().nullable(),
isTemplate: z.boolean().optional(),
tags: z.array(z.string()).optional(),
steps: z.array(z.object({
order: z.number().int().min(0),
title: z.string().min(1),
content: z.string().min(1),
type: z.nativeEnum(StepType).optional(),
isRequired: z.boolean().optional(),
})).optional(),
});
const updateWorkflowSchema = z.object({
title: z.string().min(1).max(500).optional(),
description: z.string().optional().nullable(),
categoryId: z.string().uuid().optional().nullable(),
isTemplate: z.boolean().optional(),
tags: z.array(z.string()).optional(),
});
const statusChangeSchema = z.object({
status: z.nativeEnum(WorkflowStatus),
});
const createStepSchema = z.object({
order: z.number().int().min(0),
title: z.string().min(1, 'Step title is required'),
content: z.string().min(1, 'Step content is required'),
type: z.nativeEnum(StepType).optional(),
isRequired: z.boolean().optional(),
});
const updateStepSchema = z.object({
order: z.number().int().min(0).optional(),
title: z.string().min(1).optional(),
content: z.string().min(1).optional(),
type: z.nativeEnum(StepType).optional(),
isRequired: z.boolean().optional(),
});
const reorderStepsSchema = z.object({
steps: z.array(z.object({
id: z.string().uuid(),
order: z.number().int().min(0),
})),
});
router.use(authenticate);
function getTenantFilter(user: Express.Request['user']): { tenantId?: string } {
if (!user) return {};
if (user.role === 'SYSTEM_ADMIN' || user.role === 'TECHNICIAN') {
return {};
}
return { tenantId: user.tenantId! };
}
// GET /api/workflows
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user!;
const tenantFilter = getTenantFilter(user);
const {
search,
status,
categoryId,
tags,
tenantId,
isTemplate,
page = '1',
limit = '20',
} = req.query;
const where: any = { ...tenantFilter };
// Allow SYSTEM_ADMIN/TECHNICIAN to filter by specific tenant
if ((user.role === 'SYSTEM_ADMIN' || user.role === 'TECHNICIAN') && tenantId) {
where.tenantId = tenantId as string;
}
if (search) {
where.OR = [
{ title: { contains: search as string, mode: 'insensitive' } },
{ description: { contains: search as string, mode: 'insensitive' } },
];
}
if (status) {
where.status = status as WorkflowStatus;
}
if (categoryId) {
where.categoryId = categoryId as string;
}
if (tags) {
const tagArray = (tags as string).split(',');
where.tags = { hasSome: tagArray };
}
if (isTemplate !== undefined) {
where.isTemplate = isTemplate === 'true';
}
const pageNum = Math.max(1, parseInt(page as string, 10) || 1);
const limitNum = Math.min(100, Math.max(1, parseInt(limit as string, 10) || 20));
const skip = (pageNum - 1) * limitNum;
const [workflows, total] = await Promise.all([
prisma.workflow.findMany({
where,
include: {
category: { select: { id: true, name: true, icon: true } },
createdBy: { select: { id: true, firstName: true, lastName: true, email: true } },
updatedBy: { select: { id: true, firstName: true, lastName: true, email: true } },
tenant: { select: { id: true, name: true, slug: true } },
_count: { select: { steps: true } },
},
orderBy: { updatedAt: 'desc' },
skip,
take: limitNum,
}),
prisma.workflow.count({ where }),
]);
res.json({
data: workflows,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum),
},
});
} catch (err) {
next(err);
}
});
// GET /api/workflows/:id
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user!;
const { id } = req.params;
const workflow = await prisma.workflow.findUnique({
where: { id },
include: {
category: { select: { id: true, name: true, icon: true } },
createdBy: { select: { id: true, firstName: true, lastName: true, email: true } },
updatedBy: { select: { id: true, firstName: true, lastName: true, email: true } },
tenant: { select: { id: true, name: true, slug: true } },
steps: { orderBy: { order: 'asc' } },
},
});
if (!workflow) {
res.status(404).json({ error: 'Workflow not found' });
return;
}
// Tenant isolation check
if (user.role !== 'SYSTEM_ADMIN' && user.role !== 'TECHNICIAN' && workflow.tenantId !== user.tenantId) {
res.status(403).json({ error: 'Forbidden', message: 'You cannot access this workflow' });
return;
}
res.json({ data: workflow });
} catch (err) {
next(err);
}
});
// POST /api/workflows
router.post('/', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user!;
const parsed = createWorkflowSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
return;
}
const data = parsed.data;
// Customer admins can only create in their own tenant
if (user.role === 'CUSTOMER_ADMIN') {
if (data.tenantId !== user.tenantId) {
res.status(403).json({ error: 'Forbidden', message: 'You can only create workflows in your tenant' });
return;
}
}
// Verify tenant exists
const tenant = await prisma.tenant.findUnique({ where: { id: data.tenantId } });
if (!tenant) {
res.status(404).json({ error: 'Tenant not found' });
return;
}
// Verify category if provided
if (data.categoryId) {
const category = await prisma.category.findUnique({ where: { id: data.categoryId } });
if (!category) {
res.status(404).json({ error: 'Category not found' });
return;
}
}
const workflow = await prisma.workflow.create({
data: {
title: data.title,
description: data.description ?? null,
tenantId: data.tenantId,
categoryId: data.categoryId ?? null,
isTemplate: data.isTemplate ?? false,
tags: data.tags ?? [],
createdById: user.id,
steps: data.steps ? {
create: data.steps.map((step) => ({
order: step.order,
title: step.title,
content: step.content,
type: step.type ?? 'INSTRUCTION',
isRequired: step.isRequired ?? true,
})),
} : undefined,
},
include: {
category: { select: { id: true, name: true, icon: true } },
createdBy: { select: { id: true, firstName: true, lastName: true, email: true } },
tenant: { select: { id: true, name: true, slug: true } },
steps: { orderBy: { order: 'asc' } },
},
});
res.status(201).json({ data: workflow });
} catch (err) {
next(err);
}
});
// PUT /api/workflows/:id
router.put('/:id', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user!;
const { id } = req.params;
const parsed = updateWorkflowSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
return;
}
const workflow = await prisma.workflow.findUnique({ where: { id } });
if (!workflow) {
res.status(404).json({ error: 'Workflow not found' });
return;
}
// Tenant isolation
if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) {
res.status(403).json({ error: 'Forbidden', message: 'You cannot edit this workflow' });
return;
}
// Verify category if changing
if (parsed.data.categoryId) {
const category = await prisma.category.findUnique({ where: { id: parsed.data.categoryId } });
if (!category) {
res.status(404).json({ error: 'Category not found' });
return;
}
}
const updated = await prisma.workflow.update({
where: { id },
data: {
...parsed.data,
updatedById: user.id,
version: { increment: 1 },
},
include: {
category: { select: { id: true, name: true, icon: true } },
createdBy: { select: { id: true, firstName: true, lastName: true, email: true } },
updatedBy: { select: { id: true, firstName: true, lastName: true, email: true } },
tenant: { select: { id: true, name: true, slug: true } },
steps: { orderBy: { order: 'asc' } },
},
});
res.json({ data: updated });
} catch (err) {
next(err);
}
});
// PATCH /api/workflows/:id/status
router.patch('/:id/status', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user!;
const { id } = req.params;
const parsed = statusChangeSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
return;
}
const workflow = await prisma.workflow.findUnique({ where: { id } });
if (!workflow) {
res.status(404).json({ error: 'Workflow not found' });
return;
}
if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) {
res.status(403).json({ error: 'Forbidden', message: 'You cannot modify this workflow' });
return;
}
const updated = await prisma.workflow.update({
where: { id },
data: {
status: parsed.data.status,
updatedById: user.id,
},
include: {
category: { select: { id: true, name: true, icon: true } },
createdBy: { select: { id: true, firstName: true, lastName: true, email: true } },
updatedBy: { select: { id: true, firstName: true, lastName: true, email: true } },
tenant: { select: { id: true, name: true, slug: true } },
},
});
res.json({ data: updated });
} catch (err) {
next(err);
}
});
// DELETE /api/workflows/:id
router.delete('/:id', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user!;
const { id } = req.params;
const workflow = await prisma.workflow.findUnique({ where: { id } });
if (!workflow) {
res.status(404).json({ error: 'Workflow not found' });
return;
}
if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) {
res.status(403).json({ error: 'Forbidden', message: 'You cannot delete this workflow' });
return;
}
await prisma.workflow.delete({ where: { id } });
res.json({ message: 'Workflow deleted successfully' });
} catch (err) {
next(err);
}
});
// POST /api/workflows/:id/duplicate
router.post('/:id/duplicate', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user!;
const { id } = req.params;
const targetTenantId = req.body.tenantId as string | undefined;
const workflow = await prisma.workflow.findUnique({
where: { id },
include: { steps: { orderBy: { order: 'asc' } } },
});
if (!workflow) {
res.status(404).json({ error: 'Workflow not found' });
return;
}
// Tenant isolation
if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) {
res.status(403).json({ error: 'Forbidden', message: 'You cannot duplicate this workflow' });
return;
}
// Determine target tenant
const duplicateTenantId = targetTenantId || (user.role === 'CUSTOMER_ADMIN' ? user.tenantId! : workflow.tenantId);
// Customer admins can only duplicate into their own tenant
if (user.role === 'CUSTOMER_ADMIN' && duplicateTenantId !== user.tenantId) {
res.status(403).json({ error: 'Forbidden', message: 'You can only duplicate into your own tenant' });
return;
}
const duplicated = await prisma.workflow.create({
data: {
title: `${workflow.title} (Copy)`,
description: workflow.description,
tenantId: duplicateTenantId,
categoryId: workflow.categoryId,
status: 'DRAFT',
isTemplate: false,
tags: workflow.tags,
createdById: user.id,
steps: {
create: workflow.steps.map((step) => ({
order: step.order,
title: step.title,
content: step.content,
type: step.type,
isRequired: step.isRequired,
})),
},
},
include: {
category: { select: { id: true, name: true, icon: true } },
createdBy: { select: { id: true, firstName: true, lastName: true, email: true } },
tenant: { select: { id: true, name: true, slug: true } },
steps: { orderBy: { order: 'asc' } },
},
});
res.status(201).json({ data: duplicated });
} catch (err) {
next(err);
}
});
// --- Workflow Steps ---
// POST /api/workflows/:id/steps
router.post('/:id/steps', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user!;
const { id } = req.params;
const parsed = createStepSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
return;
}
const workflow = await prisma.workflow.findUnique({ where: { id } });
if (!workflow) {
res.status(404).json({ error: 'Workflow not found' });
return;
}
if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) {
res.status(403).json({ error: 'Forbidden' });
return;
}
const step = await prisma.workflowStep.create({
data: {
workflowId: id,
order: parsed.data.order,
title: parsed.data.title,
content: parsed.data.content,
type: parsed.data.type ?? 'INSTRUCTION',
isRequired: parsed.data.isRequired ?? true,
},
});
// Update workflow's updatedBy
await prisma.workflow.update({
where: { id },
data: { updatedById: user.id },
});
res.status(201).json({ data: step });
} catch (err) {
next(err);
}
});
// PUT /api/workflows/:id/steps/:stepId
router.put('/:id/steps/:stepId', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user!;
const { id, stepId } = req.params;
const parsed = updateStepSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
return;
}
const workflow = await prisma.workflow.findUnique({ where: { id } });
if (!workflow) {
res.status(404).json({ error: 'Workflow not found' });
return;
}
if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) {
res.status(403).json({ error: 'Forbidden' });
return;
}
const step = await prisma.workflowStep.findFirst({
where: { id: stepId, workflowId: id },
});
if (!step) {
res.status(404).json({ error: 'Step not found' });
return;
}
const updated = await prisma.workflowStep.update({
where: { id: stepId },
data: parsed.data,
});
await prisma.workflow.update({
where: { id },
data: { updatedById: user.id },
});
res.json({ data: updated });
} catch (err) {
next(err);
}
});
// DELETE /api/workflows/:id/steps/:stepId
router.delete('/:id/steps/:stepId', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user!;
const { id, stepId } = req.params;
const workflow = await prisma.workflow.findUnique({ where: { id } });
if (!workflow) {
res.status(404).json({ error: 'Workflow not found' });
return;
}
if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) {
res.status(403).json({ error: 'Forbidden' });
return;
}
const step = await prisma.workflowStep.findFirst({
where: { id: stepId, workflowId: id },
});
if (!step) {
res.status(404).json({ error: 'Step not found' });
return;
}
await prisma.workflowStep.delete({ where: { id: stepId } });
await prisma.workflow.update({
where: { id },
data: { updatedById: user.id },
});
res.json({ message: 'Step deleted successfully' });
} catch (err) {
next(err);
}
});
// PATCH /api/workflows/:id/steps/reorder
router.patch('/:id/steps/reorder', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user!;
const { id } = req.params;
const parsed = reorderStepsSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
return;
}
const workflow = await prisma.workflow.findUnique({ where: { id } });
if (!workflow) {
res.status(404).json({ error: 'Workflow not found' });
return;
}
if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) {
res.status(403).json({ error: 'Forbidden' });
return;
}
// Update all step orders in a transaction
await prisma.$transaction(
parsed.data.steps.map((step) =>
prisma.workflowStep.update({
where: { id: step.id },
data: { order: step.order },
})
)
);
await prisma.workflow.update({
where: { id },
data: { updatedById: user.id },
});
const updatedSteps = await prisma.workflowStep.findMany({
where: { workflowId: id },
orderBy: { order: 'asc' },
});
res.json({ data: updatedSteps });
} catch (err) {
next(err);
}
});
export default router;
+18
View File
@@ -0,0 +1,18 @@
import { Role } from '@prisma/client';
declare global {
namespace Express {
interface Request {
user?: {
id: string;
email: string;
role: Role;
tenantId: string | null;
firstName: string;
lastName: string;
};
}
}
}
export {};
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}