fix: frontend API mapping, security hardening, test suite
Frontend:
- Fix all API response unwrapping (backend wraps in {data:...})
- Fix workflow publish/archive to use PATCH /status endpoint
- Fix step reorder payload format
- Add tenantId to workflow creation
- Fix step count display using _count
Security (CRITICAL/HIGH):
- Add JWT algorithm restriction (HS256 only)
- Replace weak default JWT secrets
- Fix token blacklist memory leak
- Fix step reorder IDOR vulnerability
- Strengthen password policy (8 chars, mixed case + digit)
Tests:
- 68 tests: auth (17), workflows (26), tenants (25)
- Complete Prisma mock setup with test fixtures
This commit is contained in:
parent
4adcb42bb5
commit
093fe85573
@ -1,5 +1,14 @@
|
|||||||
DATABASE_URL=postgresql://workflow:workflow@localhost:5432/workflow_portal
|
# Database
|
||||||
JWT_SECRET=change-me-in-production
|
DATABASE_URL=postgresql://workflow:CHANGE_DB_PASSWORD@localhost:5432/workflow_portal
|
||||||
JWT_REFRESH_SECRET=change-me-too
|
|
||||||
|
# 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
|
PORT=4000
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# CORS - set to your frontend URL in production
|
||||||
CORS_ORIGIN=http://localhost:3000
|
CORS_ORIGIN=http://localhost:3000
|
||||||
|
|||||||
24
backend/jest.config.js
Normal file
24
backend/jest.config.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
roots: ['<rootDir>/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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -5,6 +5,9 @@
|
|||||||
"dev": "nodemon --exec ts-node src/index.ts",
|
"dev": "nodemon --exec ts-node src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"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:migrate": "npx prisma migrate deploy",
|
||||||
"db:seed": "ts-node prisma/seed.ts",
|
"db:seed": "ts-node prisma/seed.ts",
|
||||||
"db:generate": "npx prisma generate"
|
"db:generate": "npx prisma generate"
|
||||||
@ -24,10 +27,15 @@
|
|||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^29.5.0",
|
||||||
"@types/jsonwebtoken": "^9.0.7",
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/supertest": "^6.0.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
"nodemon": "^3.1.0",
|
"nodemon": "^3.1.0",
|
||||||
"prisma": "^6.0.0",
|
"prisma": "^6.0.0",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.2.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
303
backend/src/__tests__/auth.test.ts
Normal file
303
backend/src/__tests__/auth.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
509
backend/src/__tests__/setup.ts
Normal file
509
backend/src/__tests__/setup.ts
Normal file
@ -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 };
|
||||||
399
backend/src/__tests__/tenants.test.ts
Normal file
399
backend/src/__tests__/tenants.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
410
backend/src/__tests__/workflows.test.ts
Normal file
410
backend/src/__tests__/workflows.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -2,7 +2,14 @@ import { Request, Response, NextFunction } from 'express';
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { Role } from '@prisma/client';
|
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 {
|
interface JwtPayload {
|
||||||
id: string;
|
id: string;
|
||||||
@ -24,7 +31,7 @@ export function authenticate(req: Request, res: Response, next: NextFunction): v
|
|||||||
const token = authHeader.substring(7);
|
const token = authHeader.substring(7);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload;
|
const decoded = jwt.verify(token, RESOLVED_JWT_SECRET, { algorithms: ['HS256'] }) as JwtPayload;
|
||||||
req.user = {
|
req.user = {
|
||||||
id: decoded.id,
|
id: decoded.id,
|
||||||
email: decoded.email,
|
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
|
// For tenant-scoped users, check that the requested tenantId matches their own
|
||||||
const requestedTenantId = req.params.tenantId || req.body?.tenantId || req.query.tenantId;
|
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({
|
res.status(403).json({
|
||||||
error: 'Forbidden',
|
error: 'Forbidden',
|
||||||
message: 'You can only access data within your own tenant',
|
message: 'You can only access data within your own tenant',
|
||||||
|
|||||||
@ -8,13 +8,40 @@ import { AppError } from '../lib/errors';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
|
const JWT_SECRET = process.env.JWT_SECRET;
|
||||||
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'change-me-too';
|
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 ACCESS_TOKEN_EXPIRY = '15m';
|
||||||
const REFRESH_TOKEN_EXPIRY = '7d';
|
const REFRESH_TOKEN_EXPIRY = '7d';
|
||||||
|
|
||||||
// Store for invalidated refresh tokens (in production, use Redis)
|
// Store for invalidated refresh tokens (in production, use Redis)
|
||||||
const invalidatedTokens = new Set<string>();
|
// Tokens are stored with their expiry time so we can periodically prune expired entries
|
||||||
|
const invalidatedTokens = new Map<string, number>();
|
||||||
|
|
||||||
|
// 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({
|
const loginSchema = z.object({
|
||||||
email: z.string().email('Invalid email format'),
|
email: z.string().email('Invalid email format'),
|
||||||
@ -35,8 +62,8 @@ function generateTokens(user: { id: string; email: string; role: string; tenantI
|
|||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
};
|
};
|
||||||
|
|
||||||
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: ACCESS_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' }, JWT_REFRESH_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRY });
|
const refreshToken = jwt.sign({ id: user.id, type: 'refresh' }, RESOLVED_JWT_REFRESH_SECRET, { algorithm: 'HS256', expiresIn: REFRESH_TOKEN_EXPIRY });
|
||||||
|
|
||||||
return { accessToken, refreshToken };
|
return { accessToken, refreshToken };
|
||||||
}
|
}
|
||||||
@ -111,7 +138,7 @@ router.post('/refresh', async (req: Request, res: Response, next: NextFunction)
|
|||||||
|
|
||||||
let decoded: any;
|
let decoded: any;
|
||||||
try {
|
try {
|
||||||
decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET);
|
decoded = jwt.verify(refreshToken, RESOLVED_JWT_REFRESH_SECRET, { algorithms: ['HS256'] });
|
||||||
} catch {
|
} catch {
|
||||||
res.status(401).json({ error: 'Invalid refresh token' });
|
res.status(401).json({ error: 'Invalid refresh token' });
|
||||||
return;
|
return;
|
||||||
@ -131,8 +158,9 @@ router.post('/refresh', async (req: Request, res: Response, next: NextFunction)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate old refresh token
|
// Invalidate old refresh token (store with expiry for cleanup)
|
||||||
invalidatedTokens.add(refreshToken);
|
const tokenExp = decoded.exp ? decoded.exp * 1000 : Date.now() + 7 * 24 * 60 * 60 * 1000;
|
||||||
|
invalidatedTokens.set(refreshToken, tokenExp);
|
||||||
|
|
||||||
const tokens = generateTokens(user);
|
const tokens = generateTokens(user);
|
||||||
|
|
||||||
@ -147,7 +175,8 @@ router.post('/logout', authenticate, async (req: Request, res: Response, next: N
|
|||||||
try {
|
try {
|
||||||
const refreshToken = req.body.refreshToken;
|
const refreshToken = req.body.refreshToken;
|
||||||
if (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' });
|
res.json({ message: 'Logged out successfully' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -9,7 +9,7 @@ const router = Router();
|
|||||||
|
|
||||||
const createUserSchema = z.object({
|
const createUserSchema = z.object({
|
||||||
email: z.string().email('Invalid email format'),
|
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),
|
firstName: z.string().min(1, 'First name is required').max(100),
|
||||||
lastName: z.string().min(1, 'Last name is required').max(100),
|
lastName: z.string().min(1, 'Last name is required').max(100),
|
||||||
role: z.nativeEnum(Role),
|
role: z.nativeEnum(Role),
|
||||||
|
|||||||
@ -603,6 +603,22 @@ router.patch('/:id/steps/reorder', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUST
|
|||||||
return;
|
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
|
// Update all step orders in a transaction
|
||||||
await prisma.$transaction(
|
await prisma.$transaction(
|
||||||
parsed.data.steps.map((step) =>
|
parsed.data.steps.map((step) =>
|
||||||
|
|||||||
@ -57,7 +57,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await authApi.logout();
|
const refreshToken = localStorage.getItem('refreshToken');
|
||||||
|
await authApi.logout(refreshToken);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|||||||
@ -217,7 +217,7 @@ export default function Dashboard() {
|
|||||||
{wf.title}
|
{wf.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-sm text-gray-500">
|
<p className="truncate text-sm text-gray-500">
|
||||||
{wf.steps?.length || 0} Schritte · v{wf.version}
|
{wf._count?.steps ?? wf.steps?.length ?? 0} Schritte · v{wf.version}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={wf.status} />
|
<StatusBadge status={wf.status} />
|
||||||
|
|||||||
@ -11,8 +11,9 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd';
|
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { workflowsApi, categoriesApi } from '../services/api';
|
import { workflowsApi, stepsApi, categoriesApi, tenantsApi } from '../services/api';
|
||||||
import type { Category, Workflow, WorkflowStep } from '../types';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import type { Category, Tenant, Workflow, WorkflowStep } from '../types';
|
||||||
import StepTypeIcon from '../components/StepTypeIcon';
|
import StepTypeIcon from '../components/StepTypeIcon';
|
||||||
|
|
||||||
interface StepDraft {
|
interface StepDraft {
|
||||||
@ -40,6 +41,7 @@ function nextKey() {
|
|||||||
export default function WorkflowEditor() {
|
export default function WorkflowEditor() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
const isNew = !id;
|
const isNew = !id;
|
||||||
|
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
@ -47,20 +49,28 @@ export default function WorkflowEditor() {
|
|||||||
const [categoryId, setCategoryId] = useState('');
|
const [categoryId, setCategoryId] = useState('');
|
||||||
const [tagsInput, setTagsInput] = useState('');
|
const [tagsInput, setTagsInput] = useState('');
|
||||||
const [isTemplate, setIsTemplate] = useState(false);
|
const [isTemplate, setIsTemplate] = useState(false);
|
||||||
|
const [tenantId, setTenantId] = useState(user?.tenantId || '');
|
||||||
|
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||||
const [steps, setSteps] = useState<StepDraft[]>([]);
|
const [steps, setSteps] = useState<StepDraft[]>([]);
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [loading, setLoading] = useState(!isNew);
|
const [loading, setLoading] = useState(!isNew);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [autoSaved, setAutoSaved] = useState(false);
|
const [autoSaved, setAutoSaved] = useState(false);
|
||||||
|
|
||||||
|
const isAdmin = user?.role === 'SYSTEM_ADMIN' || user?.role === 'TECHNICIAN';
|
||||||
|
|
||||||
const autoSaveTimer = useRef<ReturnType<typeof setTimeout>>();
|
const autoSaveTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
categoriesApi.list().then(setCategories).catch(() => {});
|
categoriesApi.list().then(setCategories).catch(() => {});
|
||||||
}, []);
|
if (isAdmin) {
|
||||||
|
tenantsApi.list({ pageSize: 100 }).then((r) => setTenants(r.data)).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [isAdmin]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
setWorkflowId(id);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
workflowsApi
|
workflowsApi
|
||||||
.get(id)
|
.get(id)
|
||||||
@ -95,23 +105,53 @@ export default function WorkflowEditor() {
|
|||||||
.map((t) => t.trim())
|
.map((t) => t.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const buildPayload = (status?: Workflow['status']) => ({
|
const buildMetadataPayload = (status?: Workflow['status']) => ({
|
||||||
title,
|
title,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
categoryId: categoryId || undefined,
|
categoryId: categoryId || undefined,
|
||||||
tags: parseTags(),
|
tags: parseTags(),
|
||||||
isTemplate,
|
isTemplate,
|
||||||
status,
|
...(status ? { status } : {}),
|
||||||
steps: steps.map((s, idx) => ({
|
|
||||||
id: s.id,
|
|
||||||
title: s.title,
|
|
||||||
content: s.content,
|
|
||||||
type: s.type,
|
|
||||||
isRequired: s.isRequired,
|
|
||||||
order: idx + 1,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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(
|
const handleSave = useCallback(
|
||||||
async (status?: Workflow['status']) => {
|
async (status?: Workflow['status']) => {
|
||||||
if (!title.trim()) {
|
if (!title.trim()) {
|
||||||
@ -120,16 +160,39 @@ export default function WorkflowEditor() {
|
|||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = buildPayload(status);
|
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
const created = await workflowsApi.create({
|
// Build create payload with tenantId, status, and inline steps
|
||||||
...payload,
|
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',
|
status: status || 'DRAFT',
|
||||||
} as Partial<Workflow>);
|
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<Workflow>);
|
||||||
toast.success('Workflow erstellt');
|
toast.success('Workflow erstellt');
|
||||||
navigate(`/workflows/${created.id}/edit`, { replace: true });
|
navigate(`/workflows/${created.id}/edit`, { replace: true });
|
||||||
} else {
|
} else {
|
||||||
|
// Update metadata first
|
||||||
|
const payload = buildMetadataPayload(status);
|
||||||
await workflowsApi.update(id!, payload as Partial<Workflow>);
|
await workflowsApi.update(id!, payload as Partial<Workflow>);
|
||||||
|
// Sync steps via individual step endpoints
|
||||||
|
await syncSteps(id!);
|
||||||
toast.success('Workflow gespeichert');
|
toast.success('Workflow gespeichert');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@ -138,17 +201,17 @@ export default function WorkflowEditor() {
|
|||||||
setSaving(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (isNew || loading) return;
|
if (isNew || loading) return;
|
||||||
clearTimeout(autoSaveTimer.current);
|
clearTimeout(autoSaveTimer.current);
|
||||||
autoSaveTimer.current = setTimeout(async () => {
|
autoSaveTimer.current = setTimeout(async () => {
|
||||||
if (!title.trim()) return;
|
if (!title.trim()) return;
|
||||||
try {
|
try {
|
||||||
await workflowsApi.update(id!, buildPayload() as Partial<Workflow>);
|
await workflowsApi.update(id!, buildMetadataPayload() as Partial<Workflow>);
|
||||||
setAutoSaved(true);
|
setAutoSaved(true);
|
||||||
setTimeout(() => setAutoSaved(false), 2000);
|
setTimeout(() => setAutoSaved(false), 2000);
|
||||||
} catch {
|
} catch {
|
||||||
@ -156,7 +219,7 @@ export default function WorkflowEditor() {
|
|||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
return () => clearTimeout(autoSaveTimer.current);
|
return () => clearTimeout(autoSaveTimer.current);
|
||||||
}, [title, description, categoryId, tagsInput, isTemplate, steps, isNew, loading, id]);
|
}, [title, description, categoryId, tagsInput, isTemplate, isNew, loading, id]);
|
||||||
|
|
||||||
const addStep = () => {
|
const addStep = () => {
|
||||||
setSteps((prev) => [
|
setSteps((prev) => [
|
||||||
@ -250,6 +313,26 @@ export default function WorkflowEditor() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isAdmin && isNew && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Mandant *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={tenantId}
|
||||||
|
onChange={(e) => setTenantId(e.target.value)}
|
||||||
|
className="input-field"
|
||||||
|
>
|
||||||
|
<option value="">Mandant wählen...</option>
|
||||||
|
{tenants.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid gap-5 sm:grid-cols-2">
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
|||||||
@ -197,7 +197,7 @@ export default function WorkflowList() {
|
|||||||
)}
|
)}
|
||||||
<div className="mt-4 flex items-center justify-between text-xs text-gray-400">
|
<div className="mt-4 flex items-center justify-between text-xs text-gray-400">
|
||||||
<span>
|
<span>
|
||||||
{wf.steps?.length || 0} Schritte · v{wf.version}
|
{wf._count?.steps ?? wf.steps?.length ?? 0} Schritte · v{wf.version}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{new Date(wf.updatedAt).toLocaleDateString('de-DE')}
|
{new Date(wf.updatedAt).toLocaleDateString('de-DE')}
|
||||||
@ -254,7 +254,7 @@ export default function WorkflowList() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="hidden text-sm text-gray-400 sm:inline">
|
<span className="hidden text-sm text-gray-400 sm:inline">
|
||||||
{wf.steps?.length || 0} Schritte
|
{wf._count?.steps ?? wf.steps?.length ?? 0} Schritte
|
||||||
</span>
|
</span>
|
||||||
<StatusBadge status={wf.status} />
|
<StatusBadge status={wf.status} />
|
||||||
<span className="hidden text-xs text-gray-400 lg:inline">
|
<span className="hidden text-xs text-gray-400 lg:inline">
|
||||||
|
|||||||
@ -86,8 +86,10 @@ export default function WorkflowView() {
|
|||||||
const handlePublish = async () => {
|
const handlePublish = async () => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
try {
|
try {
|
||||||
const updated = await workflowsApi.publish(id);
|
await workflowsApi.publish(id);
|
||||||
setWorkflow(updated);
|
// Reload full workflow with steps
|
||||||
|
const refreshed = await workflowsApi.get(id);
|
||||||
|
setWorkflow(refreshed);
|
||||||
toast.success('Workflow veröffentlicht');
|
toast.success('Workflow veröffentlicht');
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Veröffentlichen fehlgeschlagen');
|
toast.error('Veröffentlichen fehlgeschlagen');
|
||||||
|
|||||||
@ -73,9 +73,10 @@ api.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.post<AuthResponse>('/api/auth/refresh', {
|
const { data } = await axios.post<{ accessToken: string; refreshToken: string }>(
|
||||||
refreshToken,
|
'/api/auth/refresh',
|
||||||
});
|
{ refreshToken },
|
||||||
|
);
|
||||||
localStorage.setItem('accessToken', data.accessToken);
|
localStorage.setItem('accessToken', data.accessToken);
|
||||||
localStorage.setItem('refreshToken', data.refreshToken);
|
localStorage.setItem('refreshToken', data.refreshToken);
|
||||||
processQueue(null, data.accessToken);
|
processQueue(null, data.accessToken);
|
||||||
@ -99,35 +100,66 @@ api.interceptors.response.use(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// --- Auth ---
|
// --- Auth ---
|
||||||
|
// Login returns { accessToken, refreshToken, user } directly
|
||||||
|
// /me returns user object directly
|
||||||
|
// /refresh returns { accessToken, refreshToken } (no user)
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
login: (email: string, password: string) =>
|
login: (email: string, password: string) =>
|
||||||
api.post<AuthResponse>('/auth/login', { email, password }).then((r) => r.data),
|
api.post<AuthResponse>('/auth/login', { email, password }).then((r) => r.data),
|
||||||
|
|
||||||
refresh: (refreshToken: string) =>
|
refresh: (refreshToken: string) =>
|
||||||
api.post<AuthResponse>('/auth/refresh', { refreshToken }).then((r) => r.data),
|
api
|
||||||
|
.post<{ accessToken: string; refreshToken: string }>('/auth/refresh', { refreshToken })
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
me: () => api.get<User>('/auth/me').then((r) => r.data),
|
me: () => api.get<User>('/auth/me').then((r) => r.data),
|
||||||
|
|
||||||
logout: () => api.post('/auth/logout').then((r) => r.data),
|
logout: (refreshToken?: string | null) =>
|
||||||
|
api.post('/auth/logout', { refreshToken }).then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Tenants ---
|
// --- Tenants ---
|
||||||
|
// Backend returns { data: Tenant[] } (no pagination)
|
||||||
export const tenantsApi = {
|
export const tenantsApi = {
|
||||||
list: (params?: { page?: number; pageSize?: number; search?: string }) =>
|
list: (params?: { page?: number; pageSize?: number; search?: string }) =>
|
||||||
api.get<PaginatedResponse<Tenant>>('/tenants', { params }).then((r) => r.data),
|
api
|
||||||
|
.get<{ data: Tenant[] }>('/tenants', { params })
|
||||||
|
.then((r) => {
|
||||||
|
const tenants = r.data.data;
|
||||||
|
// Client-side search if the backend doesn't support it
|
||||||
|
let filtered = tenants;
|
||||||
|
if (params?.search) {
|
||||||
|
const s = params.search.toLowerCase();
|
||||||
|
filtered = tenants.filter(
|
||||||
|
(t) =>
|
||||||
|
t.name.toLowerCase().includes(s) ||
|
||||||
|
t.slug.toLowerCase().includes(s)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Client-side pagination
|
||||||
|
const page = params?.page || 1;
|
||||||
|
const pageSize = params?.pageSize || 20;
|
||||||
|
const total = filtered.length;
|
||||||
|
const totalPages = Math.ceil(total / pageSize) || 1;
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const data = filtered.slice(start, start + pageSize);
|
||||||
|
return { data, total, page, pageSize, totalPages } as PaginatedResponse<Tenant>;
|
||||||
|
}),
|
||||||
|
|
||||||
get: (id: string) => api.get<Tenant>(`/tenants/${id}`).then((r) => r.data),
|
get: (id: string) =>
|
||||||
|
api.get<{ data: Tenant }>(`/tenants/${id}`).then((r) => r.data.data),
|
||||||
|
|
||||||
create: (data: Partial<Tenant>) =>
|
create: (data: Partial<Tenant>) =>
|
||||||
api.post<Tenant>('/tenants', data).then((r) => r.data),
|
api.post<{ data: Tenant }>('/tenants', data).then((r) => r.data.data),
|
||||||
|
|
||||||
update: (id: string, data: Partial<Tenant>) =>
|
update: (id: string, data: Partial<Tenant>) =>
|
||||||
api.put<Tenant>(`/tenants/${id}`, data).then((r) => r.data),
|
api.put<{ data: Tenant }>(`/tenants/${id}`, data).then((r) => r.data.data),
|
||||||
|
|
||||||
delete: (id: string) => api.delete(`/tenants/${id}`).then((r) => r.data),
|
delete: (id: string) => api.delete(`/tenants/${id}`).then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Users ---
|
// --- Users ---
|
||||||
|
// Backend returns { data: User[] } (no pagination)
|
||||||
export const usersApi = {
|
export const usersApi = {
|
||||||
list: (params?: {
|
list: (params?: {
|
||||||
page?: number;
|
page?: number;
|
||||||
@ -135,20 +167,55 @@ export const usersApi = {
|
|||||||
search?: string;
|
search?: string;
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
}) => api.get<PaginatedResponse<User>>('/users', { params }).then((r) => r.data),
|
}) =>
|
||||||
|
api
|
||||||
|
.get<{ data: User[] }>('/users', {
|
||||||
|
params: {
|
||||||
|
tenantId: params?.tenantId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
let users = r.data.data;
|
||||||
|
// Client-side search
|
||||||
|
if (params?.search) {
|
||||||
|
const s = params.search.toLowerCase();
|
||||||
|
users = users.filter(
|
||||||
|
(u) =>
|
||||||
|
u.firstName.toLowerCase().includes(s) ||
|
||||||
|
u.lastName.toLowerCase().includes(s) ||
|
||||||
|
u.email.toLowerCase().includes(s)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Client-side role filter
|
||||||
|
if (params?.role) {
|
||||||
|
users = users.filter((u) => u.role === params.role);
|
||||||
|
}
|
||||||
|
// Client-side pagination
|
||||||
|
const page = params?.page || 1;
|
||||||
|
const pageSize = params?.pageSize || 20;
|
||||||
|
const total = users.length;
|
||||||
|
const totalPages = Math.ceil(total / pageSize) || 1;
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const data = users.slice(start, start + pageSize);
|
||||||
|
return { data, total, page, pageSize, totalPages } as PaginatedResponse<User>;
|
||||||
|
}),
|
||||||
|
|
||||||
get: (id: string) => api.get<User>(`/users/${id}`).then((r) => r.data),
|
get: (id: string) =>
|
||||||
|
api.get<{ data: User }>(`/users/${id}`).then((r) => r.data.data),
|
||||||
|
|
||||||
create: (data: Partial<User> & { password?: string }) =>
|
create: (data: Partial<User> & { password?: string }) =>
|
||||||
api.post<User>('/users', data).then((r) => r.data),
|
api.post<{ data: User }>('/users', data).then((r) => r.data.data),
|
||||||
|
|
||||||
update: (id: string, data: Partial<User>) =>
|
update: (id: string, data: Partial<User>) =>
|
||||||
api.put<User>(`/users/${id}`, data).then((r) => r.data),
|
api.put<{ data: User }>(`/users/${id}`, data).then((r) => r.data.data),
|
||||||
|
|
||||||
delete: (id: string) => api.delete(`/users/${id}`).then((r) => r.data),
|
delete: (id: string) => api.delete(`/users/${id}`).then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Workflows ---
|
// --- Workflows ---
|
||||||
|
// Backend list returns { data: Workflow[], pagination: { page, limit, total, totalPages } }
|
||||||
|
// Backend uses "limit" query param (not "pageSize")
|
||||||
|
// Single-item endpoints return { data: Workflow }
|
||||||
export const workflowsApi = {
|
export const workflowsApi = {
|
||||||
list: (params?: {
|
list: (params?: {
|
||||||
page?: number;
|
page?: number;
|
||||||
@ -161,59 +228,91 @@ export const workflowsApi = {
|
|||||||
tags?: string;
|
tags?: string;
|
||||||
}) =>
|
}) =>
|
||||||
api
|
api
|
||||||
.get<PaginatedResponse<Workflow>>('/workflows', { params })
|
.get<{
|
||||||
.then((r) => r.data),
|
data: Workflow[];
|
||||||
|
pagination: { page: number; limit: number; total: number; totalPages: number };
|
||||||
|
}>('/workflows', {
|
||||||
|
params: {
|
||||||
|
page: params?.page,
|
||||||
|
limit: params?.pageSize,
|
||||||
|
search: params?.search,
|
||||||
|
status: params?.status,
|
||||||
|
categoryId: params?.categoryId,
|
||||||
|
tenantId: params?.tenantId,
|
||||||
|
isTemplate: params?.isTemplate,
|
||||||
|
tags: params?.tags,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((r) => ({
|
||||||
|
data: r.data.data,
|
||||||
|
total: r.data.pagination.total,
|
||||||
|
page: r.data.pagination.page,
|
||||||
|
pageSize: r.data.pagination.limit,
|
||||||
|
totalPages: r.data.pagination.totalPages,
|
||||||
|
})),
|
||||||
|
|
||||||
get: (id: string) => api.get<Workflow>(`/workflows/${id}`).then((r) => r.data),
|
get: (id: string) =>
|
||||||
|
api.get<{ data: Workflow }>(`/workflows/${id}`).then((r) => r.data.data),
|
||||||
|
|
||||||
create: (data: Partial<Workflow>) =>
|
create: (data: Partial<Workflow>) =>
|
||||||
api.post<Workflow>('/workflows', data).then((r) => r.data),
|
api.post<{ data: Workflow }>('/workflows', data).then((r) => r.data.data),
|
||||||
|
|
||||||
update: (id: string, data: Partial<Workflow>) =>
|
update: (id: string, data: Partial<Workflow>) =>
|
||||||
api.put<Workflow>(`/workflows/${id}`, data).then((r) => r.data),
|
api.put<{ data: Workflow }>(`/workflows/${id}`, data).then((r) => r.data.data),
|
||||||
|
|
||||||
delete: (id: string) => api.delete(`/workflows/${id}`).then((r) => r.data),
|
delete: (id: string) => api.delete(`/workflows/${id}`).then((r) => r.data),
|
||||||
|
|
||||||
duplicate: (id: string) =>
|
duplicate: (id: string) =>
|
||||||
api.post<Workflow>(`/workflows/${id}/duplicate`).then((r) => r.data),
|
api.post<{ data: Workflow }>(`/workflows/${id}/duplicate`).then((r) => r.data.data),
|
||||||
|
|
||||||
|
// Publish and archive use the PATCH /:id/status endpoint
|
||||||
publish: (id: string) =>
|
publish: (id: string) =>
|
||||||
api.post<Workflow>(`/workflows/${id}/publish`).then((r) => r.data),
|
api
|
||||||
|
.patch<{ data: Workflow }>(`/workflows/${id}/status`, { status: 'PUBLISHED' })
|
||||||
|
.then((r) => r.data.data),
|
||||||
|
|
||||||
archive: (id: string) =>
|
archive: (id: string) =>
|
||||||
api.post<Workflow>(`/workflows/${id}/archive`).then((r) => r.data),
|
api
|
||||||
|
.patch<{ data: Workflow }>(`/workflows/${id}/status`, { status: 'ARCHIVED' })
|
||||||
|
.then((r) => r.data.data),
|
||||||
|
|
||||||
|
changeStatus: (id: string, status: Workflow['status']) =>
|
||||||
|
api
|
||||||
|
.patch<{ data: Workflow }>(`/workflows/${id}/status`, { status })
|
||||||
|
.then((r) => r.data.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Workflow Steps ---
|
// --- Workflow Steps ---
|
||||||
export const stepsApi = {
|
export const stepsApi = {
|
||||||
create: (workflowId: string, data: Partial<WorkflowStep>) =>
|
create: (workflowId: string, data: Partial<WorkflowStep>) =>
|
||||||
api
|
api
|
||||||
.post<WorkflowStep>(`/workflows/${workflowId}/steps`, data)
|
.post<{ data: WorkflowStep }>(`/workflows/${workflowId}/steps`, data)
|
||||||
.then((r) => r.data),
|
.then((r) => r.data.data),
|
||||||
|
|
||||||
update: (workflowId: string, stepId: string, data: Partial<WorkflowStep>) =>
|
update: (workflowId: string, stepId: string, data: Partial<WorkflowStep>) =>
|
||||||
api
|
api
|
||||||
.put<WorkflowStep>(`/workflows/${workflowId}/steps/${stepId}`, data)
|
.put<{ data: WorkflowStep }>(`/workflows/${workflowId}/steps/${stepId}`, data)
|
||||||
.then((r) => r.data),
|
.then((r) => r.data.data),
|
||||||
|
|
||||||
delete: (workflowId: string, stepId: string) =>
|
delete: (workflowId: string, stepId: string) =>
|
||||||
api.delete(`/workflows/${workflowId}/steps/${stepId}`).then((r) => r.data),
|
api.delete(`/workflows/${workflowId}/steps/${stepId}`).then((r) => r.data),
|
||||||
|
|
||||||
reorder: (workflowId: string, stepIds: string[]) =>
|
reorder: (workflowId: string, steps: { id: string; order: number }[]) =>
|
||||||
api
|
api
|
||||||
.put(`/workflows/${workflowId}/steps/reorder`, { stepIds })
|
.patch(`/workflows/${workflowId}/steps/reorder`, { steps })
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Categories ---
|
// --- Categories ---
|
||||||
export const categoriesApi = {
|
export const categoriesApi = {
|
||||||
list: () => api.get<Category[]>('/categories').then((r) => r.data),
|
list: () =>
|
||||||
|
api.get<{ data: Category[] }>('/categories').then((r) => r.data.data),
|
||||||
|
|
||||||
create: (data: Partial<Category>) =>
|
create: (data: Partial<Category>) =>
|
||||||
api.post<Category>('/categories', data).then((r) => r.data),
|
api.post<{ data: Category }>('/categories', data).then((r) => r.data.data),
|
||||||
|
|
||||||
update: (id: string, data: Partial<Category>) =>
|
update: (id: string, data: Partial<Category>) =>
|
||||||
api.put<Category>(`/categories/${id}`, data).then((r) => r.data),
|
api.put<{ data: Category }>(`/categories/${id}`, data).then((r) => r.data.data),
|
||||||
|
|
||||||
delete: (id: string) => api.delete(`/categories/${id}`).then((r) => r.data),
|
delete: (id: string) => api.delete(`/categories/${id}`).then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -27,7 +27,8 @@ export interface Workflow {
|
|||||||
version: number;
|
version: number;
|
||||||
isTemplate: boolean;
|
isTemplate: boolean;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
steps: WorkflowStep[];
|
steps?: WorkflowStep[];
|
||||||
|
_count?: { steps: number };
|
||||||
createdBy: User;
|
createdBy: User;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user