import { query, queryOne, getClient } from '../../config/database.js'; import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js'; export type LeadStatus = 'new' | 'contacted' | 'qualified' | 'converted' | 'lost'; export type LeadSource = 'website' | 'phone' | 'email' | 'referral' | 'social_media' | 'advertising' | 'event' | 'other'; export interface Lead { id: string; tenant_id: string; company_id: string; company_name?: string; name: string; ref?: string; contact_name?: string; email?: string; phone?: string; mobile?: string; website?: string; company_prospect_name?: string; job_position?: string; industry?: string; employee_count?: string; annual_revenue?: number; street?: string; city?: string; state?: string; zip?: string; country?: string; stage_id?: string; stage_name?: string; status: LeadStatus; user_id?: string; user_name?: string; sales_team_id?: string; source?: LeadSource; priority: number; probability: number; expected_revenue?: number; date_open?: Date; date_closed?: Date; date_deadline?: Date; date_last_activity?: Date; partner_id?: string; opportunity_id?: string; lost_reason_id?: string; lost_reason_name?: string; lost_notes?: string; description?: string; notes?: string; tags?: string[]; created_at: Date; } export interface CreateLeadDto { company_id: string; name: string; ref?: string; contact_name?: string; email?: string; phone?: string; mobile?: string; website?: string; company_prospect_name?: string; job_position?: string; industry?: string; employee_count?: string; annual_revenue?: number; street?: string; city?: string; state?: string; zip?: string; country?: string; stage_id?: string; user_id?: string; sales_team_id?: string; source?: LeadSource; priority?: number; probability?: number; expected_revenue?: number; date_deadline?: string; description?: string; notes?: string; tags?: string[]; } export interface UpdateLeadDto { name?: string; ref?: string | null; contact_name?: string | null; email?: string | null; phone?: string | null; mobile?: string | null; website?: string | null; company_prospect_name?: string | null; job_position?: string | null; industry?: string | null; employee_count?: string | null; annual_revenue?: number | null; street?: string | null; city?: string | null; state?: string | null; zip?: string | null; country?: string | null; stage_id?: string | null; user_id?: string | null; sales_team_id?: string | null; source?: LeadSource | null; priority?: number; probability?: number; expected_revenue?: number | null; date_deadline?: string | null; description?: string | null; notes?: string | null; tags?: string[] | null; } export interface LeadFilters { company_id?: string; status?: LeadStatus; stage_id?: string; user_id?: string; source?: LeadSource; priority?: number; search?: string; page?: number; limit?: number; } class LeadsService { async findAll(tenantId: string, filters: LeadFilters = {}): Promise<{ data: Lead[]; total: number }> { const { company_id, status, stage_id, user_id, source, priority, search, page = 1, limit = 20 } = filters; const offset = (page - 1) * limit; let whereClause = 'WHERE l.tenant_id = $1'; const params: any[] = [tenantId]; let paramIndex = 2; if (company_id) { whereClause += ` AND l.company_id = $${paramIndex++}`; params.push(company_id); } if (status) { whereClause += ` AND l.status = $${paramIndex++}`; params.push(status); } if (stage_id) { whereClause += ` AND l.stage_id = $${paramIndex++}`; params.push(stage_id); } if (user_id) { whereClause += ` AND l.user_id = $${paramIndex++}`; params.push(user_id); } if (source) { whereClause += ` AND l.source = $${paramIndex++}`; params.push(source); } if (priority !== undefined) { whereClause += ` AND l.priority = $${paramIndex++}`; params.push(priority); } if (search) { whereClause += ` AND (l.name ILIKE $${paramIndex} OR l.contact_name ILIKE $${paramIndex} OR l.email ILIKE $${paramIndex} OR l.company_name ILIKE $${paramIndex})`; params.push(`%${search}%`); paramIndex++; } const countResult = await queryOne<{ count: string }>( `SELECT COUNT(*) as count FROM crm.leads l ${whereClause}`, params ); params.push(limit, offset); const data = await query( `SELECT l.*, c.name as company_org_name, ls.name as stage_name, u.email as user_email, lr.name as lost_reason_name FROM crm.leads l LEFT JOIN auth.companies c ON l.company_id = c.id LEFT JOIN crm.lead_stages ls ON l.stage_id = ls.id LEFT JOIN auth.users u ON l.user_id = u.id LEFT JOIN crm.lost_reasons lr ON l.lost_reason_id = lr.id ${whereClause} ORDER BY l.priority DESC, l.created_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, params ); return { data, total: parseInt(countResult?.count || '0', 10), }; } async findById(id: string, tenantId: string): Promise { const lead = await queryOne( `SELECT l.*, c.name as company_org_name, ls.name as stage_name, u.email as user_email, lr.name as lost_reason_name FROM crm.leads l LEFT JOIN auth.companies c ON l.company_id = c.id LEFT JOIN crm.lead_stages ls ON l.stage_id = ls.id LEFT JOIN auth.users u ON l.user_id = u.id LEFT JOIN crm.lost_reasons lr ON l.lost_reason_id = lr.id WHERE l.id = $1 AND l.tenant_id = $2`, [id, tenantId] ); if (!lead) { throw new NotFoundError('Lead no encontrado'); } return lead; } async create(dto: CreateLeadDto, tenantId: string, userId: string): Promise { const lead = await queryOne( `INSERT INTO crm.leads ( tenant_id, company_id, name, ref, contact_name, email, phone, mobile, website, company_name, job_position, industry, employee_count, annual_revenue, street, city, state, zip, country, stage_id, user_id, sales_team_id, source, priority, probability, expected_revenue, date_deadline, description, notes, tags, date_open, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, CURRENT_TIMESTAMP, $31) RETURNING *`, [ tenantId, dto.company_id, dto.name, dto.ref, dto.contact_name, dto.email, dto.phone, dto.mobile, dto.website, dto.company_prospect_name, dto.job_position, dto.industry, dto.employee_count, dto.annual_revenue, dto.street, dto.city, dto.state, dto.zip, dto.country, dto.stage_id, dto.user_id, dto.sales_team_id, dto.source, dto.priority || 0, dto.probability || 0, dto.expected_revenue, dto.date_deadline, dto.description, dto.notes, dto.tags, userId ] ); return this.findById(lead!.id, tenantId); } async update(id: string, dto: UpdateLeadDto, tenantId: string, userId: string): Promise { const existing = await this.findById(id, tenantId); if (existing.status === 'converted' || existing.status === 'lost') { throw new ValidationError('No se puede editar un lead convertido o perdido'); } const updateFields: string[] = []; const values: any[] = []; let paramIndex = 1; const fieldsToUpdate = [ 'name', 'ref', 'contact_name', 'email', 'phone', 'mobile', 'website', 'company_prospect_name', 'job_position', 'industry', 'employee_count', 'annual_revenue', 'street', 'city', 'state', 'zip', 'country', 'stage_id', 'user_id', 'sales_team_id', 'source', 'priority', 'probability', 'expected_revenue', 'date_deadline', 'description', 'notes', 'tags' ]; for (const field of fieldsToUpdate) { const key = field === 'company_prospect_name' ? 'company_name' : field; if ((dto as any)[field] !== undefined) { updateFields.push(`${key} = $${paramIndex++}`); values.push((dto as any)[field]); } } if (updateFields.length === 0) { return existing; } updateFields.push(`date_last_activity = CURRENT_TIMESTAMP`); updateFields.push(`updated_by = $${paramIndex++}`); values.push(userId); values.push(id, tenantId); await query( `UPDATE crm.leads SET ${updateFields.join(', ')} WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, values ); return this.findById(id, tenantId); } async moveStage(id: string, stageId: string, tenantId: string, userId: string): Promise { const lead = await this.findById(id, tenantId); if (lead.status === 'converted' || lead.status === 'lost') { throw new ValidationError('No se puede mover un lead convertido o perdido'); } await query( `UPDATE crm.leads SET stage_id = $1, date_last_activity = CURRENT_TIMESTAMP, updated_by = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3 AND tenant_id = $4`, [stageId, userId, id, tenantId] ); return this.findById(id, tenantId); } async convert(id: string, tenantId: string, userId: string): Promise<{ lead: Lead; opportunity_id: string }> { const lead = await this.findById(id, tenantId); if (lead.status === 'converted') { throw new ValidationError('El lead ya fue convertido'); } if (lead.status === 'lost') { throw new ValidationError('No se puede convertir un lead perdido'); } const client = await getClient(); try { await client.query('BEGIN'); // Create or get partner let partnerId = lead.partner_id; if (!partnerId && lead.email) { // Check if partner exists with same email const existingPartner = await client.query( `SELECT id FROM core.partners WHERE email = $1 AND tenant_id = $2`, [lead.email, tenantId] ); if (existingPartner.rows.length > 0) { partnerId = existingPartner.rows[0].id; } else { // Create new partner const partnerResult = await client.query( `INSERT INTO core.partners (tenant_id, name, email, phone, mobile, is_customer, created_by) VALUES ($1, $2, $3, $4, $5, TRUE, $6) RETURNING id`, [tenantId, lead.contact_name || lead.name, lead.email, lead.phone, lead.mobile, userId] ); partnerId = partnerResult.rows[0].id; } } if (!partnerId) { throw new ValidationError('El lead debe tener un email o partner asociado para convertirse'); } // Get default opportunity stage const stageResult = await client.query( `SELECT id FROM crm.opportunity_stages WHERE tenant_id = $1 ORDER BY sequence LIMIT 1`, [tenantId] ); const stageId = stageResult.rows[0]?.id || null; // Create opportunity const opportunityResult = await client.query( `INSERT INTO crm.opportunities ( tenant_id, company_id, name, partner_id, contact_name, email, phone, stage_id, user_id, sales_team_id, source, priority, probability, expected_revenue, lead_id, description, notes, tags, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) RETURNING id`, [ tenantId, lead.company_id, lead.name, partnerId, lead.contact_name, lead.email, lead.phone, stageId, lead.user_id, lead.sales_team_id, lead.source, lead.priority, lead.probability, lead.expected_revenue, id, lead.description, lead.notes, lead.tags, userId ] ); const opportunityId = opportunityResult.rows[0].id; // Update lead await client.query( `UPDATE crm.leads SET status = 'converted', partner_id = $1, opportunity_id = $2, date_closed = CURRENT_TIMESTAMP, updated_by = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $4`, [partnerId, opportunityId, userId, id] ); await client.query('COMMIT'); const updatedLead = await this.findById(id, tenantId); return { lead: updatedLead, opportunity_id: opportunityId }; } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } async markLost(id: string, lostReasonId: string, notes: string | undefined, tenantId: string, userId: string): Promise { const lead = await this.findById(id, tenantId); if (lead.status === 'converted') { throw new ValidationError('No se puede marcar como perdido un lead convertido'); } if (lead.status === 'lost') { throw new ValidationError('El lead ya esta marcado como perdido'); } await query( `UPDATE crm.leads SET status = 'lost', lost_reason_id = $1, lost_notes = $2, date_closed = CURRENT_TIMESTAMP, updated_by = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $4 AND tenant_id = $5`, [lostReasonId, notes, userId, id, tenantId] ); return this.findById(id, tenantId); } async delete(id: string, tenantId: string): Promise { const lead = await this.findById(id, tenantId); if (lead.opportunity_id) { throw new ConflictError('No se puede eliminar un lead que tiene una oportunidad asociada'); } await query(`DELETE FROM crm.leads WHERE id = $1 AND tenant_id = $2`, [id, tenantId]); } } export const leadsService = new LeadsService();