acadflow / services /leadService.js
skandaedutech's picture
Upload 14 files
de09409 verified
Raw
History Blame
21.3 kB
const express = require('express');
const router = express.Router();
const { getTenantDb, requireAuth } = require('./auth');
const eventBus = require('./eventBus');
const { appendLeadToSheet, updateLeadInSheet } = require('./googleSheets');
// 1. GET /api/leads - Fetch active leads scoped by tenant organization
router.get('/', requireAuth, async (req, res) => {
try {
const db = getTenantDb(req);
const { data: leads, error } = await db
.from('active_leads')
.select(`
*,
student_id,
counselors (
name
),
admissions (
course,
fees,
payment_status
)
`)
.order('created_at', { ascending: false });
if (error) throw error;
const formattedLeads = (leads || []).map(lead => {
const hasAdmission = lead.admissions && lead.admissions.length > 0;
const admissionRecord = hasAdmission ? lead.admissions[0] : null;
return {
id: lead.id,
student_name: lead.name,
phone_number: lead.phone,
email: lead.email || '',
interested_course: lead.course_interested,
lead_source: lead.source,
counselor_name: lead.counselors ? lead.counselors.name : '',
counselor_id: lead.counselor_id,
followup_status: lead.status || 'Pending',
followup_time: lead.followup_time || 'One Day',
admission_status: hasAdmission ? 'Admitted' : 'Not Admitted',
fees: admissionRecord ? parseFloat(admissionRecord.fees || 0) : 0,
lead_score: lead.lead_score || 50,
created_date: lead.created_at,
gender: lead.gender || '',
city: lead.city || '',
fees_discussed: lead.fees_discussed ? parseFloat(lead.fees_discussed) : 0,
remarks: lead.remarks || '',
student_id: lead.student_id || '',
referred_by_student_name: lead.referred_by_student_name || ''
};
});
res.json(formattedLeads);
} catch (error) {
console.error('Error fetching leads:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/test', async (req, res) => {
try {
const db = require('../db');
const { data: leads, error } = await db
.from('active_leads')
.select(`
*,
student_id
`)
.order('created_at', { ascending: false });
if (error) throw error;
res.json(leads);
} catch (error) {
console.error('Error fetching leads test:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
// 2. POST /api/leads - Create a new lead directly in Supabase
router.post('/', requireAuth, async (req, res) => {
const {
student_name,
phone_number,
email,
interested_course,
lead_source,
counselor_id,
followup_status,
followup_time,
gender,
city,
fees_discussed,
remarks,
referred_by_student_name
} = req.body;
if (!student_name || !phone_number || !email) {
return res.status(400).json({ error: 'Student name, phone number, and email are required' });
}
try {
const db = getTenantDb(req);
const orgId = req.user?.organization_id || '00000000-0000-0000-0000-000000000001';
const branchId = req.user?.branch_id || '00000000-0000-0000-0000-000000000002';
console.log('--- LEAD CREATION DEBUG ---');
console.log('Incoming Payload:', req.body);
console.log('Phone Number Received:', phone_number);
console.log('Organization ID Received:', orgId);
// Auto-generate student ID always
const { generateNextStudentId } = require('./idGenerator');
const nextId = await generateNextStudentId();
const newLead = {
name: student_name,
student_id: nextId,
phone: phone_number,
email: email,
course_interested: interested_course || '',
source: lead_source || 'Direct Walk-in',
counselor_id: counselor_id || null,
status: followup_status || 'Pending',
followup_time: followup_time || 'One Day',
gender: gender || '',
city: city || '',
fees_discussed: fees_discussed ? parseFloat(fees_discussed) : null,
remarks: remarks || '',
referred_by_student_name: lead_source === 'Student Referral' ? referred_by_student_name : null,
organization_id: orgId,
branch_id: branchId,
lead_score: 50
};
const { data: createdData, error } = await db
.from('leads')
.insert([newLead])
.select();
if (error) throw error;
const createdLead = createdData[0];
// Log in audit logs
await db.from('audit_logs').insert([{
organization_id: orgId,
branch_id: branchId,
user_id: req.user?.id || null,
action: 'CREATE_LEAD',
entity_type: 'leads',
entity_id: createdLead.id,
new_values: newLead
}]);
// Publish event to decoupled Event Bus
eventBus.publish('lead.status_changed', {
lead_id: createdLead.id,
student_name: createdLead.name,
phone: createdLead.phone,
old_status: null,
new_status: createdLead.status,
followup_time: createdLead.followup_time,
organization_id: orgId,
branch_id: branchId
});
// Fetch counselor name for GSheet
let counselorName = '';
if (counselor_id) {
const { data: cData } = await db.from('counselors').select('name').eq('id', counselor_id);
if (cData && cData.length > 0) counselorName = cData[0].name;
}
// Automatically add to Google Sheet
appendLeadToSheet({
name: newLead.name,
phone: newLead.phone,
email: newLead.email,
course_interested: newLead.course_interested,
source: newLead.source,
status: newLead.status,
counselor_name: counselorName,
followup_time: newLead.followup_time,
remarks: newLead.remarks,
student_id: newLead.student_id
}).catch(err => console.error('Failed to append to GSheet:', err));
res.status(201).json({ message: 'Lead created successfully', lead: createdLead });
} catch (error) {
console.error('Error creating lead:', error, JSON.stringify(error));
return res.status(500).json({
success: false,
error: error.message || 'Internal Server Error',
details: error.details || null
});
}
});
// Legacy / compatibility route for update-lead (MUST be registered before dynamic param /:id)
router.put('/update-lead', requireAuth, async (req, res) => {
const { phone_number, followup_status, admission_status, followup_time } = req.body;
if (!phone_number) {
return res.status(400).json({ error: 'Phone number is required' });
}
try {
const db = getTenantDb(req);
const { data: leads, error: findError } = await db
.from('leads')
.select('id, name, course_interested, status, counselor_id, organization_id, branch_id')
.eq('phone', phone_number);
if (findError) throw findError;
if (!leads || leads.length === 0) {
return res.status(404).json({ error: 'Lead not found' });
}
const lead = leads[0];
// Map legacy update call to our PUT /:id updates
const updates = {};
if (followup_status) updates.status = followup_status;
if (followup_time) updates.followup_time = followup_time;
if (Object.keys(updates).length > 0) {
const { error: updateLeadError } = await db
.from('leads')
.update(updates)
.eq('id', lead.id);
if (updateLeadError) throw updateLeadError;
// Log followup details if status changed
if (followup_status && followup_status !== lead.status) {
await db.from('follow_ups').insert([{
lead_id: lead.id,
organization_id: lead.organization_id,
branch_id: lead.branch_id,
followup_date: new Date().toISOString(),
followup_type: 'Call',
status: followup_status === 'Pending' ? 'Pending' : 'Completed',
remarks: `Status updated via legacy API to ${followup_status}`,
created_by: lead.counselor_id || req.user?.id || null
}]);
// Publish event to decoupled Event Bus
eventBus.publish('lead.status_changed', {
lead_id: lead.id,
student_name: lead.name,
phone: phone_number,
old_status: lead.status,
new_status: followup_status,
followup_time: followup_time || 'One Day',
organization_id: lead.organization_id,
branch_id: lead.branch_id
});
}
}
// Update admissions status
if (admission_status) {
const { data: existingAdmissions, error: admSelectError } = await db
.from('admissions')
.select('id')
.eq('lead_id', lead.id);
if (admSelectError) throw admSelectError;
const hasAdmissionRecord = existingAdmissions && existingAdmissions.length > 0;
if (admission_status === 'Admitted' && !hasAdmissionRecord) {
// Trigger Lead Converted Event Bus sequence
eventBus.publish('lead.converted', {
lead_id: lead.id,
student_name: lead.name,
phone: phone_number,
course: lead.course_interested || 'Default Course',
counselor_id: lead.counselor_id,
organization_id: lead.organization_id,
branch_id: lead.branch_id
});
} else if (admission_status === 'Not Admitted' && hasAdmissionRecord) {
// Remove admission details
const { error: deleteAdmError } = await db
.from('admissions')
.delete()
.eq('lead_id', lead.id);
if (deleteAdmError) throw deleteAdmError;
}
}
res.json({ message: 'Lead updated successfully' });
} catch (error) {
console.error('Error updating lead:', error);
return res.status(500).json({
success: false,
error: error.message || 'Internal Server Error',
details: error.details || null
});
}
});
// 3. PUT /api/leads/:id - Update an existing lead record
router.put('/:id', requireAuth, async (req, res) => {
const { id } = req.params;
const {
student_id,
student_name,
phone_number,
email,
interested_course,
lead_source,
counselor_id,
followup_status,
followup_time,
gender,
city,
fees_discussed,
remarks,
referred_by_student_name
} = req.body;
if (email === "") {
return res.status(400).json({ error: 'Email is required' });
}
try {
const db = getTenantDb(req);
const { data: currentLeads, error: getError } = await db
.from('leads')
.select('*')
.eq('id', id);
if (getError) throw getError;
if (!currentLeads || currentLeads.length === 0) {
return res.status(404).json({ error: 'Lead not found' });
}
const currentLead = currentLeads[0];
const updates = {};
if (student_id !== undefined) updates.student_id = student_id === "" ? null : student_id;
if (student_name !== undefined) updates.name = student_name;
if (phone_number !== undefined) updates.phone = phone_number;
if (email !== undefined) updates.email = email;
if (interested_course !== undefined) updates.course_interested = interested_course;
if (lead_source !== undefined) updates.source = lead_source;
if (counselor_id !== undefined) updates.counselor_id = (counselor_id === "" || counselor_id === "null" || counselor_id === null) ? null : counselor_id;
if (followup_status !== undefined) updates.status = followup_status;
if (followup_time !== undefined) updates.followup_time = followup_time;
if (gender !== undefined) updates.gender = gender;
if (city !== undefined) updates.city = city;
if (fees_discussed !== undefined) {
if (fees_discussed === "" || fees_discussed === null || fees_discussed === "null") {
updates.fees_discussed = null;
} else {
const parsed = parseFloat(fees_discussed);
updates.fees_discussed = isNaN(parsed) ? null : parsed;
}
}
if (remarks !== undefined) updates.remarks = remarks;
if (referred_by_student_name !== undefined) updates.referred_by_student_name = referred_by_student_name;
const { error: updateError } = await db
.from('leads')
.update(updates)
.eq('id', id);
if (updateError) throw updateError;
// Log audit log
await db.from('audit_logs').insert([{
organization_id: currentLead.organization_id,
branch_id: currentLead.branch_id,
user_id: req.user?.id || null,
action: 'UPDATE_LEAD',
entity_type: 'leads',
entity_id: id,
old_values: currentLead,
new_values: { ...currentLead, ...updates }
}]);
// Check status change or details change
if (followup_status && followup_status !== currentLead.status) {
// Log follow up history
await db.from('follow_ups').insert([{
lead_id: id,
organization_id: currentLead.organization_id,
branch_id: currentLead.branch_id,
followup_date: new Date().toISOString(),
followup_type: 'Call',
status: followup_status === 'Pending' ? 'Pending' : 'Completed',
remarks: remarks || `Status updated via CRM edit to ${followup_status}`,
created_by: counselor_id || currentLead.counselor_id || req.user?.id || null
}]);
// Publish event
eventBus.publish('lead.status_changed', {
lead_id: id,
student_name: student_name || currentLead.name,
phone: phone_number || currentLead.phone,
old_status: currentLead.status,
new_status: followup_status,
followup_time: followup_time || currentLead.followup_time,
organization_id: currentLead.organization_id,
branch_id: currentLead.branch_id
});
}
res.json({ message: 'Lead updated successfully' });
} catch (error) {
console.error('Error updating lead:', error);
return res.status(500).json({
success: false,
error: error.message || 'Internal Server Error',
details: error.details || null
});
}
});
// 4. DELETE /api/leads/:id - Soft delete a lead
router.delete('/:id', requireAuth, async (req, res) => {
const { id } = req.params;
try {
const db = getTenantDb(req);
const { data: currentLeads, error: getError } = await db
.from('leads')
.select('*')
.eq('id', id);
if (getError) throw getError;
if (!currentLeads || currentLeads.length === 0) {
return res.status(404).json({ error: 'Lead not found' });
}
const currentLead = currentLeads[0];
// Soft delete: update deleted_at
const { error: deleteError } = await db
.from('leads')
.update({ deleted_at: new Date().toISOString() })
.eq('id', id);
if (deleteError) throw deleteError;
// Log audit log
await db.from('audit_logs').insert([{
organization_id: currentLead.organization_id,
branch_id: currentLead.branch_id,
user_id: req.user?.id || null,
action: 'DELETE_LEAD',
entity_type: 'leads',
entity_id: id,
old_values: currentLead,
new_values: { deleted_at: new Date().toISOString() }
}]);
res.json({ message: 'Lead soft-deleted successfully' });
} catch (error) {
console.error('Error deleting lead:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
// 5. POST /api/leads/follow-up - Create a new follow-up and update lead status
router.post('/follow-up', requireAuth, async (req, res) => {
const { lead_id, followup_type, status, remarks, next_followup_time } = req.body;
if (!lead_id) {
return res.status(400).json({ error: 'Lead ID is required' });
}
try {
const db = getTenantDb(req);
const { data: leads, error: getError } = await db
.from('leads')
.select('*')
.eq('id', lead_id);
if (getError) throw getError;
if (!leads || leads.length === 0) {
return res.status(404).json({ error: 'Lead not found' });
}
const lead = leads[0];
const orgId = lead.organization_id;
const branchId = lead.branch_id;
// Insert follow-up record
const { data: followUpData, error: followUpError } = await db
.from('follow_ups')
.insert([{
lead_id,
organization_id: orgId,
branch_id: branchId,
followup_date: new Date().toISOString(),
followup_type: followup_type || 'Call',
status: status || 'Completed',
remarks: remarks || '',
created_by: req.user?.id || lead.counselor_id || null
}])
.select();
if (followUpError) throw followUpError;
// Update lead notes / followup_time / status
const updates = {};
if (status && status !== lead.status) {
updates.status = status;
}
if (next_followup_time) {
updates.followup_time = next_followup_time;
}
if (remarks) {
updates.remarks = remarks;
}
if (Object.keys(updates).length > 0) {
const { error: updateError } = await db
.from('leads')
.update(updates)
.eq('id', lead_id);
if (updateError) throw updateError;
}
// Log audit log
await db.from('audit_logs').insert([{
organization_id: orgId,
branch_id: branchId,
user_id: req.user?.id || null,
action: 'ADD_FOLLOW_UP',
entity_type: 'follow_ups',
entity_id: followUpData[0].id,
new_values: { lead_id, status, remarks, next_followup_time }
}]);
// Publish event
eventBus.publish('lead.status_changed', {
lead_id,
student_name: lead.name,
phone: lead.phone,
old_status: lead.status,
new_status: status || lead.status,
followup_time: next_followup_time || lead.followup_time,
organization_id: orgId,
branch_id: branchId
});
res.status(201).json({ message: 'Follow-up logged successfully', followUp: followUpData[0] });
} catch (error) {
console.error('Error logging follow-up:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
// 6. GET /api/leads/:id/timeline - Get timeline of events/followups for a lead
router.get('/:id/timeline', requireAuth, async (req, res) => {
try {
const db = getTenantDb(req);
// Fetch follow ups for this lead
const { data: followups, error: followupsError } = await db
.from('active_follow_ups')
.select('*')
.eq('lead_id', req.params.id)
.order('followup_date', { ascending: false });
if (followupsError) throw followupsError;
// Fetch audit logs for this lead
const { data: auditLogs, error: auditError } = await db
.from('audit_logs')
.select('*')
.eq('entity_id', req.params.id)
.eq('entity_type', 'leads')
.order('created_at', { ascending: false });
if (auditError) throw auditError;
// Fetch admission details to show if converted
const { data: admissions, error: admissionError } = await db
.from('active_admissions')
.select('*')
.eq('lead_id', req.params.id);
if (admissionError) throw admissionError;
// Merge and format into timeline events
const timeline = [];
// Add follow ups
(followups || []).forEach(f => {
timeline.push({
id: f.id,
type: 'followup',
date: f.followup_date,
title: `Follow-up (${f.followup_type})`,
description: f.remarks,
status: f.status
});
});
// Add audit logs
(auditLogs || []).forEach(a => {
let title = 'Lead Modified';
if (a.action === 'CREATE_LEAD') title = 'Lead Created';
if (a.action === 'DELETE_LEAD') title = 'Lead Deleted';
if (a.action === 'UPDATE_LEAD') title = 'Lead Updated';
timeline.push({
id: a.id,
type: 'audit',
date: a.created_at,
title: title,
description: `Action: ${a.action}`
});
});
// Add admission if exists
if (admissions && admissions.length > 0) {
const adm = admissions[0];
timeline.push({
id: adm.id,
type: 'admission',
date: adm.joined_date || adm.created_at || new Date().toISOString(),
title: 'Lead Converted to Admission 🎓',
description: `Enrolled in ${adm.course} | Fees: ₹${parseFloat(adm.fees || 0).toLocaleString()} | Status: ${adm.payment_status}`
});
}
// Sort descending by date
timeline.sort((a, b) => new Date(b.date) - new Date(a.date));
res.json(timeline);
} catch (error) {
console.error('Error fetching lead timeline:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
module.exports = router;