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:
Talal Sharabi
2026-01-06 18:43:43 +04:00
commit 35daa52767
82 changed files with 29445 additions and 0 deletions

10
backend/nodemon.json Normal file
View 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

File diff suppressed because it is too large Load Diff

50
backend/package.json Normal file
View 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"
}
}

File diff suppressed because it is too large Load Diff

View 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

File diff suppressed because it is too large Load Diff

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

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

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

View 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
})
}
}
}

View 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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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
View 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"]
}