973 lines
24 KiB
TypeScript
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();
|
|
|