template-saas/apps/backend/src/modules/sales/services/leads.service.ts
Adrian Flores Cortes 529ea53b5e
Some checks are pending
CI / Backend CI (push) Waiting to run
CI / Frontend CI (push) Waiting to run
CI / Security Scan (push) Waiting to run
CI / CI Summary (push) Blocked by required conditions
[SAAS-018] feat: Complete Sales Foundation module implementation
## Backend (NestJS)
- Entities: Lead, Opportunity, PipelineStage, Activity with TypeORM
- Services: LeadsService, OpportunitiesService, PipelineService, ActivitiesService, SalesDashboardService
- Controllers: LeadsController, OpportunitiesController, PipelineController, ActivitiesController, DashboardController
- DTOs: Full set of Create/Update/Convert DTOs with validation
- Tests: 5 test suites with comprehensive coverage

## Frontend (React)
- Pages: /sales, /sales/leads, /sales/leads/[id], /sales/opportunities, /sales/opportunities/[id], /sales/activities
- Components: SalesDashboard, ConversionFunnel, LeadsList, LeadForm, LeadCard, PipelineBoard, OpportunityCard, OpportunityForm, ActivityTimeline, ActivityForm
- Hooks: useLeads, useOpportunities, usePipeline, useActivities, useSalesDashboard
- Services: leads.api, opportunities.api, activities.api, pipeline.api, dashboard.api

## Documentation
- Updated SAAS-018-sales.md with implementation details
- Updated MASTER_INVENTORY.yml - status changed from specified to completed

Story Points: 21
Sprint: 6 - Sales Foundation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 20:49:59 -06:00

236 lines
6.4 KiB
TypeScript

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<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
@Injectable()
export class LeadsService {
constructor(
@InjectRepository(Lead)
private readonly leadRepository: Repository<Lead>,
@InjectRepository(Opportunity)
private readonly opportunityRepository: Repository<Opportunity>,
) {}
async findAll(
tenantId: string,
filters: LeadFilters = {},
pagination: PaginationOptions = {},
): Promise<PaginatedResult<Lead>> {
const { page = 1, limit = 20, sortBy = 'created_at', sortOrder = 'DESC' } = pagination;
const skip = (page - 1) * limit;
const where: FindOptionsWhere<Lead> = {
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<Lead> {
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<Lead> {
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<Lead> {
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<void> {
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<Opportunity> {
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<Lead> {
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<Lead> {
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<LeadStatus, number>;
bySource: Record<LeadSource, number>;
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<LeadStatus, number>,
);
const bySource = leads.reduce(
(acc, lead) => {
acc[lead.source] = (acc[lead.source] || 0) + 1;
return acc;
},
{} as Record<LeadSource, number>,
);
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),
};
}
}