feat: Complete Z.CRM system with all 6 modules
✨ Features: - Complete authentication system with JWT - Dashboard with all 6 modules visible - Contact Management module (Salesforce-style) - CRM & Sales Pipeline module (Pipedrive-style) - Inventory & Assets module (SAP-style) - Tasks & Projects module (Jira/Asana-style) - HR Management module (BambooHR-style) - Marketing Management module (HubSpot-style) - Admin Panel with user management and role matrix - World-class UI/UX with RTL Arabic support - Cairo font (headings) + Readex Pro font (body) - Sample data for all modules - Protected routes and authentication flow - Backend API with Prisma + PostgreSQL - Comprehensive documentation 🎨 Design: - Color-coded modules - Professional data tables - Stats cards with metrics - Progress bars and status badges - Search and filters - Responsive layout 📊 Tech Stack: - Frontend: Next.js 14, TypeScript, Tailwind CSS - Backend: Node.js, Express, Prisma - Database: PostgreSQL - Auth: JWT with bcrypt 🚀 Production-ready frontend with all features accessible
This commit is contained in:
10
backend/nodemon.json
Normal file
10
backend/nodemon.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"watch": ["src"],
|
||||
"ext": "ts,json",
|
||||
"ignore": ["src/**/*.spec.ts"],
|
||||
"exec": "ts-node -r tsconfig-paths/register src/server.ts",
|
||||
"env": {
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
}
|
||||
|
||||
6041
backend/package-lock.json
generated
Normal file
6041
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
backend/package.json
Normal file
50
backend/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "z-crm-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Z.CRM Backend API",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon src/server.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "ts-node prisma/seed.ts",
|
||||
"prisma:studio": "prisma studio",
|
||||
"test": "jest"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.8.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^3.0.6",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-validator": "^7.0.1",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "^20.10.6",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.2",
|
||||
"prisma": "^5.8.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
1386
backend/prisma/migrations/20260106091550_init/migration.sql
Normal file
1386
backend/prisma/migrations/20260106091550_init/migration.sql
Normal file
File diff suppressed because it is too large
Load Diff
3
backend/prisma/migrations/migration_lock.toml
Normal file
3
backend/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
1341
backend/prisma/schema.prisma
Normal file
1341
backend/prisma/schema.prisma
Normal file
File diff suppressed because it is too large
Load Diff
349
backend/prisma/seed.ts
Normal file
349
backend/prisma/seed.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 Starting database seeding...');
|
||||
|
||||
// Create Departments
|
||||
const salesDept = await prisma.department.create({
|
||||
data: {
|
||||
name: 'Sales Department',
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
const salesRepPosition = await prisma.position.create({
|
||||
data: {
|
||||
title: 'Sales Representative',
|
||||
titleAr: 'مندوب مبيعات',
|
||||
code: 'SALES_REP',
|
||||
departmentId: salesDept.id,
|
||||
level: 3,
|
||||
description: 'Sales Representative',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Created positions');
|
||||
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 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'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Create Permissions for Sales Rep
|
||||
await prisma.positionPermission.createMany({
|
||||
data: [
|
||||
{
|
||||
positionId: salesRepPosition.id,
|
||||
module: 'contacts',
|
||||
resource: 'contacts',
|
||||
actions: ['create', 'read', 'update'],
|
||||
},
|
||||
{
|
||||
positionId: salesRepPosition.id,
|
||||
module: 'crm',
|
||||
resource: 'deals',
|
||||
actions: ['create', 'read', 'update'],
|
||||
},
|
||||
{
|
||||
positionId: salesRepPosition.id,
|
||||
module: 'crm',
|
||||
resource: 'quotes',
|
||||
actions: ['create', 'read'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
console.log('✅ Created permissions');
|
||||
|
||||
// Create Employees
|
||||
const gmEmployee = 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'),
|
||||
gender: 'MALE',
|
||||
nationality: 'Saudi',
|
||||
employmentType: 'Full-time',
|
||||
contractType: 'Unlimited',
|
||||
hireDate: new Date('2020-01-01'),
|
||||
departmentId: salesDept.id,
|
||||
positionId: gmPosition.id,
|
||||
basicSalary: 50000,
|
||||
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
|
||||
const hashedPassword = await bcrypt.hash('Admin@123', 10);
|
||||
|
||||
const gmUser = await prisma.user.create({
|
||||
data: {
|
||||
email: 'gm@atmata.com',
|
||||
username: 'admin',
|
||||
password: hashedPassword,
|
||||
employeeId: gmEmployee.id,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const salesManagerUser = await prisma.user.create({
|
||||
data: {
|
||||
email: 'sales.manager@atmata.com',
|
||||
username: 'salesmanager',
|
||||
password: hashedPassword,
|
||||
employeeId: salesManagerEmployee.id,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
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' },
|
||||
],
|
||||
});
|
||||
|
||||
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('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('1. General Manager');
|
||||
console.log(' Email: gm@atmata.com');
|
||||
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('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ Error seeding database:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
15
backend/src/config/database.ts
Normal file
15
backend/src/config/database.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development'
|
||||
? ['query', 'error', 'warn']
|
||||
: ['error'],
|
||||
});
|
||||
|
||||
// Handle Prisma Client shutdown gracefully
|
||||
process.on('beforeExit', async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
export default prisma;
|
||||
|
||||
44
backend/src/config/index.ts
Normal file
44
backend/src/config/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const config = {
|
||||
env: process.env.NODE_ENV || 'development',
|
||||
port: parseInt(process.env.PORT || '5001', 10),
|
||||
apiVersion: process.env.API_VERSION || 'v1',
|
||||
|
||||
database: {
|
||||
url: process.env.DATABASE_URL || '',
|
||||
},
|
||||
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'change-this-secret',
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
|
||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
|
||||
},
|
||||
|
||||
cors: {
|
||||
origin: 'http://localhost:3000',
|
||||
},
|
||||
|
||||
upload: {
|
||||
maxFileSize: parseInt(process.env.MAX_FILE_SIZE || '10485760', 10), // 10MB
|
||||
path: process.env.UPLOAD_PATH || './uploads',
|
||||
},
|
||||
|
||||
pagination: {
|
||||
defaultPageSize: parseInt(process.env.DEFAULT_PAGE_SIZE || '20', 10),
|
||||
maxPageSize: parseInt(process.env.MAX_PAGE_SIZE || '100', 10),
|
||||
},
|
||||
|
||||
audit: {
|
||||
retentionDays: parseInt(process.env.AUDIT_LOG_RETENTION_DAYS || '2555', 10), // ~7 years
|
||||
},
|
||||
|
||||
security: {
|
||||
bcryptRounds: parseInt(process.env.BCRYPT_ROUNDS || '10', 10),
|
||||
rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 min
|
||||
rateLimitMaxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10),
|
||||
},
|
||||
};
|
||||
|
||||
97
backend/src/modules/auth/auth.controller.ts
Normal file
97
backend/src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Request, Response } from 'express'
|
||||
import { authService } from './auth.service'
|
||||
import { AuthRequest } from '@/shared/middleware/auth'
|
||||
|
||||
export const authController = {
|
||||
register: async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await authService.register(req.body)
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'تم التسجيل بنجاح',
|
||||
data: result
|
||||
})
|
||||
} catch (error: any) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
login: async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { email, password } = req.body
|
||||
const result = await authService.login(email, password)
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'تم تسجيل الدخول بنجاح',
|
||||
data: result
|
||||
})
|
||||
} catch (error: any) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
me: async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.user?.id
|
||||
if (!userId) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'غير مصرح'
|
||||
})
|
||||
}
|
||||
|
||||
const user = await authService.getUserById(userId)
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'تم جلب البيانات بنجاح',
|
||||
data: user
|
||||
})
|
||||
} catch (error: any) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
refreshToken: async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { refreshToken } = req.body
|
||||
const result = await authService.refreshToken(refreshToken)
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'تم تحديث الرمز بنجاح',
|
||||
data: result
|
||||
})
|
||||
} catch (error: any) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
logout: async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.user?.id
|
||||
if (userId) {
|
||||
await authService.logout(userId)
|
||||
}
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'تم تسجيل الخروج بنجاح'
|
||||
})
|
||||
} catch (error: any) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
47
backend/src/modules/auth/auth.routes.ts
Normal file
47
backend/src/modules/auth/auth.routes.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Router } from 'express'
|
||||
import { authController } from './auth.controller'
|
||||
import { validate } from '@/shared/middleware/validation'
|
||||
import { authenticate } from '@/shared/middleware/auth'
|
||||
import { body } from 'express-validator'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/**
|
||||
* @route POST /api/auth/register
|
||||
* @desc Register a new user
|
||||
* @access Public
|
||||
*/
|
||||
router.post(
|
||||
'/register',
|
||||
[
|
||||
body('email').isEmail().withMessage('البريد الإلكتروني غير صالح'),
|
||||
body('username').isLength({ min: 3 }).withMessage('اسم المستخدم يجب أن يكون 3 أحرف على الأقل'),
|
||||
body('password').isLength({ min: 8 }).withMessage('كلمة المرور يجب أن تكون 8 أحرف على الأقل'),
|
||||
],
|
||||
validate,
|
||||
authController.register
|
||||
)
|
||||
|
||||
/**
|
||||
* @route POST /api/auth/login
|
||||
* @desc Login user
|
||||
* @access Public
|
||||
*/
|
||||
router.post(
|
||||
'/login',
|
||||
[
|
||||
body('email').isEmail().withMessage('البريد الإلكتروني غير صالح'),
|
||||
body('password').notEmpty().withMessage('كلمة المرور مطلوبة'),
|
||||
],
|
||||
validate,
|
||||
authController.login
|
||||
)
|
||||
|
||||
/**
|
||||
* @route GET /api/auth/me
|
||||
* @desc Get current user profile
|
||||
* @access Private
|
||||
*/
|
||||
router.get('/me', authenticate, authController.me)
|
||||
|
||||
export default router
|
||||
280
backend/src/modules/auth/auth.service.ts
Normal file
280
backend/src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt, { Secret, SignOptions } from 'jsonwebtoken';
|
||||
import prisma from '../../config/database';
|
||||
import { config } from '../../config';
|
||||
import { AppError } from '../../shared/middleware/errorHandler';
|
||||
|
||||
class AuthService {
|
||||
async register(data: {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
employeeId?: string;
|
||||
}) {
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(data.password, config.security.bcryptRounds);
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
password: hashedPassword,
|
||||
employeeId: data.employeeId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
employeeId: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Generate tokens
|
||||
const tokens = this.generateTokens(user.id, user.email);
|
||||
|
||||
// Save refresh token
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { refreshToken: tokens.refreshToken },
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
// Find user with employee info and permissions
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
include: {
|
||||
employee: {
|
||||
include: {
|
||||
position: {
|
||||
include: {
|
||||
permissions: true,
|
||||
},
|
||||
},
|
||||
department: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(401, 'بيانات الدخول غير صحيحة - Invalid credentials');
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if (!user.isActive) {
|
||||
throw new AppError(403, 'الحساب غير مفعل - Account is inactive');
|
||||
}
|
||||
|
||||
// Check if account is locked
|
||||
if (user.lockedUntil && user.lockedUntil > new Date()) {
|
||||
throw new AppError(403, 'الحساب مقفل مؤقتاً - Account is temporarily locked');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
// Increment failed login attempts
|
||||
const failedAttempts = user.failedLoginAttempts + 1;
|
||||
const updateData: any = { failedLoginAttempts: failedAttempts };
|
||||
|
||||
// Lock account after 5 failed attempts
|
||||
if (failedAttempts >= 5) {
|
||||
updateData.lockedUntil = new Date(Date.now() + 30 * 60 * 1000); // Lock for 30 minutes
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
throw new AppError(401, 'بيانات الدخول غير صحيحة - Invalid credentials');
|
||||
}
|
||||
|
||||
// Check HR requirement: Must have active employee record
|
||||
if (!user.employee || user.employee.status !== 'ACTIVE') {
|
||||
throw new AppError(403, 'الوصول مرفوض - Access denied. Active employee record required.');
|
||||
}
|
||||
|
||||
// Reset failed attempts
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
lastLogin: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Generate tokens
|
||||
const tokens = this.generateTokens(user.id, user.email);
|
||||
|
||||
// Save refresh token
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { refreshToken: tokens.refreshToken },
|
||||
});
|
||||
|
||||
// Return user data without password, with role info
|
||||
const { password: _, ...userWithoutPassword } = user;
|
||||
|
||||
// Format role and permissions
|
||||
const role = user.employee?.position ? {
|
||||
id: user.employee.position.id,
|
||||
name: user.employee.position.titleAr || user.employee.position.title,
|
||||
nameEn: user.employee.position.title,
|
||||
permissions: user.employee.position.permissions || []
|
||||
} : null;
|
||||
|
||||
return {
|
||||
user: {
|
||||
...userWithoutPassword,
|
||||
role
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async getUserById(userId: string) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
employee: {
|
||||
include: {
|
||||
position: {
|
||||
include: {
|
||||
permissions: true,
|
||||
},
|
||||
},
|
||||
department: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(404, 'المستخدم غير موجود - User not found');
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
throw new AppError(403, 'الحساب غير مفعل - Account is inactive');
|
||||
}
|
||||
|
||||
// Format user data
|
||||
const { password: _, ...userWithoutPassword } = user;
|
||||
|
||||
const role = user.employee?.position ? {
|
||||
id: user.employee.position.id,
|
||||
name: user.employee.position.titleAr || user.employee.position.title,
|
||||
nameEn: user.employee.position.title,
|
||||
permissions: user.employee.position.permissions || []
|
||||
} : null;
|
||||
|
||||
return {
|
||||
...userWithoutPassword,
|
||||
role
|
||||
};
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string) {
|
||||
try {
|
||||
const decoded = jwt.verify(refreshToken, config.jwt.secret) as {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
// Verify refresh token matches stored token
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.id },
|
||||
});
|
||||
|
||||
if (!user || user.refreshToken !== refreshToken || !user.isActive) {
|
||||
throw new AppError(401, 'رمز غير صالح - Invalid token');
|
||||
}
|
||||
|
||||
// Generate new tokens
|
||||
const tokens = this.generateTokens(user.id, user.email);
|
||||
|
||||
// Update refresh token
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { refreshToken: tokens.refreshToken },
|
||||
});
|
||||
|
||||
return tokens;
|
||||
} catch (error) {
|
||||
throw new AppError(401, 'رمز غير صالح - Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
async logout(userId: string) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { refreshToken: null },
|
||||
});
|
||||
}
|
||||
|
||||
async getUserProfile(userId: string) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
isActive: true,
|
||||
lastLogin: true,
|
||||
employee: {
|
||||
include: {
|
||||
position: {
|
||||
include: {
|
||||
permissions: true,
|
||||
},
|
||||
},
|
||||
department: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(404, 'المستخدم غير موجود - User not found');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private generateTokens(userId: string, email: string) {
|
||||
const payload = { id: userId, email };
|
||||
const secret = config.jwt.secret as Secret;
|
||||
|
||||
const accessToken = jwt.sign(
|
||||
payload,
|
||||
secret,
|
||||
{ expiresIn: config.jwt.expiresIn } as SignOptions
|
||||
);
|
||||
|
||||
const refreshToken = jwt.sign(
|
||||
payload,
|
||||
secret,
|
||||
{ expiresIn: config.jwt.refreshExpiresIn } as SignOptions
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: config.jwt.expiresIn,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
|
||||
161
backend/src/modules/contacts/contacts.controller.ts
Normal file
161
backend/src/modules/contacts/contacts.controller.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Response, NextFunction } from 'express';
|
||||
import { AuthRequest } from '../../shared/middleware/auth';
|
||||
import { contactsService } from './contacts.service';
|
||||
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
|
||||
|
||||
class ContactsController {
|
||||
async create(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = {
|
||||
...req.body,
|
||||
createdById: req.user!.id,
|
||||
};
|
||||
|
||||
const contact = await contactsService.create(data, req.user!.id);
|
||||
|
||||
res.status(201).json(
|
||||
ResponseFormatter.success(contact, 'تم إنشاء جهة الاتصال بنجاح - Contact created successfully')
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findAll(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 as string,
|
||||
type: req.query.type as string,
|
||||
status: req.query.status as string,
|
||||
category: req.query.category as string,
|
||||
source: req.query.source as string,
|
||||
rating: req.query.rating ? parseInt(req.query.rating as string) : undefined,
|
||||
createdFrom: req.query.createdFrom ? new Date(req.query.createdFrom as string) : undefined,
|
||||
createdTo: req.query.createdTo ? new Date(req.query.createdTo as string) : undefined,
|
||||
};
|
||||
|
||||
const result = await contactsService.findAll(filters, page, pageSize);
|
||||
|
||||
res.json(ResponseFormatter.paginated(
|
||||
result.contacts,
|
||||
result.total,
|
||||
result.page,
|
||||
result.pageSize
|
||||
));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findById(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contact = await contactsService.findById(req.params.id);
|
||||
res.json(ResponseFormatter.success(contact));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async update(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contact = await contactsService.update(
|
||||
req.params.id,
|
||||
req.body,
|
||||
req.user!.id
|
||||
);
|
||||
|
||||
res.json(
|
||||
ResponseFormatter.success(contact, 'تم تحديث جهة الاتصال بنجاح - Contact updated successfully')
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async archive(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contact = await contactsService.archive(
|
||||
req.params.id,
|
||||
req.user!.id,
|
||||
req.body.reason
|
||||
);
|
||||
|
||||
res.json(
|
||||
ResponseFormatter.success(contact, 'تم أرشفة جهة الاتصال بنجاح - Contact archived successfully')
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
// This should be restricted by permissions - only GM can hard delete
|
||||
const contact = await contactsService.delete(
|
||||
req.params.id,
|
||||
req.user!.id,
|
||||
req.body.reason
|
||||
);
|
||||
|
||||
res.json(
|
||||
ResponseFormatter.success(contact, 'تم حذف جهة الاتصال نهائياً - Contact deleted permanently')
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async merge(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { sourceId, targetId, reason } = req.body;
|
||||
|
||||
const result = await contactsService.merge(
|
||||
sourceId,
|
||||
targetId,
|
||||
req.user!.id,
|
||||
reason
|
||||
);
|
||||
|
||||
res.json(
|
||||
ResponseFormatter.success(result, 'تم دمج جهات الاتصال بنجاح - Contacts merged successfully')
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async addRelationship(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { toContactId, type, startDate } = req.body;
|
||||
|
||||
const relationship = await contactsService.addRelationship(
|
||||
req.params.id,
|
||||
toContactId,
|
||||
type,
|
||||
new Date(startDate),
|
||||
req.user!.id
|
||||
);
|
||||
|
||||
res.status(201).json(
|
||||
ResponseFormatter.success(relationship, 'تم إضافة العلاقة بنجاح - Relationship added successfully')
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getHistory(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const history = await contactsService.getHistory(req.params.id);
|
||||
res.json(ResponseFormatter.success(history));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const contactsController = new ContactsController();
|
||||
|
||||
112
backend/src/modules/contacts/contacts.routes.ts
Normal file
112
backend/src/modules/contacts/contacts.routes.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Router } from 'express';
|
||||
import { body, param } from 'express-validator';
|
||||
import { contactsController } from './contacts.controller';
|
||||
import { authenticate, authorize } from '../../shared/middleware/auth';
|
||||
import { validate } from '../../shared/middleware/validation';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(authenticate);
|
||||
|
||||
// Get all contacts
|
||||
router.get(
|
||||
'/',
|
||||
authorize('contacts', 'contacts', 'read'),
|
||||
contactsController.findAll
|
||||
);
|
||||
|
||||
// Get contact by ID
|
||||
router.get(
|
||||
'/:id',
|
||||
authorize('contacts', 'contacts', 'read'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
contactsController.findById
|
||||
);
|
||||
|
||||
// Get contact history
|
||||
router.get(
|
||||
'/:id/history',
|
||||
authorize('contacts', 'contacts', 'read'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
contactsController.getHistory
|
||||
);
|
||||
|
||||
// Create contact
|
||||
router.post(
|
||||
'/',
|
||||
authorize('contacts', 'contacts', 'create'),
|
||||
[
|
||||
body('type').isIn(['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT']),
|
||||
body('name').notEmpty().trim(),
|
||||
body('email').optional().isEmail(),
|
||||
body('source').notEmpty(),
|
||||
validate,
|
||||
],
|
||||
contactsController.create
|
||||
);
|
||||
|
||||
// Update contact
|
||||
router.put(
|
||||
'/:id',
|
||||
authorize('contacts', 'contacts', 'update'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
body('email').optional().isEmail(),
|
||||
validate,
|
||||
],
|
||||
contactsController.update
|
||||
);
|
||||
|
||||
// Archive contact
|
||||
router.post(
|
||||
'/:id/archive',
|
||||
authorize('contacts', 'contacts', 'archive'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
contactsController.archive
|
||||
);
|
||||
|
||||
// Hard delete contact (GM only)
|
||||
router.delete(
|
||||
'/:id',
|
||||
authorize('contacts', 'contacts', 'delete'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
body('reason').notEmpty().withMessage('السبب مطلوب - Reason required'),
|
||||
validate,
|
||||
],
|
||||
contactsController.delete
|
||||
);
|
||||
|
||||
// Merge contacts
|
||||
router.post(
|
||||
'/merge',
|
||||
authorize('contacts', 'contacts', 'merge'),
|
||||
[
|
||||
body('sourceId').isUUID(),
|
||||
body('targetId').isUUID(),
|
||||
body('reason').notEmpty().withMessage('السبب مطلوب - Reason required'),
|
||||
validate,
|
||||
],
|
||||
contactsController.merge
|
||||
);
|
||||
|
||||
// Add relationship
|
||||
router.post(
|
||||
'/:id/relationships',
|
||||
authorize('contacts', 'contacts', 'create'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
body('toContactId').isUUID(),
|
||||
body('type').notEmpty(),
|
||||
body('startDate').isISO8601(),
|
||||
validate,
|
||||
],
|
||||
contactsController.addRelationship
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
546
backend/src/modules/contacts/contacts.service.ts
Normal file
546
backend/src/modules/contacts/contacts.service.ts
Normal file
@@ -0,0 +1,546 @@
|
||||
import prisma from '../../config/database';
|
||||
import { AppError } from '../../shared/middleware/errorHandler';
|
||||
import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
interface CreateContactData {
|
||||
type: string;
|
||||
name: string;
|
||||
nameAr?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
mobile?: string;
|
||||
website?: string;
|
||||
companyName?: string;
|
||||
companyNameAr?: string;
|
||||
taxNumber?: string;
|
||||
commercialRegister?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
country?: string;
|
||||
postalCode?: string;
|
||||
categories?: string[];
|
||||
tags?: string[];
|
||||
parentId?: string;
|
||||
source: string;
|
||||
customFields?: any;
|
||||
createdById: string;
|
||||
}
|
||||
|
||||
interface UpdateContactData extends Partial<CreateContactData> {
|
||||
status?: string;
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
interface SearchFilters {
|
||||
search?: string;
|
||||
type?: string;
|
||||
status?: string;
|
||||
category?: string;
|
||||
source?: string;
|
||||
rating?: number;
|
||||
createdFrom?: Date;
|
||||
createdTo?: Date;
|
||||
}
|
||||
|
||||
class ContactsService {
|
||||
async create(data: CreateContactData, userId: string) {
|
||||
// Check for duplicates based on email, phone, or tax number
|
||||
await this.checkDuplicates(data);
|
||||
|
||||
// Generate unique contact ID
|
||||
const uniqueContactId = await this.generateUniqueContactId();
|
||||
|
||||
// Create contact
|
||||
const contact = await prisma.contact.create({
|
||||
data: {
|
||||
uniqueContactId,
|
||||
type: data.type,
|
||||
name: data.name,
|
||||
nameAr: data.nameAr,
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
mobile: data.mobile,
|
||||
website: data.website,
|
||||
companyName: data.companyName,
|
||||
companyNameAr: data.companyNameAr,
|
||||
taxNumber: data.taxNumber,
|
||||
commercialRegister: data.commercialRegister,
|
||||
address: data.address,
|
||||
city: data.city,
|
||||
country: data.country,
|
||||
postalCode: data.postalCode,
|
||||
categories: data.categories ? {
|
||||
connect: data.categories.map(id => ({ id }))
|
||||
} : undefined,
|
||||
tags: data.tags || [],
|
||||
parentId: data.parentId,
|
||||
source: data.source,
|
||||
customFields: data.customFields || {},
|
||||
createdById: data.createdById,
|
||||
},
|
||||
include: {
|
||||
categories: true,
|
||||
parent: true,
|
||||
createdBy: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Log audit
|
||||
await AuditLogger.log({
|
||||
entityType: 'CONTACT',
|
||||
entityId: contact.id,
|
||||
action: 'CREATE',
|
||||
userId,
|
||||
});
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
async findAll(filters: SearchFilters, page: number = 1, pageSize: number = 20) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
// Build where clause
|
||||
const where: Prisma.ContactWhereInput = {
|
||||
archivedAt: null, // Don't show archived contacts
|
||||
};
|
||||
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ name: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ nameAr: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ email: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ phone: { contains: filters.search } },
|
||||
{ mobile: { contains: filters.search } },
|
||||
{ companyName: { contains: filters.search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
if (filters.type) {
|
||||
where.type = filters.type;
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
where.status = filters.status;
|
||||
}
|
||||
|
||||
if (filters.source) {
|
||||
where.source = filters.source;
|
||||
}
|
||||
|
||||
if (filters.rating !== undefined) {
|
||||
where.rating = filters.rating;
|
||||
}
|
||||
|
||||
if (filters.createdFrom || filters.createdTo) {
|
||||
where.createdAt = {};
|
||||
if (filters.createdFrom) {
|
||||
where.createdAt.gte = filters.createdFrom;
|
||||
}
|
||||
if (filters.createdTo) {
|
||||
where.createdAt.lte = filters.createdTo;
|
||||
}
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const total = await prisma.contact.count({ where });
|
||||
|
||||
// Get contacts
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
categories: true,
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
createdBy: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
contacts,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
categories: true,
|
||||
parent: true,
|
||||
children: true,
|
||||
relationships: {
|
||||
include: {
|
||||
toContact: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
relatedTo: {
|
||||
include: {
|
||||
fromContact: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
activities: {
|
||||
take: 20,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
deals: {
|
||||
take: 10,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
notes: {
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
attachments: {
|
||||
orderBy: {
|
||||
uploadedAt: 'desc',
|
||||
},
|
||||
},
|
||||
createdBy: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
throw new AppError(404, 'جهة الاتصال غير موجودة - Contact not found');
|
||||
}
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateContactData, userId: string) {
|
||||
// Get existing contact
|
||||
const existing = await prisma.contact.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new AppError(404, 'جهة الاتصال غير موجودة - Contact not found');
|
||||
}
|
||||
|
||||
// Check for duplicates if email/phone/tax changed
|
||||
if (data.email || data.phone || data.taxNumber) {
|
||||
await this.checkDuplicates(data as CreateContactData, id);
|
||||
}
|
||||
|
||||
// Update contact
|
||||
const contact = await prisma.contact.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: data.name,
|
||||
nameAr: data.nameAr,
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
mobile: data.mobile,
|
||||
website: data.website,
|
||||
companyName: data.companyName,
|
||||
companyNameAr: data.companyNameAr,
|
||||
taxNumber: data.taxNumber,
|
||||
commercialRegister: data.commercialRegister,
|
||||
address: data.address,
|
||||
city: data.city,
|
||||
country: data.country,
|
||||
postalCode: data.postalCode,
|
||||
categories: data.categories ? {
|
||||
set: data.categories.map(id => ({ id }))
|
||||
} : undefined,
|
||||
tags: data.tags,
|
||||
source: data.source,
|
||||
status: data.status,
|
||||
rating: data.rating,
|
||||
customFields: data.customFields,
|
||||
},
|
||||
include: {
|
||||
categories: true,
|
||||
parent: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Log audit
|
||||
await AuditLogger.log({
|
||||
entityType: 'CONTACT',
|
||||
entityId: contact.id,
|
||||
action: 'UPDATE',
|
||||
userId,
|
||||
changes: {
|
||||
before: existing,
|
||||
after: contact,
|
||||
},
|
||||
});
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
async archive(id: string, userId: string, reason?: string) {
|
||||
const contact = await prisma.contact.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'ARCHIVED',
|
||||
archivedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'CONTACT',
|
||||
entityId: contact.id,
|
||||
action: 'ARCHIVE',
|
||||
userId,
|
||||
reason,
|
||||
});
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string, reason: string) {
|
||||
// Hard delete - only for authorized users
|
||||
// This should be restricted at the controller level
|
||||
|
||||
const contact = await prisma.contact.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'CONTACT',
|
||||
entityId: id,
|
||||
action: 'DELETE',
|
||||
userId,
|
||||
reason,
|
||||
});
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
async merge(sourceId: string, targetId: string, userId: string, reason: string) {
|
||||
// Get both contacts
|
||||
const source = await prisma.contact.findUnique({ where: { id: sourceId } });
|
||||
const target = await prisma.contact.findUnique({ where: { id: targetId } });
|
||||
|
||||
if (!source || !target) {
|
||||
throw new AppError(404, 'جهة الاتصال غير موجودة - Contact not found');
|
||||
}
|
||||
|
||||
// Start transaction
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Update all related records to point to target
|
||||
await tx.deal.updateMany({
|
||||
where: { contactId: sourceId },
|
||||
data: { contactId: targetId },
|
||||
});
|
||||
|
||||
await tx.activity.updateMany({
|
||||
where: { contactId: sourceId },
|
||||
data: { contactId: targetId },
|
||||
});
|
||||
|
||||
await tx.note.updateMany({
|
||||
where: { contactId: sourceId },
|
||||
data: { contactId: targetId },
|
||||
});
|
||||
|
||||
await tx.attachment.updateMany({
|
||||
where: { contactId: sourceId },
|
||||
data: { contactId: targetId },
|
||||
});
|
||||
|
||||
// Archive source contact
|
||||
await tx.contact.update({
|
||||
where: { id: sourceId },
|
||||
data: {
|
||||
status: 'ARCHIVED',
|
||||
archivedAt: new Date(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Log audit
|
||||
await AuditLogger.log({
|
||||
entityType: 'CONTACT',
|
||||
entityId: targetId,
|
||||
action: 'MERGE',
|
||||
userId,
|
||||
reason,
|
||||
changes: {
|
||||
sourceId,
|
||||
targetId,
|
||||
sourceData: source,
|
||||
},
|
||||
});
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
async addRelationship(
|
||||
fromContactId: string,
|
||||
toContactId: string,
|
||||
type: string,
|
||||
startDate: Date,
|
||||
userId: string
|
||||
) {
|
||||
const relationship = await prisma.contactRelationship.create({
|
||||
data: {
|
||||
fromContactId,
|
||||
toContactId,
|
||||
type,
|
||||
startDate,
|
||||
},
|
||||
include: {
|
||||
fromContact: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
toContact: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'CONTACT_RELATIONSHIP',
|
||||
entityId: relationship.id,
|
||||
action: 'CREATE',
|
||||
userId,
|
||||
});
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
async getHistory(id: string) {
|
||||
return AuditLogger.getEntityHistory('CONTACT', id);
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
private async checkDuplicates(data: CreateContactData, excludeId?: string) {
|
||||
const conditions: Prisma.ContactWhereInput[] = [];
|
||||
|
||||
if (data.email) {
|
||||
conditions.push({ email: data.email });
|
||||
}
|
||||
|
||||
if (data.phone) {
|
||||
conditions.push({ phone: data.phone });
|
||||
}
|
||||
|
||||
if (data.mobile) {
|
||||
conditions.push({ mobile: data.mobile });
|
||||
}
|
||||
|
||||
if (data.taxNumber) {
|
||||
conditions.push({ taxNumber: data.taxNumber });
|
||||
}
|
||||
|
||||
if (data.commercialRegister) {
|
||||
conditions.push({ commercialRegister: data.commercialRegister });
|
||||
}
|
||||
|
||||
if (conditions.length === 0) return;
|
||||
|
||||
const where: Prisma.ContactWhereInput = {
|
||||
OR: conditions,
|
||||
};
|
||||
|
||||
if (excludeId) {
|
||||
where.NOT = { id: excludeId };
|
||||
}
|
||||
|
||||
const duplicate = await prisma.contact.findFirst({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
mobile: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (duplicate) {
|
||||
throw new AppError(
|
||||
409,
|
||||
`جهة اتصال مكررة - Duplicate contact found: ${duplicate.name}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async generateUniqueContactId(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = `CNT-${year}-`;
|
||||
|
||||
// Get the last contact for this year
|
||||
const lastContact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
uniqueContactId: {
|
||||
startsWith: prefix,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
select: {
|
||||
uniqueContactId: true,
|
||||
},
|
||||
});
|
||||
|
||||
let nextNumber = 1;
|
||||
if (lastContact) {
|
||||
const lastNumber = parseInt(lastContact.uniqueContactId.split('-')[2]);
|
||||
nextNumber = lastNumber + 1;
|
||||
}
|
||||
|
||||
return `${prefix}${nextNumber.toString().padStart(6, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const contactsService = new ContactsService();
|
||||
|
||||
202
backend/src/modules/crm/crm.controller.ts
Normal file
202
backend/src/modules/crm/crm.controller.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { Response, NextFunction } from 'express';
|
||||
import { AuthRequest } from '../../shared/middleware/auth';
|
||||
import { dealsService } from './deals.service';
|
||||
import { quotesService } from './quotes.service';
|
||||
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
|
||||
|
||||
export class DealsController {
|
||||
async create(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = {
|
||||
...req.body,
|
||||
ownerId: req.body.ownerId || req.user!.id,
|
||||
fiscalYear: req.body.fiscalYear || new Date().getFullYear(),
|
||||
};
|
||||
|
||||
const deal = await dealsService.create(data, req.user!.id);
|
||||
|
||||
res.status(201).json(
|
||||
ResponseFormatter.success(deal, 'تم إنشاء الصفقة بنجاح - Deal created successfully')
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findAll(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,
|
||||
structure: req.query.structure,
|
||||
stage: req.query.stage,
|
||||
status: req.query.status,
|
||||
ownerId: req.query.ownerId,
|
||||
fiscalYear: req.query.fiscalYear,
|
||||
};
|
||||
|
||||
const result = await dealsService.findAll(filters, page, pageSize);
|
||||
|
||||
res.json(ResponseFormatter.paginated(
|
||||
result.deals,
|
||||
result.total,
|
||||
result.page,
|
||||
result.pageSize
|
||||
));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findById(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const deal = await dealsService.findById(req.params.id);
|
||||
res.json(ResponseFormatter.success(deal));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async update(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const deal = await dealsService.update(
|
||||
req.params.id,
|
||||
req.body,
|
||||
req.user!.id
|
||||
);
|
||||
|
||||
res.json(
|
||||
ResponseFormatter.success(deal, 'تم تحديث الصفقة بنجاح - Deal updated successfully')
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateStage(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { stage } = req.body;
|
||||
const deal = await dealsService.updateStage(
|
||||
req.params.id,
|
||||
stage,
|
||||
req.user!.id
|
||||
);
|
||||
|
||||
res.json(
|
||||
ResponseFormatter.success(deal, 'تم تحديث مرحلة الصفقة - Deal stage updated')
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async win(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { actualValue, wonReason } = req.body;
|
||||
const deal = await dealsService.win(
|
||||
req.params.id,
|
||||
actualValue,
|
||||
wonReason,
|
||||
req.user!.id
|
||||
);
|
||||
|
||||
res.json(
|
||||
ResponseFormatter.success(deal, '🎉 تم الفوز بالصفقة - Deal won successfully!')
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async lose(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { lostReason } = req.body;
|
||||
const deal = await dealsService.lose(
|
||||
req.params.id,
|
||||
lostReason,
|
||||
req.user!.id
|
||||
);
|
||||
|
||||
res.json(
|
||||
ResponseFormatter.success(deal, 'تم تسجيل خسارة الصفقة - Deal marked as lost')
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getHistory(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const history = await dealsService.getHistory(req.params.id);
|
||||
res.json(ResponseFormatter.success(history));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class QuotesController {
|
||||
async create(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const quote = await quotesService.create(req.body, req.user!.id);
|
||||
|
||||
res.status(201).json(
|
||||
ResponseFormatter.success(quote, 'تم إنشاء عرض السعر بنجاح - Quote created successfully')
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findById(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const quote = await quotesService.findById(req.params.id);
|
||||
res.json(ResponseFormatter.success(quote));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findByDeal(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const quotes = await quotesService.findByDeal(req.params.dealId);
|
||||
res.json(ResponseFormatter.success(quotes));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async approve(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const quote = await quotesService.approve(
|
||||
req.params.id,
|
||||
req.user!.id,
|
||||
req.user!.id
|
||||
);
|
||||
|
||||
res.json(
|
||||
ResponseFormatter.success(quote, 'تمت الموافقة على عرض السعر - Quote approved')
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async send(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const quote = await quotesService.markAsSent(req.params.id, req.user!.id);
|
||||
|
||||
res.json(
|
||||
ResponseFormatter.success(quote, 'تم إرسال عرض السعر - Quote sent')
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const dealsController = new DealsController();
|
||||
export const quotesController = new QuotesController();
|
||||
|
||||
157
backend/src/modules/crm/crm.routes.ts
Normal file
157
backend/src/modules/crm/crm.routes.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Router } from 'express';
|
||||
import { body, param } from 'express-validator';
|
||||
import { dealsController, quotesController } from './crm.controller';
|
||||
import { authenticate, authorize } from '../../shared/middleware/auth';
|
||||
import { validate } from '../../shared/middleware/validation';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(authenticate);
|
||||
|
||||
// ============= DEALS =============
|
||||
|
||||
// Get all deals
|
||||
router.get(
|
||||
'/deals',
|
||||
authorize('crm', 'deals', 'read'),
|
||||
dealsController.findAll
|
||||
);
|
||||
|
||||
// Get deal by ID
|
||||
router.get(
|
||||
'/deals/:id',
|
||||
authorize('crm', 'deals', 'read'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
dealsController.findById
|
||||
);
|
||||
|
||||
// Get deal history
|
||||
router.get(
|
||||
'/deals/:id/history',
|
||||
authorize('crm', 'deals', 'read'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
dealsController.getHistory
|
||||
);
|
||||
|
||||
// Create deal
|
||||
router.post(
|
||||
'/deals',
|
||||
authorize('crm', 'deals', 'create'),
|
||||
[
|
||||
body('name').notEmpty().trim(),
|
||||
body('contactId').isUUID(),
|
||||
body('structure').isIn(['B2B', 'B2C', 'B2G', 'PARTNERSHIP']),
|
||||
body('pipelineId').isUUID(),
|
||||
body('stage').notEmpty(),
|
||||
body('estimatedValue').isNumeric(),
|
||||
validate,
|
||||
],
|
||||
dealsController.create
|
||||
);
|
||||
|
||||
// Update deal
|
||||
router.put(
|
||||
'/deals/:id',
|
||||
authorize('crm', 'deals', 'update'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
dealsController.update
|
||||
);
|
||||
|
||||
// Update deal stage
|
||||
router.patch(
|
||||
'/deals/:id/stage',
|
||||
authorize('crm', 'deals', 'update'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
body('stage').notEmpty(),
|
||||
validate,
|
||||
],
|
||||
dealsController.updateStage
|
||||
);
|
||||
|
||||
// Mark deal as won
|
||||
router.post(
|
||||
'/deals/:id/win',
|
||||
authorize('crm', 'deals', 'update'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
body('actualValue').isNumeric(),
|
||||
body('wonReason').notEmpty(),
|
||||
validate,
|
||||
],
|
||||
dealsController.win
|
||||
);
|
||||
|
||||
// Mark deal as lost
|
||||
router.post(
|
||||
'/deals/:id/lose',
|
||||
authorize('crm', 'deals', 'update'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
body('lostReason').notEmpty(),
|
||||
validate,
|
||||
],
|
||||
dealsController.lose
|
||||
);
|
||||
|
||||
// ============= QUOTES =============
|
||||
|
||||
// Get quotes for a deal
|
||||
router.get(
|
||||
'/deals/:dealId/quotes',
|
||||
authorize('crm', 'quotes', 'read'),
|
||||
param('dealId').isUUID(),
|
||||
validate,
|
||||
quotesController.findByDeal
|
||||
);
|
||||
|
||||
// Get quote by ID
|
||||
router.get(
|
||||
'/quotes/:id',
|
||||
authorize('crm', 'quotes', 'read'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
quotesController.findById
|
||||
);
|
||||
|
||||
// Create quote
|
||||
router.post(
|
||||
'/quotes',
|
||||
authorize('crm', 'quotes', 'create'),
|
||||
[
|
||||
body('dealId').isUUID(),
|
||||
body('items').isArray(),
|
||||
body('subtotal').isNumeric(),
|
||||
body('taxRate').isNumeric(),
|
||||
body('taxAmount').isNumeric(),
|
||||
body('total').isNumeric(),
|
||||
body('validUntil').isISO8601(),
|
||||
validate,
|
||||
],
|
||||
quotesController.create
|
||||
);
|
||||
|
||||
// Approve quote
|
||||
router.post(
|
||||
'/quotes/:id/approve',
|
||||
authorize('crm', 'quotes', 'approve'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
quotesController.approve
|
||||
);
|
||||
|
||||
// Send quote
|
||||
router.post(
|
||||
'/quotes/:id/send',
|
||||
authorize('crm', 'quotes', 'update'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
quotesController.send
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
398
backend/src/modules/crm/deals.service.ts
Normal file
398
backend/src/modules/crm/deals.service.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
import prisma from '../../config/database';
|
||||
import { AppError } from '../../shared/middleware/errorHandler';
|
||||
import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
interface CreateDealData {
|
||||
name: string;
|
||||
contactId: string;
|
||||
structure: string; // B2B, B2C, B2G, PARTNERSHIP
|
||||
pipelineId: string;
|
||||
stage: string;
|
||||
estimatedValue: number;
|
||||
probability?: number;
|
||||
expectedCloseDate?: Date;
|
||||
ownerId: string;
|
||||
fiscalYear: number;
|
||||
}
|
||||
|
||||
interface UpdateDealData extends Partial<CreateDealData> {
|
||||
stage?: string;
|
||||
actualValue?: number;
|
||||
actualCloseDate?: Date;
|
||||
wonReason?: string;
|
||||
lostReason?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
class DealsService {
|
||||
async create(data: CreateDealData, userId: string) {
|
||||
// Generate deal number
|
||||
const dealNumber = await this.generateDealNumber();
|
||||
|
||||
const deal = await prisma.deal.create({
|
||||
data: {
|
||||
dealNumber,
|
||||
name: data.name,
|
||||
contactId: data.contactId,
|
||||
structure: data.structure,
|
||||
pipelineId: data.pipelineId,
|
||||
stage: data.stage,
|
||||
estimatedValue: data.estimatedValue,
|
||||
probability: data.probability,
|
||||
expectedCloseDate: data.expectedCloseDate,
|
||||
ownerId: data.ownerId,
|
||||
fiscalYear: data.fiscalYear,
|
||||
currency: 'SAR',
|
||||
},
|
||||
include: {
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
pipeline: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'DEAL',
|
||||
entityId: deal.id,
|
||||
action: 'CREATE',
|
||||
userId,
|
||||
});
|
||||
|
||||
return deal;
|
||||
}
|
||||
|
||||
async findAll(filters: any, page: number, pageSize: number) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.DealWhereInput = {};
|
||||
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ name: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ dealNumber: { contains: filters.search } },
|
||||
];
|
||||
}
|
||||
|
||||
if (filters.structure) {
|
||||
where.structure = filters.structure;
|
||||
}
|
||||
|
||||
if (filters.stage) {
|
||||
where.stage = filters.stage;
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
where.status = filters.status;
|
||||
}
|
||||
|
||||
if (filters.ownerId) {
|
||||
where.ownerId = filters.ownerId;
|
||||
}
|
||||
|
||||
if (filters.fiscalYear) {
|
||||
where.fiscalYear = parseInt(filters.fiscalYear);
|
||||
}
|
||||
|
||||
const total = await prisma.deal.count({ where });
|
||||
|
||||
const deals = await prisma.deal.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
pipeline: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
deals,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
const deal = await prisma.deal.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
contact: {
|
||||
include: {
|
||||
categories: true,
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
employee: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
position: true,
|
||||
department: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pipeline: true,
|
||||
quotes: {
|
||||
orderBy: {
|
||||
version: 'desc',
|
||||
},
|
||||
},
|
||||
costSheets: {
|
||||
orderBy: {
|
||||
version: 'desc',
|
||||
},
|
||||
},
|
||||
activities: {
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 20,
|
||||
},
|
||||
notes: {
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
attachments: {
|
||||
orderBy: {
|
||||
uploadedAt: 'desc',
|
||||
},
|
||||
},
|
||||
contracts: {
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
invoices: {
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!deal) {
|
||||
throw new AppError(404, 'الصفقة غير موجودة - Deal not found');
|
||||
}
|
||||
|
||||
return deal;
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateDealData, userId: string) {
|
||||
const existing = await prisma.deal.findUnique({ where: { id } });
|
||||
|
||||
if (!existing) {
|
||||
throw new AppError(404, 'الصفقة غير موجودة - Deal not found');
|
||||
}
|
||||
|
||||
const deal = await prisma.deal.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: data.name,
|
||||
contactId: data.contactId,
|
||||
stage: data.stage,
|
||||
estimatedValue: data.estimatedValue,
|
||||
actualValue: data.actualValue,
|
||||
probability: data.probability,
|
||||
expectedCloseDate: data.expectedCloseDate,
|
||||
actualCloseDate: data.actualCloseDate,
|
||||
wonReason: data.wonReason,
|
||||
lostReason: data.lostReason,
|
||||
status: data.status,
|
||||
},
|
||||
include: {
|
||||
contact: true,
|
||||
owner: true,
|
||||
pipeline: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'DEAL',
|
||||
entityId: deal.id,
|
||||
action: 'UPDATE',
|
||||
userId,
|
||||
changes: {
|
||||
before: existing,
|
||||
after: deal,
|
||||
},
|
||||
});
|
||||
|
||||
return deal;
|
||||
}
|
||||
|
||||
async updateStage(id: string, stage: string, userId: string) {
|
||||
const deal = await prisma.deal.update({
|
||||
where: { id },
|
||||
data: { stage },
|
||||
include: {
|
||||
contact: true,
|
||||
owner: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'DEAL',
|
||||
entityId: deal.id,
|
||||
action: 'STAGE_CHANGE',
|
||||
userId,
|
||||
changes: { stage },
|
||||
});
|
||||
|
||||
// Create notification
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: deal.ownerId,
|
||||
type: 'DEAL_STAGE_CHANGED',
|
||||
title: 'تغيير مرحلة الصفقة - Deal stage changed',
|
||||
message: `تم تغيير مرحلة الصفقة "${deal.name}" إلى "${stage}"`,
|
||||
entityType: 'DEAL',
|
||||
entityId: deal.id,
|
||||
},
|
||||
});
|
||||
|
||||
return deal;
|
||||
}
|
||||
|
||||
async win(id: string, actualValue: number, wonReason: string, userId: string) {
|
||||
const deal = await prisma.deal.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'WON',
|
||||
stage: 'WON',
|
||||
actualValue,
|
||||
wonReason,
|
||||
actualCloseDate: new Date(),
|
||||
},
|
||||
include: {
|
||||
contact: true,
|
||||
owner: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'DEAL',
|
||||
entityId: deal.id,
|
||||
action: 'WIN',
|
||||
userId,
|
||||
changes: {
|
||||
status: 'WON',
|
||||
actualValue,
|
||||
wonReason,
|
||||
},
|
||||
});
|
||||
|
||||
// Create notification
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: deal.ownerId,
|
||||
type: 'DEAL_WON',
|
||||
title: '🎉 صفقة رابحة - Deal Won!',
|
||||
message: `تم الفوز بالصفقة "${deal.name}" بقيمة ${actualValue} ريال`,
|
||||
entityType: 'DEAL',
|
||||
entityId: deal.id,
|
||||
},
|
||||
});
|
||||
|
||||
return deal;
|
||||
}
|
||||
|
||||
async lose(id: string, lostReason: string, userId: string) {
|
||||
const deal = await prisma.deal.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'LOST',
|
||||
stage: 'LOST',
|
||||
lostReason,
|
||||
actualCloseDate: new Date(),
|
||||
},
|
||||
include: {
|
||||
contact: true,
|
||||
owner: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'DEAL',
|
||||
entityId: deal.id,
|
||||
action: 'LOSE',
|
||||
userId,
|
||||
changes: {
|
||||
status: 'LOST',
|
||||
lostReason,
|
||||
},
|
||||
});
|
||||
|
||||
return deal;
|
||||
}
|
||||
|
||||
async getHistory(id: string) {
|
||||
return AuditLogger.getEntityHistory('DEAL', id);
|
||||
}
|
||||
|
||||
private async generateDealNumber(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = `DEAL-${year}-`;
|
||||
|
||||
const lastDeal = await prisma.deal.findFirst({
|
||||
where: {
|
||||
dealNumber: {
|
||||
startsWith: prefix,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
select: {
|
||||
dealNumber: true,
|
||||
},
|
||||
});
|
||||
|
||||
let nextNumber = 1;
|
||||
if (lastDeal) {
|
||||
const lastNumber = parseInt(lastDeal.dealNumber.split('-')[2]);
|
||||
nextNumber = lastNumber + 1;
|
||||
}
|
||||
|
||||
return `${prefix}${nextNumber.toString().padStart(6, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const dealsService = new DealsService();
|
||||
|
||||
207
backend/src/modules/crm/quotes.service.ts
Normal file
207
backend/src/modules/crm/quotes.service.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import prisma from '../../config/database';
|
||||
import { AppError } from '../../shared/middleware/errorHandler';
|
||||
import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||
|
||||
interface CreateQuoteData {
|
||||
dealId: string;
|
||||
items: any[];
|
||||
subtotal: number;
|
||||
discountType?: string;
|
||||
discountValue?: number;
|
||||
taxRate: number;
|
||||
taxAmount: number;
|
||||
total: number;
|
||||
validUntil: Date;
|
||||
paymentTerms?: string;
|
||||
deliveryTerms?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
class QuotesService {
|
||||
async create(data: CreateQuoteData, userId: string) {
|
||||
// Get latest version for this deal
|
||||
const latestQuote = await prisma.quote.findFirst({
|
||||
where: { dealId: data.dealId },
|
||||
orderBy: { version: 'desc' },
|
||||
select: { version: true },
|
||||
});
|
||||
|
||||
const version = latestQuote ? latestQuote.version + 1 : 1;
|
||||
|
||||
// Generate quote number
|
||||
const quoteNumber = await this.generateQuoteNumber();
|
||||
|
||||
const quote = await prisma.quote.create({
|
||||
data: {
|
||||
quoteNumber,
|
||||
dealId: data.dealId,
|
||||
version,
|
||||
items: data.items,
|
||||
subtotal: data.subtotal,
|
||||
discountType: data.discountType,
|
||||
discountValue: data.discountValue,
|
||||
taxRate: data.taxRate,
|
||||
taxAmount: data.taxAmount,
|
||||
total: data.total,
|
||||
validUntil: data.validUntil,
|
||||
paymentTerms: data.paymentTerms,
|
||||
deliveryTerms: data.deliveryTerms,
|
||||
notes: data.notes,
|
||||
},
|
||||
include: {
|
||||
deal: {
|
||||
include: {
|
||||
contact: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'QUOTE',
|
||||
entityId: quote.id,
|
||||
action: 'CREATE',
|
||||
userId,
|
||||
});
|
||||
|
||||
return quote;
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
const quote = await prisma.quote.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
deal: {
|
||||
include: {
|
||||
contact: true,
|
||||
owner: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!quote) {
|
||||
throw new AppError(404, 'عرض السعر غير موجود - Quote not found');
|
||||
}
|
||||
|
||||
return quote;
|
||||
}
|
||||
|
||||
async findByDeal(dealId: string) {
|
||||
return prisma.quote.findMany({
|
||||
where: { dealId },
|
||||
orderBy: {
|
||||
version: 'desc',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateStatus(id: string, status: string, userId: string) {
|
||||
const quote = await prisma.quote.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
include: {
|
||||
deal: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'QUOTE',
|
||||
entityId: quote.id,
|
||||
action: 'STATUS_CHANGE',
|
||||
userId,
|
||||
changes: { status },
|
||||
});
|
||||
|
||||
return quote;
|
||||
}
|
||||
|
||||
async approve(id: string, approvedBy: string, userId: string) {
|
||||
const quote = await prisma.quote.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'APPROVED',
|
||||
approvedBy,
|
||||
approvedAt: new Date(),
|
||||
},
|
||||
include: {
|
||||
deal: {
|
||||
include: {
|
||||
owner: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'QUOTE',
|
||||
entityId: quote.id,
|
||||
action: 'APPROVE',
|
||||
userId,
|
||||
changes: { approvedBy },
|
||||
});
|
||||
|
||||
// Create notification
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: quote.deal.ownerId,
|
||||
type: 'QUOTE_APPROVED',
|
||||
title: 'تمت الموافقة على عرض السعر - Quote Approved',
|
||||
message: `تمت الموافقة على عرض السعر رقم ${quote.quoteNumber}`,
|
||||
entityType: 'QUOTE',
|
||||
entityId: quote.id,
|
||||
},
|
||||
});
|
||||
|
||||
return quote;
|
||||
}
|
||||
|
||||
async markAsSent(id: string, userId: string) {
|
||||
const quote = await prisma.quote.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'SENT',
|
||||
sentAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'QUOTE',
|
||||
entityId: quote.id,
|
||||
action: 'SEND',
|
||||
userId,
|
||||
});
|
||||
|
||||
return quote;
|
||||
}
|
||||
|
||||
private async generateQuoteNumber(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = `QT-${year}-`;
|
||||
|
||||
const lastQuote = await prisma.quote.findFirst({
|
||||
where: {
|
||||
quoteNumber: {
|
||||
startsWith: prefix,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
select: {
|
||||
quoteNumber: true,
|
||||
},
|
||||
});
|
||||
|
||||
let nextNumber = 1;
|
||||
if (lastQuote) {
|
||||
const lastNumber = parseInt(lastQuote.quoteNumber.split('-')[2]);
|
||||
nextNumber = lastNumber + 1;
|
||||
}
|
||||
|
||||
return `${prefix}${nextNumber.toString().padStart(6, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const quotesService = new QuotesService();
|
||||
|
||||
129
backend/src/modules/hr/hr.controller.ts
Normal file
129
backend/src/modules/hr/hr.controller.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Response, NextFunction } from 'express';
|
||||
import { AuthRequest } from '../../shared/middleware/auth';
|
||||
import { hrService } from './hr.service';
|
||||
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
|
||||
|
||||
export class HRController {
|
||||
// ========== EMPLOYEES ==========
|
||||
|
||||
async createEmployee(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const employee = await hrService.createEmployee(req.body, req.user!.id);
|
||||
res.status(201).json(
|
||||
ResponseFormatter.success(employee, 'تم إضافة الموظف بنجاح - Employee created successfully')
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
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 result = await hrService.findAllEmployees(filters, page, pageSize);
|
||||
res.json(ResponseFormatter.paginated(result.employees, result.total, result.page, result.pageSize));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findEmployeeById(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const employee = await hrService.findEmployeeById(req.params.id);
|
||||
res.json(ResponseFormatter.success(employee));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateEmployee(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const employee = await hrService.updateEmployee(req.params.id, req.body, req.user!.id);
|
||||
res.json(ResponseFormatter.success(employee, 'تم تحديث بيانات الموظف - Employee updated'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async terminateEmployee(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { terminationDate, reason } = req.body;
|
||||
const employee = await hrService.terminateEmployee(
|
||||
req.params.id,
|
||||
new Date(terminationDate),
|
||||
reason,
|
||||
req.user!.id
|
||||
);
|
||||
res.json(ResponseFormatter.success(employee, 'تم إنهاء خدمة الموظف - Employee terminated'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== ATTENDANCE ==========
|
||||
|
||||
async recordAttendance(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const attendance = await hrService.recordAttendance(req.body, req.user!.id);
|
||||
res.status(201).json(ResponseFormatter.success(attendance));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getAttendance(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { employeeId } = req.params;
|
||||
const month = parseInt(req.query.month as string);
|
||||
const year = parseInt(req.query.year as string);
|
||||
|
||||
const attendance = await hrService.getAttendance(employeeId, month, year);
|
||||
res.json(ResponseFormatter.success(attendance));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== LEAVES ==========
|
||||
|
||||
async createLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const leave = await hrService.createLeaveRequest(req.body, req.user!.id);
|
||||
res.status(201).json(ResponseFormatter.success(leave, 'تم إرسال طلب الإجازة - Leave request submitted'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async approveLeave(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const leave = await hrService.approveLeave(req.params.id, req.user!.id, req.user!.id);
|
||||
res.json(ResponseFormatter.success(leave, 'تمت الموافقة على الإجازة - Leave approved'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SALARIES ==========
|
||||
|
||||
async processSalary(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { employeeId, month, year } = req.body;
|
||||
const salary = await hrService.processSalary(employeeId, month, year, req.user!.id);
|
||||
res.status(201).json(ResponseFormatter.success(salary, 'تم معالجة الراتب - Salary processed'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const hrController = new HRController();
|
||||
|
||||
33
backend/src/modules/hr/hr.routes.ts
Normal file
33
backend/src/modules/hr/hr.routes.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Router } from 'express';
|
||||
import { body, param } from 'express-validator';
|
||||
import { hrController } from './hr.controller';
|
||||
import { authenticate, authorize } from '../../shared/middleware/auth';
|
||||
import { validate } from '../../shared/middleware/validation';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate);
|
||||
|
||||
// ========== EMPLOYEES ==========
|
||||
|
||||
router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees);
|
||||
router.get('/employees/:id', authorize('hr', 'employees', 'read'), hrController.findEmployeeById);
|
||||
router.post('/employees', authorize('hr', 'employees', 'create'), hrController.createEmployee);
|
||||
router.put('/employees/:id', authorize('hr', 'employees', 'update'), hrController.updateEmployee);
|
||||
router.post('/employees/:id/terminate', authorize('hr', 'employees', 'terminate'), hrController.terminateEmployee);
|
||||
|
||||
// ========== ATTENDANCE ==========
|
||||
|
||||
router.post('/attendance', authorize('hr', 'attendance', 'create'), hrController.recordAttendance);
|
||||
router.get('/attendance/:employeeId', authorize('hr', 'attendance', 'read'), hrController.getAttendance);
|
||||
|
||||
// ========== LEAVES ==========
|
||||
|
||||
router.post('/leaves', authorize('hr', 'leaves', 'create'), hrController.createLeaveRequest);
|
||||
router.post('/leaves/:id/approve', authorize('hr', 'leaves', 'approve'), hrController.approveLeave);
|
||||
|
||||
// ========== SALARIES ==========
|
||||
|
||||
router.post('/salaries/process', authorize('hr', 'salaries', 'process'), hrController.processSalary);
|
||||
|
||||
export default router;
|
||||
|
||||
383
backend/src/modules/hr/hr.service.ts
Normal file
383
backend/src/modules/hr/hr.service.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import prisma from '../../config/database';
|
||||
import { AppError } from '../../shared/middleware/errorHandler';
|
||||
import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||
|
||||
class HRService {
|
||||
// ========== EMPLOYEES ==========
|
||||
|
||||
async createEmployee(data: any, userId: string) {
|
||||
const uniqueEmployeeId = await this.generateEmployeeId();
|
||||
|
||||
const employee = await prisma.employee.create({
|
||||
data: {
|
||||
uniqueEmployeeId,
|
||||
...data,
|
||||
},
|
||||
include: {
|
||||
department: true,
|
||||
position: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'EMPLOYEE',
|
||||
entityId: employee.id,
|
||||
action: 'CREATE',
|
||||
userId,
|
||||
});
|
||||
|
||||
return employee;
|
||||
}
|
||||
|
||||
async findAllEmployees(filters: any, page: number, pageSize: number) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where: any = {};
|
||||
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ firstName: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ lastName: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ email: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ uniqueEmployeeId: { contains: filters.search } },
|
||||
];
|
||||
}
|
||||
|
||||
if (filters.departmentId) {
|
||||
where.departmentId = filters.departmentId;
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
where.status = filters.status;
|
||||
}
|
||||
|
||||
const total = await prisma.employee.count({ where });
|
||||
|
||||
const employees = await prisma.employee.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
department: true,
|
||||
position: true,
|
||||
reportingTo: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
position: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
hireDate: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return { employees, total, page, pageSize };
|
||||
}
|
||||
|
||||
async findEmployeeById(id: string) {
|
||||
const employee = await prisma.employee.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
department: true,
|
||||
position: {
|
||||
include: {
|
||||
permissions: true,
|
||||
},
|
||||
},
|
||||
reportingTo: true,
|
||||
directReports: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
isActive: true,
|
||||
},
|
||||
},
|
||||
attendances: {
|
||||
take: 30,
|
||||
orderBy: {
|
||||
date: 'desc',
|
||||
},
|
||||
},
|
||||
leaves: {
|
||||
take: 10,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
salaries: {
|
||||
take: 12,
|
||||
orderBy: {
|
||||
year: 'desc',
|
||||
month: 'desc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!employee) {
|
||||
throw new AppError(404, 'الموظف غير موجود - Employee not found');
|
||||
}
|
||||
|
||||
return employee;
|
||||
}
|
||||
|
||||
async updateEmployee(id: string, data: any, userId: string) {
|
||||
const existing = await prisma.employee.findUnique({ where: { id } });
|
||||
|
||||
if (!existing) {
|
||||
throw new AppError(404, 'الموظف غير موجود - Employee not found');
|
||||
}
|
||||
|
||||
const employee = await prisma.employee.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
department: true,
|
||||
position: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'EMPLOYEE',
|
||||
entityId: employee.id,
|
||||
action: 'UPDATE',
|
||||
userId,
|
||||
changes: {
|
||||
before: existing,
|
||||
after: employee,
|
||||
},
|
||||
});
|
||||
|
||||
return employee;
|
||||
}
|
||||
|
||||
async terminateEmployee(id: string, terminationDate: Date, reason: string, userId: string) {
|
||||
const employee = await prisma.employee.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'TERMINATED',
|
||||
terminationDate,
|
||||
terminationReason: reason,
|
||||
},
|
||||
});
|
||||
|
||||
// Disable user account
|
||||
if (employee.id) {
|
||||
await prisma.user.updateMany({
|
||||
where: { employeeId: employee.id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
}
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'EMPLOYEE',
|
||||
entityId: employee.id,
|
||||
action: 'TERMINATE',
|
||||
userId,
|
||||
reason,
|
||||
});
|
||||
|
||||
return employee;
|
||||
}
|
||||
|
||||
// ========== ATTENDANCE ==========
|
||||
|
||||
async recordAttendance(data: any, userId: string) {
|
||||
const attendance = await prisma.attendance.create({
|
||||
data,
|
||||
});
|
||||
|
||||
return attendance;
|
||||
}
|
||||
|
||||
async getAttendance(employeeId: string, month: number, year: number) {
|
||||
return prisma.attendance.findMany({
|
||||
where: {
|
||||
employeeId,
|
||||
date: {
|
||||
gte: new Date(year, month - 1, 1),
|
||||
lte: new Date(year, month, 0),
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
date: 'asc',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ========== LEAVES ==========
|
||||
|
||||
async createLeaveRequest(data: any, userId: string) {
|
||||
const leave = await prisma.leave.create({
|
||||
data: {
|
||||
...data,
|
||||
days: this.calculateLeaveDays(data.startDate, data.endDate),
|
||||
},
|
||||
include: {
|
||||
employee: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'LEAVE',
|
||||
entityId: leave.id,
|
||||
action: 'CREATE',
|
||||
userId,
|
||||
});
|
||||
|
||||
return leave;
|
||||
}
|
||||
|
||||
async approveLeave(id: string, approvedBy: string, userId: string) {
|
||||
const leave = await prisma.leave.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'APPROVED',
|
||||
approvedBy,
|
||||
approvedAt: new Date(),
|
||||
},
|
||||
include: {
|
||||
employee: true,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'LEAVE',
|
||||
entityId: leave.id,
|
||||
action: 'APPROVE',
|
||||
userId,
|
||||
});
|
||||
|
||||
return leave;
|
||||
}
|
||||
|
||||
// ========== SALARIES ==========
|
||||
|
||||
async processSalary(employeeId: string, month: number, year: number, userId: string) {
|
||||
const employee = await prisma.employee.findUnique({
|
||||
where: { id: employeeId },
|
||||
include: {
|
||||
allowances: {
|
||||
where: {
|
||||
OR: [
|
||||
{ isRecurring: true },
|
||||
{
|
||||
startDate: {
|
||||
lte: new Date(year, month, 0),
|
||||
},
|
||||
OR: [
|
||||
{ endDate: null },
|
||||
{
|
||||
endDate: {
|
||||
gte: new Date(year, month - 1, 1),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
commissions: {
|
||||
where: {
|
||||
month,
|
||||
year,
|
||||
status: 'APPROVED',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!employee) {
|
||||
throw new AppError(404, 'الموظف غير موجود - Employee not found');
|
||||
}
|
||||
|
||||
const basicSalary = employee.basicSalary;
|
||||
const allowances = employee.allowances.reduce((sum, a) => sum + Number(a.amount), 0);
|
||||
const commissions = employee.commissions.reduce((sum, c) => sum + Number(c.amount), 0);
|
||||
|
||||
// Calculate overtime from attendance
|
||||
const attendance = await prisma.attendance.findMany({
|
||||
where: {
|
||||
employeeId,
|
||||
date: {
|
||||
gte: new Date(year, month - 1, 1),
|
||||
lte: new Date(year, month, 0),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const overtimeHours = attendance.reduce((sum, a) => sum + Number(a.overtimeHours || 0), 0);
|
||||
const overtimePay = overtimeHours * 50; // SAR 50 per hour
|
||||
|
||||
const deductions = 0; // Calculate based on business rules
|
||||
|
||||
const netSalary = Number(basicSalary) + allowances + commissions + overtimePay - deductions;
|
||||
|
||||
const salary = await prisma.salary.create({
|
||||
data: {
|
||||
employeeId,
|
||||
month,
|
||||
year,
|
||||
basicSalary,
|
||||
allowances,
|
||||
deductions,
|
||||
commissions,
|
||||
overtimePay,
|
||||
netSalary,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'SALARY',
|
||||
entityId: salary.id,
|
||||
action: 'PROCESS',
|
||||
userId,
|
||||
});
|
||||
|
||||
return salary;
|
||||
}
|
||||
|
||||
// ========== HELPERS ==========
|
||||
|
||||
private async generateEmployeeId(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = `EMP-${year}-`;
|
||||
|
||||
const lastEmployee = await prisma.employee.findFirst({
|
||||
where: {
|
||||
uniqueEmployeeId: {
|
||||
startsWith: prefix,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
select: {
|
||||
uniqueEmployeeId: true,
|
||||
},
|
||||
});
|
||||
|
||||
let nextNumber = 1;
|
||||
if (lastEmployee) {
|
||||
const lastNumber = parseInt(lastEmployee.uniqueEmployeeId.split('-')[2]);
|
||||
nextNumber = lastNumber + 1;
|
||||
}
|
||||
|
||||
return `${prefix}${nextNumber.toString().padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
private calculateLeaveDays(startDate: Date, endDate: Date): number {
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
const diffTime = Math.abs(end.getTime() - start.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
return diffDays + 1;
|
||||
}
|
||||
}
|
||||
|
||||
export const hrService = new HRService();
|
||||
|
||||
96
backend/src/modules/inventory/inventory.routes.ts
Normal file
96
backend/src/modules/inventory/inventory.routes.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate, authorize } from '../../shared/middleware/auth';
|
||||
import prisma from '../../config/database';
|
||||
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate);
|
||||
|
||||
// Products
|
||||
router.get('/products', authorize('inventory', 'products', 'read'), async (req, res, next) => {
|
||||
try {
|
||||
const products = await prisma.product.findMany({
|
||||
include: { category: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(ResponseFormatter.success(products));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/products', authorize('inventory', 'products', 'create'), async (req, res, next) => {
|
||||
try {
|
||||
const product = await prisma.product.create({
|
||||
data: req.body,
|
||||
include: { category: true },
|
||||
});
|
||||
res.status(201).json(ResponseFormatter.success(product));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Warehouses
|
||||
router.get('/warehouses', authorize('inventory', 'warehouses', 'read'), async (req, res, next) => {
|
||||
try {
|
||||
const warehouses = await prisma.warehouse.findMany({
|
||||
include: { items: { include: { product: true } } },
|
||||
});
|
||||
res.json(ResponseFormatter.success(warehouses));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/warehouses', authorize('inventory', 'warehouses', 'create'), async (req, res, next) => {
|
||||
try {
|
||||
const warehouse = await prisma.warehouse.create({ data: req.body });
|
||||
res.status(201).json(ResponseFormatter.success(warehouse));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Inventory Items
|
||||
router.get('/items', authorize('inventory', 'items', 'read'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await prisma.inventoryItem.findMany({
|
||||
include: {
|
||||
warehouse: true,
|
||||
product: true,
|
||||
},
|
||||
});
|
||||
res.json(ResponseFormatter.success(items));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Assets
|
||||
router.get('/assets', authorize('inventory', 'assets', 'read'), async (req, res, next) => {
|
||||
try {
|
||||
const assets = await prisma.asset.findMany({
|
||||
include: { maintenances: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(ResponseFormatter.success(assets));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/assets', authorize('inventory', 'assets', 'create'), async (req, res, next) => {
|
||||
try {
|
||||
const assetNumber = `AST-${new Date().getFullYear()}-${Date.now().toString().slice(-6)}`;
|
||||
const asset = await prisma.asset.create({
|
||||
data: { ...req.body, assetNumber },
|
||||
});
|
||||
res.status(201).json(ResponseFormatter.success(asset));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
147
backend/src/modules/marketing/marketing.routes.ts
Normal file
147
backend/src/modules/marketing/marketing.routes.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate, authorize } from '../../shared/middleware/auth';
|
||||
import prisma from '../../config/database';
|
||||
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
|
||||
import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate);
|
||||
|
||||
// Campaigns
|
||||
router.get('/campaigns', authorize('marketing', 'campaigns', 'read'), async (req, res, next) => {
|
||||
try {
|
||||
const where: any = {};
|
||||
if (req.query.type) where.type = req.query.type;
|
||||
if (req.query.status) where.status = req.query.status;
|
||||
if (req.query.ownerId) where.ownerId = req.query.ownerId;
|
||||
|
||||
const campaigns = await prisma.campaign.findMany({
|
||||
where,
|
||||
include: {
|
||||
owner: { select: { email: true, username: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(ResponseFormatter.success(campaigns));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/campaigns/:id', authorize('marketing', 'campaigns', 'read'), async (req, res, next) => {
|
||||
try {
|
||||
const campaign = await prisma.campaign.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
owner: { select: { email: true, username: true, employee: true } },
|
||||
activities: { orderBy: { createdAt: 'desc' } },
|
||||
},
|
||||
});
|
||||
res.json(ResponseFormatter.success(campaign));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/campaigns', authorize('marketing', 'campaigns', 'create'), async (req, res, next) => {
|
||||
try {
|
||||
const campaignNumber = `CAMP-${new Date().getFullYear()}-${Date.now().toString().slice(-6)}`;
|
||||
const campaign = await prisma.campaign.create({
|
||||
data: {
|
||||
...req.body,
|
||||
campaignNumber,
|
||||
ownerId: req.body.ownerId || (req as any).user.id,
|
||||
},
|
||||
include: { owner: true },
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'CAMPAIGN',
|
||||
entityId: campaign.id,
|
||||
action: 'CREATE',
|
||||
userId: (req as any).user.id,
|
||||
});
|
||||
|
||||
res.status(201).json(ResponseFormatter.success(campaign, 'تم إنشاء الحملة بنجاح - Campaign created'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/campaigns/:id', authorize('marketing', 'campaigns', 'update'), async (req, res, next) => {
|
||||
try {
|
||||
const campaign = await prisma.campaign.update({
|
||||
where: { id: req.params.id },
|
||||
data: req.body,
|
||||
});
|
||||
res.json(ResponseFormatter.success(campaign, 'تم تحديث الحملة - Campaign updated'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/campaigns/:id/approve', authorize('marketing', 'campaigns', 'approve'), async (req, res, next) => {
|
||||
try {
|
||||
const campaign = await prisma.campaign.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
status: 'APPROVED',
|
||||
approvedBy: (req as any).user.id,
|
||||
approvedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'CAMPAIGN',
|
||||
entityId: campaign.id,
|
||||
action: 'APPROVE',
|
||||
userId: (req as any).user.id,
|
||||
});
|
||||
|
||||
res.json(ResponseFormatter.success(campaign, 'تمت الموافقة على الحملة - Campaign approved'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/campaigns/:id/launch', authorize('marketing', 'campaigns', 'update'), async (req, res, next) => {
|
||||
try {
|
||||
const campaign = await prisma.campaign.update({
|
||||
where: { id: req.params.id },
|
||||
data: { status: 'RUNNING' },
|
||||
});
|
||||
res.json(ResponseFormatter.success(campaign, 'تم إطلاق الحملة - Campaign launched'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Campaign Statistics
|
||||
router.get('/campaigns/:id/stats', authorize('marketing', 'campaigns', 'read'), async (req, res, next) => {
|
||||
try {
|
||||
const campaign = await prisma.campaign.findUnique({
|
||||
where: { id: req.params.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
budget: true,
|
||||
actualCost: true,
|
||||
sentCount: true,
|
||||
openRate: true,
|
||||
clickRate: true,
|
||||
responseRate: true,
|
||||
leadsGenerated: true,
|
||||
conversions: true,
|
||||
expectedROI: true,
|
||||
actualROI: true,
|
||||
},
|
||||
});
|
||||
res.json(ResponseFormatter.success(campaign));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
141
backend/src/modules/projects/projects.routes.ts
Normal file
141
backend/src/modules/projects/projects.routes.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate, authorize } from '../../shared/middleware/auth';
|
||||
import prisma from '../../config/database';
|
||||
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
|
||||
import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate);
|
||||
|
||||
// Projects
|
||||
router.get('/projects', authorize('projects', 'projects', 'read'), async (req, res, next) => {
|
||||
try {
|
||||
const projects = await prisma.project.findMany({
|
||||
include: {
|
||||
phases: true,
|
||||
tasks: { take: 10 },
|
||||
members: { include: { user: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(ResponseFormatter.success(projects));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/projects/:id', authorize('projects', 'projects', 'read'), async (req, res, next) => {
|
||||
try {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
phases: { include: { tasks: true } },
|
||||
tasks: true,
|
||||
members: { include: { user: { include: { employee: true } } } },
|
||||
expenses: true,
|
||||
attachments: true,
|
||||
notes: true,
|
||||
},
|
||||
});
|
||||
res.json(ResponseFormatter.success(project));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/projects', authorize('projects', 'projects', 'create'), async (req, res, next) => {
|
||||
try {
|
||||
const projectNumber = `PRJ-${new Date().getFullYear()}-${Date.now().toString().slice(-6)}`;
|
||||
const project = await prisma.project.create({
|
||||
data: { ...req.body, projectNumber },
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'PROJECT',
|
||||
entityId: project.id,
|
||||
action: 'CREATE',
|
||||
userId: (req as any).user.id,
|
||||
});
|
||||
|
||||
res.status(201).json(ResponseFormatter.success(project));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/projects/:id', authorize('projects', 'projects', 'update'), async (req, res, next) => {
|
||||
try {
|
||||
const project = await prisma.project.update({
|
||||
where: { id: req.params.id },
|
||||
data: req.body,
|
||||
});
|
||||
res.json(ResponseFormatter.success(project));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Tasks
|
||||
router.get('/tasks', authorize('projects', 'tasks', 'read'), async (req, res, next) => {
|
||||
try {
|
||||
const where: any = {};
|
||||
if (req.query.projectId) where.projectId = req.query.projectId;
|
||||
if (req.query.assignedToId) where.assignedToId = req.query.assignedToId;
|
||||
if (req.query.status) where.status = req.query.status;
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where,
|
||||
include: {
|
||||
project: true,
|
||||
assignedTo: { select: { email: true, username: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(ResponseFormatter.success(tasks));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/tasks', authorize('projects', 'tasks', 'create'), async (req, res, next) => {
|
||||
try {
|
||||
const taskNumber = `TSK-${new Date().getFullYear()}-${Date.now().toString().slice(-6)}`;
|
||||
const task = await prisma.task.create({
|
||||
data: { ...req.body, taskNumber },
|
||||
include: { project: true, assignedTo: true },
|
||||
});
|
||||
|
||||
// Create notification for assigned user
|
||||
if (task.assignedToId) {
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: task.assignedToId,
|
||||
type: 'TASK_ASSIGNED',
|
||||
title: 'مهمة جديدة - New Task Assigned',
|
||||
message: `تم تعيينك لمهمة: ${task.title}`,
|
||||
entityType: 'TASK',
|
||||
entityId: task.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json(ResponseFormatter.success(task));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/tasks/:id', authorize('projects', 'tasks', 'update'), async (req, res, next) => {
|
||||
try {
|
||||
const task = await prisma.task.update({
|
||||
where: { id: req.params.id },
|
||||
data: req.body,
|
||||
});
|
||||
res.json(ResponseFormatter.success(task));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
40
backend/src/routes/index.ts
Normal file
40
backend/src/routes/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Router } from 'express';
|
||||
import authRoutes from '../modules/auth/auth.routes';
|
||||
import contactsRoutes from '../modules/contacts/contacts.routes';
|
||||
import crmRoutes from '../modules/crm/crm.routes';
|
||||
import hrRoutes from '../modules/hr/hr.routes';
|
||||
import inventoryRoutes from '../modules/inventory/inventory.routes';
|
||||
import projectsRoutes from '../modules/projects/projects.routes';
|
||||
import marketingRoutes from '../modules/marketing/marketing.routes';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Module routes
|
||||
router.use('/auth', authRoutes);
|
||||
router.use('/contacts', contactsRoutes);
|
||||
router.use('/crm', crmRoutes);
|
||||
router.use('/hr', hrRoutes);
|
||||
router.use('/inventory', inventoryRoutes);
|
||||
router.use('/projects', projectsRoutes);
|
||||
router.use('/marketing', marketingRoutes);
|
||||
|
||||
// API info
|
||||
router.get('/', (req, res) => {
|
||||
res.json({
|
||||
name: 'Z.CRM API',
|
||||
version: '1.0.0',
|
||||
description: 'نظام إدارة علاقات العملاء - Enterprise CRM System',
|
||||
modules: [
|
||||
'Auth',
|
||||
'Contact Management',
|
||||
'CRM',
|
||||
'HR Management',
|
||||
'Inventory & Assets',
|
||||
'Tasks & Projects',
|
||||
'Marketing',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
74
backend/src/server.ts
Normal file
74
backend/src/server.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import express, { Express } from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import compression from 'compression';
|
||||
import { config } from './config';
|
||||
import { errorHandler } from './shared/middleware/errorHandler';
|
||||
import { requestLogger } from './shared/middleware/requestLogger';
|
||||
import { notFoundHandler } from './shared/middleware/notFoundHandler';
|
||||
import routes from './routes';
|
||||
|
||||
const app: Express = express();
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet());
|
||||
|
||||
// CORS
|
||||
app.use(cors({
|
||||
origin: config.cors.origin,
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
// Body parsing
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
// Compression
|
||||
app.use(compression());
|
||||
|
||||
// Request logging
|
||||
app.use(requestLogger);
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
env: config.env
|
||||
});
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use(`/api/${config.apiVersion}`, routes);
|
||||
|
||||
// 404 handler
|
||||
app.use(notFoundHandler);
|
||||
|
||||
// Error handler (must be last)
|
||||
app.use(errorHandler);
|
||||
|
||||
// Start server
|
||||
const PORT = config.port;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ Z.CRM System - نظام إدارة علاقات العملاء ║
|
||||
║ ║
|
||||
║ Server running on: http://localhost:${PORT} ║
|
||||
║ Environment: ${config.env.toUpperCase().padEnd(10)} ║
|
||||
║ API Version: ${config.apiVersion} ║
|
||||
║ ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
});
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
process.on('unhandledRejection', (err: Error) => {
|
||||
console.error('UNHANDLED REJECTION! 💥 Shutting down...');
|
||||
console.error(err.name, err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
108
backend/src/shared/middleware/auth.ts
Normal file
108
backend/src/shared/middleware/auth.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { config } from '../../config';
|
||||
import { AppError } from './errorHandler';
|
||||
import prisma from '../../config/database';
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
employeeId?: string;
|
||||
employee?: any;
|
||||
};
|
||||
}
|
||||
|
||||
export const authenticate = async (
|
||||
req: AuthRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
try {
|
||||
// Get token from header
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new AppError(401, 'غير مصرح - Unauthorized');
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
|
||||
// Verify token
|
||||
const decoded = jwt.verify(token, config.jwt.secret) as {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
// Get user with employee info
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.id },
|
||||
include: {
|
||||
employee: {
|
||||
include: {
|
||||
position: {
|
||||
include: {
|
||||
permissions: true,
|
||||
},
|
||||
},
|
||||
department: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
throw new AppError(401, 'غير مصرح - Unauthorized');
|
||||
}
|
||||
|
||||
// HR module requirement: User must have an active employee record
|
||||
if (!user.employee || user.employee.status !== 'ACTIVE') {
|
||||
throw new AppError(403, 'الوصول مرفوض - Access denied. Active employee record required.');
|
||||
}
|
||||
|
||||
// Attach user to request
|
||||
req.user = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
employeeId: user.employeeId || undefined,
|
||||
employee: user.employee,
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof jwt.JsonWebTokenError) {
|
||||
return next(new AppError(401, 'رمز غير صالح - Invalid token'));
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Permission checking middleware
|
||||
export const authorize = (module: string, resource: string, action: string) => {
|
||||
return async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.user?.employee?.position?.permissions) {
|
||||
throw new AppError(403, 'الوصول مرفوض - Access denied');
|
||||
}
|
||||
|
||||
// Find permission for this module and resource
|
||||
const permission = req.user.employee.position.permissions.find(
|
||||
(p: any) => p.module === module && p.resource === resource
|
||||
);
|
||||
|
||||
if (!permission) {
|
||||
throw new AppError(403, 'الوصول مرفوض - Access denied');
|
||||
}
|
||||
|
||||
// Check if action is allowed
|
||||
const actions = permission.actions as string[];
|
||||
if (!actions.includes(action) && !actions.includes('*')) {
|
||||
throw new AppError(403, 'الوصول مرفوض - Access denied');
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
66
backend/src/shared/middleware/errorHandler.ts
Normal file
66
backend/src/shared/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public statusCode: number,
|
||||
public message: string,
|
||||
public isOperational = true
|
||||
) {
|
||||
super(message);
|
||||
Object.setPrototypeOf(this, AppError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export const errorHandler = (
|
||||
err: Error | AppError,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
console.error('Error:', err);
|
||||
|
||||
// Handle Prisma errors
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (err.code === 'P2002') {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: 'سجل مكرر - Duplicate record',
|
||||
error: 'DUPLICATE_RECORD',
|
||||
});
|
||||
}
|
||||
if (err.code === 'P2025') {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'السجل غير موجود - Record not found',
|
||||
error: 'RECORD_NOT_FOUND',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle validation errors
|
||||
if (err instanceof Prisma.PrismaClientValidationError) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'بيانات غير صالحة - Invalid data',
|
||||
error: 'VALIDATION_ERROR',
|
||||
});
|
||||
}
|
||||
|
||||
// Handle custom app errors
|
||||
if (err instanceof AppError) {
|
||||
return res.status(err.statusCode).json({
|
||||
success: false,
|
||||
message: err.message,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
|
||||
// Default error
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'خطأ في الخادم - Internal server error',
|
||||
error: process.env.NODE_ENV === 'development' ? err.message : 'INTERNAL_ERROR',
|
||||
});
|
||||
};
|
||||
|
||||
11
backend/src/shared/middleware/notFoundHandler.ts
Normal file
11
backend/src/shared/middleware/notFoundHandler.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
export const notFoundHandler = (req: Request, res: Response) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'الصفحة غير موجودة - Route not found',
|
||||
error: 'NOT_FOUND',
|
||||
path: req.originalUrl,
|
||||
});
|
||||
};
|
||||
|
||||
16
backend/src/shared/middleware/requestLogger.ts
Normal file
16
backend/src/shared/middleware/requestLogger.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export const requestLogger = (req: Request, res: Response, next: NextFunction) => {
|
||||
const start = Date.now();
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
const { method, originalUrl } = req;
|
||||
const { statusCode } = res;
|
||||
|
||||
console.log(`[${new Date().toISOString()}] ${method} ${originalUrl} ${statusCode} - ${duration}ms`);
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
17
backend/src/shared/middleware/validation.ts
Normal file
17
backend/src/shared/middleware/validation.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { validationResult } from 'express-validator';
|
||||
|
||||
export const validate = (req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'بيانات غير صالحة - Validation error',
|
||||
errors: errors.array(),
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
56
backend/src/shared/utils/auditLogger.ts
Normal file
56
backend/src/shared/utils/auditLogger.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import prisma from '../../config/database';
|
||||
|
||||
interface AuditLogData {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
action: string;
|
||||
userId: string;
|
||||
changes?: any;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class AuditLogger {
|
||||
static async log(data: AuditLogData): Promise<void> {
|
||||
try {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
entityType: data.entityType,
|
||||
entityId: data.entityId,
|
||||
action: data.action,
|
||||
userId: data.userId,
|
||||
changes: data.changes || {},
|
||||
ipAddress: data.ipAddress,
|
||||
userAgent: data.userAgent,
|
||||
reason: data.reason,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create audit log:', error);
|
||||
// Don't throw - audit logging should not break the main flow
|
||||
}
|
||||
}
|
||||
|
||||
static async getEntityHistory(entityType: string, entityId: string) {
|
||||
return prisma.auditLog.findMany({
|
||||
where: {
|
||||
entityType,
|
||||
entityId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
31
backend/src/shared/utils/responseFormatter.ts
Normal file
31
backend/src/shared/utils/responseFormatter.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export class ResponseFormatter {
|
||||
static success(data: any, message?: string) {
|
||||
return {
|
||||
success: true,
|
||||
message: message || 'تم بنجاح - Success',
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
static paginated(data: any[], total: number, page: number, pageSize: number) {
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static error(message: string, error?: string) {
|
||||
return {
|
||||
success: false,
|
||||
message,
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
28
backend/tsconfig.json
Normal file
28
backend/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"moduleResolution": "node",
|
||||
"baseUrl": "./src",
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@modules/*": ["./modules/*"],
|
||||
"@shared/*": ["./shared/*"],
|
||||
"@config/*": ["./config/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user