This commit is contained in:
yotakii
2026-03-05 11:57:04 +03:00
58 changed files with 5302 additions and 1608 deletions

View File

@@ -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",

View File

@@ -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,

View 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 = '*'
);

View File

@@ -0,0 +1,131 @@
-- AlterTable: Add attendancePin to employees
ALTER TABLE "employees" ADD COLUMN IF NOT EXISTS "attendancePin" TEXT;
-- CreateIndex (unique) on attendancePin - only if column added
CREATE UNIQUE INDEX IF NOT EXISTS "employees_attendancePin_key" ON "employees"("attendancePin");
-- AlterTable: Add ZK Tico fields to attendances
ALTER TABLE "attendances" ADD COLUMN IF NOT EXISTS "sourceDeviceId" TEXT;
ALTER TABLE "attendances" ADD COLUMN IF NOT EXISTS "externalId" TEXT;
ALTER TABLE "attendances" ADD COLUMN IF NOT EXISTS "rawData" JSONB;
-- CreateIndex on sourceDeviceId
CREATE INDEX IF NOT EXISTS "attendances_sourceDeviceId_idx" ON "attendances"("sourceDeviceId");
-- CreateTable: loans
CREATE TABLE "loans" (
"id" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"loanNumber" TEXT NOT NULL,
"type" TEXT NOT NULL,
"amount" DECIMAL(12,2) NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'SAR',
"installments" INTEGER NOT NULL DEFAULT 1,
"monthlyAmount" DECIMAL(12,2),
"reason" TEXT,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"approvedBy" TEXT,
"approvedAt" TIMESTAMP(3),
"rejectedReason" TEXT,
"startDate" DATE,
"endDate" DATE,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "loans_pkey" PRIMARY KEY ("id")
);
-- CreateTable: loan_installments
CREATE TABLE "loan_installments" (
"id" TEXT NOT NULL,
"loanId" TEXT NOT NULL,
"installmentNumber" INTEGER NOT NULL,
"dueDate" DATE NOT NULL,
"amount" DECIMAL(12,2) NOT NULL,
"paidDate" DATE,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "loan_installments_pkey" PRIMARY KEY ("id")
);
-- CreateTable: purchase_requests
CREATE TABLE "purchase_requests" (
"id" TEXT NOT NULL,
"requestNumber" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"items" JSONB NOT NULL,
"totalAmount" DECIMAL(12,2),
"reason" TEXT,
"priority" TEXT NOT NULL DEFAULT 'NORMAL',
"status" TEXT NOT NULL DEFAULT 'PENDING',
"approvedBy" TEXT,
"approvedAt" TIMESTAMP(3),
"rejectedReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "purchase_requests_pkey" PRIMARY KEY ("id")
);
-- CreateTable: leave_entitlements
CREATE TABLE "leave_entitlements" (
"id" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"year" INTEGER NOT NULL,
"leaveType" TEXT NOT NULL,
"totalDays" INTEGER NOT NULL DEFAULT 0,
"usedDays" INTEGER NOT NULL DEFAULT 0,
"carriedOver" INTEGER NOT NULL DEFAULT 0,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "leave_entitlements_pkey" PRIMARY KEY ("id")
);
-- CreateTable: employee_contracts
CREATE TABLE "employee_contracts" (
"id" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"contractNumber" TEXT NOT NULL,
"type" TEXT NOT NULL,
"startDate" DATE NOT NULL,
"endDate" DATE,
"salary" DECIMAL(12,2) NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'SAR',
"documentUrl" TEXT,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "employee_contracts_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "loans_loanNumber_key" ON "loans"("loanNumber");
CREATE INDEX "loans_employeeId_idx" ON "loans"("employeeId");
CREATE INDEX "loans_status_idx" ON "loans"("status");
CREATE UNIQUE INDEX "loan_installments_loanId_installmentNumber_key" ON "loan_installments"("loanId", "installmentNumber");
CREATE INDEX "loan_installments_loanId_idx" ON "loan_installments"("loanId");
CREATE UNIQUE INDEX "purchase_requests_requestNumber_key" ON "purchase_requests"("requestNumber");
CREATE INDEX "purchase_requests_employeeId_idx" ON "purchase_requests"("employeeId");
CREATE INDEX "purchase_requests_status_idx" ON "purchase_requests"("status");
CREATE UNIQUE INDEX "leave_entitlements_employeeId_year_leaveType_key" ON "leave_entitlements"("employeeId", "year", "leaveType");
CREATE INDEX "leave_entitlements_employeeId_idx" ON "leave_entitlements"("employeeId");
CREATE UNIQUE INDEX "employee_contracts_contractNumber_key" ON "employee_contracts"("contractNumber");
CREATE INDEX "employee_contracts_employeeId_idx" ON "employee_contracts"("employeeId");
CREATE INDEX "employee_contracts_status_idx" ON "employee_contracts"("status");
-- AddForeignKey
ALTER TABLE "loans" ADD CONSTRAINT "loans_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "loan_installments" ADD CONSTRAINT "loan_installments_loanId_fkey" FOREIGN KEY ("loanId") REFERENCES "loans"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "purchase_requests" ADD CONSTRAINT "purchase_requests_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "leave_entitlements" ADD CONSTRAINT "leave_entitlements_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "employee_contracts" ADD CONSTRAINT "employee_contracts_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -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;

View File

@@ -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 // رقم الموظف الموحد
@@ -129,6 +178,9 @@ model Employee {
// Documents
documents Json? // Array of document references
// ZK Tico / Attendance device - maps to employee pin on device
attendancePin String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -143,6 +195,10 @@ model Employee {
disciplinaryActions DisciplinaryAction[]
allowances Allowance[]
commissions Commission[]
loans Loan[]
purchaseRequests PurchaseRequest[]
leaveEntitlements LeaveEntitlement[]
employeeContracts EmployeeContract[]
@@index([departmentId])
@@index([positionId])
@@ -221,12 +277,18 @@ model Attendance {
status String // PRESENT, ABSENT, LATE, HALF_DAY, etc.
notes String?
// ZK Tico / External device sync
sourceDeviceId String?
externalId String?
rawData Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([employeeId, date])
@@index([employeeId])
@@index([date])
@@index([sourceDeviceId])
@@map("attendances")
}
@@ -369,6 +431,115 @@ model DisciplinaryAction {
@@map("disciplinary_actions")
}
model Loan {
id String @id @default(uuid())
employeeId String
employee Employee @relation(fields: [employeeId], references: [id])
loanNumber String @unique
type String // SALARY_ADVANCE, EQUIPMENT, PERSONAL, etc.
amount Decimal @db.Decimal(12, 2)
currency String @default("SAR")
installments Int @default(1)
monthlyAmount Decimal? @db.Decimal(12, 2)
reason String?
status String @default("PENDING") // PENDING, APPROVED, REJECTED, ACTIVE, PAID_OFF
approvedBy String?
approvedAt DateTime?
rejectedReason String?
startDate DateTime? @db.Date
endDate DateTime? @db.Date
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
installmentsList LoanInstallment[]
@@index([employeeId])
@@index([status])
@@map("loans")
}
model LoanInstallment {
id String @id @default(uuid())
loanId String
loan Loan @relation(fields: [loanId], references: [id], onDelete: Cascade)
installmentNumber Int
dueDate DateTime @db.Date
amount Decimal @db.Decimal(12, 2)
paidDate DateTime? @db.Date
status String @default("PENDING") // PENDING, PAID, OVERDUE
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([loanId, installmentNumber])
@@index([loanId])
@@map("loan_installments")
}
model PurchaseRequest {
id String @id @default(uuid())
requestNumber String @unique
employeeId String
employee Employee @relation(fields: [employeeId], references: [id])
items Json // Array of { description, quantity, estimatedPrice, etc. }
totalAmount Decimal? @db.Decimal(12, 2)
reason String?
priority String @default("NORMAL") // LOW, NORMAL, HIGH, URGENT
status String @default("PENDING") // PENDING, APPROVED, REJECTED, ORDERED
approvedBy String?
approvedAt DateTime?
rejectedReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([employeeId])
@@index([status])
@@map("purchase_requests")
}
model LeaveEntitlement {
id String @id @default(uuid())
employeeId String
employee Employee @relation(fields: [employeeId], references: [id])
year Int
leaveType String // ANNUAL, SICK, EMERGENCY, etc.
totalDays Int @default(0)
usedDays Int @default(0)
carriedOver Int @default(0)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([employeeId, year, leaveType])
@@index([employeeId])
@@map("leave_entitlements")
}
model EmployeeContract {
id String @id @default(uuid())
employeeId String
employee Employee @relation(fields: [employeeId], references: [id])
contractNumber String @unique
type String // FIXED, UNLIMITED, PROBATION, etc.
startDate DateTime @db.Date
endDate DateTime? @db.Date
salary Decimal @db.Decimal(12, 2)
currency String @default("SAR")
documentUrl String?
status String @default("ACTIVE") // DRAFT, ACTIVE, EXPIRED, TERMINATED
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([employeeId])
@@index([status])
@@map("employee_contracts")
}
// ============================================
// MODULE 1: CONTACT MANAGEMENT
// ============================================

146
backend/prisma/seed.js Normal file
View 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();
});

View File

@@ -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,342 +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');
const itSupportPosition = await prisma.position.create({
data: {
title: 'IT Support',
titleAr: 'دعم فني',
code: 'IT_SUPPORT',
departmentId: itDept.id,
level: 4,
description: 'IT Support Technician',
},
});
const itDeveloperPosition = await prisma.position.create({
data: {
title: 'Developer',
titleAr: 'مطور',
code: 'IT_DEV',
departmentId: itDept.id,
level: 4,
description: 'Software Developer',
},
});
// Employee position for ALL departments (added)
const salesEmployeePosition = await prisma.position.create({
data: {
title: 'Employee',
titleAr: 'موظف',
code: 'SALES_EMPLOYEE',
departmentId: salesDept.id,
level: 5,
description: 'General employee - Sales Department',
},
});
const itEmployeePosition = await prisma.position.create({
data: {
title: 'Employee',
titleAr: 'موظف',
code: 'IT_EMPLOYEE',
departmentId: itDept.id,
level: 5,
description: 'General employee - IT Department',
},
});
const hrEmployeePosition = await prisma.position.create({
data: {
title: 'Employee',
titleAr: 'موظف',
code: 'HR_EMPLOYEE',
departmentId: hrDept.id,
level: 5,
description: 'General employee - HR Department',
},
});
// 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({
data: {
positionId: gmPosition.id,
module: 'admin',
resource: '*',
actions: ['*'],
},
});
// 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: salesRepPosition.id, module: 'contacts', resource: '*', actions: ['read', 'create', 'update'] },
{ positionId: salesRepPosition.id, module: 'crm', resource: 'deals', actions: ['read', 'create', 'update'] },
],
});
// Create Permissions for Sales Rep
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: 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'],
},
{ 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 permissions');
console.log('✅ Created position and 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');
}
@@ -413,4 +146,3 @@ main()
.finally(async () => {
await prisma.$disconnect();
});

