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 { 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 { 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, 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 { 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();