RBAC: Phase 1-3, Total Salary fix, employee creation fix, permission groups, backup script
Made-with: Cursor
This commit is contained in:
@@ -9,13 +9,13 @@
|
||||
"start": "node dist/server.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "ts-node prisma/seed.ts",
|
||||
"prisma:seed": "node prisma/seed.js",
|
||||
"prisma:studio": "prisma studio",
|
||||
"db:clean-and-seed": "node prisma/clean-and-seed.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
"seed": "node prisma/seed.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.8.0",
|
||||
|
||||
@@ -83,7 +83,7 @@ async function main() {
|
||||
|
||||
console.log('\n🌱 Running seed...\n');
|
||||
const backendDir = path.resolve(__dirname, '..');
|
||||
execSync('node prisma/seed-prod.js', {
|
||||
execSync('node prisma/seed.js', {
|
||||
stdio: 'inherit',
|
||||
cwd: backendDir,
|
||||
env: process.env,
|
||||
|
||||
12
backend/prisma/ensure-gm-permissions.sql
Normal file
12
backend/prisma/ensure-gm-permissions.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- Ensure GM has all module permissions
|
||||
-- Run: npx prisma db execute --file prisma/ensure-gm-permissions.sql
|
||||
|
||||
INSERT INTO position_permissions (id, "positionId", module, resource, actions, "createdAt", "updatedAt")
|
||||
SELECT gen_random_uuid(), p.id, m.module, '*', '["*"]', NOW(), NOW()
|
||||
FROM positions p
|
||||
CROSS JOIN (VALUES ('contacts'), ('crm'), ('inventory'), ('projects'), ('hr'), ('marketing'), ('admin')) AS m(module)
|
||||
WHERE p.code = 'GM'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM position_permissions pp
|
||||
WHERE pp."positionId" = p.id AND pp.module = m.module AND pp.resource = '*'
|
||||
);
|
||||
@@ -0,0 +1,59 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "roles" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"nameAr" TEXT,
|
||||
"description" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "role_permissions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"roleId" TEXT NOT NULL,
|
||||
"module" TEXT NOT NULL,
|
||||
"resource" TEXT NOT NULL,
|
||||
"actions" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_roles" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"roleId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "roles_name_key" ON "roles"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "role_permissions_roleId_module_resource_key" ON "role_permissions"("roleId", "module", "resource");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_roles_userId_idx" ON "user_roles"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_roles_roleId_idx" ON "user_roles"("roleId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_roles_userId_roleId_key" ON "user_roles"("userId", "roleId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -69,10 +69,59 @@ model User {
|
||||
assignedTasks Task[]
|
||||
projectMembers ProjectMember[]
|
||||
campaigns Campaign[]
|
||||
userRoles UserRole[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
// Optional roles - user can belong to multiple permission groups (Phase 3 multi-group)
|
||||
model Role {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
nameAr String?
|
||||
description String?
|
||||
isActive Boolean @default(true)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
permissions RolePermission[]
|
||||
userRoles UserRole[]
|
||||
|
||||
@@map("roles")
|
||||
}
|
||||
|
||||
model RolePermission {
|
||||
id String @id @default(uuid())
|
||||
roleId String
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
module String
|
||||
resource String
|
||||
actions Json // ["read", "create", "update", "delete", ...]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([roleId, module, resource])
|
||||
@@map("role_permissions")
|
||||
}
|
||||
|
||||
model UserRole {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
roleId String
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([userId, roleId])
|
||||
@@index([userId])
|
||||
@@index([roleId])
|
||||
@@map("user_roles")
|
||||
}
|
||||
|
||||
model Employee {
|
||||
id String @id @default(uuid())
|
||||
uniqueEmployeeId String @unique // رقم الموظف الموحد
|
||||
|
||||
146
backend/prisma/seed.js
Normal file
146
backend/prisma/seed.js
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Minimal seed - System Administrator only.
|
||||
* Run with: node prisma/seed.js
|
||||
*/
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 Starting database seeding (minimal - System Administrator only)...');
|
||||
|
||||
const adminDept = await prisma.department.create({
|
||||
data: {
|
||||
name: 'Administration',
|
||||
nameAr: 'الإدارة',
|
||||
code: 'ADMIN',
|
||||
description: 'System administration and configuration',
|
||||
},
|
||||
});
|
||||
|
||||
const sysAdminPosition = await prisma.position.create({
|
||||
data: {
|
||||
title: 'System Administrator',
|
||||
titleAr: 'مدير النظام',
|
||||
code: 'SYS_ADMIN',
|
||||
departmentId: adminDept.id,
|
||||
level: 1,
|
||||
description: 'Full system access - configure and manage all modules',
|
||||
},
|
||||
});
|
||||
|
||||
const modules = ['contacts', 'crm', 'inventory', 'projects', 'hr', 'marketing', 'admin'];
|
||||
for (const module of modules) {
|
||||
await prisma.positionPermission.create({
|
||||
data: {
|
||||
positionId: sysAdminPosition.id,
|
||||
module,
|
||||
resource: '*',
|
||||
actions: ['*'],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Create Sales Department and restricted positions
|
||||
const salesDept = await prisma.department.create({
|
||||
data: {
|
||||
name: 'Sales',
|
||||
nameAr: 'المبيعات',
|
||||
code: 'SALES',
|
||||
description: 'Sales and business development',
|
||||
},
|
||||
});
|
||||
|
||||
const salesRepPosition = await prisma.position.create({
|
||||
data: {
|
||||
title: 'Sales Representative',
|
||||
titleAr: 'مندوب مبيعات',
|
||||
code: 'SALES_REP',
|
||||
departmentId: salesDept.id,
|
||||
level: 3,
|
||||
description: 'Limited access - Contacts and CRM deals',
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.positionPermission.createMany({
|
||||
data: [
|
||||
{ positionId: salesRepPosition.id, module: 'contacts', resource: '*', actions: ['read', 'create', 'update'] },
|
||||
{ positionId: salesRepPosition.id, module: 'crm', resource: 'deals', actions: ['read', 'create', 'update'] },
|
||||
],
|
||||
});
|
||||
|
||||
const accountantPosition = await prisma.position.create({
|
||||
data: {
|
||||
title: 'Accountant',
|
||||
titleAr: 'محاسب',
|
||||
code: 'ACCOUNTANT',
|
||||
departmentId: adminDept.id,
|
||||
level: 2,
|
||||
description: 'HR read, inventory read, contacts read',
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.positionPermission.createMany({
|
||||
data: [
|
||||
{ positionId: accountantPosition.id, module: 'contacts', resource: '*', actions: ['read'] },
|
||||
{ positionId: accountantPosition.id, module: 'crm', resource: '*', actions: ['read'] },
|
||||
{ positionId: accountantPosition.id, module: 'inventory', resource: '*', actions: ['read'] },
|
||||
{ positionId: accountantPosition.id, module: 'hr', resource: '*', actions: ['read'] },
|
||||
],
|
||||
});
|
||||
|
||||
console.log('✅ Created position and permissions');
|
||||
|
||||
const sysAdminEmployee = await prisma.employee.create({
|
||||
data: {
|
||||
uniqueEmployeeId: 'SYS-001',
|
||||
firstName: 'System',
|
||||
lastName: 'Administrator',
|
||||
firstNameAr: 'مدير',
|
||||
lastNameAr: 'النظام',
|
||||
email: 'admin@system.local',
|
||||
mobile: '+966500000000',
|
||||
dateOfBirth: new Date('1990-01-01'),
|
||||
gender: 'MALE',
|
||||
nationality: 'Saudi',
|
||||
employmentType: 'Full-time',
|
||||
contractType: 'Unlimited',
|
||||
hireDate: new Date(),
|
||||
departmentId: adminDept.id,
|
||||
positionId: sysAdminPosition.id,
|
||||
basicSalary: 0,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
const hashedPassword = await bcrypt.hash('Admin@123', 10);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: 'admin@system.local',
|
||||
username: 'admin',
|
||||
password: hashedPassword,
|
||||
employeeId: sysAdminEmployee.id,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Created System Administrator');
|
||||
console.log('\n🎉 Database seeding completed!\n');
|
||||
console.log('📋 System Administrator:');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log(' Email: admin@system.local');
|
||||
console.log(' Username: admin');
|
||||
console.log(' Password: Admin@123');
|
||||
console.log(' Access: Full system access (all modules)');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ Error seeding database:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -4,58 +4,50 @@ import bcrypt from 'bcryptjs';
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 Starting database seeding...');
|
||||
console.log('🌱 Starting database seeding (minimal - System Administrator only)...');
|
||||
|
||||
// Create Departments
|
||||
// Create Administration Department
|
||||
const adminDept = await prisma.department.create({
|
||||
data: {
|
||||
name: 'Administration',
|
||||
nameAr: 'الإدارة',
|
||||
code: 'ADMIN',
|
||||
description: 'System administration and configuration',
|
||||
},
|
||||
});
|
||||
|
||||
// Create System Administrator Position
|
||||
const sysAdminPosition = await prisma.position.create({
|
||||
data: {
|
||||
title: 'System Administrator',
|
||||
titleAr: 'مدير النظام',
|
||||
code: 'SYS_ADMIN',
|
||||
departmentId: adminDept.id,
|
||||
level: 1,
|
||||
description: 'Full system access - configure and manage all modules',
|
||||
},
|
||||
});
|
||||
|
||||
// Create full permissions for all modules
|
||||
const modules = ['contacts', 'crm', 'inventory', 'projects', 'hr', 'marketing', 'admin'];
|
||||
for (const module of modules) {
|
||||
await prisma.positionPermission.create({
|
||||
data: {
|
||||
positionId: sysAdminPosition.id,
|
||||
module,
|
||||
resource: '*',
|
||||
actions: ['*'],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Create Sales Department and restricted positions
|
||||
const salesDept = await prisma.department.create({
|
||||
data: {
|
||||
name: 'Sales Department',
|
||||
nameAr: 'قسم المبيعات',
|
||||
name: 'Sales',
|
||||
nameAr: 'المبيعات',
|
||||
code: 'SALES',
|
||||
description: 'Sales and Business Development',
|
||||
},
|
||||
});
|
||||
|
||||
const itDept = await prisma.department.create({
|
||||
data: {
|
||||
name: 'IT Department',
|
||||
nameAr: 'قسم تقنية المعلومات',
|
||||
code: 'IT',
|
||||
description: 'Information Technology',
|
||||
},
|
||||
});
|
||||
|
||||
const hrDept = await prisma.department.create({
|
||||
data: {
|
||||
name: 'HR Department',
|
||||
nameAr: 'قسم الموارد البشرية',
|
||||
code: 'HR',
|
||||
description: 'Human Resources',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Created departments');
|
||||
|
||||
// Create Positions
|
||||
const gmPosition = await prisma.position.create({
|
||||
data: {
|
||||
title: 'General Manager',
|
||||
titleAr: 'المدير العام',
|
||||
code: 'GM',
|
||||
departmentId: salesDept.id,
|
||||
level: 1,
|
||||
description: 'Chief Executive - Full Access',
|
||||
},
|
||||
});
|
||||
|
||||
const salesManagerPosition = await prisma.position.create({
|
||||
data: {
|
||||
title: 'Sales Manager',
|
||||
titleAr: 'مدير المبيعات',
|
||||
code: 'SALES_MGR',
|
||||
departmentId: salesDept.id,
|
||||
level: 2,
|
||||
description: 'Sales Department Manager',
|
||||
description: 'Sales and business development',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -66,286 +58,83 @@ async function main() {
|
||||
code: 'SALES_REP',
|
||||
departmentId: salesDept.id,
|
||||
level: 3,
|
||||
description: 'Sales Representative',
|
||||
description: 'Limited access - Contacts and CRM deals',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Created positions');
|
||||
await prisma.positionPermission.createMany({
|
||||
data: [
|
||||
{ positionId: salesRepPosition.id, module: 'contacts', resource: '*', actions: ['read', 'create', 'update'] },
|
||||
{ positionId: salesRepPosition.id, module: 'crm', resource: 'deals', actions: ['read', 'create', 'update'] },
|
||||
],
|
||||
});
|
||||
|
||||
// Create Permissions for GM (Full Access)
|
||||
const modules = ['contacts', 'crm', 'inventory', 'projects', 'hr', 'marketing'];
|
||||
const resources = ['*'];
|
||||
const actions = ['*'];
|
||||
|
||||
for (const module of modules) {
|
||||
await prisma.positionPermission.create({
|
||||
data: {
|
||||
positionId: gmPosition.id,
|
||||
module,
|
||||
resource: resources[0],
|
||||
actions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Admin permission for GM
|
||||
await prisma.positionPermission.create({
|
||||
const accountantPosition = await prisma.position.create({
|
||||
data: {
|
||||
positionId: gmPosition.id,
|
||||
module: 'admin',
|
||||
resource: '*',
|
||||
actions: ['*'],
|
||||
title: 'Accountant',
|
||||
titleAr: 'محاسب',
|
||||
code: 'ACCOUNTANT',
|
||||
departmentId: adminDept.id,
|
||||
level: 2,
|
||||
description: 'HR read, inventory read, contacts read',
|
||||
},
|
||||
});
|
||||
|
||||
// Create Permissions for Sales Manager
|
||||
await prisma.positionPermission.createMany({
|
||||
data: [
|
||||
{
|
||||
positionId: salesManagerPosition.id,
|
||||
module: 'contacts',
|
||||
resource: 'contacts',
|
||||
actions: ['create', 'read', 'update', 'merge'],
|
||||
},
|
||||
{
|
||||
positionId: salesManagerPosition.id,
|
||||
module: 'crm',
|
||||
resource: 'deals',
|
||||
actions: ['create', 'read', 'update', 'approve'],
|
||||
},
|
||||
{
|
||||
positionId: salesManagerPosition.id,
|
||||
module: 'crm',
|
||||
resource: 'quotes',
|
||||
actions: ['create', 'read', 'update', 'approve'],
|
||||
},
|
||||
{ positionId: accountantPosition.id, module: 'contacts', resource: '*', actions: ['read'] },
|
||||
{ positionId: accountantPosition.id, module: 'crm', resource: '*', actions: ['read'] },
|
||||
{ positionId: accountantPosition.id, module: 'inventory', resource: '*', actions: ['read'] },
|
||||
{ positionId: accountantPosition.id, module: 'hr', resource: '*', actions: ['read'] },
|
||||
],
|
||||
});
|
||||
|
||||
// Create Permissions for Sales Rep
|
||||
await prisma.positionPermission.createMany({
|
||||
data: [
|
||||
{
|
||||
positionId: salesRepPosition.id,
|
||||
module: 'contacts',
|
||||
resource: 'contacts',
|
||||
actions: ['create', 'read', 'update'],
|
||||
},
|
||||
{
|
||||
positionId: salesRepPosition.id,
|
||||
module: 'crm',
|
||||
resource: 'deals',
|
||||
actions: ['create', 'read', 'update'],
|
||||
},
|
||||
{
|
||||
positionId: salesRepPosition.id,
|
||||
module: 'crm',
|
||||
resource: 'quotes',
|
||||
actions: ['create', 'read'],
|
||||
},
|
||||
],
|
||||
});
|
||||
console.log('✅ Created position and permissions');
|
||||
|
||||
console.log('✅ Created permissions');
|
||||
|
||||
// Create Employees
|
||||
const gmEmployee = await prisma.employee.create({
|
||||
// Create minimal Employee for System Administrator
|
||||
const sysAdminEmployee = await prisma.employee.create({
|
||||
data: {
|
||||
uniqueEmployeeId: 'EMP-2024-0001',
|
||||
firstName: 'Ahmed',
|
||||
lastName: 'Al-Mutairi',
|
||||
firstNameAr: 'أحمد',
|
||||
lastNameAr: 'المطيري',
|
||||
email: 'gm@atmata.com',
|
||||
mobile: '+966500000001',
|
||||
dateOfBirth: new Date('1980-01-01'),
|
||||
uniqueEmployeeId: 'SYS-001',
|
||||
firstName: 'System',
|
||||
lastName: 'Administrator',
|
||||
firstNameAr: 'مدير',
|
||||
lastNameAr: 'النظام',
|
||||
email: 'admin@system.local',
|
||||
mobile: '+966500000000',
|
||||
dateOfBirth: new Date('1990-01-01'),
|
||||
gender: 'MALE',
|
||||
nationality: 'Saudi',
|
||||
employmentType: 'Full-time',
|
||||
contractType: 'Unlimited',
|
||||
hireDate: new Date('2020-01-01'),
|
||||
departmentId: salesDept.id,
|
||||
positionId: gmPosition.id,
|
||||
basicSalary: 50000,
|
||||
hireDate: new Date(),
|
||||
departmentId: adminDept.id,
|
||||
positionId: sysAdminPosition.id,
|
||||
basicSalary: 0,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
const salesManagerEmployee = await prisma.employee.create({
|
||||
data: {
|
||||
uniqueEmployeeId: 'EMP-2024-0002',
|
||||
firstName: 'Fatima',
|
||||
lastName: 'Al-Zahrani',
|
||||
firstNameAr: 'فاطمة',
|
||||
lastNameAr: 'الزهراني',
|
||||
email: 'sales.manager@atmata.com',
|
||||
mobile: '+966500000002',
|
||||
dateOfBirth: new Date('1985-05-15'),
|
||||
gender: 'FEMALE',
|
||||
nationality: 'Saudi',
|
||||
employmentType: 'Full-time',
|
||||
contractType: 'Unlimited',
|
||||
hireDate: new Date('2021-06-01'),
|
||||
departmentId: salesDept.id,
|
||||
positionId: salesManagerPosition.id,
|
||||
reportingToId: gmEmployee.id,
|
||||
basicSalary: 25000,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
const salesRepEmployee = await prisma.employee.create({
|
||||
data: {
|
||||
uniqueEmployeeId: 'EMP-2024-0003',
|
||||
firstName: 'Mohammed',
|
||||
lastName: 'Al-Qahtani',
|
||||
firstNameAr: 'محمد',
|
||||
lastNameAr: 'القحطاني',
|
||||
email: 'sales.rep@atmata.com',
|
||||
mobile: '+966500000003',
|
||||
dateOfBirth: new Date('1992-08-20'),
|
||||
gender: 'MALE',
|
||||
nationality: 'Saudi',
|
||||
employmentType: 'Full-time',
|
||||
contractType: 'Fixed',
|
||||
hireDate: new Date('2023-01-15'),
|
||||
departmentId: salesDept.id,
|
||||
positionId: salesRepPosition.id,
|
||||
reportingToId: salesManagerEmployee.id,
|
||||
basicSalary: 12000,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Created employees');
|
||||
|
||||
// Create Users
|
||||
// Create System Administrator User
|
||||
const hashedPassword = await bcrypt.hash('Admin@123', 10);
|
||||
|
||||
const gmUser = await prisma.user.create({
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: 'gm@atmata.com',
|
||||
email: 'admin@system.local',
|
||||
username: 'admin',
|
||||
password: hashedPassword,
|
||||
employeeId: gmEmployee.id,
|
||||
employeeId: sysAdminEmployee.id,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const salesManagerUser = await prisma.user.create({
|
||||
data: {
|
||||
email: 'sales.manager@atmata.com',
|
||||
username: 'salesmanager',
|
||||
password: hashedPassword,
|
||||
employeeId: salesManagerEmployee.id,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
console.log('✅ Created System Administrator');
|
||||
|
||||
const salesRepUser = await prisma.user.create({
|
||||
data: {
|
||||
email: 'sales.rep@atmata.com',
|
||||
username: 'salesrep',
|
||||
password: hashedPassword,
|
||||
employeeId: salesRepEmployee.id,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Created users');
|
||||
|
||||
// Create Contact Categories
|
||||
await prisma.contactCategory.createMany({
|
||||
data: [
|
||||
{ name: 'Customer', nameAr: 'عميل', description: 'Paying customers' },
|
||||
{ name: 'Supplier', nameAr: 'مورد', description: 'Product/Service suppliers' },
|
||||
{ name: 'Partner', nameAr: 'شريك', description: 'Business partners' },
|
||||
{ name: 'Lead', nameAr: 'عميل محتمل', description: 'Potential customers' },
|
||||
{ name: 'Company Employee', nameAr: 'موظف الشركة', description: 'Internal company staff' },
|
||||
],
|
||||
});
|
||||
|
||||
console.log('✅ Created contact categories');
|
||||
|
||||
// Create Product Categories
|
||||
await prisma.productCategory.createMany({
|
||||
data: [
|
||||
{ name: 'Electronics', nameAr: 'إلكترونيات', code: 'ELEC' },
|
||||
{ name: 'Software', nameAr: 'برمجيات', code: 'SOFT' },
|
||||
{ name: 'Services', nameAr: 'خدمات', code: 'SERV' },
|
||||
],
|
||||
});
|
||||
|
||||
console.log('✅ Created product categories');
|
||||
|
||||
// Create Pipelines
|
||||
await prisma.pipeline.create({
|
||||
data: {
|
||||
name: 'B2B Sales Pipeline',
|
||||
nameAr: 'مسار مبيعات الشركات',
|
||||
structure: 'B2B',
|
||||
stages: [
|
||||
{ name: 'OPEN', nameAr: 'مفتوحة', order: 1 },
|
||||
{ name: 'QUALIFIED', nameAr: 'مؤهلة', order: 2 },
|
||||
{ name: 'NEGOTIATION', nameAr: 'تفاوض', order: 3 },
|
||||
{ name: 'PROPOSAL', nameAr: 'عرض سعر', order: 4 },
|
||||
{ name: 'WON', nameAr: 'فازت', order: 5 },
|
||||
{ name: 'LOST', nameAr: 'خسرت', order: 6 },
|
||||
],
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.pipeline.create({
|
||||
data: {
|
||||
name: 'B2C Sales Pipeline',
|
||||
nameAr: 'مسار مبيعات الأفراد',
|
||||
structure: 'B2C',
|
||||
stages: [
|
||||
{ name: 'LEAD', nameAr: 'عميل محتمل', order: 1 },
|
||||
{ name: 'CONTACTED', nameAr: 'تم التواصل', order: 2 },
|
||||
{ name: 'QUALIFIED', nameAr: 'مؤهل', order: 3 },
|
||||
{ name: 'WON', nameAr: 'بيع', order: 4 },
|
||||
{ name: 'LOST', nameAr: 'خسارة', order: 5 },
|
||||
],
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Created pipelines');
|
||||
|
||||
// Create sample warehouse
|
||||
await prisma.warehouse.create({
|
||||
data: {
|
||||
code: 'WH-MAIN',
|
||||
name: 'Main Warehouse',
|
||||
nameAr: 'المستودع الرئيسي',
|
||||
type: 'MAIN',
|
||||
city: 'Riyadh',
|
||||
country: 'Saudi Arabia',
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Created warehouse');
|
||||
|
||||
console.log('\n🎉 Database seeding completed successfully!\n');
|
||||
console.log('📋 Default Users Created:');
|
||||
console.log('\n🎉 Database seeding completed!\n');
|
||||
console.log('📋 System Administrator:');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('1. General Manager');
|
||||
console.log(' Email: gm@atmata.com');
|
||||
console.log(' Email: admin@system.local');
|
||||
console.log(' Username: admin');
|
||||
console.log(' Password: Admin@123');
|
||||
console.log(' Access: Full System Access');
|
||||
console.log('');
|
||||
console.log('2. Sales Manager');
|
||||
console.log(' Email: sales.manager@atmata.com');
|
||||
console.log(' Password: Admin@123');
|
||||
console.log(' Access: Contacts, CRM with approvals');
|
||||
console.log('');
|
||||
console.log('3. Sales Representative');
|
||||
console.log(' Email: sales.rep@atmata.com');
|
||||
console.log(' Password: Admin@123');
|
||||
console.log(' Access: Basic Contacts and CRM');
|
||||
console.log(' Access: Full system access (all modules)');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
}
|
||||
|
||||
@@ -357,4 +146,3 @@ main()
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
|
||||
51
backend/scripts/ensure-gm-permissions.ts
Normal file
51
backend/scripts/ensure-gm-permissions.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Ensure GM position has all module permissions.
|
||||
* Adds any missing permissions for: contacts, crm, inventory, projects, hr, marketing, admin
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const GM_MODULES = ['contacts', 'crm', 'inventory', 'projects', 'hr', 'marketing', 'admin'];
|
||||
|
||||
async function main() {
|
||||
const gmPosition = await prisma.position.findFirst({ where: { code: 'GM' } });
|
||||
if (!gmPosition) {
|
||||
console.log('GM position not found.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const existing = await prisma.positionPermission.findMany({
|
||||
where: { positionId: gmPosition.id },
|
||||
select: { module: true },
|
||||
});
|
||||
const existingModules = new Set(existing.map((p) => p.module));
|
||||
|
||||
let added = 0;
|
||||
for (const module of GM_MODULES) {
|
||||
if (existingModules.has(module)) continue;
|
||||
await prisma.positionPermission.create({
|
||||
data: {
|
||||
positionId: gmPosition.id,
|
||||
module,
|
||||
resource: '*',
|
||||
actions: ['*'],
|
||||
},
|
||||
});
|
||||
console.log(`Added permission: ${module}`);
|
||||
added++;
|
||||
}
|
||||
|
||||
if (added === 0) {
|
||||
console.log('All GM permissions already exist.');
|
||||
} else {
|
||||
console.log(`Added ${added} permission(s).`);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -53,4 +53,4 @@ npm run db:clean-and-seed
|
||||
|
||||
echo ""
|
||||
echo "✅ Done. Restart the application so it uses the cleaned database."
|
||||
echo " Default logins: gm@atmata.com / sales.manager@atmata.com / sales.rep@atmata.com (Password: Admin@123)"
|
||||
echo " System Administrator: admin@system.local (Password: Admin@123)"
|
||||
|
||||
@@ -134,6 +134,40 @@ class AdminController {
|
||||
}
|
||||
}
|
||||
|
||||
async createPosition(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const position = await adminService.createPosition({
|
||||
title: req.body.title,
|
||||
titleAr: req.body.titleAr,
|
||||
code: req.body.code,
|
||||
departmentId: req.body.departmentId,
|
||||
level: req.body.level,
|
||||
description: req.body.description,
|
||||
isActive: req.body.isActive,
|
||||
});
|
||||
res.status(201).json(ResponseFormatter.success(position));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updatePosition(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const position = await adminService.updatePosition(req.params.id, {
|
||||
title: req.body.title,
|
||||
titleAr: req.body.titleAr,
|
||||
code: req.body.code,
|
||||
departmentId: req.body.departmentId,
|
||||
level: req.body.level,
|
||||
description: req.body.description,
|
||||
isActive: req.body.isActive,
|
||||
});
|
||||
res.json(ResponseFormatter.success(position));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updatePositionPermissions(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const position = await adminService.updatePositionPermissions(
|
||||
@@ -145,6 +179,74 @@ class AdminController {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== PERMISSION GROUPS (Phase 3) ==========
|
||||
|
||||
async getPermissionGroups(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const groups = await adminService.getPermissionGroups();
|
||||
res.json(ResponseFormatter.success(groups));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createPermissionGroup(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const group = await adminService.createPermissionGroup(req.body);
|
||||
res.status(201).json(ResponseFormatter.success(group));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updatePermissionGroup(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const group = await adminService.updatePermissionGroup(req.params.id, req.body);
|
||||
res.json(ResponseFormatter.success(group));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updatePermissionGroupPermissions(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const group = await adminService.updatePermissionGroupPermissions(
|
||||
req.params.id,
|
||||
req.body.permissions
|
||||
);
|
||||
res.json(ResponseFormatter.success(group));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getUserRoles(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const roles = await adminService.getUserRoles(req.params.userId);
|
||||
res.json(ResponseFormatter.success(roles));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async assignUserRole(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const userRole = await adminService.assignUserRole(req.params.userId, req.body.roleId);
|
||||
res.status(201).json(ResponseFormatter.success(userRole));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async removeUserRole(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await adminService.removeUserRole(req.params.userId, req.params.roleId);
|
||||
res.json(ResponseFormatter.success({ success: true }));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const adminController = new AdminController();
|
||||
|
||||
@@ -89,6 +89,33 @@ router.get(
|
||||
adminController.getPositions
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/positions',
|
||||
authorize('admin', 'roles', 'create'),
|
||||
[
|
||||
body('title').notEmpty().trim(),
|
||||
body('code').notEmpty().trim(),
|
||||
body('departmentId').isUUID(),
|
||||
body('level').optional().isInt({ min: 1 }),
|
||||
],
|
||||
validate,
|
||||
adminController.createPosition
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/positions/:id',
|
||||
authorize('admin', 'roles', 'update'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
body('title').optional().notEmpty().trim(),
|
||||
body('code').optional().notEmpty().trim(),
|
||||
body('departmentId').optional().isUUID(),
|
||||
body('level').optional().isInt({ min: 1 }),
|
||||
],
|
||||
validate,
|
||||
adminController.updatePosition
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/positions/:id/permissions',
|
||||
authorize('admin', 'roles', 'update'),
|
||||
@@ -100,4 +127,68 @@ router.put(
|
||||
adminController.updatePositionPermissions
|
||||
);
|
||||
|
||||
// ========== PERMISSION GROUPS (Phase 3 - multi-group) ==========
|
||||
|
||||
router.get(
|
||||
'/permission-groups',
|
||||
authorize('admin', 'roles', 'read'),
|
||||
adminController.getPermissionGroups
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/permission-groups',
|
||||
authorize('admin', 'roles', 'create'),
|
||||
[
|
||||
body('name').notEmpty().trim(),
|
||||
],
|
||||
validate,
|
||||
adminController.createPermissionGroup
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/permission-groups/:id',
|
||||
authorize('admin', 'roles', 'update'),
|
||||
[param('id').isUUID()],
|
||||
validate,
|
||||
adminController.updatePermissionGroup
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/permission-groups/:id/permissions',
|
||||
authorize('admin', 'roles', 'update'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
body('permissions').isArray(),
|
||||
],
|
||||
validate,
|
||||
adminController.updatePermissionGroupPermissions
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/users/:userId/roles',
|
||||
authorize('admin', 'users', 'read'),
|
||||
[param('userId').isUUID()],
|
||||
validate,
|
||||
adminController.getUserRoles
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/users/:userId/roles',
|
||||
authorize('admin', 'users', 'update'),
|
||||
[
|
||||
param('userId').isUUID(),
|
||||
body('roleId').isUUID(),
|
||||
],
|
||||
validate,
|
||||
adminController.assignUserRole
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/users/:userId/roles/:roleId',
|
||||
authorize('admin', 'users', 'update'),
|
||||
[param('userId').isUUID(), param('roleId').isUUID()],
|
||||
validate,
|
||||
adminController.removeUserRole
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -406,6 +406,102 @@ class AdminService {
|
||||
return withUserCount;
|
||||
}
|
||||
|
||||
async createPosition(data: {
|
||||
title: string;
|
||||
titleAr?: string;
|
||||
code: string;
|
||||
departmentId: string;
|
||||
level?: number;
|
||||
description?: string;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const existing = await prisma.position.findUnique({
|
||||
where: { code: data.code },
|
||||
});
|
||||
if (existing) {
|
||||
throw new AppError(400, 'كود الدور مستخدم - Position code already exists');
|
||||
}
|
||||
|
||||
const dept = await prisma.department.findUnique({
|
||||
where: { id: data.departmentId },
|
||||
});
|
||||
if (!dept) {
|
||||
throw new AppError(400, 'القسم غير موجود - Department not found');
|
||||
}
|
||||
|
||||
return prisma.position.create({
|
||||
data: {
|
||||
title: data.title,
|
||||
titleAr: data.titleAr,
|
||||
code: data.code.trim().toUpperCase().replace(/\s+/g, '_'),
|
||||
departmentId: data.departmentId,
|
||||
level: data.level ?? 5,
|
||||
description: data.description,
|
||||
isActive: data.isActive ?? true,
|
||||
},
|
||||
include: {
|
||||
department: { select: { name: true, nameAr: true } },
|
||||
permissions: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updatePosition(
|
||||
positionId: string,
|
||||
data: {
|
||||
title?: string;
|
||||
titleAr?: string;
|
||||
code?: string;
|
||||
departmentId?: string;
|
||||
level?: number;
|
||||
description?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
) {
|
||||
const position = await prisma.position.findUnique({
|
||||
where: { id: positionId },
|
||||
});
|
||||
if (!position) {
|
||||
throw new AppError(404, 'الدور غير موجود - Position not found');
|
||||
}
|
||||
|
||||
if (data.code && data.code !== position.code) {
|
||||
const existing = await prisma.position.findUnique({
|
||||
where: { code: data.code },
|
||||
});
|
||||
if (existing) {
|
||||
throw new AppError(400, 'كود الدور مستخدم - Position code already exists');
|
||||
}
|
||||
}
|
||||
|
||||
if (data.departmentId && data.departmentId !== position.departmentId) {
|
||||
const dept = await prisma.department.findUnique({
|
||||
where: { id: data.departmentId },
|
||||
});
|
||||
if (!dept) {
|
||||
throw new AppError(400, 'القسم غير موجود - Department not found');
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: Record<string, any> = {};
|
||||
if (data.title !== undefined) updateData.title = data.title;
|
||||
if (data.titleAr !== undefined) updateData.titleAr = data.titleAr;
|
||||
if (data.code !== undefined) updateData.code = data.code.trim().toUpperCase().replace(/\s+/g, '_');
|
||||
if (data.departmentId !== undefined) updateData.departmentId = data.departmentId;
|
||||
if (data.level !== undefined) updateData.level = data.level;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
if (data.isActive !== undefined) updateData.isActive = data.isActive;
|
||||
|
||||
return prisma.position.update({
|
||||
where: { id: positionId },
|
||||
data: updateData,
|
||||
include: {
|
||||
department: { select: { name: true, nameAr: true } },
|
||||
permissions: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updatePositionPermissions(positionId: string, permissions: Array<{ module: string; resource: string; actions: string[] }>) {
|
||||
const position = await prisma.position.findUnique({ where: { id: positionId } });
|
||||
if (!position) {
|
||||
@@ -429,6 +525,116 @@ class AdminService {
|
||||
|
||||
return this.getPositions().then((pos) => pos.find((p) => p.id === positionId) || position);
|
||||
}
|
||||
|
||||
// ========== PERMISSION GROUPS (Phase 3 - optional roles for multi-group) ==========
|
||||
|
||||
async getPermissionGroups() {
|
||||
return prisma.role.findMany({
|
||||
where: { isActive: true },
|
||||
include: {
|
||||
permissions: true,
|
||||
_count: { select: { userRoles: true } },
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async createPermissionGroup(data: { name: string; nameAr?: string; description?: string }) {
|
||||
const existing = await prisma.role.findUnique({ where: { name: data.name } });
|
||||
if (existing) {
|
||||
throw new AppError(400, 'اسم المجموعة مستخدم - Group name already exists');
|
||||
}
|
||||
return prisma.role.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
nameAr: data.nameAr,
|
||||
description: data.description,
|
||||
},
|
||||
include: { permissions: true },
|
||||
});
|
||||
}
|
||||
|
||||
async updatePermissionGroup(
|
||||
id: string,
|
||||
data: { name?: string; nameAr?: string; description?: string; isActive?: boolean }
|
||||
) {
|
||||
const role = await prisma.role.findUnique({ where: { id } });
|
||||
if (!role) {
|
||||
throw new AppError(404, 'المجموعة غير موجودة - Group not found');
|
||||
}
|
||||
if (data.name && data.name !== role.name) {
|
||||
const existing = await prisma.role.findUnique({ where: { name: data.name } });
|
||||
if (existing) {
|
||||
throw new AppError(400, 'اسم المجموعة مستخدم - Group name already exists');
|
||||
}
|
||||
}
|
||||
return prisma.role.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: { permissions: true },
|
||||
});
|
||||
}
|
||||
|
||||
async updatePermissionGroupPermissions(
|
||||
roleId: string,
|
||||
permissions: Array<{ module: string; resource: string; actions: string[] }>
|
||||
) {
|
||||
await prisma.rolePermission.deleteMany({ where: { roleId } });
|
||||
if (permissions.length > 0) {
|
||||
await prisma.rolePermission.createMany({
|
||||
data: permissions.map((p) => ({
|
||||
roleId,
|
||||
module: p.module,
|
||||
resource: p.resource,
|
||||
actions: p.actions,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return prisma.role.findUnique({
|
||||
where: { id: roleId },
|
||||
include: { permissions: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getUserRoles(userId: string) {
|
||||
return prisma.userRole.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
role: { include: { permissions: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async assignUserRole(userId: string, roleId: string) {
|
||||
const [user, role] = await Promise.all([
|
||||
prisma.user.findUnique({ where: { id: userId } }),
|
||||
prisma.role.findFirst({ where: { id: roleId, isActive: true } }),
|
||||
]);
|
||||
if (!user) throw new AppError(404, 'المستخدم غير موجود - User not found');
|
||||
if (!role) throw new AppError(404, 'المجموعة غير موجودة - Group not found');
|
||||
|
||||
const existing = await prisma.userRole.findUnique({
|
||||
where: { userId_roleId: { userId, roleId } },
|
||||
});
|
||||
if (existing) {
|
||||
throw new AppError(400, 'المستخدم منتمي بالفعل لهذه المجموعة - User already in group');
|
||||
}
|
||||
|
||||
return prisma.userRole.create({
|
||||
data: { userId, roleId },
|
||||
include: { role: true },
|
||||
});
|
||||
}
|
||||
|
||||
async removeUserRole(userId: string, roleId: string) {
|
||||
const deleted = await prisma.userRole.deleteMany({
|
||||
where: { userId, roleId },
|
||||
});
|
||||
if (deleted.count === 0) {
|
||||
throw new AppError(404, 'لم يتم العثور على الانتماء - User not in group');
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
export const adminService = new AdminService();
|
||||
|
||||
35
backend/src/modules/dashboard/dashboard.controller.ts
Normal file
35
backend/src/modules/dashboard/dashboard.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Response } from 'express';
|
||||
import prisma from '../../config/database';
|
||||
import { AuthRequest } from '../../shared/middleware/auth';
|
||||
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
|
||||
|
||||
class DashboardController {
|
||||
async getStats(req: AuthRequest, res: Response) {
|
||||
const userId = req.user!.id;
|
||||
|
||||
const [contactsCount, activeTasksCount, unreadNotificationsCount] = await Promise.all([
|
||||
prisma.contact.count(),
|
||||
prisma.task.count({
|
||||
where: {
|
||||
status: { notIn: ['COMPLETED', 'CANCELLED'] },
|
||||
},
|
||||
}),
|
||||
prisma.notification.count({
|
||||
where: {
|
||||
userId,
|
||||
isRead: false,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
res.json(
|
||||
ResponseFormatter.success({
|
||||
contacts: contactsCount,
|
||||
activeTasks: activeTasksCount,
|
||||
notifications: unreadNotificationsCount,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new DashboardController();
|
||||
9
backend/src/modules/dashboard/dashboard.routes.ts
Normal file
9
backend/src/modules/dashboard/dashboard.routes.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../../shared/middleware/auth';
|
||||
import dashboardController from './dashboard.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/stats', authenticate, dashboardController.getStats.bind(dashboardController));
|
||||
|
||||
export default router;
|
||||
@@ -19,15 +19,21 @@ export class HRController {
|
||||
|
||||
async findAllEmployees(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const pageSize = parseInt(req.query.pageSize as string) || 20;
|
||||
|
||||
const filters = {
|
||||
search: req.query.search,
|
||||
departmentId: req.query.departmentId,
|
||||
status: req.query.status,
|
||||
};
|
||||
|
||||
const rawPage = parseInt(req.query.page as string, 10);
|
||||
const rawPageSize = parseInt(req.query.pageSize as string, 10);
|
||||
const page = Number.isNaN(rawPage) || rawPage < 1 ? 1 : rawPage;
|
||||
const pageSize = Number.isNaN(rawPageSize) || rawPageSize < 1 || rawPageSize > 100 ? 20 : rawPageSize;
|
||||
|
||||
const rawSearch = req.query.search as string;
|
||||
const rawDepartmentId = req.query.departmentId as string;
|
||||
const rawStatus = req.query.status as string;
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
const filters: Record<string, string | undefined> = {};
|
||||
if (rawSearch && typeof rawSearch === 'string' && rawSearch.trim()) filters.search = rawSearch.trim();
|
||||
if (rawDepartmentId && uuidRegex.test(rawDepartmentId)) filters.departmentId = rawDepartmentId;
|
||||
if (rawStatus && rawStatus !== 'all' && rawStatus.trim()) filters.status = rawStatus;
|
||||
|
||||
const result = await hrService.findAllEmployees(filters, page, pageSize);
|
||||
res.json(ResponseFormatter.paginated(result.employees, result.total, result.page, result.pageSize));
|
||||
} catch (error) {
|
||||
@@ -135,6 +141,42 @@ export class HRController {
|
||||
}
|
||||
}
|
||||
|
||||
async getDepartmentsHierarchy(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const tree = await hrService.getDepartmentsHierarchy();
|
||||
res.json(ResponseFormatter.success(tree));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createDepartment(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const department = await hrService.createDepartment(req.body, req.user!.id);
|
||||
res.status(201).json(ResponseFormatter.success(department, 'تم إضافة القسم بنجاح - Department created'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateDepartment(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const department = await hrService.updateDepartment(req.params.id, req.body, req.user!.id);
|
||||
res.json(ResponseFormatter.success(department, 'تم تحديث القسم - Department updated'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteDepartment(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await hrService.deleteDepartment(req.params.id, req.user!.id);
|
||||
res.json(ResponseFormatter.success({ success: true }, 'تم حذف القسم - Department deleted'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== POSITIONS ==========
|
||||
|
||||
async findAllPositions(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
|
||||
@@ -32,6 +32,10 @@ router.post('/salaries/process', authorize('hr', 'salaries', 'process'), hrContr
|
||||
// ========== DEPARTMENTS ==========
|
||||
|
||||
router.get('/departments', authorize('hr', 'all', 'read'), hrController.findAllDepartments);
|
||||
router.get('/departments/hierarchy', authorize('hr', 'all', 'read'), hrController.getDepartmentsHierarchy);
|
||||
router.post('/departments', authorize('hr', 'all', 'create'), hrController.createDepartment);
|
||||
router.put('/departments/:id', authorize('hr', 'all', 'update'), hrController.updateDepartment);
|
||||
router.delete('/departments/:id', authorize('hr', 'all', 'delete'), hrController.deleteDepartment);
|
||||
|
||||
// ========== POSITIONS ==========
|
||||
|
||||
|
||||
@@ -5,14 +5,52 @@ import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||
class HRService {
|
||||
// ========== EMPLOYEES ==========
|
||||
|
||||
private normalizeEmployeeData(data: any): Record<string, any> {
|
||||
const toStr = (v: any) => (v != null && String(v).trim()) ? String(v).trim() : undefined;
|
||||
const toDate = (v: any) => {
|
||||
if (!v || !String(v).trim()) return undefined;
|
||||
const d = new Date(v);
|
||||
return isNaN(d.getTime()) ? undefined : d;
|
||||
};
|
||||
const toNum = (v: any) => (v != null && v !== '') ? Number(v) : undefined;
|
||||
|
||||
const raw: Record<string, any> = {
|
||||
firstName: toStr(data.firstName),
|
||||
lastName: toStr(data.lastName),
|
||||
firstNameAr: toStr(data.firstNameAr),
|
||||
lastNameAr: toStr(data.lastNameAr),
|
||||
email: toStr(data.email),
|
||||
phone: toStr(data.phone),
|
||||
mobile: toStr(data.mobile),
|
||||
dateOfBirth: toDate(data.dateOfBirth),
|
||||
gender: toStr(data.gender),
|
||||
nationality: toStr(data.nationality),
|
||||
nationalId: toStr(data.nationalId),
|
||||
employmentType: toStr(data.employmentType),
|
||||
contractType: toStr(data.contractType),
|
||||
hireDate: toDate(data.hireDate),
|
||||
departmentId: toStr(data.departmentId),
|
||||
positionId: toStr(data.positionId),
|
||||
reportingToId: toStr(data.reportingToId) || undefined,
|
||||
basicSalary: toNum(data.baseSalary ?? data.basicSalary) ?? 0,
|
||||
};
|
||||
return Object.fromEntries(Object.entries(raw).filter(([, v]) => v !== undefined));
|
||||
}
|
||||
|
||||
async createEmployee(data: any, userId: string) {
|
||||
const uniqueEmployeeId = await this.generateEmployeeId();
|
||||
const payload = this.normalizeEmployeeData(data);
|
||||
|
||||
if (!payload.firstName || !payload.lastName || !payload.email || !payload.mobile ||
|
||||
!payload.hireDate || !payload.departmentId || !payload.positionId) {
|
||||
throw new AppError(400, 'بيانات غير مكتملة - Missing required fields: firstName, lastName, email, mobile, hireDate, departmentId, positionId');
|
||||
}
|
||||
|
||||
const employee = await prisma.employee.create({
|
||||
data: {
|
||||
uniqueEmployeeId,
|
||||
...data,
|
||||
},
|
||||
...payload,
|
||||
} as any,
|
||||
include: {
|
||||
department: true,
|
||||
position: true,
|
||||
@@ -132,9 +170,10 @@ class HRService {
|
||||
throw new AppError(404, 'الموظف غير موجود - Employee not found');
|
||||
}
|
||||
|
||||
const payload = this.normalizeEmployeeData(data);
|
||||
const employee = await prisma.employee.update({
|
||||
where: { id },
|
||||
data,
|
||||
data: payload,
|
||||
include: {
|
||||
department: true,
|
||||
position: true,
|
||||
@@ -383,11 +422,112 @@ class HRService {
|
||||
async findAllDepartments() {
|
||||
const departments = await prisma.department.findMany({
|
||||
where: { isActive: true },
|
||||
include: {
|
||||
parent: { select: { id: true, name: true, nameAr: true } },
|
||||
_count: { select: { children: true, employees: true } }
|
||||
},
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
return departments;
|
||||
}
|
||||
|
||||
async getDepartmentsHierarchy() {
|
||||
const departments = await prisma.department.findMany({
|
||||
where: { isActive: true },
|
||||
include: {
|
||||
parent: { select: { id: true, name: true, nameAr: true } },
|
||||
employees: {
|
||||
where: { status: 'ACTIVE' },
|
||||
select: { id: true, firstName: true, lastName: true, firstNameAr: true, lastNameAr: true, position: { select: { title: true, titleAr: true } } }
|
||||
},
|
||||
positions: { select: { id: true, title: true, titleAr: true } },
|
||||
_count: { select: { children: true, employees: true } }
|
||||
},
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
const buildTree = (parentId: string | null): any[] =>
|
||||
departments
|
||||
.filter((d) => d.parentId === parentId)
|
||||
.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
nameAr: d.nameAr,
|
||||
code: d.code,
|
||||
parentId: d.parentId,
|
||||
description: d.description,
|
||||
employees: d.employees,
|
||||
positions: d.positions,
|
||||
_count: d._count,
|
||||
children: buildTree(d.id)
|
||||
}));
|
||||
return buildTree(null);
|
||||
}
|
||||
|
||||
async createDepartment(data: { name: string; nameAr?: string; code: string; parentId?: string; description?: string }, userId: string) {
|
||||
const existing = await prisma.department.findUnique({ where: { code: data.code } });
|
||||
if (existing) {
|
||||
throw new AppError(400, 'كود القسم مستخدم مسبقاً - Department code already exists');
|
||||
}
|
||||
if (data.parentId) {
|
||||
const parent = await prisma.department.findUnique({ where: { id: data.parentId } });
|
||||
if (!parent) {
|
||||
throw new AppError(400, 'القسم الأب غير موجود - Parent department not found');
|
||||
}
|
||||
}
|
||||
const department = await prisma.department.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
nameAr: data.nameAr,
|
||||
code: data.code,
|
||||
parentId: data.parentId || null,
|
||||
description: data.description
|
||||
}
|
||||
});
|
||||
await AuditLogger.log({ entityType: 'DEPARTMENT', entityId: department.id, action: 'CREATE', userId, changes: { created: department } });
|
||||
return department;
|
||||
}
|
||||
|
||||
async updateDepartment(id: string, data: { name?: string; nameAr?: string; code?: string; parentId?: string; description?: string; isActive?: boolean }, userId: string) {
|
||||
const existing = await prisma.department.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new AppError(404, 'القسم غير موجود - Department not found');
|
||||
}
|
||||
if (data.code && data.code !== existing.code) {
|
||||
const duplicate = await prisma.department.findUnique({ where: { code: data.code } });
|
||||
if (duplicate) {
|
||||
throw new AppError(400, 'كود القسم مستخدم مسبقاً - Department code already exists');
|
||||
}
|
||||
}
|
||||
if (data.parentId === id) {
|
||||
throw new AppError(400, 'لا يمكن تعيين القسم كأب لنفسه - Department cannot be its own parent');
|
||||
}
|
||||
const department = await prisma.department.update({
|
||||
where: { id },
|
||||
data: { name: data.name, nameAr: data.nameAr, code: data.code, parentId: data.parentId ?? undefined, description: data.description, isActive: data.isActive }
|
||||
});
|
||||
await AuditLogger.log({ entityType: 'DEPARTMENT', entityId: id, action: 'UPDATE', userId, changes: { before: existing, after: department } });
|
||||
return department;
|
||||
}
|
||||
|
||||
async deleteDepartment(id: string, userId: string) {
|
||||
const dept = await prisma.department.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { children: true, employees: true } } }
|
||||
});
|
||||
if (!dept) {
|
||||
throw new AppError(404, 'القسم غير موجود - Department not found');
|
||||
}
|
||||
if (dept._count.children > 0) {
|
||||
throw new AppError(400, 'لا يمكن حذف قسم يحتوي على أقسام فرعية - Cannot delete department with sub-departments');
|
||||
}
|
||||
if (dept._count.employees > 0) {
|
||||
throw new AppError(400, 'لا يمكن حذف قسم فيه موظفون - Cannot delete department with employees');
|
||||
}
|
||||
await prisma.department.delete({ where: { id } });
|
||||
await AuditLogger.log({ entityType: 'DEPARTMENT', entityId: id, action: 'DELETE', userId });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ========== POSITIONS ==========
|
||||
|
||||
async findAllPositions() {
|
||||
|
||||
@@ -3,6 +3,7 @@ import adminRoutes from '../modules/admin/admin.routes';
|
||||
import authRoutes from '../modules/auth/auth.routes';
|
||||
import contactsRoutes from '../modules/contacts/contacts.routes';
|
||||
import crmRoutes from '../modules/crm/crm.routes';
|
||||
import dashboardRoutes from '../modules/dashboard/dashboard.routes';
|
||||
import hrRoutes from '../modules/hr/hr.routes';
|
||||
import inventoryRoutes from '../modules/inventory/inventory.routes';
|
||||
import projectsRoutes from '../modules/projects/projects.routes';
|
||||
@@ -12,6 +13,7 @@ const router = Router();
|
||||
|
||||
// Module routes
|
||||
router.use('/admin', adminRoutes);
|
||||
router.use('/dashboard', dashboardRoutes);
|
||||
router.use('/auth', authRoutes);
|
||||
router.use('/contacts', contactsRoutes);
|
||||
router.use('/crm', crmRoutes);
|
||||
|
||||
@@ -4,12 +4,47 @@ import { config } from '../../config';
|
||||
import { AppError } from './errorHandler';
|
||||
import prisma from '../../config/database';
|
||||
|
||||
export interface EffectivePermission {
|
||||
module: string;
|
||||
resource: string;
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
function mergePermissions(
|
||||
positionPerms: { module: string; resource: string; actions: unknown }[],
|
||||
rolePerms: { module: string; resource: string; actions: unknown }[]
|
||||
): EffectivePermission[] {
|
||||
const key = (m: string, r: string) => `${m}:${r}`;
|
||||
const map = new Map<string, Set<string>>();
|
||||
|
||||
const add = (m: string, r: string, actions: unknown) => {
|
||||
const arr = Array.isArray(actions) ? actions : [];
|
||||
const actionSet = new Set<string>(arr.map(String));
|
||||
const k = key(m, r);
|
||||
const existing = map.get(k);
|
||||
if (existing) {
|
||||
actionSet.forEach((a) => existing.add(a));
|
||||
} else {
|
||||
map.set(k, actionSet);
|
||||
}
|
||||
};
|
||||
|
||||
(positionPerms || []).forEach((p) => add(p.module, p.resource, p.actions));
|
||||
(rolePerms || []).forEach((p) => add(p.module, p.resource, p.actions));
|
||||
|
||||
return Array.from(map.entries()).map(([k, actions]) => {
|
||||
const [module, resource] = k.split(':');
|
||||
return { module, resource, actions: Array.from(actions) };
|
||||
});
|
||||
}
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
employeeId?: string;
|
||||
employee?: any;
|
||||
effectivePermissions?: EffectivePermission[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,7 +68,7 @@ export const authenticate = async (
|
||||
email: string;
|
||||
};
|
||||
|
||||
// Get user with employee info
|
||||
// Get user with employee + roles (Phase 3: multi-group)
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.id },
|
||||
include: {
|
||||
@@ -47,6 +82,14 @@ export const authenticate = async (
|
||||
department: true,
|
||||
},
|
||||
},
|
||||
userRoles: {
|
||||
where: { role: { isActive: true } },
|
||||
include: {
|
||||
role: {
|
||||
include: { permissions: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -59,12 +102,18 @@ export const authenticate = async (
|
||||
throw new AppError(403, 'الوصول مرفوض - Access denied. Active employee record required.');
|
||||
}
|
||||
|
||||
// Attach user to request
|
||||
const positionPerms = user.employee?.position?.permissions ?? [];
|
||||
const rolePerms = (user as any).userRoles?.flatMap(
|
||||
(ur: any) => ur.role?.permissions ?? []
|
||||
) ?? [];
|
||||
const effectivePermissions = mergePermissions(positionPerms, rolePerms);
|
||||
|
||||
req.user = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
employeeId: user.employeeId || undefined,
|
||||
employee: user.employee,
|
||||
effectivePermissions,
|
||||
};
|
||||
|
||||
next();
|
||||
@@ -76,25 +125,24 @@ export const authenticate = async (
|
||||
}
|
||||
};
|
||||
|
||||
// Permission checking middleware
|
||||
// Permission checking middleware (Position + Role permissions merged)
|
||||
export const authorize = (module: string, resource: string, action: string) => {
|
||||
return async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.user?.employee?.position?.permissions) {
|
||||
const perms = req.user?.effectivePermissions;
|
||||
if (!perms || perms.length === 0) {
|
||||
throw new AppError(403, 'الوصول مرفوض - Access denied');
|
||||
}
|
||||
|
||||
// Find permission for this module and resource (check exact match or wildcard)
|
||||
const permission = req.user.employee.position.permissions.find(
|
||||
(p: any) => p.module === module && (p.resource === resource || p.resource === '*' || p.resource === 'all')
|
||||
const permission = perms.find(
|
||||
(p) => p.module === module && (p.resource === resource || p.resource === '*' || p.resource === 'all')
|
||||
);
|
||||
|
||||
if (!permission) {
|
||||
throw new AppError(403, 'الوصول مرفوض - Access denied');
|
||||
}
|
||||
|
||||
// Check if action is allowed (check exact match or wildcard)
|
||||
const actions = permission.actions as string[];
|
||||
const actions = permission.actions;
|
||||
if (!actions.includes(action) && !actions.includes('*') && !actions.includes('all')) {
|
||||
throw new AppError(403, 'الوصول مرفوض - Access denied');
|
||||
}
|
||||
|
||||
@@ -40,10 +40,12 @@ export const errorHandler = (
|
||||
|
||||
// Handle validation errors
|
||||
if (err instanceof Prisma.PrismaClientValidationError) {
|
||||
const detail = process.env.NODE_ENV !== 'production' ? err.message : undefined;
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'بيانات غير صالحة - Invalid data',
|
||||
error: 'VALIDATION_ERROR',
|
||||
...(detail && { detail }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user