View 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());

View File

@@ -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)"

View File

@@ -136,17 +136,15 @@ class AdminController {
async createPosition(req: AuthRequest, res: Response, next: NextFunction) {
try {
const userId = req.user!.id;
const position = await adminService.createPosition(
{
title: req.body.title,
titleAr: req.body.titleAr,
departmentId: req.body.departmentId,
level: req.body.level,
code: req.body.code,
},
userId
);
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);
@@ -155,15 +153,15 @@ class AdminController {
async updatePosition(req: AuthRequest, res: Response, next: NextFunction) {
try {
const userId = req.user!.id;
const position = await adminService.updatePosition(
req.params.id,
{
title: req.body.title,
titleAr: req.body.titleAr,
},
userId
);
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);
@@ -182,15 +180,73 @@ class AdminController {
}
}
async deletePosition(req: AuthRequest, res: Response, next: NextFunction) {
// ========== PERMISSION GROUPS (Phase 3) ==========
async getPermissionGroups(req: AuthRequest, res: Response, next: NextFunction) {
try {
const userId = req.user!.id;
await adminService.deletePosition(req.params.id, userId);
res.json(ResponseFormatter.success(null, 'Role deleted successfully'));
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();
export const adminController = new AdminController();

View File

@@ -89,43 +89,33 @@ router.get(
adminController.getPositions
);
// Create role
router.post(
'/positions',
authorize('admin', 'roles', 'create'),
[
body('title').notEmpty().trim(),
body('titleAr').optional().isString().trim(),
body('code').notEmpty().trim(),
body('departmentId').isUUID(),
body('level').optional().isInt({ min: 1 }),
body('code').optional().isString().trim(),
],
validate,
adminController.createPosition
);
// Update role name (title/titleAr)
router.put(
'/positions/:id',
authorize('admin', 'roles', 'update'),
[
param('id').isUUID(),
body('title').optional().notEmpty().trim(),
body('titleAr').optional().isString().trim(),
body('code').optional().notEmpty().trim(),
body('departmentId').optional().isUUID(),
body('level').optional().isInt({ min: 1 }),
],
validate,
adminController.updatePosition
);
// Delete (soft delete) a role/position
router.delete(
'/positions/:id',
authorize('admin', 'roles', 'delete'),
param('id').isUUID(),
validate,
adminController.deletePosition
);
router.put(
'/positions/:id/permissions',
authorize('admin', 'roles', 'update'),
@@ -137,4 +127,68 @@ router.put(
adminController.updatePositionPermissions
);
export default router;
// ========== 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;

View File

@@ -39,19 +39,6 @@ export interface AuditLogFilters {
pageSize?: number;
}
export interface CreatePositionData {
title: string;
titleAr?: string;
departmentId: string;
level?: number;
code?: string;
}
export interface UpdatePositionData {
title?: string;
titleAr?: string;
}
class AdminService {
// ========== USERS ==========
@@ -107,7 +94,7 @@ class AdminService {
]);
const sanitized = users.map((u) => {
const { password: _, ...rest } = u as any;
const { password: _, ...rest } = u;
return rest;
});
@@ -137,7 +124,7 @@ class AdminService {
throw new AppError(404, 'المستخدم غير موجود - User not found');
}
const { password: _, ...rest } = user as any;
const { password: _, ...rest } = user;
return rest;
}
@@ -236,7 +223,7 @@ class AdminService {
...(data.email && { email: data.email }),
...(data.username && { username: data.username }),
...(data.isActive !== undefined && { isActive: data.isActive }),
...(data.employeeId !== undefined && { employeeId: (data.employeeId as any) || null }),
...(data.employeeId !== undefined && { employeeId: data.employeeId || null }),
};
if (data.password && data.password.length >= 8) {
@@ -255,7 +242,7 @@ class AdminService {
},
});
const { password: _, ...sanitized } = user as any;
const { password: _, ...sanitized } = user;
await AuditLogger.log({
entityType: 'USER',
@@ -286,7 +273,7 @@ class AdminService {
},
});
const { password: _, ...sanitized } = updated as any;
const { password: _, ...sanitized } = updated;
await AuditLogger.log({
entityType: 'USER',
@@ -393,7 +380,7 @@ class AdminService {
const positions = await prisma.position.findMany({
where: { isActive: true },
include: {
department: { select: { id: true, name: true, nameAr: true } },
department: { select: { name: true, nameAr: true } },
permissions: true,
_count: {
select: {
@@ -419,118 +406,100 @@ class AdminService {
return withUserCount;
}
private async generateUniqueCode(base: string) {
const cleaned = (base || 'ROLE')
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 18) || 'ROLE';
for (let i = 0; i < 25; i++) {
const suffix = Math.floor(1000 + Math.random() * 9000);
const code = `${cleaned}_${suffix}`;
const exists = await prisma.position.findUnique({ where: { code } });
if (!exists) return code;
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');
}
// fallback
return `${cleaned}_${Date.now()}`;
}
async createPosition(data: CreatePositionData, createdById: string) {
const title = (data.title || '').trim();
const titleAr = (data.titleAr || '').trim();
if (!title && !titleAr) {
throw new AppError(400, 'اسم الدور مطلوب - Role name is required');
}
const department = await prisma.department.findUnique({
const dept = await prisma.department.findUnique({
where: { id: data.departmentId },
});
if (!department || !department.isActive) {
if (!dept) {
throw new AppError(400, 'القسم غير موجود - Department not found');
}
let code = (data.code || '').trim();
if (code) {
code = code.toUpperCase().replace(/[^A-Z0-9_]+/g, '_');
const exists = await prisma.position.findUnique({ where: { code } });
if (exists) {
throw new AppError(400, 'الكود مستخدم بالفعل - Code already exists');
}
} else {
code = await this.generateUniqueCode(title || titleAr || 'ROLE');
}
const level = Number.isFinite(data.level as any) ? Math.max(1, Number(data.level)) : 1;
const created = await prisma.position.create({
return prisma.position.create({
data: {
title: title || titleAr,
titleAr: titleAr || null,
code,
title: data.title,
titleAr: data.titleAr,
code: data.code.trim().toUpperCase().replace(/\s+/g, '_'),
departmentId: data.departmentId,
level,
level: data.level ?? 5,
description: data.description,
isActive: data.isActive ?? true,
},
include: {
department: { select: { name: true, nameAr: true } },
permissions: true,
},
});
await AuditLogger.log({
entityType: 'POSITION',
entityId: created.id,
action: 'CREATE',
userId: createdById,
changes: {
created: {
title: created.title,
titleAr: created.titleAr,
code: created.code,
departmentId: created.departmentId,
level: created.level,
},
},
});
const all = await this.getPositions();
return all.find((p: any) => p.id === created.id) || created;
}
async updatePosition(positionId: string, data: UpdatePositionData, updatedById: string) {
const existing = await prisma.position.findUnique({ where: { id: positionId } });
if (!existing) {
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');
}
const nextTitle = data.title !== undefined ? (data.title || '').trim() : existing.title;
const nextTitleAr = data.titleAr !== undefined ? (data.titleAr || '').trim() : (existing.titleAr || '');
const finalTitle = nextTitle || nextTitleAr;
if (!finalTitle) {
throw new AppError(400, 'اسم الدور مطلوب - Role name is required');
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');
}
}
const updated = await prisma.position.update({
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: {
title: finalTitle,
titleAr: nextTitleAr ? nextTitleAr : null,
data: updateData,
include: {
department: { select: { name: true, nameAr: true } },
permissions: true,
},
});
await AuditLogger.log({
entityType: 'POSITION',
entityId: positionId,
action: 'UPDATE',
userId: updatedById,
changes: {
before: { title: existing.title, titleAr: existing.titleAr },
after: { title: updated.title, titleAr: updated.titleAr },
},
});
const all = await this.getPositions();
return all.find((p: any) => p.id === positionId) || updated;
}
async updatePositionPermissions(positionId: string, permissions: Array<{ module: string; resource: string; actions: string[] }>) {
@@ -554,59 +523,118 @@ class AdminService {
});
}
return this.getPositions().then((pos: any) => pos.find((p: any) => p.id === positionId) || position);
return this.getPositions().then((pos) => pos.find((p) => p.id === positionId) || position);
}
/**
* Soft delete a role (Position).
* - Prevent deletion if the position is assigned to any employees.
* - Clean up position permissions.
*/
async deletePosition(positionId: string, deletedById: string) {
const position = await prisma.position.findUnique({
where: { id: positionId },
// ========== PERMISSION GROUPS (Phase 3 - optional roles for multi-group) ==========
async getPermissionGroups() {
return prisma.role.findMany({
where: { isActive: true },
include: {
_count: { select: { employees: true } },
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 } },
},
});
}
if (!position) {
throw new AppError(404, 'الدور غير موجود - Position not found');
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');
}
if (position._count.employees > 0) {
throw new AppError(
400,
'لا يمكن حذف هذا الدور لأنه مرتبط بموظفين. قم بتغيير دور الموظفين أولاً - Cannot delete: position is assigned to employees'
);
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');
}
// Soft delete the position
await prisma.position.update({
where: { id: positionId },
data: { isActive: false },
});
// Clean up permissions linked to this position
await prisma.positionPermission.deleteMany({
where: { positionId },
});
await AuditLogger.log({
entityType: 'POSITION',
entityId: positionId,
action: 'DELETE',
userId: deletedById,
changes: {
softDeleted: true,
title: position.title,
titleAr: position.titleAr,
code: position.code,
},
});
return { success: true };
}
}
export const adminService = new AdminService();
export const adminService = new AdminService();

View 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();

View 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;

View File

@@ -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) {
@@ -92,6 +98,16 @@ export class HRController {
}
}
async bulkSyncAttendance(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { deviceId, records } = req.body;
const results = await hrService.bulkSyncAttendanceFromDevice(deviceId, records || [], req.user!.id);
res.json(ResponseFormatter.success(results, 'تم مزامنة الحضور - Attendance synced'));
} catch (error) {
next(error);
}
}
// ========== LEAVES ==========
async createLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
@@ -112,6 +128,29 @@ export class HRController {
}
}
async rejectLeave(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { rejectedReason } = req.body;
const leave = await hrService.rejectLeave(req.params.id, rejectedReason || '', req.user!.id);
res.json(ResponseFormatter.success(leave, 'تم رفض طلب الإجازة - Leave rejected'));
} catch (error) {
next(error);
}
}
async findAllLeaves(req: AuthRequest, res: Response, next: NextFunction) {
try {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
const employeeId = req.query.employeeId as string | undefined;
const status = req.query.status as string | undefined;
const result = await hrService.findAllLeaves({ employeeId, status }, page, pageSize);
res.json(ResponseFormatter.paginated(result.leaves, result.total, result.page, result.pageSize));
} catch (error) {
next(error);
}
}
// ========== SALARIES ==========
async processSalary(req: AuthRequest, res: Response, next: NextFunction) {
@@ -135,6 +174,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) {
@@ -145,6 +220,198 @@ export class HRController {
next(error);
}
}
// ========== LOANS ==========
async findAllLoans(req: AuthRequest, res: Response, next: NextFunction) {
try {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
const employeeId = req.query.employeeId as string | undefined;
const status = req.query.status as string | undefined;
const result = await hrService.findAllLoans({ employeeId, status }, page, pageSize);
res.json(ResponseFormatter.paginated(result.loans, result.total, result.page, result.pageSize));
} catch (error) {
next(error);
}
}
async findLoanById(req: AuthRequest, res: Response, next: NextFunction) {
try {
const loan = await hrService.findLoanById(req.params.id);
res.json(ResponseFormatter.success(loan));
} catch (error) {
next(error);
}
}
async createLoan(req: AuthRequest, res: Response, next: NextFunction) {
try {
const loan = await hrService.createLoan(req.body, req.user!.id);
res.status(201).json(ResponseFormatter.success(loan, 'تم إنشاء طلب القرض - Loan request created'));
} catch (error) {
next(error);
}
}
async approveLoan(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { startDate } = req.body;
const loan = await hrService.approveLoan(req.params.id, req.user!.id, startDate ? new Date(startDate) : new Date(), req.user!.id);
res.json(ResponseFormatter.success(loan, 'تمت الموافقة على القرض - Loan approved'));
} catch (error) {
next(error);
}
}
async rejectLoan(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { rejectedReason } = req.body;
const loan = await hrService.rejectLoan(req.params.id, rejectedReason || '', req.user!.id);
res.json(ResponseFormatter.success(loan, 'تم رفض القرض - Loan rejected'));
} catch (error) {
next(error);
}
}
async recordLoanInstallmentPayment(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { installmentId, paidDate } = req.body;
const loan = await hrService.recordLoanInstallmentPayment(req.params.id, installmentId, paidDate ? new Date(paidDate) : new Date(), req.user!.id);
res.json(ResponseFormatter.success(loan, 'تم تسجيل الدفعة - Payment recorded'));
} catch (error) {
next(error);
}
}
// ========== PURCHASE REQUESTS ==========
async findAllPurchaseRequests(req: AuthRequest, res: Response, next: NextFunction) {
try {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
const employeeId = req.query.employeeId as string | undefined;
const status = req.query.status as string | undefined;
const result = await hrService.findAllPurchaseRequests({ employeeId, status }, page, pageSize);
res.json(ResponseFormatter.paginated(result.purchaseRequests, result.total, result.page, result.pageSize));
} catch (error) {
next(error);
}
}
async findPurchaseRequestById(req: AuthRequest, res: Response, next: NextFunction) {
try {
const pr = await hrService.findPurchaseRequestById(req.params.id);
res.json(ResponseFormatter.success(pr));
} catch (error) {
next(error);
}
}
async createPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const pr = await hrService.createPurchaseRequest(req.body, req.user!.id);
res.status(201).json(ResponseFormatter.success(pr, 'تم إنشاء طلب الشراء - Purchase request created'));
} catch (error) {
next(error);
}
}
async approvePurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const pr = await hrService.approvePurchaseRequest(req.params.id, req.user!.id, req.user!.id);
res.json(ResponseFormatter.success(pr, 'تمت الموافقة على طلب الشراء - Purchase request approved'));
} catch (error) {
next(error);
}
}
async rejectPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const { rejectedReason } = req.body;
const pr = await hrService.rejectPurchaseRequest(req.params.id, rejectedReason || '', req.user!.id);
res.json(ResponseFormatter.success(pr, 'تم رفض طلب الشراء - Purchase request rejected'));
} catch (error) {
next(error);
}
}
// ========== LEAVE ENTITLEMENTS ==========
async getLeaveBalance(req: AuthRequest, res: Response, next: NextFunction) {
try {
const employeeId = req.params.employeeId || req.query.employeeId as string;
const year = parseInt(req.query.year as string) || new Date().getFullYear();
const balance = await hrService.getLeaveBalance(employeeId, year);
res.json(ResponseFormatter.success(balance));
} catch (error) {
next(error);
}
}
async findAllLeaveEntitlements(req: AuthRequest, res: Response, next: NextFunction) {
try {
const employeeId = req.query.employeeId as string | undefined;
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
const list = await hrService.findAllLeaveEntitlements(employeeId, year);
res.json(ResponseFormatter.success(list));
} catch (error) {
next(error);
}
}
async upsertLeaveEntitlement(req: AuthRequest, res: Response, next: NextFunction) {
try {
const ent = await hrService.upsertLeaveEntitlement(req.body, req.user!.id);
res.json(ResponseFormatter.success(ent, 'تم حفظ رصيد الإجازة - Leave entitlement saved'));
} catch (error) {
next(error);
}
}
// ========== EMPLOYEE CONTRACTS ==========
async findAllEmployeeContracts(req: AuthRequest, res: Response, next: NextFunction) {
try {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20));
const employeeId = req.query.employeeId as string | undefined;
const status = req.query.status as string | undefined;
const result = await hrService.findAllEmployeeContracts({ employeeId, status }, page, pageSize);
res.json(ResponseFormatter.paginated(result.contracts, result.total, result.page, result.pageSize));
} catch (error) {
next(error);
}
}
async findEmployeeContractById(req: AuthRequest, res: Response, next: NextFunction) {
try {
const c = await hrService.findEmployeeContractById(req.params.id);
res.json(ResponseFormatter.success(c));
} catch (error) {
next(error);
}
}
async createEmployeeContract(req: AuthRequest, res: Response, next: NextFunction) {
try {
const data = { ...req.body, startDate: new Date(req.body.startDate), endDate: req.body.endDate ? new Date(req.body.endDate) : undefined };
const c = await hrService.createEmployeeContract(data, req.user!.id);
res.status(201).json(ResponseFormatter.success(c, 'تم إنشاء العقد - Contract created'));
} catch (error) {
next(error);
}
}
async updateEmployeeContract(req: AuthRequest, res: Response, next: NextFunction) {
try {
const data = { ...req.body, endDate: req.body.endDate ? new Date(req.body.endDate) : undefined };
const c = await hrService.updateEmployeeContract(req.params.id, data, req.user!.id);
res.json(ResponseFormatter.success(c, 'تم تحديث العقد - Contract updated'));
} catch (error) {
next(error);
}
}
}
export const hrController = new HRController();

