import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, FindOptionsWhere, ILike } from 'typeorm'; import { Lead, LeadStatus, LeadSource } from '../entities/lead.entity'; import { Opportunity, OpportunityStage } from '../entities/opportunity.entity'; import { CreateLeadDto, UpdateLeadDto, ConvertLeadDto } from '../dto'; export interface LeadFilters { status?: LeadStatus; source?: LeadSource; assigned_to?: string; search?: string; } export interface PaginationOptions { page?: number; limit?: number; sortBy?: string; sortOrder?: 'ASC' | 'DESC'; } export interface PaginatedResult { data: T[]; total: number; page: number; limit: number; totalPages: number; } @Injectable() export class LeadsService { constructor( @InjectRepository(Lead) private readonly leadRepository: Repository, @InjectRepository(Opportunity) private readonly opportunityRepository: Repository, ) {} async findAll( tenantId: string, filters: LeadFilters = {}, pagination: PaginationOptions = {}, ): Promise> { const { page = 1, limit = 20, sortBy = 'created_at', sortOrder = 'DESC' } = pagination; const skip = (page - 1) * limit; const where: FindOptionsWhere = { tenant_id: tenantId, deleted_at: undefined, }; if (filters.status) { where.status = filters.status; } if (filters.source) { where.source = filters.source; } if (filters.assigned_to) { where.assigned_to = filters.assigned_to; } const queryBuilder = this.leadRepository.createQueryBuilder('lead') .where('lead.tenant_id = :tenantId', { tenantId }) .andWhere('lead.deleted_at IS NULL'); if (filters.status) { queryBuilder.andWhere('lead.status = :status', { status: filters.status }); } if (filters.source) { queryBuilder.andWhere('lead.source = :source', { source: filters.source }); } if (filters.assigned_to) { queryBuilder.andWhere('lead.assigned_to = :assignedTo', { assignedTo: filters.assigned_to }); } if (filters.search) { queryBuilder.andWhere( '(lead.first_name ILIKE :search OR lead.last_name ILIKE :search OR lead.email ILIKE :search OR lead.company ILIKE :search)', { search: `%${filters.search}%` }, ); } const [data, total] = await queryBuilder .orderBy(`lead.${sortBy}`, sortOrder) .skip(skip) .take(limit) .getManyAndCount(); return { data, total, page, limit, totalPages: Math.ceil(total / limit), }; } async findOne(tenantId: string, id: string): Promise { const lead = await this.leadRepository.findOne({ where: { id, tenant_id: tenantId, deleted_at: undefined }, relations: ['assignedUser'], }); if (!lead) { throw new NotFoundException(`Lead with ID ${id} not found`); } return lead; } async create(tenantId: string, dto: CreateLeadDto, createdBy?: string): Promise { const lead = this.leadRepository.create({ ...dto, tenant_id: tenantId, created_by: createdBy, }); return this.leadRepository.save(lead); } async update(tenantId: string, id: string, dto: UpdateLeadDto): Promise { const lead = await this.findOne(tenantId, id); if (lead.status === LeadStatus.CONVERTED) { throw new BadRequestException('Cannot update a converted lead'); } Object.assign(lead, dto); return this.leadRepository.save(lead); } async remove(tenantId: string, id: string): Promise { const lead = await this.findOne(tenantId, id); lead.deleted_at = new Date(); await this.leadRepository.save(lead); } async convertToOpportunity( tenantId: string, id: string, dto: ConvertLeadDto, ): Promise { const lead = await this.findOne(tenantId, id); if (lead.status === LeadStatus.CONVERTED) { throw new BadRequestException('Lead is already converted'); } // Create opportunity from lead const opportunity = this.opportunityRepository.create({ tenant_id: tenantId, name: dto.opportunity_name || `${lead.company || lead.fullName} - Opportunity`, lead_id: lead.id, stage: OpportunityStage.PROSPECTING, amount: dto.amount || 0, currency: dto.currency || 'USD', expected_close_date: dto.expected_close_date, assigned_to: lead.assigned_to, contact_name: lead.fullName, contact_email: lead.email, contact_phone: lead.phone, company_name: lead.company, notes: lead.notes, created_by: lead.created_by, }); const savedOpportunity = await this.opportunityRepository.save(opportunity); // Update lead as converted lead.status = LeadStatus.CONVERTED; lead.converted_at = new Date(); lead.converted_to_opportunity_id = savedOpportunity.id; await this.leadRepository.save(lead); return savedOpportunity; } async assignTo(tenantId: string, id: string, userId: string): Promise { const lead = await this.findOne(tenantId, id); lead.assigned_to = userId; return this.leadRepository.save(lead); } async updateScore(tenantId: string, id: string, score: number): Promise { if (score < 0 || score > 100) { throw new BadRequestException('Score must be between 0 and 100'); } const lead = await this.findOne(tenantId, id); lead.score = score; return this.leadRepository.save(lead); } async getStats(tenantId: string): Promise<{ total: number; byStatus: Record; bySource: Record; avgScore: number; }> { const leads = await this.leadRepository.find({ where: { tenant_id: tenantId, deleted_at: undefined }, }); const byStatus = leads.reduce( (acc, lead) => { acc[lead.status] = (acc[lead.status] || 0) + 1; return acc; }, {} as Record, ); const bySource = leads.reduce( (acc, lead) => { acc[lead.source] = (acc[lead.source] || 0) + 1; return acc; }, {} as Record, ); const avgScore = leads.length > 0 ? leads.reduce((sum, lead) => sum + lead.score, 0) / leads.length : 0; return { total: leads.length, byStatus, bySource, avgScore: Math.round(avgScore), }; } }