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

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