View File

@@ -1,12 +1,24 @@
import { Router } from 'express';
import { body, param } from 'express-validator';
import { hrController } from './hr.controller';
import { portalController } from './portal.controller';
import { authenticate, authorize } from '../../shared/middleware/auth';
import { validate } from '../../shared/middleware/validation';
const router = Router();
router.use(authenticate);
// ========== EMPLOYEE PORTAL (authenticate only, scoped by employeeId) ==========
router.get('/portal/me', portalController.getMe);
router.get('/portal/loans', portalController.getMyLoans);
router.post('/portal/loans', portalController.submitLoanRequest);
router.get('/portal/leave-balance', portalController.getMyLeaveBalance);
router.get('/portal/leaves', portalController.getMyLeaves);
router.post('/portal/leaves', portalController.submitLeaveRequest);
router.get('/portal/purchase-requests', portalController.getMyPurchaseRequests);
router.post('/portal/purchase-requests', portalController.submitPurchaseRequest);
router.get('/portal/attendance', portalController.getMyAttendance);
router.get('/portal/salaries', portalController.getMySalaries);
// ========== EMPLOYEES ==========
router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees);
@@ -19,11 +31,14 @@ router.post('/employees/:id/terminate', authorize('hr', 'employees', 'terminate'
router.post('/attendance', authorize('hr', 'attendance', 'create'), hrController.recordAttendance);
router.get('/attendance/:employeeId', authorize('hr', 'attendance', 'read'), hrController.getAttendance);
router.post('/attendance/sync', authorize('hr', 'attendance', 'create'), hrController.bulkSyncAttendance);
// ========== LEAVES ==========
router.get('/leaves', authorize('hr', 'leaves', 'read'), hrController.findAllLeaves);
router.post('/leaves', authorize('hr', 'leaves', 'create'), hrController.createLeaveRequest);
router.post('/leaves/:id/approve', authorize('hr', 'leaves', 'approve'), hrController.approveLeave);
router.post('/leaves/:id/reject', authorize('hr', 'leaves', 'approve'), hrController.rejectLeave);
// ========== SALARIES ==========
@@ -32,10 +47,44 @@ 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 ==========
router.get('/positions', authorize('hr', 'all', 'read'), hrController.findAllPositions);
// ========== LOANS ==========
router.get('/loans', authorize('hr', 'all', 'read'), hrController.findAllLoans);
router.get('/loans/:id', authorize('hr', 'all', 'read'), hrController.findLoanById);
router.post('/loans', authorize('hr', 'all', 'create'), hrController.createLoan);
router.post('/loans/:id/approve', authorize('hr', 'all', 'approve'), hrController.approveLoan);
router.post('/loans/:id/reject', authorize('hr', 'all', 'approve'), hrController.rejectLoan);
router.post('/loans/:id/pay-installment', authorize('hr', 'all', 'update'), hrController.recordLoanInstallmentPayment);
// ========== PURCHASE REQUESTS ==========
router.get('/purchase-requests', authorize('hr', 'all', 'read'), hrController.findAllPurchaseRequests);
router.get('/purchase-requests/:id', authorize('hr', 'all', 'read'), hrController.findPurchaseRequestById);
router.post('/purchase-requests', authorize('hr', 'all', 'create'), hrController.createPurchaseRequest);
router.post('/purchase-requests/:id/approve', authorize('hr', 'all', 'approve'), hrController.approvePurchaseRequest);
router.post('/purchase-requests/:id/reject', authorize('hr', 'all', 'approve'), hrController.rejectPurchaseRequest);
// ========== LEAVE ENTITLEMENTS ==========
router.get('/leave-balance/:employeeId', authorize('hr', 'all', 'read'), hrController.getLeaveBalance);
router.get('/leave-entitlements', authorize('hr', 'all', 'read'), hrController.findAllLeaveEntitlements);
router.post('/leave-entitlements', authorize('hr', 'all', 'create'), hrController.upsertLeaveEntitlement);
// ========== EMPLOYEE CONTRACTS ==========
router.get('/contracts', authorize('hr', 'all', 'read'), hrController.findAllEmployeeContracts);
router.get('/contracts/:id', authorize('hr', 'all', 'read'), hrController.findEmployeeContractById);
router.post('/contracts', authorize('hr', 'all', 'create'), hrController.createEmployeeContract);
router.put('/contracts/:id', authorize('hr', 'all', 'update'), hrController.updateEmployeeContract);
export default router;

View File

@@ -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,
@@ -188,12 +227,74 @@ class HRService {
async recordAttendance(data: any, userId: string) {
const attendance = await prisma.attendance.create({
data,
data: {
...data,
sourceDeviceId: data.sourceDeviceId ?? undefined,
externalId: data.externalId ?? undefined,
rawData: data.rawData ?? undefined,
},
});
return attendance;
}
async bulkSyncAttendanceFromDevice(deviceId: string, records: Array<{ employeePin: string; checkIn?: string; checkOut?: string; date: string }>, userId: string) {
const results: { created: number; updated: number; skipped: number } = { created: 0, updated: 0, skipped: 0 };
for (const rec of records) {
const emp = await prisma.employee.findFirst({
where: {
OR: [
{ attendancePin: rec.employeePin },
{ uniqueEmployeeId: rec.employeePin },
],
},
});
if (!emp) {
results.skipped++;
continue;
}
const date = new Date(rec.date);
const existing = await prisma.attendance.findUnique({
where: { employeeId_date: { employeeId: emp.id, date } },
});
const checkIn = rec.checkIn ? new Date(rec.checkIn) : null;
const checkOut = rec.checkOut ? new Date(rec.checkOut) : null;
const workHours = checkIn && checkOut ? (checkOut.getTime() - checkIn.getTime()) / (1000 * 60 * 60) : null;
if (existing) {
await prisma.attendance.update({
where: { id: existing.id },
data: {
checkIn: checkIn ?? existing.checkIn,
checkOut: checkOut ?? existing.checkOut,
workHours: workHours ?? existing.workHours,
status: rec.checkIn ? 'PRESENT' : existing.status,
sourceDeviceId: deviceId,
externalId: `${deviceId}-${emp.id}-${rec.date}`,
rawData: rec as any,
},
});
results.updated++;
} else {
await prisma.attendance.create({
data: {
employeeId: emp.id,
date,
checkIn,
checkOut,
workHours,
status: rec.checkIn ? 'PRESENT' : 'ABSENT',
sourceDeviceId: deviceId,
externalId: `${deviceId}-${emp.id}-${rec.date}`,
rawData: rec as any,
},
});
results.created++;
}
}
await AuditLogger.log({ entityType: 'ATTENDANCE', entityId: deviceId, action: 'BULK_SYNC', userId, changes: results });
return results;
}
async getAttendance(employeeId: string, month: number, year: number) {
return prisma.attendance.findMany({
where: {
@@ -212,10 +313,24 @@ class HRService {
// ========== LEAVES ==========
async createLeaveRequest(data: any, userId: string) {
const days = this.calculateLeaveDays(data.startDate, data.endDate);
const startDate = new Date(data.startDate);
const year = startDate.getFullYear();
const ent = await prisma.leaveEntitlement.findUnique({
where: { employeeId_year_leaveType: { employeeId: data.employeeId, year, leaveType: data.leaveType } },
});
if (ent) {
const available = ent.totalDays + ent.carriedOver - ent.usedDays;
if (days > available) {
throw new AppError(400, `رصيد الإجازة غير كافٍ - Insufficient leave balance. Available: ${available}, Requested: ${days}`);
}
}
const leave = await prisma.leave.create({
data: {
...data,
days: this.calculateLeaveDays(data.startDate, data.endDate),
days,
},
include: {
employee: true,
@@ -239,12 +354,16 @@ class HRService {
status: 'APPROVED',
approvedBy,
approvedAt: new Date(),
rejectedReason: null,
},
include: {
employee: true,
},
});
const year = new Date(leave.startDate).getFullYear();
await this.updateLeaveEntitlementUsed(leave.employeeId, year, leave.leaveType, leave.days);
await AuditLogger.log({
entityType: 'LEAVE',
entityId: leave.id,
@@ -255,6 +374,62 @@ class HRService {
return leave;
}
async rejectLeave(id: string, rejectedReason: string, userId: string) {
const leave = await prisma.leave.findUnique({ where: { id } });
if (!leave) throw new AppError(404, 'طلب الإجازة غير موجود - Leave request not found');
const updated = await prisma.leave.update({
where: { id },
data: {
status: 'REJECTED',
rejectedReason,
approvedBy: null,
approvedAt: null,
},
include: { employee: true },
});
await AuditLogger.log({
entityType: 'LEAVE',
entityId: id,
action: 'REJECT',
userId,
reason: rejectedReason,
});
return updated;
}
async findAllLeaves(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
const skip = (page - 1) * pageSize;
const where: any = {};
if (filters.employeeId) where.employeeId = filters.employeeId;
if (filters.status && filters.status !== 'all') where.status = filters.status;
const [total, leaves] = await Promise.all([
prisma.leave.count({ where }),
prisma.leave.findMany({
where,
skip,
take: pageSize,
include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } } },
orderBy: { createdAt: 'desc' },
}),
]);
return { leaves, total, page, pageSize };
}
private async updateLeaveEntitlementUsed(employeeId: string, year: number, leaveType: string, days: number) {
const ent = await prisma.leaveEntitlement.findUnique({
where: { employeeId_year_leaveType: { employeeId, year, leaveType } },
});
if (ent) {
await prisma.leaveEntitlement.update({
where: { employeeId_year_leaveType: { employeeId, year, leaveType } },
data: { usedDays: { increment: days } },
});
}
}
// ========== SALARIES ==========
async processSalary(employeeId: string, month: number, year: number, userId: string) {
@@ -341,6 +516,323 @@ class HRService {
return salary;
}
// ========== LOANS ==========
private async generateLoanNumber(): Promise<string> {
const year = new Date().getFullYear();
const prefix = `LN-${year}-`;
const last = await prisma.loan.findFirst({
where: { loanNumber: { startsWith: prefix } },
orderBy: { createdAt: 'desc' },
select: { loanNumber: true },
});
let next = 1;
if (last) {
const parts = last.loanNumber.split('-');
next = parseInt(parts[2] || '0') + 1;
}
return `${prefix}${next.toString().padStart(4, '0')}`;
}
async findAllLoans(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
const skip = (page - 1) * pageSize;
const where: any = {};
if (filters.employeeId) where.employeeId = filters.employeeId;
if (filters.status && filters.status !== 'all') where.status = filters.status;
const [total, loans] = await Promise.all([
prisma.loan.count({ where }),
prisma.loan.findMany({
where,
skip,
take: pageSize,
include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } }, installmentsList: true },
orderBy: { createdAt: 'desc' },
}),
]);
return { loans, total, page, pageSize };
}
async findLoanById(id: string) {
const loan = await prisma.loan.findUnique({
where: { id },
include: { employee: true, installmentsList: { orderBy: { installmentNumber: 'asc' } } },
});
if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found');
return loan;
}
async createLoan(data: { employeeId: string; type: string; amount: number; installments?: number; reason?: string }, userId: string) {
const loanNumber = await this.generateLoanNumber();
const installments = data.installments || 1;
const monthlyAmount = data.amount / installments;
const loan = await prisma.loan.create({
data: {
loanNumber,
employeeId: data.employeeId,
type: data.type,
amount: data.amount,
installments,
monthlyAmount,
reason: data.reason,
status: 'PENDING',
},
include: { employee: true },
});
await AuditLogger.log({ entityType: 'LOAN', entityId: loan.id, action: 'CREATE', userId });
return loan;
}
async approveLoan(id: string, approvedBy: string, startDate: Date, userId: string) {
const loan = await prisma.loan.findUnique({ where: { id }, include: { installmentsList: true } });
if (!loan) throw new AppError(404, 'القرض غير موجود - Loan not found');
if (loan.status !== 'PENDING') throw new AppError(400, 'لا يمكن الموافقة على قرض غير معلق - Cannot approve non-pending loan');
const monthlyAmount = Number(loan.monthlyAmount || loan.amount) / loan.installments;
const installments: { installmentNumber: number; dueDate: Date; amount: number }[] = [];
let d = new Date(startDate);
for (let i = 1; i <= loan.installments; i++) {
installments.push({ installmentNumber: i, dueDate: new Date(d), amount: monthlyAmount });
d.setMonth(d.getMonth() + 1);
}
const endDate = installments.length ? installments[installments.length - 1].dueDate : null;
await prisma.$transaction([
prisma.loan.update({
where: { id },
data: { status: 'ACTIVE', approvedBy, approvedAt: new Date(), startDate, endDate },
}),
...installments.map((inst) =>
prisma.loanInstallment.create({
data: { loanId: id, installmentNumber: inst.installmentNumber, dueDate: inst.dueDate, amount: inst.amount, status: 'PENDING' },
})
),
]);
await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'APPROVE', userId });
return this.findLoanById(id);
}
async rejectLoan(id: string, rejectedReason: string, userId: string) {
const loan = await prisma.loan.update({
where: { id },
data: { status: 'REJECTED', rejectedReason },
include: { employee: true },
});
await AuditLogger.log({ entityType: 'LOAN', entityId: id, action: 'REJECT', userId, reason: rejectedReason });
return loan;
}
async recordLoanInstallmentPayment(loanId: string, installmentId: string, paidDate: Date, userId: string) {
await prisma.loanInstallment.update({
where: { id: installmentId },
data: { status: 'PAID', paidDate },
});
const allPaid = (await prisma.loanInstallment.count({ where: { loanId, status: 'PENDING' } })) === 0;
if (allPaid) {
await prisma.loan.update({ where: { id: loanId }, data: { status: 'PAID_OFF' } });
}
await AuditLogger.log({ entityType: 'LOAN_INSTALLMENT', entityId: installmentId, action: 'PAY', userId });
return this.findLoanById(loanId);
}
// ========== PURCHASE REQUESTS ==========
private async generatePurchaseRequestNumber(): Promise<string> {
const year = new Date().getFullYear();
const prefix = `PR-${year}-`;
const last = await prisma.purchaseRequest.findFirst({
where: { requestNumber: { startsWith: prefix } },
orderBy: { createdAt: 'desc' },
select: { requestNumber: true },
});
let next = 1;
if (last) {
const parts = last.requestNumber.split('-');
next = parseInt(parts[2] || '0') + 1;
}
return `${prefix}${next.toString().padStart(4, '0')}`;
}
async findAllPurchaseRequests(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
const skip = (page - 1) * pageSize;
const where: any = {};
if (filters.employeeId) where.employeeId = filters.employeeId;
if (filters.status && filters.status !== 'all') where.status = filters.status;
const [total, requests] = await Promise.all([
prisma.purchaseRequest.count({ where }),
prisma.purchaseRequest.findMany({
where,
skip,
take: pageSize,
include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } } },
orderBy: { createdAt: 'desc' },
}),
]);
return { purchaseRequests: requests, total, page, pageSize };
}
async findPurchaseRequestById(id: string) {
const req = await prisma.purchaseRequest.findUnique({ where: { id }, include: { employee: true } });
if (!req) throw new AppError(404, 'طلب الشراء غير موجود - Purchase request not found');
return req;
}
async createPurchaseRequest(data: { employeeId: string; items: any[]; reason?: string; priority?: string }, userId: string) {
const requestNumber = await this.generatePurchaseRequestNumber();
const totalAmount = Array.isArray(data.items)
? data.items.reduce((s: number, i: any) => s + (Number(i.estimatedPrice || 0) * Number(i.quantity || 1)), 0)
: 0;
const req = await prisma.purchaseRequest.create({
data: {
requestNumber,
employeeId: data.employeeId,
items: data.items,
totalAmount,
reason: data.reason,
priority: data.priority || 'NORMAL',
status: 'PENDING',
},
include: { employee: true },
});
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: req.id, action: 'CREATE', userId });
return req;
}
async approvePurchaseRequest(id: string, approvedBy: string, userId: string) {
const req = await prisma.purchaseRequest.update({
where: { id },
data: { status: 'APPROVED', approvedBy, approvedAt: new Date(), rejectedReason: null },
include: { employee: true },
});
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'APPROVE', userId });
return req;
}
async rejectPurchaseRequest(id: string, rejectedReason: string, userId: string) {
const req = await prisma.purchaseRequest.update({
where: { id },
data: { status: 'REJECTED', rejectedReason },
include: { employee: true },
});
await AuditLogger.log({ entityType: 'PURCHASE_REQUEST', entityId: id, action: 'REJECT', userId, reason: rejectedReason });
return req;
}
// ========== LEAVE ENTITLEMENTS ==========
async getLeaveBalance(employeeId: string, year: number) {
const entitlements = await prisma.leaveEntitlement.findMany({
where: { employeeId, year },
});
const approvedLeaves = await prisma.leave.findMany({
where: { employeeId, status: 'APPROVED', startDate: { gte: new Date(year, 0, 1) }, endDate: { lte: new Date(year, 11, 31) } },
});
const usedByType: Record<string, number> = {};
for (const l of approvedLeaves) {
usedByType[l.leaveType] = (usedByType[l.leaveType] || 0) + l.days;
}
return entitlements.map((e) => ({
leaveType: e.leaveType,
totalDays: e.totalDays,
carriedOver: e.carriedOver,
usedDays: usedByType[e.leaveType] ?? e.usedDays,
available: e.totalDays + e.carriedOver - (usedByType[e.leaveType] ?? e.usedDays),
}));
}
async findAllLeaveEntitlements(employeeId?: string, year?: number) {
const where: any = {};
if (employeeId) where.employeeId = employeeId;
if (year) where.year = year;
return prisma.leaveEntitlement.findMany({
where,
include: { employee: { select: { id: true, firstName: true, lastName: true } } },
orderBy: [{ employeeId: 'asc' }, { year: 'desc' }, { leaveType: 'asc' } ],
});
}
async upsertLeaveEntitlement(data: { employeeId: string; year: number; leaveType: string; totalDays: number; carriedOver?: number; notes?: string }, userId: string) {
const ent = await prisma.leaveEntitlement.upsert({
where: { employeeId_year_leaveType: { employeeId: data.employeeId, year: data.year, leaveType: data.leaveType } },
create: { employeeId: data.employeeId, year: data.year, leaveType: data.leaveType, totalDays: data.totalDays, carriedOver: data.carriedOver || 0, notes: data.notes },
update: { totalDays: data.totalDays, carriedOver: data.carriedOver ?? undefined, notes: data.notes },
});
await AuditLogger.log({ entityType: 'LEAVE_ENTITLEMENT', entityId: ent.id, action: 'UPSERT', userId });
return ent;
}
// ========== EMPLOYEE CONTRACTS ==========
private async generateContractNumber(): Promise<string> {
const year = new Date().getFullYear();
const prefix = `ECT-${year}-`;
const last = await prisma.employeeContract.findFirst({
where: { contractNumber: { startsWith: prefix } },
orderBy: { createdAt: 'desc' },
select: { contractNumber: true },
});
let next = 1;
if (last) {
const parts = last.contractNumber.split('-');
next = parseInt(parts[2] || '0') + 1;
}
return `${prefix}${next.toString().padStart(4, '0')}`;
}
async findAllEmployeeContracts(filters: { employeeId?: string; status?: string }, page: number, pageSize: number) {
const skip = (page - 1) * pageSize;
const where: any = {};
if (filters.employeeId) where.employeeId = filters.employeeId;
if (filters.status && filters.status !== 'all') where.status = filters.status;
const [total, contracts] = await Promise.all([
prisma.employeeContract.count({ where }),
prisma.employeeContract.findMany({
where,
skip,
take: pageSize,
include: { employee: { select: { id: true, firstName: true, lastName: true, uniqueEmployeeId: true } } },
orderBy: { startDate: 'desc' },
}),
]);
return { contracts, total, page, pageSize };
}
async findEmployeeContractById(id: string) {
const c = await prisma.employeeContract.findUnique({ where: { id }, include: { employee: true } });
if (!c) throw new AppError(404, 'العقد غير موجود - Contract not found');
return c;
}
async createEmployeeContract(data: { employeeId: string; type: string; startDate: Date; endDate?: Date; salary: number; documentUrl?: string; notes?: string }, userId: string) {
const contractNumber = await this.generateContractNumber();
const contract = await prisma.employeeContract.create({
data: {
contractNumber,
employeeId: data.employeeId,
type: data.type,
startDate: data.startDate,
endDate: data.endDate,
salary: data.salary,
documentUrl: data.documentUrl,
notes: data.notes,
status: 'ACTIVE',
},
include: { employee: true },
});
await AuditLogger.log({ entityType: 'EMPLOYEE_CONTRACT', entityId: contract.id, action: 'CREATE', userId });
return contract;
}
async updateEmployeeContract(id: string, data: { type?: string; endDate?: Date; salary?: number; documentUrl?: string; status?: string; notes?: string }, userId: string) {
const contract = await prisma.employeeContract.update({
where: { id },
data,
include: { employee: true },
});
await AuditLogger.log({ entityType: 'EMPLOYEE_CONTRACT', entityId: id, action: 'UPDATE', userId });
return contract;
}
// ========== HELPERS ==========
private async generateEmployeeId(): Promise<string> {
@@ -383,11 +875,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() {

View File

@@ -0,0 +1,106 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../../shared/middleware/auth';
import { portalService } from './portal.service';
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
export class PortalController {
async getMe(req: AuthRequest, res: Response, next: NextFunction) {
try {
const data = await portalService.getMe(req.user?.employeeId);
res.json(ResponseFormatter.success(data));
} catch (error) {
next(error);
}
}
async getMyLoans(req: AuthRequest, res: Response, next: NextFunction) {
try {
const loans = await portalService.getMyLoans(req.user?.employeeId);
res.json(ResponseFormatter.success(loans));
} catch (error) {
next(error);
}
}
async submitLoanRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const loan = await portalService.submitLoanRequest(req.user?.employeeId, req.body, req.user!.id);
res.status(201).json(ResponseFormatter.success(loan, 'تم إرسال طلب القرض - Loan request submitted'));
} catch (error) {
next(error);
}
}
async getMyLeaveBalance(req: AuthRequest, res: Response, next: NextFunction) {
try {
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
const balance = await portalService.getMyLeaveBalance(req.user?.employeeId, year);
res.json(ResponseFormatter.success(balance));
} catch (error) {
next(error);
}
}
async getMyLeaves(req: AuthRequest, res: Response, next: NextFunction) {
try {
const leaves = await portalService.getMyLeaves(req.user?.employeeId);
res.json(ResponseFormatter.success(leaves));
} catch (error) {
next(error);
}
}
async submitLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const data = {
...req.body,
startDate: new Date(req.body.startDate),
endDate: new Date(req.body.endDate),
};
const leave = await portalService.submitLeaveRequest(req.user?.employeeId, data, req.user!.id);
res.status(201).json(ResponseFormatter.success(leave, 'تم إرسال طلب الإجازة - Leave request submitted'));
} catch (error) {
next(error);
}
}
async getMyPurchaseRequests(req: AuthRequest, res: Response, next: NextFunction) {
try {
const requests = await portalService.getMyPurchaseRequests(req.user?.employeeId);
res.json(ResponseFormatter.success(requests));
} catch (error) {
next(error);
}
}
async submitPurchaseRequest(req: AuthRequest, res: Response, next: NextFunction) {
try {
const pr = await portalService.submitPurchaseRequest(req.user?.employeeId, req.body, req.user!.id);
res.status(201).json(ResponseFormatter.success(pr, 'تم إرسال طلب الشراء - Purchase request submitted'));
} catch (error) {
next(error);
}
}
async getMyAttendance(req: AuthRequest, res: Response, next: NextFunction) {
try {
const month = req.query.month ? parseInt(req.query.month as string) : undefined;
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
const attendance = await portalService.getMyAttendance(req.user?.employeeId, month, year);
res.json(ResponseFormatter.success(attendance));
} catch (error) {
next(error);
}
}
async getMySalaries(req: AuthRequest, res: Response, next: NextFunction) {
try {
const salaries = await portalService.getMySalaries(req.user?.employeeId);
res.json(ResponseFormatter.success(salaries));
} catch (error) {
next(error);
}
}
}
export const portalController = new PortalController();

