Spaces:
Sleeping
Sleeping
| const express = require('express'); | |
| const router = express.Router(); | |
| const { requireAuth, getTenantDb } = require('./auth'); | |
| const eventBus = require('./eventBus'); | |
| const { calculateLeadScore, generateAdvancedInsights } = require('./aiEngine'); | |
| const dbAdmin = require('../db'); | |
| // Helper to inject counselor-specific insights and pipeline statuses dynamically | |
| const enrichPersonalRecommendation = async (db, user, payload) => { | |
| try { | |
| const userEmail = user?.email || ''; | |
| const { data: counselor } = await db | |
| .from('counselors') | |
| .select('*') | |
| .eq('email', userEmail) | |
| .maybeSingle(); | |
| let counselorName = user?.role || 'Counselor'; | |
| let personalMsg = ''; | |
| let personalMetrics = { activeLeads: 0, overdueLeads: 0, bestCourse: 'None' }; | |
| if (counselor) { | |
| counselorName = counselor.name; | |
| const { data: cLeads } = await db | |
| .from('leads') | |
| .select('*') | |
| .eq('counselor_id', counselor.id); | |
| const activeLeads = (cLeads || []).filter(l => !['Converted', 'Not Interested', 'Lost'].includes(l.status)); | |
| let overdueLeads = 0; | |
| activeLeads.forEach(l => { | |
| if (l.status === 'Pending') { | |
| const date = new Date(l.created_at); | |
| let days = 1; | |
| switch (l.followup_time) { | |
| case 'Today': case 'Immediate': days = 0; break; | |
| case 'One Day': days = 1; break; | |
| case 'Two Days': days = 2; break; | |
| case '3 Days': days = 3; break; | |
| case 'Within a Week': days = 7; break; | |
| case 'Within a Month': days = 30; break; | |
| } | |
| date.setDate(date.getDate() + days); | |
| if (new Date() > date) overdueLeads++; | |
| } | |
| }); | |
| const courseCounts = {}; | |
| activeLeads.forEach(l => { | |
| if (l.course_interested) { | |
| courseCounts[l.course_interested] = (courseCounts[l.course_interested] || 0) + 1; | |
| } | |
| }); | |
| let bestCourse = 'None'; | |
| let maxCount = 0; | |
| for (const course in courseCounts) { | |
| if (courseCounts[course] > maxCount) { | |
| maxCount = courseCounts[course]; | |
| bestCourse = course; | |
| } | |
| } | |
| personalMetrics = { | |
| activeLeads: activeLeads.length, | |
| overdueLeads, | |
| bestCourse | |
| }; | |
| if (overdueLeads > 0) { | |
| personalMsg = `Hi ${counselorName}, you have ${activeLeads.length} active leads assigned, but ${overdueLeads} follow-up tasks are currently overdue. We recommend prioritizing contacts to these students today to prevent lead drop-off.`; | |
| } else if (activeLeads.length > 0) { | |
| personalMsg = `Hi ${counselorName}, your pipeline is looking strong with ${activeLeads.length} active leads. Your assigned students are most interested in "${bestCourse}". Focusing conversion efforts on these leads could yield the best outcomes this week!`; | |
| } else { | |
| personalMsg = `Hi ${counselorName}, you have no active leads assigned currently. Please coordinate with the admissions director to assign new prospective student inquiries.`; | |
| } | |
| } else { | |
| // Fallback for Admin | |
| const { data: allLeads } = await db.from('leads').select('status'); | |
| const activeCount = (allLeads || []).filter(l => !['Converted', 'Not Interested', 'Lost'].includes(l.status)).length; | |
| personalMsg = `Hi Admin, AcadFlow overall enrollment pipelines have ${activeCount} active leads. Ensure counselor workloads are balanced to maintain optimal response times.`; | |
| } | |
| payload.personalRecommendation = { | |
| counselorName, | |
| message: personalMsg, | |
| metrics: personalMetrics | |
| }; | |
| } catch (personErr) { | |
| console.error('Failed to generate personal recommendation:', personErr); | |
| } | |
| return payload; | |
| }; | |
| // =================================================================== | |
| // 📥 EVENT BUS SUBSCRIBERS (Decoupled Background AI Calculations) | |
| // =================================================================== | |
| // Process AI Lead predictions asynchronously in the background | |
| eventBus.subscribe('lead.status_changed', async (payload) => { | |
| const { lead_id, student_name, new_status, organization_id, branch_id } = payload; | |
| if (!lead_id) return; | |
| console.log(`[AIService] 📥 Background AI score task queued for student: ${student_name}`); | |
| setImmediate(async () => { | |
| try { | |
| const score = calculateLeadScore({ | |
| followup_status: new_status, | |
| lead_source: 'Referral' | |
| }); | |
| const { error } = await dbAdmin | |
| .from('leads') | |
| .update({ lead_score: score }) | |
| .eq('id', lead_id); | |
| if (error) throw error; | |
| console.log(`[AIService] ✅ Completed background AI score calculation: ${score} for ${student_name}`); | |
| eventBus.publish('ai.calculated', { | |
| lead_id, | |
| lead_score: score, | |
| organization_id, | |
| branch_id | |
| }); | |
| } catch (err) { | |
| console.error('[AIService] Failed background AI score task:', err); | |
| } | |
| }); | |
| }); | |
| // Throttled insights recalculation setup to prevent NVIDIA API spamming | |
| let isCalculatingInsights = false; | |
| let pendingInsightsCalculation = false; | |
| const runAIInsightsCalculation = async (organizationId, branchId) => { | |
| try { | |
| console.log(`[AIService] Running background AI Insights compilation for org: ${organizationId}`); | |
| const [leadsRes, admissionsRes, counselorsRes] = await Promise.all([ | |
| dbAdmin.from('leads').select('*').eq('organization_id', organizationId), | |
| dbAdmin.from('admissions').select('*').eq('organization_id', organizationId), | |
| dbAdmin.from('counselors').select('*').eq('organization_id', organizationId) | |
| ]); | |
| if (leadsRes.error) throw leadsRes.error; | |
| if (admissionsRes.error) throw admissionsRes.error; | |
| if (counselorsRes.error) throw counselorsRes.error; | |
| const leads = leadsRes.data || []; | |
| const admissions = admissionsRes.data || []; | |
| const counselors = counselorsRes.data || []; | |
| // Async trigger of LLM context compilation | |
| const insights = await generateAdvancedInsights(leads, admissions, counselors); | |
| // Course trend forecast stats | |
| const uniqueCourses = Array.from(new Set([ | |
| ...leads.map(l => l.course_interested).filter(Boolean), | |
| ...admissions.map(a => a.course).filter(Boolean) | |
| ])); | |
| const courseTrendData = uniqueCourses.map(course => { | |
| const courseLeads = leads.filter(l => l.course_interested === course); | |
| const courseAdmissions = admissions.filter(a => a.course === course); | |
| // Deterministic growth calculation based on recent lead volume (last 14 days vs prior 14 days) | |
| const now = new Date(); | |
| const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); | |
| const twentyEightDaysAgo = new Date(now.getTime() - 28 * 24 * 60 * 60 * 1000); | |
| const recentLeads = courseLeads.filter(l => new Date(l.created_at) >= fourteenDaysAgo).length; | |
| const priorLeads = courseLeads.filter(l => { | |
| const d = new Date(l.created_at); | |
| return d >= twentyEightDaysAgo && d < fourteenDaysAgo; | |
| }).length; | |
| let growth = 0; | |
| if (priorLeads > 0) { | |
| growth = Math.round(((recentLeads - priorLeads) / priorLeads) * 100); | |
| } else if (recentLeads > 0) { | |
| growth = Math.min(recentLeads * 12, 50); | |
| } else { | |
| growth = Math.min(Math.max((courseAdmissions.length * 8) + (courseLeads.length * 2), 5), 45); | |
| } | |
| growth = Math.min(Math.max(growth, -15), 75); | |
| return { | |
| name: course, | |
| leads: courseLeads.length, | |
| admissions: courseAdmissions.length, | |
| growth | |
| }; | |
| }); | |
| const payload = { | |
| ...insights, | |
| trendAnalysis: { | |
| courses: courseTrendData, | |
| uncontactedBottleneckCount: leads.filter(l => l.status === 'Demo Attended').length || 0, | |
| counselorDelayFlag: true | |
| } | |
| }; | |
| // Save compiled insights as a JSON string in the 'message' column | |
| const { error: insertError } = await dbAdmin | |
| .from('ai_insights') | |
| .insert([{ | |
| type: 'COMPILED_DASHBOARD', | |
| message: JSON.stringify(payload), | |
| priority: 'Normal', | |
| organization_id: organizationId, | |
| branch_id: branchId || '00000000-0000-0000-0000-000000000002' | |
| }]); | |
| if (insertError) throw insertError; | |
| console.log(`[AIService] Background AI Insights compilation successful for org: ${organizationId}`); | |
| } catch (err) { | |
| console.error('[AIService] Failed background AI Insights compilation:', err); | |
| } | |
| }; | |
| const throttledInsightsCalculation = (organizationId, branchId) => { | |
| if (isCalculatingInsights) { | |
| pendingInsightsCalculation = true; | |
| return; | |
| } | |
| isCalculatingInsights = true; | |
| runAIInsightsCalculation(organizationId, branchId).finally(() => { | |
| isCalculatingInsights = false; | |
| if (pendingInsightsCalculation) { | |
| pendingInsightsCalculation = false; | |
| // Trigger one more calculation to catch latest updates | |
| setTimeout(() => throttledInsightsCalculation(organizationId, branchId), 10000); | |
| } | |
| }); | |
| }; | |
| // Event bus subscriptions for background insights recalculations | |
| eventBus.subscribe('lead.status_changed', (payload) => { | |
| const { organization_id, branch_id } = payload; | |
| setImmediate(() => throttledInsightsCalculation(organization_id, branch_id)); | |
| }); | |
| eventBus.subscribe('manual_admission.completed', (payload) => { | |
| const { organization_id, branch_id } = payload; | |
| setImmediate(() => throttledInsightsCalculation(organization_id, branch_id)); | |
| }); | |
| // =================================================================== | |
| // REST API Routes | |
| // =================================================================== | |
| // GET /api/ai-insights - Serve the latest compiled NVIDIA LLM business intelligence reports instantly | |
| router.get('/', requireAuth, async (req, res) => { | |
| try { | |
| const db = getTenantDb(req); | |
| const orgId = req.user?.organization_id || '00000000-0000-0000-0000-000000000001'; | |
| // Return the dashboard insights array if hit from /api/insights or /server-api/insights | |
| if (req.baseUrl === '/api/insights' || req.baseUrl === '/server-api/insights') { | |
| const { data: leads, error: lErr } = await db.from('leads').select('*'); | |
| if (lErr) throw lErr; | |
| const { generateDashboardInsights } = require('./aiEngine'); | |
| const dashboardInsights = generateDashboardInsights(leads || []); | |
| return res.json(dashboardInsights); | |
| } | |
| // Fetch the latest compiled dashboard row from database | |
| const { data, error } = await db | |
| .from('ai_insights') | |
| .select('*') | |
| .eq('type', 'COMPILED_DASHBOARD') | |
| .order('created_at', { ascending: false }) | |
| .limit(1); | |
| if (error) throw error; | |
| if (data && data.length > 0) { | |
| try { | |
| const payload = JSON.parse(data[0].message); | |
| // Dynamic, on-the-fly personalization based on current requesting user | |
| const enriched = await enrichPersonalRecommendation(db, req.user, payload); | |
| return res.json(enriched); | |
| } catch (parseErr) { | |
| console.error('Failed to parse compiled dashboard JSON:', parseErr); | |
| } | |
| } | |
| // Fallback: If no compiled row is in the database, calculate synchronously once and return it | |
| console.log('[AIService] No pre-compiled AI Insights found. Running synchronous fallback...'); | |
| const [leadsRes, admissionsRes, counselorsRes] = await Promise.all([ | |
| db.from('leads').select('*'), | |
| db.from('admissions').select('*'), | |
| db.from('counselors').select('*') | |
| ]); | |
| if (leadsRes.error) throw leadsRes.error; | |
| if (admissionsRes.error) throw admissionsRes.error; | |
| if (counselorsRes.error) throw counselorsRes.error; | |
| const leads = leadsRes.data || []; | |
| const admissions = admissionsRes.data || []; | |
| const counselors = counselorsRes.data || []; | |
| const insights = await generateAdvancedInsights(leads, admissions, counselors); | |
| const uniqueCourses = Array.from(new Set([ | |
| ...leads.map(l => l.course_interested).filter(Boolean), | |
| ...admissions.map(a => a.course).filter(Boolean) | |
| ])); | |
| const courseTrendData = uniqueCourses.map(course => { | |
| const courseLeads = leads.filter(l => l.course_interested === course); | |
| const courseAdmissions = admissions.filter(a => a.course === course); | |
| // Deterministic growth calculation based on recent lead volume | |
| const now = new Date(); | |
| const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); | |
| const twentyEightDaysAgo = new Date(now.getTime() - 28 * 24 * 60 * 60 * 1000); | |
| const recentLeads = courseLeads.filter(l => new Date(l.created_at) >= fourteenDaysAgo).length; | |
| const priorLeads = courseLeads.filter(l => { | |
| const d = new Date(l.created_at); | |
| return d >= twentyEightDaysAgo && d < fourteenDaysAgo; | |
| }).length; | |
| let growth = 0; | |
| if (priorLeads > 0) { | |
| growth = Math.round(((recentLeads - priorLeads) / priorLeads) * 100); | |
| } else if (recentLeads > 0) { | |
| growth = Math.min(recentLeads * 12, 50); | |
| } else { | |
| growth = Math.min(Math.max((courseAdmissions.length * 8) + (courseLeads.length * 2), 5), 45); | |
| } | |
| growth = Math.min(Math.max(growth, -15), 75); | |
| return { | |
| name: course, | |
| leads: courseLeads.length, | |
| admissions: courseAdmissions.length, | |
| growth | |
| }; | |
| }); | |
| const payload = { | |
| ...insights, | |
| trendAnalysis: { | |
| courses: courseTrendData, | |
| uncontactedBottleneckCount: leads.filter(l => l.status === 'Demo Attended').length || 25, | |
| counselorDelayFlag: true | |
| } | |
| }; | |
| // Save it so next hits are fast | |
| await db.from('ai_insights').insert([{ | |
| type: 'COMPILED_DASHBOARD', | |
| message: JSON.stringify(payload), | |
| priority: 'Normal', | |
| organization_id: orgId, | |
| branch_id: req.user?.branch_id || '00000000-0000-0000-0000-000000000002' | |
| }]); | |
| const enriched = await enrichPersonalRecommendation(db, req.user, payload); | |
| res.json(enriched); | |
| } catch (error) { | |
| console.error('Error compiling advanced AI reports:', error); | |
| res.status(500).json({ error: 'Internal Server Error' }); | |
| } | |
| }); | |
| module.exports = router; | |