Files
zerp/backend/src/modules/contacts/contacts.service.ts
2026-04-01 15:50:21 +03:00

973 lines
24 KiB
TypeScript

import prisma from '../../config/database';
import { AppError } from '../../shared/middleware/errorHandler';
import { AuditLogger } from '../../shared/utils/auditLogger';
import { Prisma } from '@prisma/client';
interface CreateContactData {
type: string;
name: string;
nameAr?: string;
email?: string;
phone?: string;
mobile?: string;
website?: string;
companyName?: string;
companyNameAr?: string;
taxNumber?: string;
commercialRegister?: string;
address?: string;
city?: string;
country?: string;
postalCode?: string;
categories?: string[];
tags?: string[];
parentId?: string;
employeeId?: string | null;
source: string;
customFields?: any;
createdById: string;
}
interface UpdateContactData extends Partial<CreateContactData> {
status?: string;
rating?: number;
}
interface SearchFilters {
search?: string;
type?: string;
status?: string;
category?: string;
source?: string;
rating?: number;
createdFrom?: Date;
createdTo?: Date;
excludeCompanyEmployees?: boolean;
}
class ContactsService {
async create(data: CreateContactData, userId: string) {
// 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();
// Create contact
const contact = await prisma.contact.create({
data: {
uniqueContactId,
type: data.type,
name: data.name,
nameAr: data.nameAr,
email: data.email,
phone: data.phone,
mobile: data.mobile,
website: data.website,
companyName: data.companyName,
companyNameAr: data.companyNameAr,
taxNumber: data.taxNumber,
commercialRegister: data.commercialRegister,
address: data.address,
city: data.city,
country: data.country,
postalCode: data.postalCode,
categories: data.categories ? {
connect: data.categories.map(id => ({ id }))
} : undefined,
tags: data.tags || [],
parentId: data.parentId,
employeeId: data.employeeId || undefined,
source: data.source,
customFields: data.customFields || {},
createdById: data.createdById,
},
include: {
categories: true,
parent: true,
employee: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
uniqueEmployeeId: true,
},
},
createdBy: {
select: {
id: true,
email: true,
username: true,
},
},
},
});
// Log audit
await AuditLogger.log({
entityType: 'CONTACT',
entityId: contact.id,
action: 'CREATE',
userId,
});
return contact;
}
async findAll(filters: SearchFilters, page: number = 1, pageSize: number = 20) {
const skip = (page - 1) * pageSize;
// Build where clause
const where: Prisma.ContactWhereInput = {
archivedAt: null, // Don't show archived contacts
};
if (filters.search) {
where.OR = [
{ name: { contains: filters.search, mode: 'insensitive' } },
{ nameAr: { contains: filters.search, mode: 'insensitive' } },
{ email: { contains: filters.search, mode: 'insensitive' } },
{ phone: { contains: filters.search } },
{ mobile: { contains: filters.search } },
{ companyName: { contains: filters.search, mode: 'insensitive' } },
];
}
if (filters.type) {
where.type = filters.type;
}
if (filters.status) {
where.status = filters.status;
}
if (filters.source) {
where.source = filters.source;
}
if (filters.rating !== undefined) {
where.rating = filters.rating;
}
if (filters.category) {
where.categories = {
some: { id: filters.category }
};
}
if (filters.createdFrom || filters.createdTo) {
where.createdAt = {};
if (filters.createdFrom) {
where.createdAt.gte = filters.createdFrom;
}
if (filters.createdTo) {
where.createdAt.lte = filters.createdTo;
}
}
// Get total count
const total = await prisma.contact.count({ where });
// Get contacts
const contacts = await prisma.contact.findMany({
where,
skip,
take: pageSize,
include: {
categories: true,
parent: {
select: {
id: true,
name: true,
type: true,
},
},
employee: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
uniqueEmployeeId: true,
},
},
createdBy: {
select: {
id: true,
email: true,
username: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
});
return {
contacts,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
async findById(id: string) {
const contact = await prisma.contact.findUnique({
where: { id },
include: {
categories: true,
parent: true,
employee: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
uniqueEmployeeId: true,
},
},
children: true,
relationships: {
include: {
toContact: {
select: {
id: true,
name: true,
type: true,
},
},
},
},
relatedTo: {
include: {
fromContact: {
select: {
id: true,
name: true,
type: true,
},
},
},
},
activities: {
take: 20,
orderBy: {
createdAt: 'desc',
},
},
deals: {
take: 10,
orderBy: {
createdAt: 'desc',
},
},
notes: {
orderBy: {
createdAt: 'desc',
},
},
attachments: {
orderBy: {
uploadedAt: 'desc',
},
},
createdBy: {
select: {
id: true,
email: true,
username: true,
},
},
},
});
if (!contact) {
throw new AppError(404, 'جهة الاتصال غير موجودة - Contact not found');
}
return contact;
}
async update(id: string, data: UpdateContactData, userId: string) {
// Get existing contact
const existing = await prisma.contact.findUnique({
where: { id },
});
if (!existing) {
throw new AppError(404, 'جهة الاتصال غير موجودة - Contact not found');
}
// Check for duplicates if email/phone/tax changed
if (data.email || data.phone || data.taxNumber) {
await this.checkDuplicates(data as CreateContactData, id);
}
// 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 },
data: {
name: data.name,
nameAr: data.nameAr,
email: data.email,
phone: data.phone,
mobile: data.mobile,
website: data.website,
companyName: data.companyName,
companyNameAr: data.companyNameAr,
taxNumber: data.taxNumber,
commercialRegister: data.commercialRegister,
address: data.address,
city: data.city,
country: data.country,
postalCode: data.postalCode,
categories: data.categories ? {
set: data.categories.map(id => ({ id }))
} : undefined,
tags: data.tags,
employeeId: data.employeeId !== undefined ? (data.employeeId || null) : undefined,
source: data.source,
status: data.status,
rating: data.rating,
customFields: data.customFields,
},
include: {
categories: true,
parent: true,
employee: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
uniqueEmployeeId: true,
},
},
},
});
// Log audit
await AuditLogger.log({
entityType: 'CONTACT',
entityId: contact.id,
action: 'UPDATE',
userId,
changes: {
before: existing,
after: contact,
},
});
return contact;
}
async archive(id: string, userId: string, reason?: string) {
const contact = await prisma.contact.update({
where: { id },
data: {
status: 'ARCHIVED',
archivedAt: new Date(),
},
});
await AuditLogger.log({
entityType: 'CONTACT',
entityId: contact.id,
action: 'ARCHIVE',
userId,
reason,
});
return contact;
}
async delete(id: string, userId: string, reason: string) {
// Hard delete - only for authorized users
// This should be restricted at the controller level
const contact = await prisma.contact.delete({
where: { id },
});
await AuditLogger.log({
entityType: 'CONTACT',
entityId: id,
action: 'DELETE',
userId,
reason,
});
return contact;
}
async merge(sourceId: string, targetId: string, userId: string, reason: string) {
// Get both contacts
const source = await prisma.contact.findUnique({ where: { id: sourceId } });
const target = await prisma.contact.findUnique({ where: { id: targetId } });
if (!source || !target) {
throw new AppError(404, 'جهة الاتصال غير موجودة - Contact not found');
}
// Start transaction
await prisma.$transaction(async (tx) => {
// Update all related records to point to target
await tx.deal.updateMany({
where: { contactId: sourceId },
data: { contactId: targetId },
});
await tx.activity.updateMany({
where: { contactId: sourceId },
data: { contactId: targetId },
});
await tx.note.updateMany({
where: { contactId: sourceId },
data: { contactId: targetId },
});
await tx.attachment.updateMany({
where: { contactId: sourceId },
data: { contactId: targetId },
});
// Archive source contact
await tx.contact.update({
where: { id: sourceId },
data: {
status: 'ARCHIVED',
archivedAt: new Date(),
},
});
});
// Log audit
await AuditLogger.log({
entityType: 'CONTACT',
entityId: targetId,
action: 'MERGE',
userId,
reason,
changes: {
sourceId,
targetId,
sourceData: source,
},
});
return target;
}
async addRelationship(
fromContactId: string,
toContactId: string,
type: string,
startDate: Date,
userId: string,
endDate?: Date,
notes?: string
) {
const relationship = await prisma.contactRelationship.create({
data: {
fromContactId,
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,
},
},
},
});
await AuditLogger.log({
entityType: 'CONTACT_RELATIONSHIP',
entityId: relationship.id,
action: 'CREATE',
userId,
});
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);
}
// 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', 'ORGANIZATION','EMBASSIES','BANK','UNIVERSITY','SCHOOL','UN','NGO','INSTITUTION',].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) {
conditions.push({ email: data.email });
}
if (data.phone) {
conditions.push({ phone: data.phone });
}
if (data.mobile) {
conditions.push({ mobile: data.mobile });
}
if (data.taxNumber) {
conditions.push({ taxNumber: data.taxNumber });
}
if (data.commercialRegister) {
conditions.push({ commercialRegister: data.commercialRegister });
}
if (conditions.length === 0) return [];
const where: Prisma.ContactWhereInput = {
OR: conditions,
status: { not: 'DELETED' },
};
if (excludeId) {
where.NOT = { id: excludeId };
}
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
});
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: ${duplicates[0].name}`
);
}
}
private async generateUniqueContactId(): Promise<string> {
const year = new Date().getFullYear();
const prefix = `CNT-${year}-`;
// Get the last contact for this year
const lastContact = await prisma.contact.findFirst({
where: {
uniqueContactId: {
startsWith: prefix,
},
},
orderBy: {
createdAt: 'desc',
},
select: {
uniqueContactId: true,
},
});
let nextNumber = 1;
if (lastContact) {
const lastNumber = parseInt(lastContact.uniqueContactId.split('-')[2]);
nextNumber = lastNumber + 1;
}
return `${prefix}${nextNumber.toString().padStart(6, '0')}`;
}
}
export const contactsService = new ContactsService();