Deploy rule, CRM enhancements, Company Employee category, bilingual, contacts module completion
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
93
backend/src/modules/contacts/categories.controller.ts
Normal file
93
backend/src/modules/contacts/categories.controller.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { categoriesService } from './categories.service'
|
||||
import { AuthRequest } from '@/shared/middleware/auth'
|
||||
|
||||
export class CategoriesController {
|
||||
// Get all categories
|
||||
async findAll(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const categories = await categoriesService.findAll()
|
||||
res.json({
|
||||
success: true,
|
||||
data: categories
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Get category tree
|
||||
async getTree(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const tree = await categoriesService.getTree()
|
||||
res.json({
|
||||
success: true,
|
||||
data: tree
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Get category by ID
|
||||
async findById(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const category = await categoriesService.findById(id)
|
||||
res.json({
|
||||
success: true,
|
||||
data: category
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Create category
|
||||
async create(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = req.body
|
||||
const category = await categoriesService.create(data)
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Category created successfully',
|
||||
data: category
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Update category
|
||||
async update(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const data = req.body
|
||||
const category = await categoriesService.update(id, data)
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Category updated successfully',
|
||||
data: category
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete category
|
||||
async delete(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const result = await categoriesService.delete(id)
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Category deleted successfully',
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const categoriesController = new CategoriesController()
|
||||
54
backend/src/modules/contacts/categories.routes.ts
Normal file
54
backend/src/modules/contacts/categories.routes.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Router } from 'express'
|
||||
import { authenticate, authorize } from '@/shared/middleware/auth'
|
||||
import { categoriesController } from './categories.controller'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// All routes require authentication
|
||||
router.use(authenticate)
|
||||
|
||||
// ========== CATEGORIES ==========
|
||||
|
||||
// Get all categories (flat list)
|
||||
router.get(
|
||||
'/',
|
||||
authorize('contacts', 'all', 'read'),
|
||||
categoriesController.findAll.bind(categoriesController)
|
||||
)
|
||||
|
||||
// Get category tree (hierarchical)
|
||||
router.get(
|
||||
'/tree',
|
||||
authorize('contacts', 'all', 'read'),
|
||||
categoriesController.getTree.bind(categoriesController)
|
||||
)
|
||||
|
||||
// Get category by ID
|
||||
router.get(
|
||||
'/:id',
|
||||
authorize('contacts', 'all', 'read'),
|
||||
categoriesController.findById.bind(categoriesController)
|
||||
)
|
||||
|
||||
// Create category
|
||||
router.post(
|
||||
'/',
|
||||
authorize('contacts', 'all', 'create'),
|
||||
categoriesController.create.bind(categoriesController)
|
||||
)
|
||||
|
||||
// Update category
|
||||
router.put(
|
||||
'/:id',
|
||||
authorize('contacts', 'all', 'update'),
|
||||
categoriesController.update.bind(categoriesController)
|
||||
)
|
||||
|
||||
// Delete category
|
||||
router.delete(
|
||||
'/:id',
|
||||
authorize('contacts', 'all', 'delete'),
|
||||
categoriesController.delete.bind(categoriesController)
|
||||
)
|
||||
|
||||
export default router
|
||||
214
backend/src/modules/contacts/categories.service.ts
Normal file
214
backend/src/modules/contacts/categories.service.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
export class CategoriesService {
|
||||
// Find all categories (tree structure)
|
||||
async findAll() {
|
||||
const categories = await prisma.contactCategory.findMany({
|
||||
where: {
|
||||
isActive: true
|
||||
},
|
||||
include: {
|
||||
parent: true,
|
||||
children: true,
|
||||
_count: {
|
||||
select: {
|
||||
contacts: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
}
|
||||
})
|
||||
|
||||
return categories
|
||||
}
|
||||
|
||||
// Find category by ID
|
||||
async findById(id: string) {
|
||||
const category = await prisma.contactCategory.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
parent: true,
|
||||
children: true,
|
||||
_count: {
|
||||
select: {
|
||||
contacts: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!category) {
|
||||
throw new Error('Category not found')
|
||||
}
|
||||
|
||||
return category
|
||||
}
|
||||
|
||||
// Create category
|
||||
async create(data: {
|
||||
name: string
|
||||
nameAr?: string
|
||||
parentId?: string
|
||||
description?: string
|
||||
}) {
|
||||
// Validate parent exists if provided
|
||||
if (data.parentId) {
|
||||
const parent = await prisma.contactCategory.findUnique({
|
||||
where: { id: data.parentId }
|
||||
})
|
||||
if (!parent) {
|
||||
throw new Error('Parent category not found')
|
||||
}
|
||||
}
|
||||
|
||||
const category = await prisma.contactCategory.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
nameAr: data.nameAr,
|
||||
parentId: data.parentId,
|
||||
description: data.description
|
||||
},
|
||||
include: {
|
||||
parent: true,
|
||||
children: true
|
||||
}
|
||||
})
|
||||
|
||||
return category
|
||||
}
|
||||
|
||||
// Update category
|
||||
async update(id: string, data: {
|
||||
name?: string
|
||||
nameAr?: string
|
||||
parentId?: string
|
||||
description?: string
|
||||
isActive?: boolean
|
||||
}) {
|
||||
// Check if category exists
|
||||
const existing = await prisma.contactCategory.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
throw new Error('Category not found')
|
||||
}
|
||||
|
||||
// Validate parent exists if provided and prevent circular reference
|
||||
if (data.parentId) {
|
||||
if (data.parentId === id) {
|
||||
throw new Error('Category cannot be its own parent')
|
||||
}
|
||||
|
||||
const parent = await prisma.contactCategory.findUnique({
|
||||
where: { id: data.parentId }
|
||||
})
|
||||
if (!parent) {
|
||||
throw new Error('Parent category not found')
|
||||
}
|
||||
|
||||
// Check for circular reference
|
||||
let currentParent = parent
|
||||
while (currentParent.parentId) {
|
||||
if (currentParent.parentId === id) {
|
||||
throw new Error('Circular reference detected')
|
||||
}
|
||||
const nextParent = await prisma.contactCategory.findUnique({
|
||||
where: { id: currentParent.parentId }
|
||||
})
|
||||
if (!nextParent) break
|
||||
currentParent = nextParent
|
||||
}
|
||||
}
|
||||
|
||||
const category = await prisma.contactCategory.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
parent: true,
|
||||
children: true
|
||||
}
|
||||
})
|
||||
|
||||
return category
|
||||
}
|
||||
|
||||
// Delete category (soft delete)
|
||||
async delete(id: string) {
|
||||
// Check if category exists
|
||||
const existing = await prisma.contactCategory.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
children: true,
|
||||
_count: {
|
||||
select: {
|
||||
contacts: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
throw new Error('Category not found')
|
||||
}
|
||||
|
||||
// Check if category has children
|
||||
if (existing.children.length > 0) {
|
||||
throw new Error('Cannot delete category with subcategories')
|
||||
}
|
||||
|
||||
// Check if category is in use
|
||||
if (existing._count.contacts > 0) {
|
||||
// Soft delete by setting isActive to false
|
||||
const category = await prisma.contactCategory.update({
|
||||
where: { id },
|
||||
data: { isActive: false }
|
||||
})
|
||||
return category
|
||||
}
|
||||
|
||||
// Hard delete if no contacts use it
|
||||
await prisma.contactCategory.delete({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
return { id, deleted: true }
|
||||
}
|
||||
|
||||
// Get category tree (hierarchical structure)
|
||||
async getTree() {
|
||||
const categories = await prisma.contactCategory.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
parentId: null // Only root categories
|
||||
},
|
||||
include: {
|
||||
children: {
|
||||
include: {
|
||||
children: {
|
||||
include: {
|
||||
children: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
contacts: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
}
|
||||
})
|
||||
|
||||
return categories
|
||||
}
|
||||
}
|
||||
|
||||
export const categoriesService = new CategoriesService()
|
||||
@@ -129,14 +129,16 @@ class ContactsController {
|
||||
|
||||
async addRelationship(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { toContactId, type, startDate } = req.body;
|
||||
const { toContactId, type, startDate, endDate, notes } = req.body;
|
||||
|
||||
const relationship = await contactsService.addRelationship(
|
||||
req.params.id,
|
||||
toContactId,
|
||||
type,
|
||||
new Date(startDate),
|
||||
req.user!.id
|
||||
req.user!.id,
|
||||
endDate ? new Date(endDate) : undefined,
|
||||
notes
|
||||
);
|
||||
|
||||
res.status(201).json(
|
||||
@@ -147,6 +149,55 @@ class ContactsController {
|
||||
}
|
||||
}
|
||||
|
||||
async getRelationships(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const relationships = await contactsService.getRelationships(req.params.id);
|
||||
res.json(ResponseFormatter.success(relationships));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateRelationship(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { type, startDate, endDate, notes, isActive } = req.body;
|
||||
const data: any = {};
|
||||
|
||||
if (type) data.type = type;
|
||||
if (startDate) data.startDate = new Date(startDate);
|
||||
if (endDate) data.endDate = new Date(endDate);
|
||||
if (notes !== undefined) data.notes = notes;
|
||||
if (isActive !== undefined) data.isActive = isActive;
|
||||
|
||||
const relationship = await contactsService.updateRelationship(
|
||||
req.params.relationshipId,
|
||||
data,
|
||||
req.user!.id
|
||||
);
|
||||
|
||||
res.json(
|
||||
ResponseFormatter.success(relationship, 'تم تحديث العلاقة بنجاح - Relationship updated successfully')
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteRelationship(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await contactsService.deleteRelationship(
|
||||
req.params.relationshipId,
|
||||
req.user!.id
|
||||
);
|
||||
|
||||
res.json(
|
||||
ResponseFormatter.success(null, 'تم حذف العلاقة بنجاح - Relationship deleted successfully')
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getHistory(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const history = await contactsService.getHistory(req.params.id);
|
||||
@@ -155,6 +206,80 @@ class ContactsController {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async import(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json(
|
||||
ResponseFormatter.error('ملف مطلوب - File required')
|
||||
);
|
||||
}
|
||||
|
||||
const result = await contactsService.import(
|
||||
req.file.buffer,
|
||||
req.user!.id
|
||||
);
|
||||
|
||||
res.json(
|
||||
ResponseFormatter.success(
|
||||
result,
|
||||
`تم استيراد ${result.success} جهة اتصال بنجاح - Imported ${result.success} contacts successfully`
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async export(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const filters = {
|
||||
search: req.query.search as string,
|
||||
type: req.query.type as string,
|
||||
status: req.query.status as string,
|
||||
source: req.query.source as string,
|
||||
category: req.query.category as string,
|
||||
rating: req.query.rating ? parseInt(req.query.rating as string) : undefined,
|
||||
excludeCompanyEmployees: req.query.excludeCompanyEmployees === 'true',
|
||||
};
|
||||
|
||||
const buffer = await contactsService.export(filters);
|
||||
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename=contacts-${Date.now()}.xlsx`
|
||||
);
|
||||
res.send(buffer);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async checkDuplicates(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { email, phone, mobile, taxNumber, commercialRegister, excludeId } = req.body;
|
||||
|
||||
const duplicates = await contactsService.findDuplicates(
|
||||
{ email, phone, mobile, taxNumber, commercialRegister },
|
||||
excludeId
|
||||
);
|
||||
|
||||
res.json(
|
||||
ResponseFormatter.success(
|
||||
duplicates,
|
||||
duplicates.length > 0
|
||||
? `تم العثور على ${duplicates.length} جهات اتصال مشابهة - Found ${duplicates.length} similar contacts`
|
||||
: 'لا توجد تكرارات - No duplicates found'
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const contactsController = new ContactsController();
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import { body, param } from 'express-validator';
|
||||
import multer from 'multer';
|
||||
import { contactsController } from './contacts.controller';
|
||||
import { authenticate, authorize } from '../../shared/middleware/auth';
|
||||
import { validate } from '../../shared/middleware/validation';
|
||||
import categoriesRouter from './categories.routes';
|
||||
|
||||
const router = Router();
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
// All routes require authentication
|
||||
router.use(authenticate);
|
||||
@@ -94,6 +97,15 @@ router.post(
|
||||
contactsController.merge
|
||||
);
|
||||
|
||||
// Get relationships for a contact
|
||||
router.get(
|
||||
'/:id/relationships',
|
||||
authorize('contacts', 'contacts', 'read'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
contactsController.getRelationships
|
||||
);
|
||||
|
||||
// Add relationship
|
||||
router.post(
|
||||
'/:id/relationships',
|
||||
@@ -103,10 +115,75 @@ router.post(
|
||||
body('toContactId').isUUID(),
|
||||
body('type').notEmpty(),
|
||||
body('startDate').isISO8601(),
|
||||
body('endDate').optional().isISO8601(),
|
||||
body('notes').optional(),
|
||||
validate,
|
||||
],
|
||||
contactsController.addRelationship
|
||||
);
|
||||
|
||||
// Update relationship
|
||||
router.put(
|
||||
'/:id/relationships/:relationshipId',
|
||||
authorize('contacts', 'contacts', 'update'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
param('relationshipId').isUUID(),
|
||||
body('type').optional(),
|
||||
body('startDate').optional().isISO8601(),
|
||||
body('endDate').optional().isISO8601(),
|
||||
body('notes').optional(),
|
||||
body('isActive').optional().isBoolean(),
|
||||
validate,
|
||||
],
|
||||
contactsController.updateRelationship
|
||||
);
|
||||
|
||||
// Delete relationship
|
||||
router.delete(
|
||||
'/:id/relationships/:relationshipId',
|
||||
authorize('contacts', 'contacts', 'delete'),
|
||||
[
|
||||
param('id').isUUID(),
|
||||
param('relationshipId').isUUID(),
|
||||
validate,
|
||||
],
|
||||
contactsController.deleteRelationship
|
||||
);
|
||||
|
||||
// Check for duplicates
|
||||
router.post(
|
||||
'/check-duplicates',
|
||||
authorize('contacts', 'contacts', 'read'),
|
||||
[
|
||||
body('email').optional().isEmail(),
|
||||
body('phone').optional(),
|
||||
body('mobile').optional(),
|
||||
body('taxNumber').optional(),
|
||||
body('commercialRegister').optional(),
|
||||
body('excludeId').optional().isUUID(),
|
||||
validate,
|
||||
],
|
||||
contactsController.checkDuplicates
|
||||
);
|
||||
|
||||
// Import contacts
|
||||
router.post(
|
||||
'/import',
|
||||
authorize('contacts', 'contacts', 'create'),
|
||||
upload.single('file'),
|
||||
contactsController.import
|
||||
);
|
||||
|
||||
// Export contacts
|
||||
router.get(
|
||||
'/export',
|
||||
authorize('contacts', 'contacts', 'read'),
|
||||
contactsController.export
|
||||
);
|
||||
|
||||
// Mount categories router
|
||||
router.use('/categories', categoriesRouter);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ interface CreateContactData {
|
||||
categories?: string[];
|
||||
tags?: string[];
|
||||
parentId?: string;
|
||||
employeeId?: string | null;
|
||||
source: string;
|
||||
customFields?: any;
|
||||
createdById: string;
|
||||
@@ -41,6 +42,7 @@ interface SearchFilters {
|
||||
rating?: number;
|
||||
createdFrom?: Date;
|
||||
createdTo?: Date;
|
||||
excludeCompanyEmployees?: boolean;
|
||||
}
|
||||
|
||||
class ContactsService {
|
||||
@@ -48,6 +50,16 @@ class ContactsService {
|
||||
// Check for duplicates based on email, phone, or tax number
|
||||
await this.checkDuplicates(data);
|
||||
|
||||
// Validate employeeId if provided
|
||||
if (data.employeeId) {
|
||||
const employee = await prisma.employee.findUnique({
|
||||
where: { id: data.employeeId },
|
||||
});
|
||||
if (!employee) {
|
||||
throw new AppError(400, 'Employee not found - الموظف غير موجود');
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique contact ID
|
||||
const uniqueContactId = await this.generateUniqueContactId();
|
||||
|
||||
@@ -75,6 +87,7 @@ class ContactsService {
|
||||
} : undefined,
|
||||
tags: data.tags || [],
|
||||
parentId: data.parentId,
|
||||
employeeId: data.employeeId || undefined,
|
||||
source: data.source,
|
||||
customFields: data.customFields || {},
|
||||
createdById: data.createdById,
|
||||
@@ -82,6 +95,15 @@ class ContactsService {
|
||||
include: {
|
||||
categories: true,
|
||||
parent: true,
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
uniqueEmployeeId: true,
|
||||
},
|
||||
},
|
||||
createdBy: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -138,6 +160,12 @@ class ContactsService {
|
||||
where.rating = filters.rating;
|
||||
}
|
||||
|
||||
if (filters.category) {
|
||||
where.categories = {
|
||||
some: { id: filters.category }
|
||||
};
|
||||
}
|
||||
|
||||
if (filters.createdFrom || filters.createdTo) {
|
||||
where.createdAt = {};
|
||||
if (filters.createdFrom) {
|
||||
@@ -165,6 +193,15 @@ class ContactsService {
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
uniqueEmployeeId: true,
|
||||
},
|
||||
},
|
||||
createdBy: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -193,6 +230,15 @@ class ContactsService {
|
||||
include: {
|
||||
categories: true,
|
||||
parent: true,
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
uniqueEmployeeId: true,
|
||||
},
|
||||
},
|
||||
children: true,
|
||||
relationships: {
|
||||
include: {
|
||||
@@ -270,6 +316,16 @@ class ContactsService {
|
||||
await this.checkDuplicates(data as CreateContactData, id);
|
||||
}
|
||||
|
||||
// Validate employeeId if provided
|
||||
if (data.employeeId !== undefined && data.employeeId !== null) {
|
||||
const employee = await prisma.employee.findUnique({
|
||||
where: { id: data.employeeId },
|
||||
});
|
||||
if (!employee) {
|
||||
throw new AppError(400, 'Employee not found - الموظف غير موجود');
|
||||
}
|
||||
}
|
||||
|
||||
// Update contact
|
||||
const contact = await prisma.contact.update({
|
||||
where: { id },
|
||||
@@ -292,6 +348,7 @@ class ContactsService {
|
||||
set: data.categories.map(id => ({ id }))
|
||||
} : undefined,
|
||||
tags: data.tags,
|
||||
employeeId: data.employeeId !== undefined ? (data.employeeId || null) : undefined,
|
||||
source: data.source,
|
||||
status: data.status,
|
||||
rating: data.rating,
|
||||
@@ -300,6 +357,15 @@ class ContactsService {
|
||||
include: {
|
||||
categories: true,
|
||||
parent: true,
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
uniqueEmployeeId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -421,7 +487,9 @@ class ContactsService {
|
||||
toContactId: string,
|
||||
type: string,
|
||||
startDate: Date,
|
||||
userId: string
|
||||
userId: string,
|
||||
endDate?: Date,
|
||||
notes?: string
|
||||
) {
|
||||
const relationship = await prisma.contactRelationship.create({
|
||||
data: {
|
||||
@@ -429,18 +497,28 @@ class ContactsService {
|
||||
toContactId,
|
||||
type,
|
||||
startDate,
|
||||
endDate,
|
||||
notes,
|
||||
},
|
||||
include: {
|
||||
fromContact: {
|
||||
select: {
|
||||
id: true,
|
||||
uniqueContactId: true,
|
||||
type: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
},
|
||||
},
|
||||
toContact: {
|
||||
select: {
|
||||
id: true,
|
||||
uniqueContactId: true,
|
||||
type: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -456,12 +534,344 @@ class ContactsService {
|
||||
return relationship;
|
||||
}
|
||||
|
||||
async getRelationships(contactId: string) {
|
||||
const relationships = await prisma.contactRelationship.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ fromContactId: contactId },
|
||||
{ toContactId: contactId }
|
||||
],
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
fromContact: {
|
||||
select: {
|
||||
id: true,
|
||||
uniqueContactId: true,
|
||||
type: true,
|
||||
name: true,
|
||||
nameAr: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
toContact: {
|
||||
select: {
|
||||
id: true,
|
||||
uniqueContactId: true,
|
||||
type: true,
|
||||
name: true,
|
||||
nameAr: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return relationships;
|
||||
}
|
||||
|
||||
async updateRelationship(
|
||||
id: string,
|
||||
data: {
|
||||
type?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
notes?: string;
|
||||
isActive?: boolean;
|
||||
},
|
||||
userId: string
|
||||
) {
|
||||
const relationship = await prisma.contactRelationship.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
fromContact: {
|
||||
select: {
|
||||
id: true,
|
||||
uniqueContactId: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
toContact: {
|
||||
select: {
|
||||
id: true,
|
||||
uniqueContactId: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'CONTACT_RELATIONSHIP',
|
||||
entityId: relationship.id,
|
||||
action: 'UPDATE',
|
||||
userId,
|
||||
changes: data,
|
||||
});
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
async deleteRelationship(id: string, userId: string) {
|
||||
// Soft delete by marking as inactive
|
||||
const relationship = await prisma.contactRelationship.update({
|
||||
where: { id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'CONTACT_RELATIONSHIP',
|
||||
entityId: id,
|
||||
action: 'DELETE',
|
||||
userId,
|
||||
});
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
async getHistory(id: string) {
|
||||
return AuditLogger.getEntityHistory('CONTACT', id);
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
private async checkDuplicates(data: CreateContactData, excludeId?: string) {
|
||||
// Import contacts from Excel/CSV
|
||||
async import(fileBuffer: Buffer, userId: string): Promise<{
|
||||
success: number;
|
||||
failed: number;
|
||||
duplicates: number;
|
||||
errors: Array<{ row: number; field: string; message: string; data?: any }>;
|
||||
}> {
|
||||
const xlsx = require('xlsx');
|
||||
const workbook = xlsx.read(fileBuffer, { type: 'buffer' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const data = xlsx.utils.sheet_to_json(worksheet);
|
||||
|
||||
const results = {
|
||||
success: 0,
|
||||
failed: 0,
|
||||
duplicates: 0,
|
||||
errors: [] as Array<{ row: number; field: string; message: string; data?: any }>,
|
||||
};
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const row: any = data[i];
|
||||
const rowNumber = i + 2; // Excel rows start at 1, header is row 1
|
||||
|
||||
try {
|
||||
// Validate required fields
|
||||
if (!row.name || !row.type || !row.source) {
|
||||
results.errors.push({
|
||||
row: rowNumber,
|
||||
field: !row.name ? 'name' : !row.type ? 'type' : 'source',
|
||||
message: 'Required field missing',
|
||||
data: row,
|
||||
});
|
||||
results.failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate type
|
||||
if (!['INDIVIDUAL', 'COMPANY', 'HOLDING', 'GOVERNMENT'].includes(row.type)) {
|
||||
results.errors.push({
|
||||
row: rowNumber,
|
||||
field: 'type',
|
||||
message: 'Invalid type. Must be INDIVIDUAL, COMPANY, HOLDING, or GOVERNMENT',
|
||||
data: row,
|
||||
});
|
||||
results.failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
try {
|
||||
const contactData: CreateContactData = {
|
||||
type: row.type,
|
||||
name: row.name,
|
||||
nameAr: row.nameAr || row.name_ar,
|
||||
email: row.email,
|
||||
phone: row.phone,
|
||||
mobile: row.mobile,
|
||||
website: row.website,
|
||||
companyName: row.companyName || row.company_name,
|
||||
companyNameAr: row.companyNameAr || row.company_name_ar,
|
||||
taxNumber: row.taxNumber || row.tax_number,
|
||||
commercialRegister: row.commercialRegister || row.commercial_register,
|
||||
address: row.address,
|
||||
city: row.city,
|
||||
country: row.country,
|
||||
postalCode: row.postalCode || row.postal_code,
|
||||
source: row.source,
|
||||
tags: row.tags ? (typeof row.tags === 'string' ? row.tags.split(',').map((t: string) => t.trim()) : row.tags) : [],
|
||||
customFields: {},
|
||||
createdById: userId,
|
||||
};
|
||||
|
||||
await this.checkDuplicates(contactData);
|
||||
|
||||
// Create contact
|
||||
await this.create(contactData, userId);
|
||||
results.success++;
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 409) {
|
||||
results.duplicates++;
|
||||
results.errors.push({
|
||||
row: rowNumber,
|
||||
field: 'duplicate',
|
||||
message: error.message,
|
||||
data: row,
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
results.failed++;
|
||||
results.errors.push({
|
||||
row: rowNumber,
|
||||
field: 'general',
|
||||
message: error.message || 'Unknown error',
|
||||
data: row,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Export contacts to Excel
|
||||
async export(filters: SearchFilters): Promise<Buffer> {
|
||||
const xlsx = require('xlsx');
|
||||
|
||||
// Build query
|
||||
const where: Prisma.ContactWhereInput = {
|
||||
status: { not: 'DELETED' },
|
||||
};
|
||||
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ name: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ email: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ 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) where.rating = filters.rating;
|
||||
|
||||
if (filters.excludeCompanyEmployees) {
|
||||
const companyEmployeeCategory = await prisma.contactCategory.findFirst({
|
||||
where: { name: 'Company Employee', isActive: true },
|
||||
});
|
||||
if (companyEmployeeCategory) {
|
||||
where.NOT = {
|
||||
categories: {
|
||||
some: { id: companyEmployeeCategory.id },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all contacts (no pagination for export)
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where,
|
||||
include: {
|
||||
categories: true,
|
||||
parent: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
createdBy: {
|
||||
select: {
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
// Transform data for Excel
|
||||
const exportData = contacts.map(contact => ({
|
||||
'Contact ID': contact.uniqueContactId,
|
||||
'Type': contact.type,
|
||||
'Name': contact.name,
|
||||
'Name (Arabic)': contact.nameAr || '',
|
||||
'Email': contact.email || '',
|
||||
'Phone': contact.phone || '',
|
||||
'Mobile': contact.mobile || '',
|
||||
'Website': contact.website || '',
|
||||
'Company Name': contact.companyName || '',
|
||||
'Company Name (Arabic)': contact.companyNameAr || '',
|
||||
'Tax Number': contact.taxNumber || '',
|
||||
'Commercial Register': contact.commercialRegister || '',
|
||||
'Address': contact.address || '',
|
||||
'City': contact.city || '',
|
||||
'Country': contact.country || '',
|
||||
'Postal Code': contact.postalCode || '',
|
||||
'Source': contact.source,
|
||||
'Rating': contact.rating || '',
|
||||
'Status': contact.status,
|
||||
'Tags': contact.tags?.join(', ') || '',
|
||||
'Categories': contact.categories?.map((c: any) => c.name).join(', ') || '',
|
||||
'Parent Company': contact.parent?.name || '',
|
||||
'Created By': contact.createdBy?.username || '',
|
||||
'Created At': contact.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
// Create workbook and worksheet
|
||||
const worksheet = xlsx.utils.json_to_sheet(exportData);
|
||||
const workbook = xlsx.utils.book_new();
|
||||
xlsx.utils.book_append_sheet(workbook, worksheet, 'Contacts');
|
||||
|
||||
// Set column widths
|
||||
const columnWidths = [
|
||||
{ wch: 15 }, // Contact ID
|
||||
{ wch: 12 }, // Type
|
||||
{ wch: 25 }, // Name
|
||||
{ wch: 25 }, // Name (Arabic)
|
||||
{ wch: 30 }, // Email
|
||||
{ wch: 15 }, // Phone
|
||||
{ wch: 15 }, // Mobile
|
||||
{ wch: 30 }, // Website
|
||||
{ wch: 25 }, // Company Name
|
||||
{ wch: 25 }, // Company Name (Arabic)
|
||||
{ wch: 20 }, // Tax Number
|
||||
{ wch: 20 }, // Commercial Register
|
||||
{ wch: 30 }, // Address
|
||||
{ wch: 15 }, // City
|
||||
{ wch: 15 }, // Country
|
||||
{ wch: 12 }, // Postal Code
|
||||
{ wch: 15 }, // Source
|
||||
{ wch: 8 }, // Rating
|
||||
{ wch: 10 }, // Status
|
||||
{ wch: 30 }, // Tags
|
||||
{ wch: 30 }, // Categories
|
||||
{ wch: 25 }, // Parent Company
|
||||
{ wch: 15 }, // Created By
|
||||
{ wch: 20 }, // Created At
|
||||
];
|
||||
worksheet['!cols'] = columnWidths;
|
||||
|
||||
// Generate buffer
|
||||
const buffer = xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
||||
return buffer;
|
||||
}
|
||||
|
||||
// Check for potential duplicates (public method for API endpoint)
|
||||
async findDuplicates(data: Partial<CreateContactData>, excludeId?: string) {
|
||||
const conditions: Prisma.ContactWhereInput[] = [];
|
||||
|
||||
if (data.email) {
|
||||
@@ -484,31 +894,47 @@ class ContactsService {
|
||||
conditions.push({ commercialRegister: data.commercialRegister });
|
||||
}
|
||||
|
||||
if (conditions.length === 0) return;
|
||||
if (conditions.length === 0) return [];
|
||||
|
||||
const where: Prisma.ContactWhereInput = {
|
||||
OR: conditions,
|
||||
status: { not: 'DELETED' },
|
||||
};
|
||||
|
||||
if (excludeId) {
|
||||
where.NOT = { id: excludeId };
|
||||
}
|
||||
|
||||
const duplicate = await prisma.contact.findFirst({
|
||||
const duplicates = await prisma.contact.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
uniqueContactId: true,
|
||||
type: true,
|
||||
name: true,
|
||||
nameAr: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
mobile: true,
|
||||
taxNumber: true,
|
||||
commercialRegister: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
},
|
||||
take: 10, // Limit to 10 potential duplicates
|
||||
});
|
||||
|
||||
if (duplicate) {
|
||||
return duplicates;
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
private async checkDuplicates(data: CreateContactData, excludeId?: string) {
|
||||
const duplicates = await this.findDuplicates(data, excludeId);
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
throw new AppError(
|
||||
409,
|
||||
`جهة اتصال مكررة - Duplicate contact found: ${duplicate.name}`
|
||||
`جهة اتصال مكررة - Duplicate contact found: ${duplicates[0].name}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,41 @@ import { Response, NextFunction } from 'express';
|
||||
import { AuthRequest } from '../../shared/middleware/auth';
|
||||
import { dealsService } from './deals.service';
|
||||
import { quotesService } from './quotes.service';
|
||||
import { pipelinesService } from './pipelines.service';
|
||||
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
|
||||
|
||||
export class PipelinesController {
|
||||
async findAll(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const structure = req.query.structure as string | undefined;
|
||||
const pipelines = await pipelinesService.findAll({ structure });
|
||||
res.json(ResponseFormatter.success(pipelines));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findById(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const pipeline = await pipelinesService.findById(req.params.id);
|
||||
res.json(ResponseFormatter.success(pipeline));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class DealsController {
|
||||
async create(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const expectedCloseDate = req.body.expectedCloseDate
|
||||
? new Date(req.body.expectedCloseDate)
|
||||
: undefined;
|
||||
const data = {
|
||||
...req.body,
|
||||
ownerId: req.body.ownerId || req.user!.id,
|
||||
fiscalYear: req.body.fiscalYear || new Date().getFullYear(),
|
||||
expectedCloseDate,
|
||||
};
|
||||
|
||||
const deal = await dealsService.create(data, req.user!.id);
|
||||
@@ -61,9 +87,12 @@ export class DealsController {
|
||||
|
||||
async update(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const body = { ...req.body } as Record<string, unknown>;
|
||||
if (body.expectedCloseDate) body.expectedCloseDate = new Date(body.expectedCloseDate as string);
|
||||
if (body.actualCloseDate) body.actualCloseDate = new Date(body.actualCloseDate as string);
|
||||
const deal = await dealsService.update(
|
||||
req.params.id,
|
||||
req.body,
|
||||
body as any,
|
||||
req.user!.id
|
||||
);
|
||||
|
||||
@@ -197,6 +226,7 @@ export class QuotesController {
|
||||
}
|
||||
}
|
||||
|
||||
export const pipelinesController = new PipelinesController();
|
||||
export const dealsController = new DealsController();
|
||||
export const quotesController = new QuotesController();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { body, param } from 'express-validator';
|
||||
import { dealsController, quotesController } from './crm.controller';
|
||||
import { pipelinesController, dealsController, quotesController } from './crm.controller';
|
||||
import { authenticate, authorize } from '../../shared/middleware/auth';
|
||||
import { validate } from '../../shared/middleware/validation';
|
||||
|
||||
@@ -9,6 +9,24 @@ const router = Router();
|
||||
// All routes require authentication
|
||||
router.use(authenticate);
|
||||
|
||||
// ============= PIPELINES =============
|
||||
|
||||
// Get all pipelines
|
||||
router.get(
|
||||
'/pipelines',
|
||||
authorize('crm', 'deals', 'read'),
|
||||
pipelinesController.findAll
|
||||
);
|
||||
|
||||
// Get pipeline by ID
|
||||
router.get(
|
||||
'/pipelines/:id',
|
||||
authorize('crm', 'deals', 'read'),
|
||||
param('id').isUUID(),
|
||||
validate,
|
||||
pipelinesController.findById
|
||||
);
|
||||
|
||||
// ============= DEALS =============
|
||||
|
||||
// Get all deals
|
||||
|
||||
60
backend/src/modules/crm/pipelines.service.ts
Normal file
60
backend/src/modules/crm/pipelines.service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import prisma from '../../config/database';
|
||||
import { AppError } from '../../shared/middleware/errorHandler';
|
||||
|
||||
interface PipelineFilters {
|
||||
structure?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
class PipelinesService {
|
||||
async findAll(filters: PipelineFilters = {}) {
|
||||
const where: any = {};
|
||||
|
||||
if (filters.structure) {
|
||||
where.structure = filters.structure;
|
||||
}
|
||||
|
||||
if (filters.isActive !== undefined) {
|
||||
where.isActive = filters.isActive;
|
||||
} else {
|
||||
where.isActive = true;
|
||||
}
|
||||
|
||||
const pipelines = await prisma.pipeline.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nameAr: true,
|
||||
structure: true,
|
||||
stages: true,
|
||||
isActive: true,
|
||||
},
|
||||
orderBy: [{ structure: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
|
||||
return pipelines;
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
const pipeline = await prisma.pipeline.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nameAr: true,
|
||||
structure: true,
|
||||
stages: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!pipeline) {
|
||||
throw new AppError(404, 'المسار غير موجود - Pipeline not found');
|
||||
}
|
||||
|
||||
return pipeline;
|
||||
}
|
||||
}
|
||||
|
||||
export const pipelinesService = new PipelinesService();
|
||||
Reference in New Issue
Block a user