Deploy rule, CRM enhancements, Company Employee category, bilingual, contacts module completion
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user