diff --git a/backend/.env.example b/backend/.env.example
index be8c8be..5dd74db 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -1,5 +1,14 @@
-DATABASE_URL=postgresql://workflow:workflow@localhost:5432/workflow_portal
-JWT_SECRET=change-me-in-production
-JWT_REFRESH_SECRET=change-me-too
+# Database
+DATABASE_URL=postgresql://workflow:CHANGE_DB_PASSWORD@localhost:5432/workflow_portal
+
+# JWT Secrets - MUST be changed before deploying!
+# Generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
+JWT_SECRET=GENERATE_A_STRONG_RANDOM_SECRET_HERE
+JWT_REFRESH_SECRET=GENERATE_ANOTHER_STRONG_RANDOM_SECRET_HERE
+
+# Server
PORT=4000
+NODE_ENV=development
+
+# CORS - set to your frontend URL in production
CORS_ORIGIN=http://localhost:3000
diff --git a/backend/jest.config.js b/backend/jest.config.js
new file mode 100644
index 0000000..ade8740
--- /dev/null
+++ b/backend/jest.config.js
@@ -0,0 +1,24 @@
+/** @type {import('ts-jest').JestConfigWithTsJest} */
+module.exports = {
+ preset: 'ts-jest',
+ testEnvironment: 'node',
+ roots: ['/src'],
+ testMatch: ['**/__tests__/**/*.test.ts'],
+ moduleFileExtensions: ['ts', 'js', 'json'],
+ clearMocks: true,
+ collectCoverageFrom: [
+ 'src/**/*.ts',
+ '!src/**/*.d.ts',
+ '!src/__tests__/**',
+ '!src/index.ts',
+ ],
+ coverageDirectory: 'coverage',
+ coverageThresholds: {
+ global: {
+ branches: 70,
+ functions: 80,
+ lines: 80,
+ statements: 80,
+ },
+ },
+};
diff --git a/backend/package.json b/backend/package.json
index 29418ce..3462582 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -5,6 +5,9 @@
"dev": "nodemon --exec ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
+ "test": "jest --forceExit --detectOpenHandles",
+ "test:coverage": "jest --forceExit --detectOpenHandles --coverage",
+ "test:watch": "jest --watch",
"db:migrate": "npx prisma migrate deploy",
"db:seed": "ts-node prisma/seed.ts",
"db:generate": "npx prisma generate"
@@ -24,10 +27,15 @@
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
+ "@types/jest": "^29.5.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.0.0",
+ "@types/supertest": "^6.0.0",
+ "jest": "^29.7.0",
"nodemon": "^3.1.0",
"prisma": "^6.0.0",
+ "supertest": "^7.0.0",
+ "ts-jest": "^29.2.0",
"ts-node": "^10.9.2",
"typescript": "^5.6.0"
}
diff --git a/backend/src/__tests__/auth.test.ts b/backend/src/__tests__/auth.test.ts
new file mode 100644
index 0000000..8e1cf66
--- /dev/null
+++ b/backend/src/__tests__/auth.test.ts
@@ -0,0 +1,303 @@
+import request from 'supertest';
+import jwt from 'jsonwebtoken';
+import {
+ seedTestData,
+ clearTestData,
+ generateTestToken,
+ generateTestRefreshToken,
+ TEST_SYSTEM_ADMIN,
+ TEST_CUSTOMER_USER_A,
+ TEST_INACTIVE_USER,
+} from './setup';
+
+// The mock must be set up before importing app
+import app from '../index';
+
+describe('Auth Routes', () => {
+ beforeEach(() => {
+ seedTestData();
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ clearTestData();
+ });
+
+ // ============================================================
+ // POST /api/auth/login
+ // ============================================================
+ describe('POST /api/auth/login', () => {
+ it('should return 200 and tokens with valid credentials', async () => {
+ const res = await request(app)
+ .post('/api/auth/login')
+ .send({ email: TEST_CUSTOMER_USER_A.email, password: 'password123' });
+
+ expect(res.status).toBe(200);
+ expect(res.body).toHaveProperty('accessToken');
+ expect(res.body).toHaveProperty('refreshToken');
+ expect(res.body).toHaveProperty('user');
+ expect(res.body.user.email).toBe(TEST_CUSTOMER_USER_A.email);
+ expect(res.body.user.firstName).toBe(TEST_CUSTOMER_USER_A.firstName);
+ expect(res.body.user.lastName).toBe(TEST_CUSTOMER_USER_A.lastName);
+ expect(res.body.user.role).toBe(TEST_CUSTOMER_USER_A.role);
+ expect(res.body.user.tenantId).toBe(TEST_CUSTOMER_USER_A.tenantId);
+ // Ensure password hash is not leaked
+ expect(res.body.user).not.toHaveProperty('passwordHash');
+ });
+
+ it('should return 200 with tenant info when user has a tenant', async () => {
+ const res = await request(app)
+ .post('/api/auth/login')
+ .send({ email: TEST_CUSTOMER_USER_A.email, password: 'password123' });
+
+ expect(res.status).toBe(200);
+ expect(res.body.user.tenant).toBeTruthy();
+ expect(res.body.user.tenant.id).toBe('tenant-a-id');
+ expect(res.body.user.tenant.name).toBe('Tenant A');
+ expect(res.body.user.tenant.slug).toBe('tenant-a');
+ });
+
+ it('should return tokens that are valid JWTs', async () => {
+ const res = await request(app)
+ .post('/api/auth/login')
+ .send({ email: TEST_CUSTOMER_USER_A.email, password: 'password123' });
+
+ expect(res.status).toBe(200);
+
+ const accessDecoded = jwt.verify(res.body.accessToken, process.env.JWT_SECRET!) as any;
+ expect(accessDecoded.id).toBe(TEST_CUSTOMER_USER_A.id);
+ expect(accessDecoded.email).toBe(TEST_CUSTOMER_USER_A.email);
+ expect(accessDecoded.role).toBe(TEST_CUSTOMER_USER_A.role);
+
+ const refreshDecoded = jwt.verify(res.body.refreshToken, process.env.JWT_REFRESH_SECRET!) as any;
+ expect(refreshDecoded.id).toBe(TEST_CUSTOMER_USER_A.id);
+ expect(refreshDecoded.type).toBe('refresh');
+ });
+
+ it('should return 401 with invalid password', async () => {
+ const res = await request(app)
+ .post('/api/auth/login')
+ .send({ email: TEST_CUSTOMER_USER_A.email, password: 'wrong-password' });
+
+ expect(res.status).toBe(401);
+ expect(res.body).toHaveProperty('error');
+ expect(res.body.error).toBe('Invalid credentials');
+ expect(res.body).not.toHaveProperty('accessToken');
+ expect(res.body).not.toHaveProperty('refreshToken');
+ });
+
+ it('should return 401 with non-existent email', async () => {
+ const res = await request(app)
+ .post('/api/auth/login')
+ .send({ email: 'nobody@nowhere.test', password: 'password123' });
+
+ expect(res.status).toBe(401);
+ expect(res.body.error).toBe('Invalid credentials');
+ });
+
+ it('should return 401 for inactive user', async () => {
+ const res = await request(app)
+ .post('/api/auth/login')
+ .send({ email: TEST_INACTIVE_USER.email, password: 'password123' });
+
+ expect(res.status).toBe(401);
+ expect(res.body.error).toBe('Invalid credentials');
+ });
+
+ it('should return 400 with missing email', async () => {
+ const res = await request(app)
+ .post('/api/auth/login')
+ .send({ password: 'password123' });
+
+ expect(res.status).toBe(400);
+ expect(res.body).toHaveProperty('error');
+ });
+
+ it('should return 400 with invalid email format', async () => {
+ const res = await request(app)
+ .post('/api/auth/login')
+ .send({ email: 'not-an-email', password: 'password123' });
+
+ expect(res.status).toBe(400);
+ expect(res.body).toHaveProperty('error');
+ });
+
+ it('should return 400 with missing password', async () => {
+ const res = await request(app)
+ .post('/api/auth/login')
+ .send({ email: TEST_CUSTOMER_USER_A.email });
+
+ expect(res.status).toBe(400);
+ expect(res.body).toHaveProperty('error');
+ });
+ });
+
+ // ============================================================
+ // GET /api/auth/me
+ // ============================================================
+ describe('GET /api/auth/me', () => {
+ it('should return 200 and user data with valid token', async () => {
+ const token = generateTestToken(TEST_CUSTOMER_USER_A);
+
+ const res = await request(app)
+ .get('/api/auth/me')
+ .set('Authorization', `Bearer ${token}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.id).toBe(TEST_CUSTOMER_USER_A.id);
+ expect(res.body.email).toBe(TEST_CUSTOMER_USER_A.email);
+ expect(res.body.firstName).toBe(TEST_CUSTOMER_USER_A.firstName);
+ expect(res.body.lastName).toBe(TEST_CUSTOMER_USER_A.lastName);
+ expect(res.body.role).toBe(TEST_CUSTOMER_USER_A.role);
+ expect(res.body.tenantId).toBe(TEST_CUSTOMER_USER_A.tenantId);
+ expect(res.body).not.toHaveProperty('passwordHash');
+ });
+
+ it('should include tenant details in response', async () => {
+ const token = generateTestToken(TEST_CUSTOMER_USER_A);
+
+ const res = await request(app)
+ .get('/api/auth/me')
+ .set('Authorization', `Bearer ${token}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.tenant).toBeTruthy();
+ expect(res.body.tenant.id).toBe('tenant-a-id');
+ expect(res.body.tenant.name).toBe('Tenant A');
+ });
+
+ it('should return 401 without token', async () => {
+ const res = await request(app).get('/api/auth/me');
+
+ expect(res.status).toBe(401);
+ expect(res.body).toHaveProperty('error');
+ });
+
+ it('should return 401 with invalid token', async () => {
+ const res = await request(app)
+ .get('/api/auth/me')
+ .set('Authorization', 'Bearer invalid-token-string');
+
+ expect(res.status).toBe(401);
+ expect(res.body).toHaveProperty('error');
+ });
+
+ it('should return 401 with expired token', async () => {
+ const expiredToken = jwt.sign(
+ {
+ id: TEST_CUSTOMER_USER_A.id,
+ email: TEST_CUSTOMER_USER_A.email,
+ role: TEST_CUSTOMER_USER_A.role,
+ tenantId: TEST_CUSTOMER_USER_A.tenantId,
+ firstName: TEST_CUSTOMER_USER_A.firstName,
+ lastName: TEST_CUSTOMER_USER_A.lastName,
+ },
+ process.env.JWT_SECRET!,
+ { expiresIn: '0s' }
+ );
+
+ // Small delay to ensure token is expired
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ const res = await request(app)
+ .get('/api/auth/me')
+ .set('Authorization', `Bearer ${expiredToken}`);
+
+ expect(res.status).toBe(401);
+ });
+
+ it('should return 401 with malformed Authorization header', async () => {
+ const res = await request(app)
+ .get('/api/auth/me')
+ .set('Authorization', 'NotBearer some-token');
+
+ expect(res.status).toBe(401);
+ });
+
+ it('should work for SYSTEM_ADMIN with null tenant', async () => {
+ const token = generateTestToken(TEST_SYSTEM_ADMIN);
+
+ const res = await request(app)
+ .get('/api/auth/me')
+ .set('Authorization', `Bearer ${token}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.role).toBe('SYSTEM_ADMIN');
+ expect(res.body.tenant).toBeNull();
+ });
+ });
+
+ // ============================================================
+ // POST /api/auth/refresh
+ // ============================================================
+ describe('POST /api/auth/refresh', () => {
+ it('should return new tokens with valid refresh token', async () => {
+ const refreshToken = generateTestRefreshToken(TEST_CUSTOMER_USER_A.id);
+
+ const res = await request(app)
+ .post('/api/auth/refresh')
+ .send({ refreshToken });
+
+ expect(res.status).toBe(200);
+ expect(res.body).toHaveProperty('accessToken');
+ expect(res.body).toHaveProperty('refreshToken');
+ // New refresh token should be different from the old one
+ expect(res.body.refreshToken).not.toBe(refreshToken);
+ });
+
+ it('should invalidate the old refresh token after use', async () => {
+ const refreshToken = generateTestRefreshToken(TEST_CUSTOMER_USER_A.id);
+
+ // First use - should succeed
+ const res1 = await request(app)
+ .post('/api/auth/refresh')
+ .send({ refreshToken });
+ expect(res1.status).toBe(200);
+
+ // Second use of the same token - should fail (token invalidated)
+ const res2 = await request(app)
+ .post('/api/auth/refresh')
+ .send({ refreshToken });
+ expect(res2.status).toBe(401);
+ });
+
+ it('should return 401 with invalid refresh token', async () => {
+ const res = await request(app)
+ .post('/api/auth/refresh')
+ .send({ refreshToken: 'not-a-valid-jwt' });
+
+ expect(res.status).toBe(401);
+ expect(res.body).toHaveProperty('error');
+ });
+
+ it('should return 401 when using an access token as refresh token', async () => {
+ const accessToken = generateTestToken(TEST_CUSTOMER_USER_A);
+
+ const res = await request(app)
+ .post('/api/auth/refresh')
+ .send({ refreshToken: accessToken });
+
+ // The access token is signed with a different secret, so verification fails
+ expect(res.status).toBe(401);
+ });
+
+ it('should return 400 when refreshToken is missing', async () => {
+ const res = await request(app)
+ .post('/api/auth/refresh')
+ .send({});
+
+ expect(res.status).toBe(400);
+ expect(res.body).toHaveProperty('error');
+ });
+
+ it('should return 401 for inactive user refresh', async () => {
+ const refreshToken = generateTestRefreshToken(TEST_INACTIVE_USER.id);
+
+ const res = await request(app)
+ .post('/api/auth/refresh')
+ .send({ refreshToken });
+
+ expect(res.status).toBe(401);
+ });
+ });
+});
diff --git a/backend/src/__tests__/setup.ts b/backend/src/__tests__/setup.ts
new file mode 100644
index 0000000..cda1c30
--- /dev/null
+++ b/backend/src/__tests__/setup.ts
@@ -0,0 +1,509 @@
+import jwt from 'jsonwebtoken';
+import bcrypt from 'bcryptjs';
+
+// Test environment variables - set before any imports that use them
+process.env.NODE_ENV = 'test';
+process.env.JWT_SECRET = 'test-jwt-secret-key';
+process.env.JWT_REFRESH_SECRET = 'test-jwt-refresh-secret-key';
+process.env.PORT = '0'; // Random port for tests
+process.env.CORS_ORIGIN = 'http://localhost:3000';
+
+// ============================================================
+// Prisma Mock
+// ============================================================
+
+// In-memory data stores for the mock
+const mockTenants: any[] = [];
+const mockUsers: any[] = [];
+const mockWorkflows: any[] = [];
+const mockWorkflowSteps: any[] = [];
+const mockCategories: any[] = [];
+
+// Helper to find by unique constraints
+function findInArray(arr: any[], where: any): any {
+ return arr.find((item) => {
+ return Object.entries(where).every(([key, value]) => item[key] === value);
+ });
+}
+
+function filterArray(arr: any[], where: any): any[] {
+ if (!where || Object.keys(where).length === 0) return [...arr];
+ return arr.filter((item) => {
+ return Object.entries(where).every(([key, value]: [string, any]) => {
+ if (key === 'OR') {
+ return (value as any[]).some((condition: any) =>
+ Object.entries(condition).every(([k, v]: [string, any]) => {
+ if (typeof v === 'object' && v.contains) {
+ return item[k]?.toLowerCase().includes(v.contains.toLowerCase());
+ }
+ return item[k] === v;
+ })
+ );
+ }
+ if (key === 'tags' && typeof value === 'object' && value.hasSome) {
+ return value.hasSome.some((tag: string) => item.tags?.includes(tag));
+ }
+ return item[key] === value;
+ });
+ });
+}
+
+function addIncludes(item: any, include: any): any {
+ if (!include || !item) return item;
+ const result = { ...item };
+
+ if (include.tenant) {
+ result.tenant = mockTenants.find((t) => t.id === item.tenantId) || null;
+ if (result.tenant && typeof include.tenant === 'object' && include.tenant.select) {
+ const selected: any = {};
+ for (const key of Object.keys(include.tenant.select)) {
+ selected[key] = result.tenant[key];
+ }
+ result.tenant = selected;
+ }
+ }
+
+ if (include.createdBy) {
+ const user = mockUsers.find((u) => u.id === item.createdById);
+ if (user && typeof include.createdBy === 'object' && include.createdBy.select) {
+ const selected: any = {};
+ for (const key of Object.keys(include.createdBy.select)) {
+ selected[key] = user[key];
+ }
+ result.createdBy = selected;
+ } else {
+ result.createdBy = user || null;
+ }
+ }
+
+ if (include.updatedBy) {
+ const user = mockUsers.find((u) => u.id === item.updatedById);
+ if (user && typeof include.updatedBy === 'object' && include.updatedBy.select) {
+ const selected: any = {};
+ for (const key of Object.keys(include.updatedBy.select)) {
+ selected[key] = user[key];
+ }
+ result.updatedBy = selected;
+ } else {
+ result.updatedBy = user || null;
+ }
+ }
+
+ if (include.category) {
+ const cat = mockCategories.find((c) => c.id === item.categoryId);
+ if (cat && typeof include.category === 'object' && include.category.select) {
+ const selected: any = {};
+ for (const key of Object.keys(include.category.select)) {
+ selected[key] = cat[key];
+ }
+ result.category = selected;
+ } else {
+ result.category = cat || null;
+ }
+ }
+
+ if (include.steps) {
+ result.steps = mockWorkflowSteps
+ .filter((s) => s.workflowId === item.id)
+ .sort((a, b) => a.order - b.order);
+ }
+
+ if (include._count) {
+ result._count = {};
+ if (include._count.select?.users) {
+ result._count.users = mockUsers.filter((u) => u.tenantId === item.id).length;
+ }
+ if (include._count.select?.workflows) {
+ result._count.workflows = mockWorkflows.filter((w) => w.tenantId === item.id).length;
+ }
+ if (include._count.select?.steps) {
+ result._count.steps = mockWorkflowSteps.filter((s) => s.workflowId === item.id).length;
+ }
+ }
+
+ return result;
+}
+
+// Build mock Prisma client
+const mockPrismaClient = {
+ user: {
+ findUnique: jest.fn(async ({ where, include }: any) => {
+ const user = findInArray(mockUsers, where);
+ return user ? addIncludes(user, include) : null;
+ }),
+ findMany: jest.fn(async ({ where, include }: any = {}) => {
+ return filterArray(mockUsers, where).map((u) => addIncludes(u, include));
+ }),
+ create: jest.fn(async ({ data, include }: any) => {
+ const user = { ...data, createdAt: new Date(), updatedAt: new Date() };
+ mockUsers.push(user);
+ return addIncludes(user, include);
+ }),
+ update: jest.fn(async ({ where, data, include }: any) => {
+ const idx = mockUsers.findIndex((u) => u.id === where.id);
+ if (idx === -1) throw { code: 'P2025' };
+ mockUsers[idx] = { ...mockUsers[idx], ...data, updatedAt: new Date() };
+ return addIncludes(mockUsers[idx], include);
+ }),
+ count: jest.fn(async ({ where }: any = {}) => filterArray(mockUsers, where).length),
+ },
+ tenant: {
+ findUnique: jest.fn(async ({ where, include }: any) => {
+ const tenant = findInArray(mockTenants, where);
+ return tenant ? addIncludes(tenant, include) : null;
+ }),
+ findMany: jest.fn(async ({ where, include, orderBy }: any = {}) => {
+ return filterArray(mockTenants, where).map((t) => addIncludes(t, include));
+ }),
+ create: jest.fn(async ({ data }: any) => {
+ const tenant = { ...data, createdAt: new Date(), updatedAt: new Date() };
+ mockTenants.push(tenant);
+ return tenant;
+ }),
+ update: jest.fn(async ({ where, data, include }: any) => {
+ const idx = mockTenants.findIndex((t) => t.id === where.id);
+ if (idx === -1) throw { code: 'P2025' };
+ mockTenants[idx] = { ...mockTenants[idx], ...data, updatedAt: new Date() };
+ return addIncludes(mockTenants[idx], include);
+ }),
+ delete: jest.fn(async ({ where }: any) => {
+ const idx = mockTenants.findIndex((t) => t.id === where.id);
+ if (idx === -1) throw { code: 'P2025' };
+ return mockTenants.splice(idx, 1)[0];
+ }),
+ count: jest.fn(async ({ where }: any = {}) => filterArray(mockTenants, where).length),
+ },
+ workflow: {
+ findUnique: jest.fn(async ({ where, include }: any) => {
+ const wf = findInArray(mockWorkflows, where);
+ return wf ? addIncludes(wf, include) : null;
+ }),
+ findMany: jest.fn(async ({ where, include, orderBy, skip, take }: any = {}) => {
+ let results = filterArray(mockWorkflows, where);
+ if (skip) results = results.slice(skip);
+ if (take) results = results.slice(0, take);
+ return results.map((w) => addIncludes(w, include));
+ }),
+ create: jest.fn(async ({ data, include }: any) => {
+ const { steps: stepsData, ...workflowData } = data;
+ const workflow = {
+ ...workflowData,
+ id: workflowData.id || `wf-${Date.now()}-${Math.random().toString(36).slice(2)}`,
+ status: workflowData.status || 'DRAFT',
+ version: workflowData.version || 1,
+ isTemplate: workflowData.isTemplate || false,
+ tags: workflowData.tags || [],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ mockWorkflows.push(workflow);
+
+ if (stepsData?.create) {
+ const stepsToCreate = Array.isArray(stepsData.create) ? stepsData.create : [stepsData.create];
+ for (const stepData of stepsToCreate) {
+ const step = {
+ ...stepData,
+ id: `step-${Date.now()}-${Math.random().toString(36).slice(2)}`,
+ workflowId: workflow.id,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ mockWorkflowSteps.push(step);
+ }
+ }
+
+ return addIncludes(workflow, include);
+ }),
+ update: jest.fn(async ({ where, data, include }: any) => {
+ const idx = mockWorkflows.findIndex((w) => w.id === where.id);
+ if (idx === -1) throw { code: 'P2025' };
+ const updateData = { ...data };
+ if (updateData.version?.increment) {
+ updateData.version = mockWorkflows[idx].version + updateData.version.increment;
+ }
+ mockWorkflows[idx] = { ...mockWorkflows[idx], ...updateData, updatedAt: new Date() };
+ return addIncludes(mockWorkflows[idx], include);
+ }),
+ delete: jest.fn(async ({ where }: any) => {
+ const idx = mockWorkflows.findIndex((w) => w.id === where.id);
+ if (idx === -1) throw { code: 'P2025' };
+ // Also delete associated steps
+ const wfId = mockWorkflows[idx].id;
+ for (let i = mockWorkflowSteps.length - 1; i >= 0; i--) {
+ if (mockWorkflowSteps[i].workflowId === wfId) {
+ mockWorkflowSteps.splice(i, 1);
+ }
+ }
+ return mockWorkflows.splice(idx, 1)[0];
+ }),
+ count: jest.fn(async ({ where }: any = {}) => filterArray(mockWorkflows, where).length),
+ },
+ workflowStep: {
+ findFirst: jest.fn(async ({ where }: any) => {
+ return mockWorkflowSteps.find((s) =>
+ Object.entries(where).every(([k, v]) => s[k] === v)
+ ) || null;
+ }),
+ findMany: jest.fn(async ({ where, orderBy }: any = {}) => {
+ let results = filterArray(mockWorkflowSteps, where);
+ if (orderBy?.order === 'asc') results.sort((a, b) => a.order - b.order);
+ return results;
+ }),
+ create: jest.fn(async ({ data }: any) => {
+ const step = {
+ ...data,
+ id: `step-${Date.now()}-${Math.random().toString(36).slice(2)}`,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ mockWorkflowSteps.push(step);
+ return step;
+ }),
+ update: jest.fn(async ({ where, data }: any) => {
+ const idx = mockWorkflowSteps.findIndex((s) => s.id === where.id);
+ if (idx === -1) throw { code: 'P2025' };
+ mockWorkflowSteps[idx] = { ...mockWorkflowSteps[idx], ...data, updatedAt: new Date() };
+ return mockWorkflowSteps[idx];
+ }),
+ delete: jest.fn(async ({ where }: any) => {
+ const idx = mockWorkflowSteps.findIndex((s) => s.id === where.id);
+ if (idx === -1) throw { code: 'P2025' };
+ return mockWorkflowSteps.splice(idx, 1)[0];
+ }),
+ },
+ category: {
+ findUnique: jest.fn(async ({ where }: any) => findInArray(mockCategories, where)),
+ findMany: jest.fn(async ({ where }: any = {}) => filterArray(mockCategories, where)),
+ create: jest.fn(async ({ data }: any) => {
+ const cat = { ...data, id: data.id || `cat-${Date.now()}`, createdAt: new Date() };
+ mockCategories.push(cat);
+ return cat;
+ }),
+ },
+ $transaction: jest.fn(async (operations: any) => {
+ if (Array.isArray(operations)) {
+ return Promise.all(operations);
+ }
+ return operations(mockPrismaClient);
+ }),
+ $disconnect: jest.fn(),
+};
+
+// Mock the prisma module
+jest.mock('../lib/prisma', () => ({
+ __esModule: true,
+ default: mockPrismaClient,
+}));
+
+// ============================================================
+// Test Data Helpers
+// ============================================================
+
+export const TEST_TENANT_A = {
+ id: 'tenant-a-id',
+ name: 'Tenant A',
+ slug: 'tenant-a',
+ logo: null,
+ active: true,
+ createdAt: new Date('2025-01-01'),
+ updatedAt: new Date('2025-01-01'),
+};
+
+export const TEST_TENANT_B = {
+ id: 'tenant-b-id',
+ name: 'Tenant B',
+ slug: 'tenant-b',
+ logo: null,
+ active: true,
+ createdAt: new Date('2025-01-01'),
+ updatedAt: new Date('2025-01-01'),
+};
+
+const passwordHash = bcrypt.hashSync('password123', 10);
+
+export const TEST_SYSTEM_ADMIN = {
+ id: 'system-admin-id',
+ email: 'admin@system.test',
+ passwordHash,
+ firstName: 'System',
+ lastName: 'Admin',
+ role: 'SYSTEM_ADMIN' as const,
+ tenantId: null,
+ active: true,
+ createdAt: new Date('2025-01-01'),
+ updatedAt: new Date('2025-01-01'),
+};
+
+export const TEST_TECHNICIAN = {
+ id: 'technician-id',
+ email: 'tech@system.test',
+ passwordHash,
+ firstName: 'Tech',
+ lastName: 'Nician',
+ role: 'TECHNICIAN' as const,
+ tenantId: null,
+ active: true,
+ createdAt: new Date('2025-01-01'),
+ updatedAt: new Date('2025-01-01'),
+};
+
+export const TEST_CUSTOMER_ADMIN_A = {
+ id: 'customer-admin-a-id',
+ email: 'admin@tenanta.test',
+ passwordHash,
+ firstName: 'Customer',
+ lastName: 'AdminA',
+ role: 'CUSTOMER_ADMIN' as const,
+ tenantId: 'tenant-a-id',
+ active: true,
+ createdAt: new Date('2025-01-01'),
+ updatedAt: new Date('2025-01-01'),
+};
+
+export const TEST_CUSTOMER_USER_A = {
+ id: 'customer-user-a-id',
+ email: 'user@tenanta.test',
+ passwordHash,
+ firstName: 'Customer',
+ lastName: 'UserA',
+ role: 'CUSTOMER_USER' as const,
+ tenantId: 'tenant-a-id',
+ active: true,
+ createdAt: new Date('2025-01-01'),
+ updatedAt: new Date('2025-01-01'),
+};
+
+export const TEST_CUSTOMER_USER_B = {
+ id: 'customer-user-b-id',
+ email: 'user@tenantb.test',
+ passwordHash,
+ firstName: 'Customer',
+ lastName: 'UserB',
+ role: 'CUSTOMER_USER' as const,
+ tenantId: 'tenant-b-id',
+ active: true,
+ createdAt: new Date('2025-01-01'),
+ updatedAt: new Date('2025-01-01'),
+};
+
+export const TEST_INACTIVE_USER = {
+ id: 'inactive-user-id',
+ email: 'inactive@test.com',
+ passwordHash,
+ firstName: 'Inactive',
+ lastName: 'User',
+ role: 'CUSTOMER_USER' as const,
+ tenantId: 'tenant-a-id',
+ active: false,
+ createdAt: new Date('2025-01-01'),
+ updatedAt: new Date('2025-01-01'),
+};
+
+/**
+ * Generate a JWT access token for testing.
+ */
+export function generateTestToken(user: {
+ id: string;
+ email: string;
+ role: string;
+ tenantId: string | null;
+ firstName: string;
+ lastName: string;
+}): string {
+ return jwt.sign(
+ {
+ id: user.id,
+ email: user.email,
+ role: user.role,
+ tenantId: user.tenantId,
+ firstName: user.firstName,
+ lastName: user.lastName,
+ },
+ process.env.JWT_SECRET!,
+ { expiresIn: '15m' }
+ );
+}
+
+/**
+ * Generate a JWT refresh token for testing.
+ */
+export function generateTestRefreshToken(userId: string): string {
+ return jwt.sign(
+ { id: userId, type: 'refresh' },
+ process.env.JWT_REFRESH_SECRET!,
+ { expiresIn: '7d' }
+ );
+}
+
+/**
+ * Seed the mock data stores with test data.
+ * Call this in beforeEach to have a clean state.
+ */
+export function seedTestData(): void {
+ // Clear all arrays
+ mockTenants.length = 0;
+ mockUsers.length = 0;
+ mockWorkflows.length = 0;
+ mockWorkflowSteps.length = 0;
+ mockCategories.length = 0;
+
+ // Seed tenants
+ mockTenants.push({ ...TEST_TENANT_A }, { ...TEST_TENANT_B });
+
+ // Seed users
+ mockUsers.push(
+ { ...TEST_SYSTEM_ADMIN },
+ { ...TEST_TECHNICIAN },
+ { ...TEST_CUSTOMER_ADMIN_A },
+ { ...TEST_CUSTOMER_USER_A },
+ { ...TEST_CUSTOMER_USER_B },
+ { ...TEST_INACTIVE_USER }
+ );
+
+ // Seed a workflow for tenant A
+ mockWorkflows.push({
+ id: 'workflow-a1-id',
+ title: 'Workflow A1',
+ description: 'Test workflow for tenant A',
+ tenantId: 'tenant-a-id',
+ categoryId: null,
+ status: 'DRAFT',
+ version: 1,
+ isTemplate: false,
+ tags: ['test'],
+ createdById: 'customer-admin-a-id',
+ updatedById: null,
+ createdAt: new Date('2025-01-01'),
+ updatedAt: new Date('2025-01-01'),
+ });
+
+ // Seed a workflow for tenant B
+ mockWorkflows.push({
+ id: 'workflow-b1-id',
+ title: 'Workflow B1',
+ description: 'Test workflow for tenant B',
+ tenantId: 'tenant-b-id',
+ categoryId: null,
+ status: 'PUBLISHED',
+ version: 1,
+ isTemplate: false,
+ tags: ['production'],
+ createdById: 'customer-user-b-id',
+ updatedById: null,
+ createdAt: new Date('2025-01-02'),
+ updatedAt: new Date('2025-01-02'),
+ });
+}
+
+/**
+ * Clear all mock data stores.
+ */
+export function clearTestData(): void {
+ mockTenants.length = 0;
+ mockUsers.length = 0;
+ mockWorkflows.length = 0;
+ mockWorkflowSteps.length = 0;
+ mockCategories.length = 0;
+}
+
+export { mockPrismaClient };
diff --git a/backend/src/__tests__/tenants.test.ts b/backend/src/__tests__/tenants.test.ts
new file mode 100644
index 0000000..358db39
--- /dev/null
+++ b/backend/src/__tests__/tenants.test.ts
@@ -0,0 +1,399 @@
+import request from 'supertest';
+import {
+ seedTestData,
+ clearTestData,
+ generateTestToken,
+ TEST_SYSTEM_ADMIN,
+ TEST_TECHNICIAN,
+ TEST_CUSTOMER_ADMIN_A,
+ TEST_CUSTOMER_USER_A,
+ TEST_CUSTOMER_USER_B,
+ TEST_TENANT_A,
+ TEST_TENANT_B,
+} from './setup';
+
+import app from '../index';
+
+describe('Tenant Routes', () => {
+ let systemAdminToken: string;
+ let technicianToken: string;
+ let customerAdminAToken: string;
+ let customerUserAToken: string;
+ let customerUserBToken: string;
+
+ beforeEach(() => {
+ seedTestData();
+ jest.clearAllMocks();
+
+ systemAdminToken = generateTestToken(TEST_SYSTEM_ADMIN);
+ technicianToken = generateTestToken(TEST_TECHNICIAN);
+ customerAdminAToken = generateTestToken(TEST_CUSTOMER_ADMIN_A);
+ customerUserAToken = generateTestToken(TEST_CUSTOMER_USER_A);
+ customerUserBToken = generateTestToken(TEST_CUSTOMER_USER_B);
+ });
+
+ afterEach(() => {
+ clearTestData();
+ });
+
+ // ============================================================
+ // POST /api/tenants (Create)
+ // ============================================================
+ describe('POST /api/tenants', () => {
+ const newTenant = {
+ name: 'New Tenant',
+ slug: 'new-tenant',
+ };
+
+ it('should allow SYSTEM_ADMIN to create a tenant', async () => {
+ const res = await request(app)
+ .post('/api/tenants')
+ .set('Authorization', `Bearer ${systemAdminToken}`)
+ .send(newTenant);
+
+ expect(res.status).toBe(201);
+ expect(res.body).toHaveProperty('data');
+ expect(res.body.data.name).toBe(newTenant.name);
+ expect(res.body.data.slug).toBe(newTenant.slug);
+ });
+
+ it('should reject TECHNICIAN from creating a tenant', async () => {
+ const res = await request(app)
+ .post('/api/tenants')
+ .set('Authorization', `Bearer ${technicianToken}`)
+ .send(newTenant);
+
+ expect(res.status).toBe(403);
+ });
+
+ it('should reject CUSTOMER_ADMIN from creating a tenant', async () => {
+ const res = await request(app)
+ .post('/api/tenants')
+ .set('Authorization', `Bearer ${customerAdminAToken}`)
+ .send(newTenant);
+
+ expect(res.status).toBe(403);
+ });
+
+ it('should reject CUSTOMER_USER from creating a tenant', async () => {
+ const res = await request(app)
+ .post('/api/tenants')
+ .set('Authorization', `Bearer ${customerUserAToken}`)
+ .send(newTenant);
+
+ expect(res.status).toBe(403);
+ });
+
+ it('should reject unauthenticated tenant creation', async () => {
+ const res = await request(app)
+ .post('/api/tenants')
+ .send(newTenant);
+
+ expect(res.status).toBe(401);
+ });
+
+ it('should return 400 with missing name', async () => {
+ const res = await request(app)
+ .post('/api/tenants')
+ .set('Authorization', `Bearer ${systemAdminToken}`)
+ .send({ slug: 'no-name' });
+
+ expect(res.status).toBe(400);
+ expect(res.body).toHaveProperty('error');
+ });
+
+ it('should return 400 with missing slug', async () => {
+ const res = await request(app)
+ .post('/api/tenants')
+ .set('Authorization', `Bearer ${systemAdminToken}`)
+ .send({ name: 'No Slug' });
+
+ expect(res.status).toBe(400);
+ });
+
+ it('should return 400 with invalid slug format', async () => {
+ const res = await request(app)
+ .post('/api/tenants')
+ .set('Authorization', `Bearer ${systemAdminToken}`)
+ .send({ name: 'Bad Slug', slug: 'BAD SLUG!' });
+
+ expect(res.status).toBe(400);
+ });
+
+ it('should return 409 when slug already exists', async () => {
+ const res = await request(app)
+ .post('/api/tenants')
+ .set('Authorization', `Bearer ${systemAdminToken}`)
+ .send({ name: 'Duplicate', slug: 'tenant-a' });
+
+ expect(res.status).toBe(409);
+ expect(res.body.error).toBe('Conflict');
+ });
+ });
+
+ // ============================================================
+ // GET /api/tenants (List)
+ // ============================================================
+ describe('GET /api/tenants', () => {
+ it('should return all tenants for SYSTEM_ADMIN', async () => {
+ const res = await request(app)
+ .get('/api/tenants')
+ .set('Authorization', `Bearer ${systemAdminToken}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body).toHaveProperty('data');
+ expect(Array.isArray(res.body.data)).toBe(true);
+ expect(res.body.data.length).toBe(2);
+ });
+
+ it('should return all tenants for TECHNICIAN', async () => {
+ const res = await request(app)
+ .get('/api/tenants')
+ .set('Authorization', `Bearer ${technicianToken}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.data.length).toBe(2);
+ });
+
+ it('should return only own tenant for CUSTOMER_USER', async () => {
+ const res = await request(app)
+ .get('/api/tenants')
+ .set('Authorization', `Bearer ${customerUserAToken}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.data.length).toBe(1);
+ expect(res.body.data[0].id).toBe(TEST_TENANT_A.id);
+ });
+
+ it('should return only own tenant for CUSTOMER_ADMIN', async () => {
+ const res = await request(app)
+ .get('/api/tenants')
+ .set('Authorization', `Bearer ${customerAdminAToken}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.data.length).toBe(1);
+ expect(res.body.data[0].id).toBe(TEST_TENANT_A.id);
+ });
+
+ it('tenant B user should only see tenant B', async () => {
+ const res = await request(app)
+ .get('/api/tenants')
+ .set('Authorization', `Bearer ${customerUserBToken}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.data.length).toBe(1);
+ expect(res.body.data[0].id).toBe(TEST_TENANT_B.id);
+ });
+
+ it('should return 401 without authentication', async () => {
+ const res = await request(app).get('/api/tenants');
+
+ expect(res.status).toBe(401);
+ });
+
+ it('should include user and workflow counts', async () => {
+ const res = await request(app)
+ .get('/api/tenants')
+ .set('Authorization', `Bearer ${systemAdminToken}`);
+
+ expect(res.status).toBe(200);
+ for (const tenant of res.body.data) {
+ expect(tenant).toHaveProperty('_count');
+ expect(tenant._count).toHaveProperty('users');
+ expect(tenant._count).toHaveProperty('workflows');
+ }
+ });
+ });
+
+ // ============================================================
+ // GET /api/tenants/:id (Get Single)
+ // ============================================================
+ describe('GET /api/tenants/:id', () => {
+ it('should return tenant details for SYSTEM_ADMIN', async () => {
+ const res = await request(app)
+ .get(`/api/tenants/${TEST_TENANT_A.id}`)
+ .set('Authorization', `Bearer ${systemAdminToken}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body).toHaveProperty('data');
+ expect(res.body.data.id).toBe(TEST_TENANT_A.id);
+ expect(res.body.data.name).toBe(TEST_TENANT_A.name);
+ expect(res.body.data.slug).toBe(TEST_TENANT_A.slug);
+ });
+
+ it('should allow TECHNICIAN to view any tenant', async () => {
+ const res = await request(app)
+ .get(`/api/tenants/${TEST_TENANT_B.id}`)
+ .set('Authorization', `Bearer ${technicianToken}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.data.id).toBe(TEST_TENANT_B.id);
+ });
+
+ it('should allow CUSTOMER_USER to view own tenant', async () => {
+ const res = await request(app)
+ .get(`/api/tenants/${TEST_TENANT_A.id}`)
+ .set('Authorization', `Bearer ${customerUserAToken}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.data.id).toBe(TEST_TENANT_A.id);
+ });
+
+ it('should deny CUSTOMER_USER access to other tenant', async () => {
+ const res = await request(app)
+ .get(`/api/tenants/${TEST_TENANT_B.id}`)
+ .set('Authorization', `Bearer ${customerUserAToken}`);
+
+ expect(res.status).toBe(403);
+ });
+
+ it('should deny CUSTOMER_ADMIN access to other tenant', async () => {
+ const res = await request(app)
+ .get(`/api/tenants/${TEST_TENANT_B.id}`)
+ .set('Authorization', `Bearer ${customerAdminAToken}`);
+
+ expect(res.status).toBe(403);
+ });
+
+ it('should return 404 for non-existent tenant', async () => {
+ const res = await request(app)
+ .get('/api/tenants/non-existent-id')
+ .set('Authorization', `Bearer ${systemAdminToken}`);
+
+ expect(res.status).toBe(404);
+ });
+
+ it('should return 401 without authentication', async () => {
+ const res = await request(app).get(`/api/tenants/${TEST_TENANT_A.id}`);
+
+ expect(res.status).toBe(401);
+ });
+ });
+
+ // ============================================================
+ // PUT /api/tenants/:id (Update)
+ // ============================================================
+ describe('PUT /api/tenants/:id', () => {
+ it('should allow SYSTEM_ADMIN to update a tenant', async () => {
+ const res = await request(app)
+ .put(`/api/tenants/${TEST_TENANT_A.id}`)
+ .set('Authorization', `Bearer ${systemAdminToken}`)
+ .send({ name: 'Updated Tenant A' });
+
+ expect(res.status).toBe(200);
+ expect(res.body.data.name).toBe('Updated Tenant A');
+ });
+
+ it('should reject TECHNICIAN from updating a tenant', async () => {
+ const res = await request(app)
+ .put(`/api/tenants/${TEST_TENANT_A.id}`)
+ .set('Authorization', `Bearer ${technicianToken}`)
+ .send({ name: 'Should Fail' });
+
+ expect(res.status).toBe(403);
+ });
+
+ it('should reject CUSTOMER_ADMIN from updating a tenant', async () => {
+ const res = await request(app)
+ .put(`/api/tenants/${TEST_TENANT_A.id}`)
+ .set('Authorization', `Bearer ${customerAdminAToken}`)
+ .send({ name: 'Should Fail' });
+
+ expect(res.status).toBe(403);
+ });
+
+ it('should reject CUSTOMER_USER from updating a tenant', async () => {
+ const res = await request(app)
+ .put(`/api/tenants/${TEST_TENANT_A.id}`)
+ .set('Authorization', `Bearer ${customerUserAToken}`)
+ .send({ name: 'Should Fail' });
+
+ expect(res.status).toBe(403);
+ });
+
+ it('should return 404 for non-existent tenant', async () => {
+ const res = await request(app)
+ .put('/api/tenants/non-existent-id')
+ .set('Authorization', `Bearer ${systemAdminToken}`)
+ .send({ name: 'Ghost' });
+
+ expect(res.status).toBe(404);
+ });
+ });
+
+ // ============================================================
+ // DELETE /api/tenants/:id (Deactivate)
+ // ============================================================
+ describe('DELETE /api/tenants/:id', () => {
+ it('should allow SYSTEM_ADMIN to deactivate a tenant', async () => {
+ const res = await request(app)
+ .delete(`/api/tenants/${TEST_TENANT_A.id}`)
+ .set('Authorization', `Bearer ${systemAdminToken}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.message).toBe('Tenant deactivated successfully');
+ });
+
+ it('should reject TECHNICIAN from deleting a tenant', async () => {
+ const res = await request(app)
+ .delete(`/api/tenants/${TEST_TENANT_A.id}`)
+ .set('Authorization', `Bearer ${technicianToken}`);
+
+ expect(res.status).toBe(403);
+ });
+
+ it('should reject CUSTOMER_ADMIN from deleting a tenant', async () => {
+ const res = await request(app)
+ .delete(`/api/tenants/${TEST_TENANT_A.id}`)
+ .set('Authorization', `Bearer ${customerAdminAToken}`);
+
+ expect(res.status).toBe(403);
+ });
+
+ it('should reject CUSTOMER_USER from deleting a tenant', async () => {
+ const res = await request(app)
+ .delete(`/api/tenants/${TEST_TENANT_A.id}`)
+ .set('Authorization', `Bearer ${customerUserAToken}`);
+
+ expect(res.status).toBe(403);
+ });
+ });
+
+ // ============================================================
+ // Role-based Access Summary
+ // ============================================================
+ describe('Role-based access control', () => {
+ it('only SYSTEM_ADMIN can perform write operations on tenants', async () => {
+ const createPayload = { name: 'Role Test', slug: 'role-test' };
+ const updatePayload = { name: 'Updated' };
+
+ // SYSTEM_ADMIN - create should work
+ const createRes = await request(app)
+ .post('/api/tenants')
+ .set('Authorization', `Bearer ${systemAdminToken}`)
+ .send(createPayload);
+ expect(createRes.status).toBe(201);
+
+ // TECHNICIAN - create should be forbidden
+ const techCreateRes = await request(app)
+ .post('/api/tenants')
+ .set('Authorization', `Bearer ${technicianToken}`)
+ .send({ name: 'Tech Try', slug: 'tech-try' });
+ expect(techCreateRes.status).toBe(403);
+
+ // CUSTOMER_ADMIN - create should be forbidden
+ const adminCreateRes = await request(app)
+ .post('/api/tenants')
+ .set('Authorization', `Bearer ${customerAdminAToken}`)
+ .send({ name: 'Admin Try', slug: 'admin-try' });
+ expect(adminCreateRes.status).toBe(403);
+
+ // CUSTOMER_USER - create should be forbidden
+ const userCreateRes = await request(app)
+ .post('/api/tenants')
+ .set('Authorization', `Bearer ${customerUserAToken}`)
+ .send({ name: 'User Try', slug: 'user-try' });
+ expect(userCreateRes.status).toBe(403);
+ });
+ });
+});
diff --git a/backend/src/__tests__/workflows.test.ts b/backend/src/__tests__/workflows.test.ts
new file mode 100644
index 0000000..f7a05d9
--- /dev/null
+++ b/backend/src/__tests__/workflows.test.ts
@@ -0,0 +1,410 @@
+import request from 'supertest';
+import {
+ seedTestData,
+ clearTestData,
+ generateTestToken,
+ TEST_SYSTEM_ADMIN,
+ TEST_TECHNICIAN,
+ TEST_CUSTOMER_ADMIN_A,
+ TEST_CUSTOMER_USER_A,
+ TEST_CUSTOMER_USER_B,
+ TEST_TENANT_A,
+ TEST_TENANT_B,
+} from './setup';
+
+import app from '../index';
+
+describe('Workflow Routes', () => {
+ let systemAdminToken: string;
+ let technicianToken: string;
+ let customerAdminAToken: string;
+ let customerUserAToken: string;
+ let customerUserBToken: string;
+
+ beforeEach(() => {
+ seedTestData();
+ jest.clearAllMocks();
+
+ systemAdminToken = generateTestToken(TEST_SYSTEM_ADMIN);
+ technicianToken = generateTestToken(TEST_TECHNICIAN);
+ customerAdminAToken = generateTestToken(TEST_CUSTOMER_ADMIN_A);
+ customerUserAToken = generateTestToken(TEST_CUSTOMER_USER_A);
+ customerUserBToken = generateTestToken(TEST_CUSTOMER_USER_B);
+ });
+
+ afterEach(() => {
+ clearTestData();
+ });
+
+ // ============================================================
+ // GET /api/workflows (List)
+ // ============================================================
+ describe('GET /api/workflows', () => {
+ it('should return 401 without authentication', async () => {
+ const res = await request(app).get('/api/workflows');
+
+ expect(res.status).toBe(401);
+ expect(res.body).toHaveProperty('error');
+ });
+
+ it('should return 200 and workflow list with valid auth', async () => {
+ const res = await request(app)
+ .get('/api/workflows')
+ .set('Authorization', `Bearer ${systemAdminToken}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body).toHaveProperty('data');
+ expect(res.body).toHaveProperty('pagination');
+ expect(Array.isArray(res.body.data)).toBe(true);
+ expect(res.body.pagination).toHaveProperty('page');
+ expect(res.body.pagination).toHaveProperty('limit');
+ expect(res.body.pagination).toHaveProperty('total');
+ expect(res.body.pagination).toHaveProperty('totalPages');
+ });
+
+ it('should return all workflows for SYSTEM_ADMIN', async () => {
+ const res = await request(app)
+ .get('/api/workflows')
+ .set('Authorization', `Bearer ${systemAdminToken}`);
+
+ expect(res.status).toBe(200);
+ // SYSTEM_ADMIN sees all workflows (both tenant A and B)
+ expect(res.body.data.length).toBe(2);
+ });
+
+ it('should return all workflows for TECHNICIAN', async () => {
+ const res = await request(app)
+ .get('/api/workflows')
+ .set('Authorization', `Bearer ${technicianToken}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.data.length).toBe(2);
+ });
+
+ it('should only return own tenant workflows for CUSTOMER_USER', async () => {
+ const res = await request(app)
+ .get('/api/workflows')
+ .set('Authorization', `Bearer ${customerUserAToken}`);
+
+ expect(res.status).toBe(200);
+ // CUSTOMER_USER_A should only see tenant A workflows
+ expect(res.body.data.length).toBe(1);
+ expect(res.body.data[0].tenantId).toBe(TEST_TENANT_A.id);
+ });
+
+ it('should only return tenant B workflows for tenant B user', async () => {
+ const res = await request(app)
+ .get('/api/workflows')
+ .set('Authorization', `Bearer ${customerUserBToken}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.data.length).toBe(1);
+ expect(res.body.data[0].tenantId).toBe(TEST_TENANT_B.id);
+ });
+ });
+
+ // ============================================================
+ // POST /api/workflows (Create)
+ // ============================================================
+ describe('POST /api/workflows', () => {
+ const validWorkflow = {
+ title: 'New Test Workflow',
+ description: 'A test workflow description',
+ tenantId: 'tenant-a-id',
+ tags: ['new', 'test'],
+ };
+
+ it('should create a workflow as SYSTEM_ADMIN', async () => {
+ const res = await request(app)
+ .post('/api/workflows')
+ .set('Authorization', `Bearer ${systemAdminToken}`)
+ .send(validWorkflow);
+
+ expect(res.status).toBe(201);
+ expect(res.body).toHaveProperty('data');
+ expect(res.body.data.title).toBe(validWorkflow.title);
+ expect(res.body.data.description).toBe(validWorkflow.description);
+ expect(res.body.data.tenantId).toBe(validWorkflow.tenantId);
+ expect(res.body.data.status).toBe('DRAFT');
+ });
+
+ it('should create a workflow as TECHNICIAN', async () => {
+ const res = await request(app)
+ .post('/api/workflows')
+ .set('Authorization', `Bearer ${technicianToken}`)
+ .send(validWorkflow);
+
+ expect(res.status).toBe(201);
+ expect(res.body.data.title).toBe(validWorkflow.title);
+ });
+
+ it('should create a workflow as CUSTOMER_ADMIN in own tenant', async () => {
+ const res = await request(app)
+ .post('/api/workflows')
+ .set('Authorization', `Bearer ${customerAdminAToken}`)
+ .send(validWorkflow);
+
+ expect(res.status).toBe(201);
+ expect(res.body.data.tenantId).toBe('tenant-a-id');
+ });
+
+ it('should reject CUSTOMER_ADMIN creating workflow in another tenant', async () => {
+ const res = await request(app)
+ .post('/api/workflows')
+ .set('Authorization', `Bearer ${customerAdminAToken}`)
+ .send({ ...validWorkflow, tenantId: 'tenant-b-id' });
+
+ expect(res.status).toBe(403);
+ });
+
+ it('should reject CUSTOMER_USER creating workflows (no permission)', async () => {
+ const res = await request(app)
+ .post('/api/workflows')
+ .set('Authorization', `Bearer ${customerUserAToken}`)
+ .send(validWorkflow);
+
+ expect(res.status).toBe(403);
+ });
+
+ it('should reject creation without authentication', async () => {
+ const res = await request(app)
+ .post('/api/workflows')
+ .send(validWorkflow);
+
+ expect(res.status).toBe(401);
+ });
+
+ it('should return 400 with missing title', async () => {
+ const res = await request(app)
+ .post('/api/workflows')
+ .set('Authorization', `Bearer ${systemAdminToken}`)
+ .send({ tenantId: 'tenant-a-id' });
+
+ expect(res.status).toBe(400);
+ expect(res.body).toHaveProperty('error');
+ });
+
+ it('should return 400 with missing tenantId', async () => {
+ const res = await request(app)
+ .post('/api/workflows')
+ .set('Authorization', `Bearer ${systemAdminToken}`)
+ .send({ title: 'No Tenant' });
+
+ expect(res.status).toBe(400);
+ });
+
+ it('should create a workflow with steps', async () => {
+ const workflowWithSteps = {
+ ...validWorkflow,
+ steps: [
+ { order: 0, title: 'Step 1', content: 'Do this first', type: 'INSTRUCTION' },
+ { order: 1, title: 'Step 2', content: 'Then do this', type: 'CHECKLIST' },
+ ],
+ };
+
+ const res = await request(app)
+ .post('/api/workflows')
+ .set('Authorization', `Bearer ${systemAdminToken}`)
+ .send(workflowWithSteps);
+
+ expect(res.status).toBe(201);
+ expect(res.body.data.steps).toBeDefined();
+ expect(res.body.data.steps.length).toBe(2);
+ });
+ });
+
+ // ============================================================
+ // GET /api/workflows/:id (Get Single)
+ // ============================================================
+ describe('GET /api/workflows/:id', () => {
+ it('should return a workflow by ID', async () => {
+ const res = await request(app)
+ .get('/api/workflows/workflow-a1-id')
+ .set('Authorization', `Bearer ${systemAdminToken}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body).toHaveProperty('data');
+ expect(res.body.data.id).toBe('workflow-a1-id');
+ expect(res.body.data.title).toBe('Workflow A1');
+ });
+
+ it('should return 404 for non-existent workflow', async () => {
+ const res = await request(app)
+ .get('/api/workflows/non-existent-id')
+ .set('Authorization', `Bearer ${systemAdminToken}`);
+
+ expect(res.status).toBe(404);
+ });
+
+ it('should allow CUSTOMER_USER to view own tenant workflow', async () => {
+ const res = await request(app)
+ .get('/api/workflows/workflow-a1-id')
+ .set('Authorization', `Bearer ${customerUserAToken}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.data.id).toBe('workflow-a1-id');
+ });
+
+ it('should deny CUSTOMER_USER access to other tenant workflow', async () => {
+ const res = await request(app)
+ .get('/api/workflows/workflow-b1-id')
+ .set('Authorization', `Bearer ${customerUserAToken}`);
+
+ expect(res.status).toBe(403);
+ });
+
+ it('should allow TECHNICIAN to view any tenant workflow', async () => {
+ const res = await request(app)
+ .get('/api/workflows/workflow-b1-id')
+ .set('Authorization', `Bearer ${technicianToken}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.data.id).toBe('workflow-b1-id');
+ });
+
+ it('should return 401 without authentication', async () => {
+ const res = await request(app).get('/api/workflows/workflow-a1-id');
+
+ expect(res.status).toBe(401);
+ });
+ });
+
+ // ============================================================
+ // PUT /api/workflows/:id (Update)
+ // ============================================================
+ describe('PUT /api/workflows/:id', () => {
+ it('should update a workflow as SYSTEM_ADMIN', async () => {
+ const res = await request(app)
+ .put('/api/workflows/workflow-a1-id')
+ .set('Authorization', `Bearer ${systemAdminToken}`)
+ .send({ title: 'Updated Title' });
+
+ expect(res.status).toBe(200);
+ expect(res.body.data.title).toBe('Updated Title');
+ });
+
+ it('should update a workflow as TECHNICIAN', async () => {
+ const res = await request(app)
+ .put('/api/workflows/workflow-a1-id')
+ .set('Authorization', `Bearer ${technicianToken}`)
+ .send({ description: 'Updated description' });
+
+ expect(res.status).toBe(200);
+ expect(res.body.data.description).toBe('Updated description');
+ });
+
+ it('should allow CUSTOMER_ADMIN to update own tenant workflow', async () => {
+ const res = await request(app)
+ .put('/api/workflows/workflow-a1-id')
+ .set('Authorization', `Bearer ${customerAdminAToken}`)
+ .send({ title: 'Admin Updated' });
+
+ expect(res.status).toBe(200);
+ expect(res.body.data.title).toBe('Admin Updated');
+ });
+
+ it('should deny CUSTOMER_ADMIN updating another tenant workflow', async () => {
+ const res = await request(app)
+ .put('/api/workflows/workflow-b1-id')
+ .set('Authorization', `Bearer ${customerAdminAToken}`)
+ .send({ title: 'Should Not Work' });
+
+ expect(res.status).toBe(403);
+ });
+
+ it('should deny CUSTOMER_USER updating workflows', async () => {
+ const res = await request(app)
+ .put('/api/workflows/workflow-a1-id')
+ .set('Authorization', `Bearer ${customerUserAToken}`)
+ .send({ title: 'User Try' });
+
+ expect(res.status).toBe(403);
+ });
+
+ it('should return 404 for non-existent workflow', async () => {
+ const res = await request(app)
+ .put('/api/workflows/non-existent-id')
+ .set('Authorization', `Bearer ${systemAdminToken}`)
+ .send({ title: 'Ghost' });
+
+ expect(res.status).toBe(404);
+ });
+
+ it('should increment version on update', async () => {
+ const res = await request(app)
+ .put('/api/workflows/workflow-a1-id')
+ .set('Authorization', `Bearer ${systemAdminToken}`)
+ .send({ title: 'Version Bump' });
+
+ expect(res.status).toBe(200);
+ expect(res.body.data.version).toBe(2);
+ });
+ });
+
+ // ============================================================
+ // Tenant Isolation
+ // ============================================================
+ describe('Tenant Isolation', () => {
+ it('user from tenant A cannot see tenant B workflows in list', async () => {
+ const res = await request(app)
+ .get('/api/workflows')
+ .set('Authorization', `Bearer ${customerUserAToken}`);
+
+ expect(res.status).toBe(200);
+ const tenantBWorkflows = res.body.data.filter(
+ (w: any) => w.tenantId === TEST_TENANT_B.id
+ );
+ expect(tenantBWorkflows.length).toBe(0);
+ });
+
+ it('user from tenant B cannot see tenant A workflows in list', async () => {
+ const res = await request(app)
+ .get('/api/workflows')
+ .set('Authorization', `Bearer ${customerUserBToken}`);
+
+ expect(res.status).toBe(200);
+ const tenantAWorkflows = res.body.data.filter(
+ (w: any) => w.tenantId === TEST_TENANT_A.id
+ );
+ expect(tenantAWorkflows.length).toBe(0);
+ });
+
+ it('user from tenant A cannot access tenant B workflow by ID', async () => {
+ const res = await request(app)
+ .get('/api/workflows/workflow-b1-id')
+ .set('Authorization', `Bearer ${customerUserAToken}`);
+
+ expect(res.status).toBe(403);
+ });
+
+ it('user from tenant B cannot access tenant A workflow by ID', async () => {
+ const res = await request(app)
+ .get('/api/workflows/workflow-a1-id')
+ .set('Authorization', `Bearer ${customerUserBToken}`);
+
+ expect(res.status).toBe(403);
+ });
+
+ it('SYSTEM_ADMIN can see workflows from all tenants', async () => {
+ const res = await request(app)
+ .get('/api/workflows')
+ .set('Authorization', `Bearer ${systemAdminToken}`);
+
+ expect(res.status).toBe(200);
+ const tenantIds = res.body.data.map((w: any) => w.tenantId);
+ expect(tenantIds).toContain(TEST_TENANT_A.id);
+ expect(tenantIds).toContain(TEST_TENANT_B.id);
+ });
+
+ it('TECHNICIAN can see workflows from all tenants', async () => {
+ const res = await request(app)
+ .get('/api/workflows')
+ .set('Authorization', `Bearer ${technicianToken}`);
+
+ expect(res.status).toBe(200);
+ const tenantIds = res.body.data.map((w: any) => w.tenantId);
+ expect(tenantIds).toContain(TEST_TENANT_A.id);
+ expect(tenantIds).toContain(TEST_TENANT_B.id);
+ });
+ });
+});
diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts
index a13b690..c560c53 100644
--- a/backend/src/middleware/auth.ts
+++ b/backend/src/middleware/auth.ts
@@ -2,7 +2,14 @@ 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';
+const JWT_SECRET = process.env.JWT_SECRET;
+if (!JWT_SECRET || JWT_SECRET === 'change-me-in-production') {
+ if (process.env.NODE_ENV === 'production') {
+ throw new Error('FATAL: JWT_SECRET must be set to a strong, unique value in production');
+ }
+ console.warn('WARNING: Using insecure default JWT_SECRET. Set JWT_SECRET env var before deploying.');
+}
+const RESOLVED_JWT_SECRET = JWT_SECRET || 'change-me-in-production';
interface JwtPayload {
id: string;
@@ -24,7 +31,7 @@ export function authenticate(req: Request, res: Response, next: NextFunction): v
const token = authHeader.substring(7);
try {
- const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload;
+ const decoded = jwt.verify(token, RESOLVED_JWT_SECRET, { algorithms: ['HS256'] }) as JwtPayload;
req.user = {
id: decoded.id,
email: decoded.email,
@@ -77,7 +84,15 @@ export function tenantGuard(req: Request, res: Response, next: NextFunction): vo
// 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) {
+ // Default-deny: if a tenant-scoped user makes a request without specifying a tenantId,
+ // reject it rather than silently allowing access to all tenants
+ if (!requestedTenantId) {
+ // Allow if the route doesn't need tenant context (handled by individual route guards)
+ next();
+ return;
+ }
+
+ if (requestedTenantId !== req.user.tenantId) {
res.status(403).json({
error: 'Forbidden',
message: 'You can only access data within your own tenant',
diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts
index abc4fdd..edc9541 100644
--- a/backend/src/routes/auth.routes.ts
+++ b/backend/src/routes/auth.routes.ts
@@ -8,13 +8,40 @@ 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 JWT_SECRET = process.env.JWT_SECRET;
+const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
+
+if (!JWT_SECRET || JWT_SECRET === 'change-me-in-production') {
+ if (process.env.NODE_ENV === 'production') {
+ throw new Error('FATAL: JWT_SECRET must be set to a strong, unique value in production');
+ }
+ console.warn('WARNING: Using insecure default JWT_SECRET. Set JWT_SECRET env var before deploying.');
+}
+if (!JWT_REFRESH_SECRET || JWT_REFRESH_SECRET === 'change-me-too') {
+ if (process.env.NODE_ENV === 'production') {
+ throw new Error('FATAL: JWT_REFRESH_SECRET must be set to a strong, unique value in production');
+ }
+ console.warn('WARNING: Using insecure default JWT_REFRESH_SECRET. Set JWT_REFRESH_SECRET env var before deploying.');
+}
+
+const RESOLVED_JWT_SECRET = JWT_SECRET || 'change-me-in-production';
+const RESOLVED_JWT_REFRESH_SECRET = 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();
+// Tokens are stored with their expiry time so we can periodically prune expired entries
+const invalidatedTokens = new Map();
+
+// Prune expired tokens every 15 minutes to prevent unbounded memory growth
+setInterval(() => {
+ const now = Date.now();
+ for (const [token, expiresAt] of invalidatedTokens) {
+ if (expiresAt < now) {
+ invalidatedTokens.delete(token);
+ }
+ }
+}, 15 * 60 * 1000);
const loginSchema = z.object({
email: z.string().email('Invalid email format'),
@@ -35,8 +62,8 @@ function generateTokens(user: { id: string; email: string; role: string; tenantI
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 });
+ const accessToken = jwt.sign(payload, RESOLVED_JWT_SECRET, { algorithm: 'HS256', expiresIn: ACCESS_TOKEN_EXPIRY });
+ const refreshToken = jwt.sign({ id: user.id, type: 'refresh' }, RESOLVED_JWT_REFRESH_SECRET, { algorithm: 'HS256', expiresIn: REFRESH_TOKEN_EXPIRY });
return { accessToken, refreshToken };
}
@@ -111,7 +138,7 @@ router.post('/refresh', async (req: Request, res: Response, next: NextFunction)
let decoded: any;
try {
- decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET);
+ decoded = jwt.verify(refreshToken, RESOLVED_JWT_REFRESH_SECRET, { algorithms: ['HS256'] });
} catch {
res.status(401).json({ error: 'Invalid refresh token' });
return;
@@ -131,8 +158,9 @@ router.post('/refresh', async (req: Request, res: Response, next: NextFunction)
return;
}
- // Invalidate old refresh token
- invalidatedTokens.add(refreshToken);
+ // Invalidate old refresh token (store with expiry for cleanup)
+ const tokenExp = decoded.exp ? decoded.exp * 1000 : Date.now() + 7 * 24 * 60 * 60 * 1000;
+ invalidatedTokens.set(refreshToken, tokenExp);
const tokens = generateTokens(user);
@@ -147,7 +175,8 @@ router.post('/logout', authenticate, async (req: Request, res: Response, next: N
try {
const refreshToken = req.body.refreshToken;
if (refreshToken) {
- invalidatedTokens.add(refreshToken);
+ // Store with 7-day expiry for cleanup
+ invalidatedTokens.set(refreshToken, Date.now() + 7 * 24 * 60 * 60 * 1000);
}
res.json({ message: 'Logged out successfully' });
} catch (err) {
diff --git a/backend/src/routes/user.routes.ts b/backend/src/routes/user.routes.ts
index 92a4225..58471e0 100644
--- a/backend/src/routes/user.routes.ts
+++ b/backend/src/routes/user.routes.ts
@@ -9,7 +9,7 @@ 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'),
+ password: z.string().min(8, 'Password must be at least 8 characters').regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/, 'Password must contain uppercase, lowercase, and a number'),
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),
diff --git a/backend/src/routes/workflow.routes.ts b/backend/src/routes/workflow.routes.ts
index e4cd0d1..41f349a 100644
--- a/backend/src/routes/workflow.routes.ts
+++ b/backend/src/routes/workflow.routes.ts
@@ -603,6 +603,22 @@ router.patch('/:id/steps/reorder', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUST
return;
}
+ // Verify all step IDs belong to this workflow before reordering
+ const stepIds = parsed.data.steps.map((s) => s.id);
+ const existingSteps = await prisma.workflowStep.findMany({
+ where: { id: { in: stepIds }, workflowId: id },
+ select: { id: true },
+ });
+ const existingStepIds = new Set(existingSteps.map((s) => s.id));
+ const invalidStepIds = stepIds.filter((sid) => !existingStepIds.has(sid));
+ if (invalidStepIds.length > 0) {
+ res.status(400).json({
+ error: 'Invalid step IDs',
+ message: `Steps [${invalidStepIds.join(', ')}] do not belong to this workflow`,
+ });
+ return;
+ }
+
// Update all step orders in a transaction
await prisma.$transaction(
parsed.data.steps.map((step) =>
diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx
index 8dfd74a..5a1faef 100644
--- a/frontend/src/hooks/useAuth.tsx
+++ b/frontend/src/hooks/useAuth.tsx
@@ -57,7 +57,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const logout = useCallback(async () => {
try {
- await authApi.logout();
+ const refreshToken = localStorage.getItem('refreshToken');
+ await authApi.logout(refreshToken);
} catch {
// ignore
}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index 8e9f99d..8ef66be 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -217,7 +217,7 @@ export default function Dashboard() {
{wf.title}
- {wf.steps?.length || 0} Schritte · v{wf.version}
+ {wf._count?.steps ?? wf.steps?.length ?? 0} Schritte · v{wf.version}
diff --git a/frontend/src/pages/WorkflowEditor.tsx b/frontend/src/pages/WorkflowEditor.tsx
index d8c700e..cef2ffa 100644
--- a/frontend/src/pages/WorkflowEditor.tsx
+++ b/frontend/src/pages/WorkflowEditor.tsx
@@ -11,8 +11,9 @@ import {
} from 'lucide-react';
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd';
import toast from 'react-hot-toast';
-import { workflowsApi, categoriesApi } from '../services/api';
-import type { Category, Workflow, WorkflowStep } from '../types';
+import { workflowsApi, stepsApi, categoriesApi, tenantsApi } from '../services/api';
+import { useAuth } from '../hooks/useAuth';
+import type { Category, Tenant, Workflow, WorkflowStep } from '../types';
import StepTypeIcon from '../components/StepTypeIcon';
interface StepDraft {
@@ -40,6 +41,7 @@ function nextKey() {
export default function WorkflowEditor() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
+ const { user } = useAuth();
const isNew = !id;
const [title, setTitle] = useState('');
@@ -47,20 +49,28 @@ export default function WorkflowEditor() {
const [categoryId, setCategoryId] = useState('');
const [tagsInput, setTagsInput] = useState('');
const [isTemplate, setIsTemplate] = useState(false);
+ const [tenantId, setTenantId] = useState(user?.tenantId || '');
+ const [tenants, setTenants] = useState([]);
const [steps, setSteps] = useState([]);
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(!isNew);
const [saving, setSaving] = useState(false);
const [autoSaved, setAutoSaved] = useState(false);
+ const isAdmin = user?.role === 'SYSTEM_ADMIN' || user?.role === 'TECHNICIAN';
+
const autoSaveTimer = useRef>();
useEffect(() => {
categoriesApi.list().then(setCategories).catch(() => {});
- }, []);
+ if (isAdmin) {
+ tenantsApi.list({ pageSize: 100 }).then((r) => setTenants(r.data)).catch(() => {});
+ }
+ }, [isAdmin]);
useEffect(() => {
if (!id) return;
+ setWorkflowId(id);
setLoading(true);
workflowsApi
.get(id)
@@ -95,23 +105,53 @@ export default function WorkflowEditor() {
.map((t) => t.trim())
.filter(Boolean);
- const buildPayload = (status?: Workflow['status']) => ({
+ const buildMetadataPayload = (status?: Workflow['status']) => ({
title,
description: description || undefined,
categoryId: categoryId || undefined,
tags: parseTags(),
isTemplate,
- status,
- steps: steps.map((s, idx) => ({
- id: s.id,
- title: s.title,
- content: s.content,
- type: s.type,
- isRequired: s.isRequired,
- order: idx + 1,
- })),
+ ...(status ? { status } : {}),
});
+ const syncSteps = async (wfId: string) => {
+ // Get current steps from server
+ const currentWorkflow = await workflowsApi.get(wfId);
+ const serverSteps = currentWorkflow.steps || [];
+ const serverStepIds = new Set(serverSteps.map((s) => s.id));
+
+ // Determine which local steps need to be created, updated, or deleted
+ const localStepIds = new Set(steps.filter((s) => s.id).map((s) => s.id!));
+
+ // Delete steps that no longer exist locally
+ for (const serverStep of serverSteps) {
+ if (!localStepIds.has(serverStep.id)) {
+ await stepsApi.delete(wfId, serverStep.id);
+ }
+ }
+
+ // Create or update steps
+ for (let idx = 0; idx < steps.length; idx++) {
+ const step = steps[idx];
+ const stepData = {
+ title: step.title || 'Unbenannter Schritt',
+ content: step.content || ' ',
+ type: step.type,
+ isRequired: step.isRequired,
+ order: idx + 1,
+ };
+ if (step.id && serverStepIds.has(step.id)) {
+ // Update existing step
+ await stepsApi.update(wfId, step.id, stepData);
+ } else {
+ // Create new step
+ const created = await stepsApi.create(wfId, stepData);
+ // Update local step with new ID
+ step.id = created.id;
+ }
+ }
+ };
+
const handleSave = useCallback(
async (status?: Workflow['status']) => {
if (!title.trim()) {
@@ -120,16 +160,39 @@ export default function WorkflowEditor() {
}
setSaving(true);
try {
- const payload = buildPayload(status);
if (isNew) {
- const created = await workflowsApi.create({
- ...payload,
+ // Build create payload with tenantId, status, and inline steps
+ const effectiveTenantId = tenantId || user?.tenantId;
+ if (!effectiveTenantId) {
+ toast.error('Bitte wählen Sie einen Mandanten');
+ setSaving(false);
+ return;
+ }
+ const createPayload = {
+ title,
+ description: description || undefined,
+ tenantId: effectiveTenantId,
+ categoryId: categoryId || undefined,
+ tags: parseTags(),
+ isTemplate,
status: status || 'DRAFT',
- } as Partial);
+ steps: steps.map((s, idx) => ({
+ title: s.title || 'Unbenannter Schritt',
+ content: s.content || ' ',
+ type: s.type,
+ isRequired: s.isRequired,
+ order: idx + 1,
+ })),
+ };
+ const created = await workflowsApi.create(createPayload as Partial);
toast.success('Workflow erstellt');
navigate(`/workflows/${created.id}/edit`, { replace: true });
} else {
+ // Update metadata first
+ const payload = buildMetadataPayload(status);
await workflowsApi.update(id!, payload as Partial);
+ // Sync steps via individual step endpoints
+ await syncSteps(id!);
toast.success('Workflow gespeichert');
}
} catch {
@@ -138,17 +201,17 @@ export default function WorkflowEditor() {
setSaving(false);
}
},
- [title, description, categoryId, tagsInput, isTemplate, steps, isNew, id, navigate]
+ [title, description, categoryId, tagsInput, isTemplate, tenantId, steps, isNew, id, navigate, user]
);
- // Auto-save (only for existing workflows)
+ // Auto-save (only for existing workflows) - metadata only
useEffect(() => {
if (isNew || loading) return;
clearTimeout(autoSaveTimer.current);
autoSaveTimer.current = setTimeout(async () => {
if (!title.trim()) return;
try {
- await workflowsApi.update(id!, buildPayload() as Partial);
+ await workflowsApi.update(id!, buildMetadataPayload() as Partial);
setAutoSaved(true);
setTimeout(() => setAutoSaved(false), 2000);
} catch {
@@ -156,7 +219,7 @@ export default function WorkflowEditor() {
}
}, 5000);
return () => clearTimeout(autoSaveTimer.current);
- }, [title, description, categoryId, tagsInput, isTemplate, steps, isNew, loading, id]);
+ }, [title, description, categoryId, tagsInput, isTemplate, isNew, loading, id]);
const addStep = () => {
setSteps((prev) => [
@@ -250,6 +313,26 @@ export default function WorkflowEditor() {
/>
+ {isAdmin && isNew && (
+
+
+
+
+ )}
+