View File

@@ -0,0 +1,114 @@
import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler';
import { hrService } from './hr.service';
class PortalService {
private requireEmployeeId(employeeId: string | undefined): string {
if (!employeeId) {
throw new AppError(403, 'يجب ربط المستخدم بموظف للوصول للبوابة - Employee link required for portal access');
}
return employeeId;
}
async getMe(employeeId: string | undefined) {
const empId = this.requireEmployeeId(employeeId);
const employee = await prisma.employee.findUnique({
where: { id: empId },
select: {
id: true,
uniqueEmployeeId: true,
firstName: true,
lastName: true,
firstNameAr: true,
lastNameAr: true,
email: true,
department: { select: { name: true, nameAr: true } },
position: { select: { title: true, titleAr: true } },
},
});
if (!employee) throw new AppError(404, 'الموظف غير موجود - Employee not found');
const [loansCount, pendingLeaves, pendingPurchaseRequests, leaveBalance] = await Promise.all([
prisma.loan.count({ where: { employeeId: empId, status: { in: ['PENDING', 'ACTIVE'] } } }),
prisma.leave.count({ where: { employeeId: empId, status: 'PENDING' } }),
prisma.purchaseRequest.count({ where: { employeeId: empId, status: 'PENDING' } }),
hrService.getLeaveBalance(empId, new Date().getFullYear()),
]);
return {
employee,
stats: {
activeLoansCount: loansCount,
pendingLeavesCount: pendingLeaves,
pendingPurchaseRequestsCount: pendingPurchaseRequests,
leaveBalance,
},
};
}
async getMyLoans(employeeId: string | undefined) {
const empId = this.requireEmployeeId(employeeId);
return prisma.loan.findMany({
where: { employeeId: empId },
include: { installmentsList: { orderBy: { installmentNumber: 'asc' } } },
orderBy: { createdAt: 'desc' },
});
}
async submitLoanRequest(employeeId: string | undefined, data: { type: string; amount: number; installments?: number; reason?: string }, userId: string) {
const empId = this.requireEmployeeId(employeeId);
return hrService.createLoan({ ...data, employeeId: empId }, userId);
}
async getMyLeaveBalance(employeeId: string | undefined, year?: number) {
const empId = this.requireEmployeeId(employeeId);
const y = year || new Date().getFullYear();
return hrService.getLeaveBalance(empId, y);
}
async getMyLeaves(employeeId: string | undefined) {
const empId = this.requireEmployeeId(employeeId);
return prisma.leave.findMany({
where: { employeeId: empId },
orderBy: { createdAt: 'desc' },
take: 50,
});
}
async submitLeaveRequest(employeeId: string | undefined, data: { leaveType: string; startDate: Date; endDate: Date; reason?: string }, userId: string) {
const empId = this.requireEmployeeId(employeeId);
return hrService.createLeaveRequest({ ...data, employeeId: empId }, userId);
}
async getMyPurchaseRequests(employeeId: string | undefined) {
const empId = this.requireEmployeeId(employeeId);
return prisma.purchaseRequest.findMany({
where: { employeeId: empId },
orderBy: { createdAt: 'desc' },
});
}
async submitPurchaseRequest(employeeId: string | undefined, data: { items: any[]; reason?: string; priority?: string }, userId: string) {
const empId = this.requireEmployeeId(employeeId);
return hrService.createPurchaseRequest({ ...data, employeeId: empId }, userId);
}
async getMyAttendance(employeeId: string | undefined, month?: number, year?: number) {
const empId = this.requireEmployeeId(employeeId);
const now = new Date();
const m = month ?? now.getMonth() + 1;
const y = year ?? now.getFullYear();
return hrService.getAttendance(empId, m, y);
}
async getMySalaries(employeeId: string | undefined) {
const empId = this.requireEmployeeId(employeeId);
return prisma.salary.findMany({
where: { employeeId: empId },
orderBy: [{ year: 'desc' }, { month: 'desc' }],
take: 24,
});
}
}
export const portalService = new PortalService();

View File

@@ -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);

View File

@@ -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');
}

View File

@@ -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 }),
});
}