feat: Complete Z.CRM system with all 6 modules
✨ Features: - Complete authentication system with JWT - Dashboard with all 6 modules visible - Contact Management module (Salesforce-style) - CRM & Sales Pipeline module (Pipedrive-style) - Inventory & Assets module (SAP-style) - Tasks & Projects module (Jira/Asana-style) - HR Management module (BambooHR-style) - Marketing Management module (HubSpot-style) - Admin Panel with user management and role matrix - World-class UI/UX with RTL Arabic support - Cairo font (headings) + Readex Pro font (body) - Sample data for all modules - Protected routes and authentication flow - Backend API with Prisma + PostgreSQL - Comprehensive documentation 🎨 Design: - Color-coded modules - Professional data tables - Stats cards with metrics - Progress bars and status badges - Search and filters - Responsive layout 📊 Tech Stack: - Frontend: Next.js 14, TypeScript, Tailwind CSS - Backend: Node.js, Express, Prisma - Database: PostgreSQL - Auth: JWT with bcrypt 🚀 Production-ready frontend with all features accessible
This commit is contained in:
546
backend/src/modules/contacts/contacts.service.ts
Normal file
546
backend/src/modules/contacts/contacts.service.ts
Normal file
@@ -0,0 +1,546 @@
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
class ContactsService {
|
||||
async create(data: CreateContactData, userId: string) {
|
||||
// Check for duplicates based on email, phone, or tax number
|
||||
await this.checkDuplicates(data);
|
||||
|
||||
// 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,
|
||||
source: data.source,
|
||||
customFields: data.customFields || {},
|
||||
createdById: data.createdById,
|
||||
},
|
||||
include: {
|
||||
categories: true,
|
||||
parent: 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.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,
|
||||
},
|
||||
},
|
||||
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,
|
||||
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);
|
||||
}
|
||||
|
||||
// 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,
|
||||
source: data.source,
|
||||
status: data.status,
|
||||
rating: data.rating,
|
||||
customFields: data.customFields,
|
||||
},
|
||||
include: {
|
||||
categories: true,
|
||||
parent: 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
|
||||
) {
|
||||
const relationship = await prisma.contactRelationship.create({
|
||||
data: {
|
||||
fromContactId,
|
||||
toContactId,
|
||||
type,
|
||||
startDate,
|
||||
},
|
||||
include: {
|
||||
fromContact: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
toContact: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'CONTACT_RELATIONSHIP',
|
||||
entityId: relationship.id,
|
||||
action: 'CREATE',
|
||||
userId,
|
||||
});
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
async getHistory(id: string) {
|
||||
return AuditLogger.getEntityHistory('CONTACT', id);
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
private async checkDuplicates(data: 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,
|
||||
};
|
||||
|
||||
if (excludeId) {
|
||||
where.NOT = { id: excludeId };
|
||||
}
|
||||
|
||||
const duplicate = await prisma.contact.findFirst({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
mobile: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (duplicate) {
|
||||
throw new AppError(
|
||||
409,
|
||||
`جهة اتصال مكررة - Duplicate contact found: ${duplicate.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();
|
||||
|
||||
Reference in New Issue
Block a user