Deploy rule, CRM enhancements, Company Employee category, bilingual, contacts module completion

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Talal Sharabi
2026-02-19 14:59:34 +04:00
parent 0b126cb676
commit 680ba3871e
51 changed files with 11456 additions and 477 deletions

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

View 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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