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:
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.js.map
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default prisma;
|
||||
@@ -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! };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Vendored
+18
@@ -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 {};
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user