Spaces:
Sleeping
Sleeping
Upload 14 files
#1
by skandaedutech - opened
- server.js +179 -776
- services/admissionService.js +344 -0
- services/aiEngine.js +231 -231
- services/aiService.js +377 -0
- services/auth.js +95 -0
- services/counselorService.js +154 -0
- services/courseService.js +140 -0
- services/eventBus.js +36 -0
- services/googleSheets.js +447 -358
- services/idGenerator.js +45 -0
- services/leadService.js +653 -0
- services/notificationService.js +188 -0
- services/publicService.js +88 -0
- services/whatsappService.js +60 -0
server.js
CHANGED
|
@@ -1,776 +1,179 @@
|
|
| 1 |
-
const express = require('express');
|
| 2 |
-
const
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
const
|
| 6 |
-
const
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
app.
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
});
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
// ---
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
const
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
}
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
});
|
| 179 |
-
|
| 180 |
-
// 3. POST /api/sync-sheet - Trigger manual sync
|
| 181 |
-
app.post('/api/sync-sheet', async (req, res) => {
|
| 182 |
-
try {
|
| 183 |
-
await syncSheetsToDB(io);
|
| 184 |
-
res.json({ message: 'Sync triggered successfully' });
|
| 185 |
-
} catch (error) {
|
| 186 |
-
res.status(500).json({ error: 'Sync failed' });
|
| 187 |
-
}
|
| 188 |
-
});
|
| 189 |
-
|
| 190 |
-
// 4. GET /api/stats - Dashboard KPI Stats (using PostgreSQL aggregate/join equivalent logic)
|
| 191 |
-
app.get('/api/stats', async (req, res) => {
|
| 192 |
-
try {
|
| 193 |
-
// A. Count total leads
|
| 194 |
-
const { count: totalLeads, error: totalError } = await db
|
| 195 |
-
.from('leads')
|
| 196 |
-
.select('*', { count: 'exact', head: true });
|
| 197 |
-
|
| 198 |
-
if (totalError) throw totalError;
|
| 199 |
-
|
| 200 |
-
// B. Count active leads (leads whose status is not 'Not Interested' and not 'Converted')
|
| 201 |
-
const { count: activeLeads, error: activeError } = await db
|
| 202 |
-
.from('leads')
|
| 203 |
-
.select('*', { count: 'exact', head: true })
|
| 204 |
-
.not('status', 'in', '("Not Interested","Converted")');
|
| 205 |
-
|
| 206 |
-
if (activeError) throw activeError;
|
| 207 |
-
|
| 208 |
-
// C. Count admissions
|
| 209 |
-
const { count: admissions, error: admissionsError } = await db
|
| 210 |
-
.from('admissions')
|
| 211 |
-
.select('*', { count: 'exact', head: true });
|
| 212 |
-
|
| 213 |
-
if (admissionsError) throw admissionsError;
|
| 214 |
-
|
| 215 |
-
// D. Calculate total revenue from admissions table
|
| 216 |
-
const { data: admissionsData, error: revenueError } = await db
|
| 217 |
-
.from('admissions')
|
| 218 |
-
.select('fees');
|
| 219 |
-
|
| 220 |
-
if (revenueError) throw revenueError;
|
| 221 |
-
|
| 222 |
-
const revenue = admissionsData
|
| 223 |
-
? admissionsData.reduce((sum, adm) => sum + parseFloat(adm.fees || 0), 0)
|
| 224 |
-
: 0;
|
| 225 |
-
|
| 226 |
-
res.json({
|
| 227 |
-
totalLeads: totalLeads || 0,
|
| 228 |
-
activeLeads: activeLeads || 0,
|
| 229 |
-
admissions: admissions || 0,
|
| 230 |
-
revenue
|
| 231 |
-
});
|
| 232 |
-
} catch (error) {
|
| 233 |
-
console.error('Error fetching stats:', error);
|
| 234 |
-
res.status(500).json({ error: 'Internal Server Error' });
|
| 235 |
-
}
|
| 236 |
-
});
|
| 237 |
-
|
| 238 |
-
// 5. GET /api/insights - AI Insights
|
| 239 |
-
app.get('/api/insights', async (req, res) => {
|
| 240 |
-
try {
|
| 241 |
-
// Fetch all leads with their current status to generate deterministic insights
|
| 242 |
-
const { data: leads, error } = await db
|
| 243 |
-
.from('leads')
|
| 244 |
-
.select('source, course_interested, status');
|
| 245 |
-
|
| 246 |
-
if (error) throw error;
|
| 247 |
-
|
| 248 |
-
// Map database properties to the format expected by generateDashboardInsights
|
| 249 |
-
const mappedLeads = (leads || []).map(lead => ({
|
| 250 |
-
lead_source: lead.source,
|
| 251 |
-
interested_course: lead.course_interested,
|
| 252 |
-
followup_status: lead.status
|
| 253 |
-
}));
|
| 254 |
-
|
| 255 |
-
const insights = generateDashboardInsights(mappedLeads);
|
| 256 |
-
res.json(insights);
|
| 257 |
-
} catch (error) {
|
| 258 |
-
console.error('Error fetching insights:', error);
|
| 259 |
-
res.status(500).json({ error: 'Internal Server Error' });
|
| 260 |
-
}
|
| 261 |
-
});
|
| 262 |
-
|
| 263 |
-
// 6. GET /api/admissions - Fetch all admitted students details
|
| 264 |
-
app.get('/api/admissions', async (req, res) => {
|
| 265 |
-
try {
|
| 266 |
-
const { data: admissions, error } = await db
|
| 267 |
-
.from('admissions')
|
| 268 |
-
.select(`
|
| 269 |
-
id,
|
| 270 |
-
course,
|
| 271 |
-
fees,
|
| 272 |
-
payment_status,
|
| 273 |
-
joined_date,
|
| 274 |
-
leads (
|
| 275 |
-
id,
|
| 276 |
-
name,
|
| 277 |
-
phone,
|
| 278 |
-
email,
|
| 279 |
-
created_at,
|
| 280 |
-
counselors (
|
| 281 |
-
name
|
| 282 |
-
)
|
| 283 |
-
)
|
| 284 |
-
`)
|
| 285 |
-
.order('joined_date', { ascending: false });
|
| 286 |
-
|
| 287 |
-
if (error) throw error;
|
| 288 |
-
|
| 289 |
-
// Map database models to admissions list
|
| 290 |
-
const formattedAdmissions = (admissions || []).map(adm => {
|
| 291 |
-
const lead = adm.leads || {};
|
| 292 |
-
const counselor = lead.counselors || {};
|
| 293 |
-
const feesVal = parseFloat(adm.fees || 0);
|
| 294 |
-
|
| 295 |
-
// Calculate payment breakdown based on payment_status or dynamic logic
|
| 296 |
-
let emiStatus = adm.payment_status || 'Pending'; // 'Paid', 'Pending', 'Overdue', 'Upcoming'
|
| 297 |
-
if (emiStatus === 'Pending') {
|
| 298 |
-
// Distribute randomly for visual richness
|
| 299 |
-
const randomStatuses = ['Pending', 'Overdue', 'Upcoming'];
|
| 300 |
-
const seed = lead.name ? lead.name.charCodeAt(0) : 0;
|
| 301 |
-
emiStatus = randomStatuses[seed % randomStatuses.length];
|
| 302 |
-
}
|
| 303 |
-
|
| 304 |
-
let amountPaid = 0;
|
| 305 |
-
if (emiStatus === 'Paid') {
|
| 306 |
-
amountPaid = feesVal;
|
| 307 |
-
} else if (emiStatus === 'Pending') {
|
| 308 |
-
amountPaid = Math.round(feesVal * 0.4); // Paid 40%
|
| 309 |
-
} else if (emiStatus === 'Overdue') {
|
| 310 |
-
amountPaid = Math.round(feesVal * 0.2); // Paid 20%
|
| 311 |
-
} else if (emiStatus === 'Upcoming') {
|
| 312 |
-
amountPaid = Math.round(feesVal * 0.6); // Paid 60%
|
| 313 |
-
}
|
| 314 |
-
|
| 315 |
-
const pendingAmount = Math.max(feesVal - amountPaid, 0);
|
| 316 |
-
|
| 317 |
-
return {
|
| 318 |
-
id: adm.id,
|
| 319 |
-
student_name: lead.name || 'Unknown',
|
| 320 |
-
phone_number: lead.phone || '',
|
| 321 |
-
email: lead.email || '',
|
| 322 |
-
course: adm.course || lead.course_interested || '',
|
| 323 |
-
total_fee: feesVal,
|
| 324 |
-
amount_paid: amountPaid,
|
| 325 |
-
pending_amount: pendingAmount,
|
| 326 |
-
emi_status: emiStatus,
|
| 327 |
-
counselor_name: counselor.name || 'Unassigned',
|
| 328 |
-
admission_date: adm.joined_date || lead.created_at || new Date().toISOString()
|
| 329 |
-
};
|
| 330 |
-
});
|
| 331 |
-
|
| 332 |
-
res.json(formattedAdmissions);
|
| 333 |
-
} catch (error) {
|
| 334 |
-
console.error('Error fetching admissions:', error);
|
| 335 |
-
res.status(500).json({ error: 'Internal Server Error' });
|
| 336 |
-
}
|
| 337 |
-
});
|
| 338 |
-
|
| 339 |
-
// 7. GET /api/ai-insights - Advanced AI insights dashboard data
|
| 340 |
-
app.get('/api/ai-insights', async (req, res) => {
|
| 341 |
-
try {
|
| 342 |
-
const [leadsRes, admissionsRes, counselorsRes] = await Promise.all([
|
| 343 |
-
db.from('leads').select('*'),
|
| 344 |
-
db.from('admissions').select('*'),
|
| 345 |
-
db.from('counselors').select('*')
|
| 346 |
-
]);
|
| 347 |
-
|
| 348 |
-
if (leadsRes.error) throw leadsRes.error;
|
| 349 |
-
if (admissionsRes.error) throw admissionsRes.error;
|
| 350 |
-
if (counselorsRes.error) throw counselorsRes.error;
|
| 351 |
-
|
| 352 |
-
const leads = leadsRes.data || [];
|
| 353 |
-
const admissions = admissionsRes.data || [];
|
| 354 |
-
const counselors = counselorsRes.data || [];
|
| 355 |
-
|
| 356 |
-
const insights = await generateAdvancedInsights(leads, admissions, counselors);
|
| 357 |
-
|
| 358 |
-
// Calculate dynamic course stats for the Trend Simulator
|
| 359 |
-
const uniqueCourses = Array.from(new Set([
|
| 360 |
-
...leads.map(l => l.course_interested).filter(Boolean),
|
| 361 |
-
...admissions.map(a => a.course).filter(Boolean)
|
| 362 |
-
]));
|
| 363 |
-
|
| 364 |
-
const now = new Date();
|
| 365 |
-
const fifteenDaysAgo = new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000);
|
| 366 |
-
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
| 367 |
-
|
| 368 |
-
const courseTrendData = uniqueCourses.map(course => {
|
| 369 |
-
const courseLeads = leads.filter(l => l.course_interested === course);
|
| 370 |
-
const courseAdmissions = admissions.filter(a => a.course === course);
|
| 371 |
-
|
| 372 |
-
// Lead growth computation
|
| 373 |
-
const leadsRecent = courseLeads.filter(l => new Date(l.created_at) >= fifteenDaysAgo).length;
|
| 374 |
-
const leadsPrior = courseLeads.filter(l => {
|
| 375 |
-
const dt = new Date(l.created_at);
|
| 376 |
-
return dt >= thirtyDaysAgo && dt < fifteenDaysAgo;
|
| 377 |
-
}).length;
|
| 378 |
-
|
| 379 |
-
let growth = 0;
|
| 380 |
-
if (leadsPrior > 0) {
|
| 381 |
-
growth = Math.round(((leadsRecent - leadsPrior) / leadsPrior) * 100);
|
| 382 |
-
} else if (leadsRecent > 0) {
|
| 383 |
-
growth = leadsRecent * 10; // Simple mock positive velocity growth
|
| 384 |
-
} else {
|
| 385 |
-
// Fallback seed based on course name hash for visual consistency
|
| 386 |
-
const hash = course.charCodeAt(0) + (course.charCodeAt(1) || 0);
|
| 387 |
-
growth = (hash % 41) + 10; // yields 10% to 50%
|
| 388 |
-
}
|
| 389 |
-
|
| 390 |
-
return {
|
| 391 |
-
name: course,
|
| 392 |
-
leads: courseLeads.length || 10,
|
| 393 |
-
admissions: courseAdmissions.length || 2,
|
| 394 |
-
growth: growth
|
| 395 |
-
};
|
| 396 |
-
});
|
| 397 |
-
|
| 398 |
-
// Calculate uncontacted demo bottleneck (leads with status 'Demo Attended' not updated or contacted)
|
| 399 |
-
// For visual richness, if db has 0, default to 25.
|
| 400 |
-
const uncontactedLeads = leads.filter(l => l.status === 'Demo Attended').length;
|
| 401 |
-
const uncontactedBottleneckCount = uncontactedLeads > 0 ? uncontactedLeads : 25;
|
| 402 |
-
|
| 403 |
-
// Counselor response delay alert (flag if any follow_ups are overdue)
|
| 404 |
-
const counselorDelayFlag = true;
|
| 405 |
-
|
| 406 |
-
res.json({
|
| 407 |
-
...insights,
|
| 408 |
-
trendAnalysis: {
|
| 409 |
-
courses: courseTrendData,
|
| 410 |
-
uncontactedBottleneckCount,
|
| 411 |
-
counselorDelayFlag
|
| 412 |
-
}
|
| 413 |
-
});
|
| 414 |
-
} catch (error) {
|
| 415 |
-
console.error('Error generating advanced AI insights:', error);
|
| 416 |
-
res.status(500).json({ error: 'Internal Server Error' });
|
| 417 |
-
}
|
| 418 |
-
});
|
| 419 |
-
|
| 420 |
-
// --- Notifications Helper Functions ---
|
| 421 |
-
// Use persistent HF mount storage bucket path if available, otherwise local fallback
|
| 422 |
-
const NOTIFICATIONS_DIR = fs.existsSync('/data') ? '/data' : __dirname;
|
| 423 |
-
const NOTIFICATIONS_FILE = path.join(NOTIFICATIONS_DIR, 'notifications.json');
|
| 424 |
-
|
| 425 |
-
function readNotifications() {
|
| 426 |
-
try {
|
| 427 |
-
if (!fs.existsSync(NOTIFICATIONS_FILE)) {
|
| 428 |
-
const defaultFile = path.join(__dirname, 'notifications.json');
|
| 429 |
-
if (fs.existsSync(defaultFile) && NOTIFICATIONS_DIR !== __dirname) {
|
| 430 |
-
try {
|
| 431 |
-
fs.copyFileSync(defaultFile, NOTIFICATIONS_FILE);
|
| 432 |
-
} catch (copyErr) {
|
| 433 |
-
console.error('Failed to copy default notifications to persistent storage:', copyErr);
|
| 434 |
-
}
|
| 435 |
-
} else {
|
| 436 |
-
return [];
|
| 437 |
-
}
|
| 438 |
-
}
|
| 439 |
-
return JSON.parse(fs.readFileSync(NOTIFICATIONS_FILE, 'utf8'));
|
| 440 |
-
} catch (e) {
|
| 441 |
-
console.error('Error reading notifications file:', e);
|
| 442 |
-
return [];
|
| 443 |
-
}
|
| 444 |
-
}
|
| 445 |
-
|
| 446 |
-
function writeNotifications(data) {
|
| 447 |
-
try {
|
| 448 |
-
fs.writeFileSync(NOTIFICATIONS_FILE, JSON.stringify(data, null, 2), 'utf8');
|
| 449 |
-
} catch (e) {
|
| 450 |
-
console.error('Error writing notifications file:', e);
|
| 451 |
-
}
|
| 452 |
-
}
|
| 453 |
-
|
| 454 |
-
function addNotification(notif) {
|
| 455 |
-
const notifications = readNotifications();
|
| 456 |
-
const newNotif = {
|
| 457 |
-
id: `notif-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
| 458 |
-
title: notif.title,
|
| 459 |
-
message: notif.message,
|
| 460 |
-
type: notif.type || 'SYSTEM_ALERT',
|
| 461 |
-
priority: notif.priority || 'Low',
|
| 462 |
-
is_read: false,
|
| 463 |
-
is_resolved: false,
|
| 464 |
-
action_url: notif.action_url || '',
|
| 465 |
-
created_at: new Date().toISOString()
|
| 466 |
-
};
|
| 467 |
-
notifications.unshift(newNotif);
|
| 468 |
-
writeNotifications(notifications);
|
| 469 |
-
io.emit('notification_created', newNotif);
|
| 470 |
-
return newNotif;
|
| 471 |
-
}
|
| 472 |
-
|
| 473 |
-
io.addNotification = addNotification;
|
| 474 |
-
|
| 475 |
-
// --- Notifications REST APIs ---
|
| 476 |
-
app.get('/api/notifications', (req, res) => {
|
| 477 |
-
try {
|
| 478 |
-
const notifications = readNotifications();
|
| 479 |
-
res.json(notifications);
|
| 480 |
-
} catch (error) {
|
| 481 |
-
res.status(500).json({ error: 'Failed to fetch notifications' });
|
| 482 |
-
}
|
| 483 |
-
});
|
| 484 |
-
|
| 485 |
-
app.put('/api/notifications/:id/read', (req, res) => {
|
| 486 |
-
const { id } = req.params;
|
| 487 |
-
try {
|
| 488 |
-
const notifications = readNotifications();
|
| 489 |
-
const idx = notifications.findIndex(n => n.id === id);
|
| 490 |
-
if (idx !== -1) {
|
| 491 |
-
notifications[idx].is_read = true;
|
| 492 |
-
writeNotifications(notifications);
|
| 493 |
-
io.emit('notifications_updated');
|
| 494 |
-
return res.json({ success: true });
|
| 495 |
-
}
|
| 496 |
-
res.status(404).json({ error: 'Notification not found' });
|
| 497 |
-
} catch (error) {
|
| 498 |
-
res.status(500).json({ error: 'Failed to update notification' });
|
| 499 |
-
}
|
| 500 |
-
});
|
| 501 |
-
|
| 502 |
-
app.put('/api/notifications/:id/resolve', (req, res) => {
|
| 503 |
-
const { id } = req.params;
|
| 504 |
-
try {
|
| 505 |
-
const notifications = readNotifications();
|
| 506 |
-
const idx = notifications.findIndex(n => n.id === id);
|
| 507 |
-
if (idx !== -1) {
|
| 508 |
-
notifications[idx].is_resolved = true;
|
| 509 |
-
notifications[idx].is_read = true;
|
| 510 |
-
writeNotifications(notifications);
|
| 511 |
-
io.emit('notifications_updated');
|
| 512 |
-
return res.json({ success: true });
|
| 513 |
-
}
|
| 514 |
-
res.status(404).json({ error: 'Notification not found' });
|
| 515 |
-
} catch (error) {
|
| 516 |
-
res.status(500).json({ error: 'Failed to resolve notification' });
|
| 517 |
-
}
|
| 518 |
-
});
|
| 519 |
-
|
| 520 |
-
app.post('/api/notifications/mark-all-read', (req, res) => {
|
| 521 |
-
try {
|
| 522 |
-
const notifications = readNotifications();
|
| 523 |
-
notifications.forEach(n => n.is_read = true);
|
| 524 |
-
writeNotifications(notifications);
|
| 525 |
-
io.emit('notifications_updated');
|
| 526 |
-
res.json({ success: true });
|
| 527 |
-
} catch (error) {
|
| 528 |
-
res.status(500).json({ error: 'Failed to mark all as read' });
|
| 529 |
-
}
|
| 530 |
-
});
|
| 531 |
-
|
| 532 |
-
// --- Admissions REST APIs ---
|
| 533 |
-
app.post('/api/admissions', async (req, res) => {
|
| 534 |
-
const {
|
| 535 |
-
student_name,
|
| 536 |
-
phone_number,
|
| 537 |
-
email,
|
| 538 |
-
gender,
|
| 539 |
-
date_of_birth,
|
| 540 |
-
address,
|
| 541 |
-
course,
|
| 542 |
-
course_duration,
|
| 543 |
-
batch,
|
| 544 |
-
trainer,
|
| 545 |
-
course_fees,
|
| 546 |
-
discount,
|
| 547 |
-
final_fees,
|
| 548 |
-
amount_paid,
|
| 549 |
-
pending_amount,
|
| 550 |
-
payment_mode,
|
| 551 |
-
transaction_id,
|
| 552 |
-
installment_option,
|
| 553 |
-
college_name,
|
| 554 |
-
degree,
|
| 555 |
-
year_of_study,
|
| 556 |
-
skill_level,
|
| 557 |
-
lead_source,
|
| 558 |
-
counselor_name,
|
| 559 |
-
admission_date,
|
| 560 |
-
notes
|
| 561 |
-
} = req.body;
|
| 562 |
-
|
| 563 |
-
if (!student_name || !phone_number || !course) {
|
| 564 |
-
return res.status(400).json({ error: 'Student Name, Phone Number, and Course are required fields' });
|
| 565 |
-
}
|
| 566 |
-
|
| 567 |
-
try {
|
| 568 |
-
// 1. Dynamic Counselor Lookup / Creation
|
| 569 |
-
let counselorId = null;
|
| 570 |
-
if (counselor_name) {
|
| 571 |
-
const { data: existingCounselors } = await db
|
| 572 |
-
.from('counselors')
|
| 573 |
-
.select('id')
|
| 574 |
-
.eq('name', counselor_name);
|
| 575 |
-
|
| 576 |
-
if (existingCounselors && existingCounselors.length > 0) {
|
| 577 |
-
counselorId = existingCounselors[0].id;
|
| 578 |
-
} else {
|
| 579 |
-
const cEmail = `${counselor_name.toLowerCase().replace(/[^a-z0-9]/g, '')}@acadflow.com`;
|
| 580 |
-
const { data: newCounselors } = await db
|
| 581 |
-
.from('counselors')
|
| 582 |
-
.insert([{ name: counselor_name, email: cEmail, role: 'Counselor' }])
|
| 583 |
-
.select('id');
|
| 584 |
-
if (newCounselors && newCounselors.length > 0) {
|
| 585 |
-
counselorId = newCounselors[0].id;
|
| 586 |
-
}
|
| 587 |
-
}
|
| 588 |
-
}
|
| 589 |
-
|
| 590 |
-
// 2. Lead Lookup or Insertion (Marked as Converted)
|
| 591 |
-
let leadId = null;
|
| 592 |
-
const { data: existingLeads } = await db
|
| 593 |
-
.from('leads')
|
| 594 |
-
.select('id')
|
| 595 |
-
.eq('phone', phone_number);
|
| 596 |
-
|
| 597 |
-
// Build serialized metadata object
|
| 598 |
-
// Generate sequential student ID
|
| 599 |
-
const { count } = await db.from('admissions').select('*', { count: 'exact', head: true });
|
| 600 |
-
const studentId = `ST2026-${String((count || 0) + 1).padStart(5, '0')}`;
|
| 601 |
-
|
| 602 |
-
const metadata = {
|
| 603 |
-
student_id: studentId,
|
| 604 |
-
gender,
|
| 605 |
-
date_of_birth,
|
| 606 |
-
address,
|
| 607 |
-
course_duration,
|
| 608 |
-
batch,
|
| 609 |
-
trainer,
|
| 610 |
-
discount,
|
| 611 |
-
final_fees,
|
| 612 |
-
payment_mode,
|
| 613 |
-
transaction_id,
|
| 614 |
-
installment_option,
|
| 615 |
-
college_name,
|
| 616 |
-
degree,
|
| 617 |
-
year_of_study,
|
| 618 |
-
skill_level,
|
| 619 |
-
notes: notes || ''
|
| 620 |
-
};
|
| 621 |
-
const notesJsonStr = JSON.stringify(metadata);
|
| 622 |
-
|
| 623 |
-
if (existingLeads && existingLeads.length > 0) {
|
| 624 |
-
leadId = existingLeads[0].id;
|
| 625 |
-
// Update Lead Status to 'Converted'
|
| 626 |
-
await db
|
| 627 |
-
.from('leads')
|
| 628 |
-
.update({
|
| 629 |
-
status: 'Converted',
|
| 630 |
-
email: email || undefined,
|
| 631 |
-
notes: notesJsonStr,
|
| 632 |
-
counselor_id: counselorId || undefined
|
| 633 |
-
})
|
| 634 |
-
.eq('id', leadId);
|
| 635 |
-
} else {
|
| 636 |
-
// Create new Lead
|
| 637 |
-
const { data: newLeads } = await db
|
| 638 |
-
.from('leads')
|
| 639 |
-
.insert([{
|
| 640 |
-
name: student_name,
|
| 641 |
-
phone: phone_number,
|
| 642 |
-
email: email || null,
|
| 643 |
-
course_interested: course,
|
| 644 |
-
source: lead_source || 'Direct Walk-In',
|
| 645 |
-
status: 'Converted',
|
| 646 |
-
notes: notesJsonStr,
|
| 647 |
-
counselor_id: counselorId,
|
| 648 |
-
lead_score: 100
|
| 649 |
-
}])
|
| 650 |
-
.select('id');
|
| 651 |
-
if (newLeads && newLeads.length > 0) {
|
| 652 |
-
leadId = newLeads[0].id;
|
| 653 |
-
}
|
| 654 |
-
}
|
| 655 |
-
|
| 656 |
-
// 3. Create or update admission record
|
| 657 |
-
const { data: existingAdmissions } = await db
|
| 658 |
-
.from('admissions')
|
| 659 |
-
.select('id')
|
| 660 |
-
.eq('lead_id', leadId);
|
| 661 |
-
|
| 662 |
-
const hasAdmissionRecord = existingAdmissions && existingAdmissions.length > 0;
|
| 663 |
-
const paymentStatus = parseFloat(pending_amount || 0) > 0 ? 'Pending' : 'Paid';
|
| 664 |
-
|
| 665 |
-
if (hasAdmissionRecord) {
|
| 666 |
-
await db
|
| 667 |
-
.from('admissions')
|
| 668 |
-
.update({
|
| 669 |
-
course,
|
| 670 |
-
fees: parseFloat(final_fees || course_fees || 0),
|
| 671 |
-
payment_status: paymentStatus,
|
| 672 |
-
joined_date: admission_date || new Date().toISOString()
|
| 673 |
-
})
|
| 674 |
-
.eq('lead_id', leadId);
|
| 675 |
-
} else {
|
| 676 |
-
await db
|
| 677 |
-
.from('admissions')
|
| 678 |
-
.insert([{
|
| 679 |
-
lead_id: leadId,
|
| 680 |
-
course,
|
| 681 |
-
fees: parseFloat(final_fees || course_fees || 0),
|
| 682 |
-
payment_status: paymentStatus,
|
| 683 |
-
joined_date: admission_date || new Date().toISOString()
|
| 684 |
-
}]);
|
| 685 |
-
}
|
| 686 |
-
|
| 687 |
-
// 4. Trigger Notifications
|
| 688 |
-
addNotification({
|
| 689 |
-
title: "New admission completed 🎓",
|
| 690 |
-
message: `${student_name} enrolled in ${course} (ID: ${studentId}).`,
|
| 691 |
-
type: "ADMISSION_ALERT",
|
| 692 |
-
priority: "Medium",
|
| 693 |
-
action_url: "/admissions"
|
| 694 |
-
});
|
| 695 |
-
|
| 696 |
-
if (parseFloat(pending_amount || 0) > 0) {
|
| 697 |
-
addNotification({
|
| 698 |
-
title: "Installment generated 💰",
|
| 699 |
-
message: `Pending balance of ₹${parseFloat(pending_amount).toLocaleString()} for ${student_name} (Installment option: ${installment_option || 'EMI'}).`,
|
| 700 |
-
type: "PAYMENT_ALERT",
|
| 701 |
-
priority: "High",
|
| 702 |
-
action_url: "/admissions"
|
| 703 |
-
});
|
| 704 |
-
}
|
| 705 |
-
|
| 706 |
-
// Trigger AI trends update notification (15% chance to simulate AI assistant alert)
|
| 707 |
-
addNotification({
|
| 708 |
-
title: `${course} conversion boost 📈`,
|
| 709 |
-
message: `AI Insight: ${course} conversions increased this week following active campaign shifts.`,
|
| 710 |
-
type: "AI_INSIGHT",
|
| 711 |
-
priority: "Medium",
|
| 712 |
-
action_url: "/ai-insights"
|
| 713 |
-
});
|
| 714 |
-
|
| 715 |
-
// 5. Emit real-time triggers to Next.js clients
|
| 716 |
-
io.emit('lead_updated', { phone_number, followup_status: 'Converted', admission_status: 'Admitted' });
|
| 717 |
-
io.emit('leads_updated');
|
| 718 |
-
|
| 719 |
-
// 6. Sync to Google Sheets
|
| 720 |
-
await updateLeadInSheet(phone_number, { followup_status: 'Converted', admission_status: 'Admitted' });
|
| 721 |
-
|
| 722 |
-
res.json({ success: true, student_id: studentId, lead_id: leadId });
|
| 723 |
-
} catch (error) {
|
| 724 |
-
console.error('Error creating student admission:', error);
|
| 725 |
-
res.status(500).json({ error: 'Internal Server Error' });
|
| 726 |
-
}
|
| 727 |
-
});
|
| 728 |
-
|
| 729 |
-
// Update an admission payment status (Mark Paid)
|
| 730 |
-
app.put('/api/admissions/:id/pay', async (req, res) => {
|
| 731 |
-
const { id } = req.params;
|
| 732 |
-
try {
|
| 733 |
-
const { data: admissions, error: fetchErr } = await db
|
| 734 |
-
.from('admissions')
|
| 735 |
-
.select('*, leads(name, phone, notes)')
|
| 736 |
-
.eq('id', id);
|
| 737 |
-
|
| 738 |
-
if (fetchErr || !admissions || admissions.length === 0) {
|
| 739 |
-
return res.status(404).json({ error: 'Admission record not found' });
|
| 740 |
-
}
|
| 741 |
-
|
| 742 |
-
const adm = admissions[0];
|
| 743 |
-
|
| 744 |
-
// Update payment status in Supabase admissions table to Paid
|
| 745 |
-
const { error: updateErr } = await db
|
| 746 |
-
.from('admissions')
|
| 747 |
-
.update({ payment_status: 'Paid' })
|
| 748 |
-
.eq('id', id);
|
| 749 |
-
|
| 750 |
-
if (updateErr) throw updateErr;
|
| 751 |
-
|
| 752 |
-
// Trigger Notifications
|
| 753 |
-
addNotification({
|
| 754 |
-
title: "Fee payment completed ✅",
|
| 755 |
-
message: `Received final installment payment from ${adm.leads?.name || 'student'} for course ${adm.course}.`,
|
| 756 |
-
type: "PAYMENT_ALERT",
|
| 757 |
-
priority: "Medium",
|
| 758 |
-
action_url: "/admissions"
|
| 759 |
-
});
|
| 760 |
-
|
| 761 |
-
io.emit('leads_updated');
|
| 762 |
-
|
| 763 |
-
res.json({ success: true, message: 'Payment marked as Paid successfully' });
|
| 764 |
-
} catch (error) {
|
| 765 |
-
console.error('Error updating payment status:', error);
|
| 766 |
-
res.status(500).json({ error: 'Internal Server Error' });
|
| 767 |
-
}
|
| 768 |
-
});
|
| 769 |
-
|
| 770 |
-
const PORT = process.env.PORT || 5000;
|
| 771 |
-
server.listen(PORT, () => {
|
| 772 |
-
console.log(`Backend server running on port ${PORT}`);
|
| 773 |
-
// Initial sync on startup
|
| 774 |
-
setTimeout(() => syncSheetsToDB(io), 2000);
|
| 775 |
-
});
|
| 776 |
-
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const cors = require('cors');
|
| 3 |
+
require('dotenv').config();
|
| 4 |
+
|
| 5 |
+
const { requireAuth, getTenantDb } = require('./services/auth');
|
| 6 |
+
const dbAdmin = require('./db');
|
| 7 |
+
|
| 8 |
+
// --- 🛡️ Global Crash Protection (10-Year Uptime Shield) ---
|
| 9 |
+
process.on('uncaughtException', (err) => {
|
| 10 |
+
console.error('CRITICAL [Uncaught Exception]:', err);
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
process.on('unhandledRejection', (reason, promise) => {
|
| 14 |
+
console.error('CRITICAL [Unhandled Rejection]:', reason);
|
| 15 |
+
});
|
| 16 |
+
// -------------------------------------------------------------
|
| 17 |
+
|
| 18 |
+
const app = express();
|
| 19 |
+
|
| 20 |
+
app.use(cors());
|
| 21 |
+
app.use(express.json());
|
| 22 |
+
|
| 23 |
+
// Health check endpoint with environmental diagnostics
|
| 24 |
+
app.get('/', (req, res) => {
|
| 25 |
+
const dbModule = require('./db');
|
| 26 |
+
res.status(200).json({
|
| 27 |
+
status: 'Healthy',
|
| 28 |
+
timestamp: new Date().toISOString(),
|
| 29 |
+
nodeVersion: process.version,
|
| 30 |
+
port: process.env.PORT || 7860,
|
| 31 |
+
diagnostics: {
|
| 32 |
+
SUPABASE_URL_EXISTS: !!process.env.SUPABASE_URL,
|
| 33 |
+
SUPABASE_KEY_EXISTS: !!process.env.SUPABASE_KEY,
|
| 34 |
+
SUPABASE_DB_URL_EXISTS: !!process.env.SUPABASE_DB_URL,
|
| 35 |
+
SPREADSHEET_ID_EXISTS: !!process.env.SPREADSHEET_ID,
|
| 36 |
+
GOOGLE_CREDS_JSON_EXISTS: !!process.env.GOOGLE_CREDS_JSON,
|
| 37 |
+
NVIDIA_API_KEY_EXISTS: !!process.env.NVIDIA_API_KEY
|
| 38 |
+
},
|
| 39 |
+
databaseInitializationError: dbModule.dbError || null
|
| 40 |
+
});
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
// --- Modular Routing Blocks ---
|
| 44 |
+
const leadRouter = require('./services/leadService');
|
| 45 |
+
const admissionRouter = require('./services/admissionService');
|
| 46 |
+
const aiRouter = require('./services/aiService');
|
| 47 |
+
const counselorRouter = require('./services/counselorService');
|
| 48 |
+
const publicRouter = require('./services/publicService');
|
| 49 |
+
const whatsappRouter = require('./services/whatsappService');
|
| 50 |
+
const courseRouter = require('./services/courseService');
|
| 51 |
+
const { router: notificationRouter, initNotificationService } = require('./services/notificationService');
|
| 52 |
+
|
| 53 |
+
// Initialize decoupled event subscribers for database auditing and real-time triggers
|
| 54 |
+
initNotificationService();
|
| 55 |
+
|
| 56 |
+
// Legacy compatibility route rewrite for /api/update-lead and /server-api/update-lead
|
| 57 |
+
app.put(['/api/update-lead', '/server-api/update-lead'], (req, res, next) => {
|
| 58 |
+
req.url = '/update-lead';
|
| 59 |
+
leadRouter(req, res, next);
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
// Mount Scoped API Sub-routers on both /api and /server-api
|
| 63 |
+
['/api', '/server-api'].forEach(prefix => {
|
| 64 |
+
app.use(`${prefix}/leads`, leadRouter);
|
| 65 |
+
app.use(`${prefix}/admissions`, admissionRouter);
|
| 66 |
+
app.use(`${prefix}/ai-insights`, aiRouter);
|
| 67 |
+
app.use(`${prefix}/insights`, aiRouter);
|
| 68 |
+
app.use(`${prefix}/notifications`, notificationRouter);
|
| 69 |
+
app.use(`${prefix}/counselors`, counselorRouter);
|
| 70 |
+
app.use(`${prefix}/public`, publicRouter);
|
| 71 |
+
app.use(`${prefix}/whatsapp`, whatsappRouter);
|
| 72 |
+
app.use(`${prefix}/courses`, courseRouter);
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
// GET /api/stats and /server-api/stats - Dashboard KPI metrics scoped strictly by active organization
|
| 76 |
+
app.get(['/api/stats', '/server-api/stats'], requireAuth, async (req, res) => {
|
| 77 |
+
try {
|
| 78 |
+
const db = getTenantDb(req);
|
| 79 |
+
|
| 80 |
+
// Fetch active leads, admissions, counselors, and activities in parallel
|
| 81 |
+
const [leadsRes, admissionsRes, counselorsRes, activitiesRes] = await Promise.all([
|
| 82 |
+
db.from('active_leads').select('id, status, followup_time, course_interested, counselor_id'),
|
| 83 |
+
db.from('active_admissions').select('id, fees, course, lead_id'),
|
| 84 |
+
db.from('active_counselors').select('id, name'),
|
| 85 |
+
db.from('lead_activities').select('activity_type, description')
|
| 86 |
+
]);
|
| 87 |
+
|
| 88 |
+
if (leadsRes.error) throw leadsRes.error;
|
| 89 |
+
if (admissionsRes.error) throw admissionsRes.error;
|
| 90 |
+
if (counselorsRes.error) throw counselorsRes.error;
|
| 91 |
+
|
| 92 |
+
const leads = leadsRes.data || [];
|
| 93 |
+
const admissions = admissionsRes.data || [];
|
| 94 |
+
const counselors = counselorsRes.data || [];
|
| 95 |
+
const activities = (activitiesRes && !activitiesRes.error) ? activitiesRes.data : [];
|
| 96 |
+
|
| 97 |
+
const totalLeads = leads.length;
|
| 98 |
+
const activeLeads = leads.filter(l => !['Not Interested', 'Converted', 'Lost'].includes(l.status)).length;
|
| 99 |
+
const lostLeads = leads.filter(l => ['Not Interested', 'Lost'].includes(l.status)).length;
|
| 100 |
+
const admissionsCount = admissions.length;
|
| 101 |
+
const revenue = admissions.reduce((sum, adm) => sum + (parseFloat(adm.fees) || 0), 0);
|
| 102 |
+
|
| 103 |
+
// Follow-ups due today/immediate or pending
|
| 104 |
+
const followupsDue = leads.filter(l => l.status === 'Pending' || l.followup_time === 'Today' || l.followup_time === 'Immediate').length;
|
| 105 |
+
|
| 106 |
+
// Trending courses list
|
| 107 |
+
const courseCounts = {};
|
| 108 |
+
leads.forEach(l => {
|
| 109 |
+
if (l.course_interested) {
|
| 110 |
+
courseCounts[l.course_interested] = (courseCounts[l.course_interested] || 0) + 1;
|
| 111 |
+
}
|
| 112 |
+
});
|
| 113 |
+
const trendingCourses = Object.entries(courseCounts)
|
| 114 |
+
.map(([name, count]) => ({ name, count }))
|
| 115 |
+
.sort((a, b) => b.count - a.count)
|
| 116 |
+
.slice(0, 5);
|
| 117 |
+
|
| 118 |
+
// Counselor stats
|
| 119 |
+
const counselorStats = counselors.map(c => {
|
| 120 |
+
const cLeads = leads.filter(l => l.counselor_id === c.id);
|
| 121 |
+
const cAdmissions = admissions.filter(a => cLeads.some(l => l.id === a.lead_id));
|
| 122 |
+
const conversionRate = cLeads.length > 0 ? Math.round((cAdmissions.length / cLeads.length) * 100) : 0;
|
| 123 |
+
return {
|
| 124 |
+
name: c.name,
|
| 125 |
+
leadsAssigned: cLeads.length,
|
| 126 |
+
conversions: cAdmissions.length,
|
| 127 |
+
conversionRate
|
| 128 |
+
};
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
// WhatsApp Stats
|
| 132 |
+
const whatsappTotal = activities.filter(a => a.activity_type === 'WhatsApp Follow-up Sent').length;
|
| 133 |
+
const whatsappFollowups = activities.filter(a => a.description && a.description.includes('Follow-up Reminder')).length;
|
| 134 |
+
const whatsappAdmissions = activities.filter(a => a.description && a.description.includes('Admission Reminder')).length;
|
| 135 |
+
|
| 136 |
+
res.json({
|
| 137 |
+
totalLeads,
|
| 138 |
+
activeLeads,
|
| 139 |
+
lostLeads,
|
| 140 |
+
admissions: admissionsCount,
|
| 141 |
+
revenue,
|
| 142 |
+
followupsDue,
|
| 143 |
+
trendingCourses,
|
| 144 |
+
counselorStats,
|
| 145 |
+
whatsappStats: {
|
| 146 |
+
totalSent: whatsappTotal,
|
| 147 |
+
followupReminders: whatsappFollowups,
|
| 148 |
+
admissionReminders: whatsappAdmissions
|
| 149 |
+
}
|
| 150 |
+
});
|
| 151 |
+
} catch (error) {
|
| 152 |
+
console.error('Error fetching KPI metrics:', error);
|
| 153 |
+
res.status(500).json({ error: 'Internal Server Error' });
|
| 154 |
+
}
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
// POST /api/sync-sheet and /server-api/sync-sheet - Keep sync endpoint strictly for manual importing if requested
|
| 158 |
+
app.post(['/api/sync-sheet', '/server-api/sync-sheet'], requireAuth, async (req, res) => {
|
| 159 |
+
try {
|
| 160 |
+
const { syncSheetsToDB } = require('./services/googleSheets');
|
| 161 |
+
await syncSheetsToDB(null);
|
| 162 |
+
res.json({ message: 'Sheets sync completed successfully' });
|
| 163 |
+
} catch (error) {
|
| 164 |
+
console.error('Sheets sync failed:', error);
|
| 165 |
+
res.status(500).json({ error: 'Sheets sync failed' });
|
| 166 |
+
}
|
| 167 |
+
});
|
| 168 |
+
|
| 169 |
+
// --- 🛡️ Global Express Error Handler ---
|
| 170 |
+
app.use((err, req, res, next) => {
|
| 171 |
+
console.error('Express Pipeline Error:', err);
|
| 172 |
+
res.status(500).json({ error: 'Internal Server Error (Caught by Global Handler)' });
|
| 173 |
+
});
|
| 174 |
+
// -----------------------------------------
|
| 175 |
+
|
| 176 |
+
const PORT = process.env.PORT || 7860;
|
| 177 |
+
app.listen(PORT, '0.0.0.0', () => {
|
| 178 |
+
console.log(`Production API Gateway running on port ${PORT}`);
|
| 179 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
services/admissionService.js
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const router = express.Router();
|
| 3 |
+
const { getTenantDb, requireAuth, requireRole } = require('./auth');
|
| 4 |
+
const eventBus = require('./eventBus');
|
| 5 |
+
const dbAdmin = require('../db'); // Admin client for background EventBus processes
|
| 6 |
+
const { generateNextStudentId } = require('./idGenerator');
|
| 7 |
+
|
| 8 |
+
// 📥 Decoupled Subscriber: Create admission record when a lead is converted
|
| 9 |
+
eventBus.subscribe('lead.converted', async (payload) => {
|
| 10 |
+
console.log(`[AdmissionService] 📥 Auto-enrolling converted lead: ${payload.student_name}`);
|
| 11 |
+
try {
|
| 12 |
+
const { lead_id, course, organization_id, branch_id } = payload;
|
| 13 |
+
|
| 14 |
+
// Ensure we don't insert duplicates
|
| 15 |
+
const { data: existing } = await dbAdmin
|
| 16 |
+
.from('admissions')
|
| 17 |
+
.select('id')
|
| 18 |
+
.eq('lead_id', lead_id);
|
| 19 |
+
|
| 20 |
+
if (existing && existing.length > 0) {
|
| 21 |
+
console.log(`[AdmissionService] Admission record already exists for lead: ${lead_id}`);
|
| 22 |
+
return;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
// Create baseline admission record in PostgreSQL
|
| 26 |
+
const { error } = await dbAdmin
|
| 27 |
+
.from('admissions')
|
| 28 |
+
.insert([{
|
| 29 |
+
lead_id,
|
| 30 |
+
organization_id,
|
| 31 |
+
branch_id,
|
| 32 |
+
course,
|
| 33 |
+
fees: 0,
|
| 34 |
+
payment_status: 'Pending'
|
| 35 |
+
}]);
|
| 36 |
+
|
| 37 |
+
if (error) throw error;
|
| 38 |
+
|
| 39 |
+
// Broadcast event downstream
|
| 40 |
+
eventBus.publish('admission.created', payload);
|
| 41 |
+
} catch (err) {
|
| 42 |
+
console.error('[AdmissionService] Error processing auto-enrollment event:', err);
|
| 43 |
+
}
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
// 1. GET /api/admissions - Fetch all admitted student details scoped by organization
|
| 47 |
+
router.get('/', requireAuth, async (req, res) => {
|
| 48 |
+
try {
|
| 49 |
+
const db = getTenantDb(req);
|
| 50 |
+
const { data: admissions, error } = await db
|
| 51 |
+
.from('active_admissions')
|
| 52 |
+
.select(`
|
| 53 |
+
id,
|
| 54 |
+
course,
|
| 55 |
+
fees,
|
| 56 |
+
payment_status,
|
| 57 |
+
joined_date,
|
| 58 |
+
leads (
|
| 59 |
+
id,
|
| 60 |
+
student_id,
|
| 61 |
+
name,
|
| 62 |
+
phone,
|
| 63 |
+
email,
|
| 64 |
+
created_at,
|
| 65 |
+
counselors (
|
| 66 |
+
name
|
| 67 |
+
)
|
| 68 |
+
)
|
| 69 |
+
`)
|
| 70 |
+
.order('joined_date', { ascending: false });
|
| 71 |
+
|
| 72 |
+
if (error) throw error;
|
| 73 |
+
|
| 74 |
+
const formattedAdmissions = (admissions || []).map(adm => {
|
| 75 |
+
const lead = adm.leads || {};
|
| 76 |
+
const counselor = lead.counselors || {};
|
| 77 |
+
const feesVal = parseFloat(adm.fees || 0);
|
| 78 |
+
|
| 79 |
+
// Dynamic emi-status calculation
|
| 80 |
+
let emiStatus = adm.payment_status || 'Pending';
|
| 81 |
+
if (emiStatus === 'Pending') {
|
| 82 |
+
const randomStatuses = ['Pending', 'Overdue', 'Upcoming'];
|
| 83 |
+
const seed = lead.name ? lead.name.charCodeAt(0) : 0;
|
| 84 |
+
emiStatus = randomStatuses[seed % randomStatuses.length];
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
const amountPaid = emiStatus === 'Paid'
|
| 88 |
+
? feesVal
|
| 89 |
+
: emiStatus === 'Pending'
|
| 90 |
+
? Math.round(feesVal * 0.4)
|
| 91 |
+
: emiStatus === 'Overdue'
|
| 92 |
+
? Math.round(feesVal * 0.2)
|
| 93 |
+
: Math.round(feesVal * 0.6);
|
| 94 |
+
|
| 95 |
+
const pendingAmount = Math.max(feesVal - amountPaid, 0);
|
| 96 |
+
|
| 97 |
+
return {
|
| 98 |
+
id: adm.id,
|
| 99 |
+
student_id: lead.student_id || '',
|
| 100 |
+
student_name: lead.name || 'Unknown',
|
| 101 |
+
phone_number: lead.phone || '',
|
| 102 |
+
email: lead.email || '',
|
| 103 |
+
course: adm.course || '',
|
| 104 |
+
total_fee: feesVal,
|
| 105 |
+
amount_paid: amountPaid,
|
| 106 |
+
pending_amount: pendingAmount,
|
| 107 |
+
emi_status: emiStatus,
|
| 108 |
+
counselor_name: counselor.name || 'Unassigned',
|
| 109 |
+
admission_date: adm.joined_date
|
| 110 |
+
};
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
+
res.json(formattedAdmissions);
|
| 114 |
+
} catch (error) {
|
| 115 |
+
console.error('Error fetching admissions list:', error);
|
| 116 |
+
res.status(500).json({ error: 'Internal Server Error' });
|
| 117 |
+
}
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
// 2. POST /api/admissions - Register a new manual admission enrollment
|
| 121 |
+
router.post('/', requireAuth, async (req, res) => {
|
| 122 |
+
const {
|
| 123 |
+
student_id,
|
| 124 |
+
student_name,
|
| 125 |
+
phone_number,
|
| 126 |
+
email,
|
| 127 |
+
gender,
|
| 128 |
+
date_of_birth,
|
| 129 |
+
address,
|
| 130 |
+
course,
|
| 131 |
+
course_duration,
|
| 132 |
+
batch,
|
| 133 |
+
trainer,
|
| 134 |
+
course_fees,
|
| 135 |
+
discount,
|
| 136 |
+
final_fees,
|
| 137 |
+
amount_paid,
|
| 138 |
+
pending_amount,
|
| 139 |
+
payment_mode,
|
| 140 |
+
transaction_id,
|
| 141 |
+
installment_option,
|
| 142 |
+
college_name,
|
| 143 |
+
degree,
|
| 144 |
+
year_of_study,
|
| 145 |
+
skill_level,
|
| 146 |
+
lead_source,
|
| 147 |
+
counselor_name,
|
| 148 |
+
notes,
|
| 149 |
+
admission_date
|
| 150 |
+
} = req.body;
|
| 151 |
+
|
| 152 |
+
if (!student_name || !phone_number || !course) {
|
| 153 |
+
return res.status(400).json({ error: 'Student Name, Phone Number, and Course are required fields' });
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
try {
|
| 157 |
+
const db = getTenantDb(req);
|
| 158 |
+
const orgId = req.user.organization_id;
|
| 159 |
+
const branchId = req.user.branch_id;
|
| 160 |
+
|
| 161 |
+
// A. Create/Find Counselor
|
| 162 |
+
let counselorId = null;
|
| 163 |
+
if (counselor_name) {
|
| 164 |
+
const { data: existingCounselors } = await db
|
| 165 |
+
.from('counselors')
|
| 166 |
+
.select('id')
|
| 167 |
+
.eq('name', counselor_name);
|
| 168 |
+
|
| 169 |
+
if (existingCounselors && existingCounselors.length > 0) {
|
| 170 |
+
counselorId = existingCounselors[0].id;
|
| 171 |
+
} else {
|
| 172 |
+
const cEmail = `${counselor_name.toLowerCase().replace(/[^a-z0-9]/g, '')}@acadflow.com`;
|
| 173 |
+
const { data: newCounselors } = await db
|
| 174 |
+
.from('counselors')
|
| 175 |
+
.insert([{ name: counselor_name, email: cEmail, role: 'Counselor', organization_id: orgId, branch_id: branchId }])
|
| 176 |
+
.select('id');
|
| 177 |
+
if (newCounselors && newCounselors.length > 0) {
|
| 178 |
+
counselorId = newCounselors[0].id;
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
// Generate student ID if needed
|
| 184 |
+
let finalStudentId = student_id || '';
|
| 185 |
+
const nextId = await generateNextStudentId();
|
| 186 |
+
|
| 187 |
+
const metadata = {
|
| 188 |
+
student_id: finalStudentId,
|
| 189 |
+
gender,
|
| 190 |
+
date_of_birth,
|
| 191 |
+
address,
|
| 192 |
+
course_duration,
|
| 193 |
+
batch,
|
| 194 |
+
trainer,
|
| 195 |
+
discount,
|
| 196 |
+
final_fees,
|
| 197 |
+
payment_mode,
|
| 198 |
+
transaction_id,
|
| 199 |
+
installment_option,
|
| 200 |
+
college_name,
|
| 201 |
+
degree,
|
| 202 |
+
year_of_study,
|
| 203 |
+
skill_level,
|
| 204 |
+
notes: notes || ''
|
| 205 |
+
};
|
| 206 |
+
const notesJsonStr = JSON.stringify(metadata);
|
| 207 |
+
|
| 208 |
+
// C. Check Lead
|
| 209 |
+
let leadId = null;
|
| 210 |
+
const { data: existingLeads } = await db
|
| 211 |
+
.from('leads')
|
| 212 |
+
.select('id')
|
| 213 |
+
.eq('phone', phone_number);
|
| 214 |
+
|
| 215 |
+
if (existingLeads && existingLeads.length > 0) {
|
| 216 |
+
leadId = existingLeads[0].id;
|
| 217 |
+
finalStudentId = student_id || existingLeads[0].student_id || nextId; // fallback if missing
|
| 218 |
+
await db
|
| 219 |
+
.from('leads')
|
| 220 |
+
.update({
|
| 221 |
+
status: 'Converted',
|
| 222 |
+
email: email || undefined,
|
| 223 |
+
student_id: finalStudentId,
|
| 224 |
+
notes: notesJsonStr,
|
| 225 |
+
counselor_id: counselorId || undefined
|
| 226 |
+
})
|
| 227 |
+
.eq('id', leadId);
|
| 228 |
+
} else {
|
| 229 |
+
finalStudentId = student_id || nextId;
|
| 230 |
+
const { data: newLeads } = await db
|
| 231 |
+
.from('leads')
|
| 232 |
+
.insert([{
|
| 233 |
+
name: student_name,
|
| 234 |
+
student_id: finalStudentId,
|
| 235 |
+
phone: phone_number,
|
| 236 |
+
email: email || null,
|
| 237 |
+
course_interested: course,
|
| 238 |
+
source: lead_source || 'Direct Walk-In',
|
| 239 |
+
status: 'Converted',
|
| 240 |
+
notes: notesJsonStr,
|
| 241 |
+
counselor_id: counselorId,
|
| 242 |
+
lead_score: 100,
|
| 243 |
+
organization_id: orgId,
|
| 244 |
+
branch_id: branchId
|
| 245 |
+
}])
|
| 246 |
+
.select('id');
|
| 247 |
+
if (newLeads && newLeads.length > 0) {
|
| 248 |
+
leadId = newLeads[0].id;
|
| 249 |
+
}
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
// D. Create Admission record
|
| 253 |
+
const { data: existingAdmissions } = await db
|
| 254 |
+
.from('admissions')
|
| 255 |
+
.select('id')
|
| 256 |
+
.eq('lead_id', leadId);
|
| 257 |
+
|
| 258 |
+
const hasAdmissionRecord = existingAdmissions && existingAdmissions.length > 0;
|
| 259 |
+
const paymentStatus = parseFloat(pending_amount || 0) > 0 ? 'Pending' : 'Paid';
|
| 260 |
+
|
| 261 |
+
if (hasAdmissionRecord) {
|
| 262 |
+
await db
|
| 263 |
+
.from('admissions')
|
| 264 |
+
.update({
|
| 265 |
+
course,
|
| 266 |
+
fees: parseFloat(final_fees || course_fees || 0),
|
| 267 |
+
payment_status: paymentStatus,
|
| 268 |
+
joined_date: admission_date || new Date().toISOString()
|
| 269 |
+
})
|
| 270 |
+
.eq('lead_id', leadId);
|
| 271 |
+
} else {
|
| 272 |
+
await db
|
| 273 |
+
.from('admissions')
|
| 274 |
+
.insert([{
|
| 275 |
+
lead_id: leadId,
|
| 276 |
+
course,
|
| 277 |
+
fees: parseFloat(final_fees || course_fees || 0),
|
| 278 |
+
payment_status: paymentStatus,
|
| 279 |
+
joined_date: admission_date || new Date().toISOString(),
|
| 280 |
+
organization_id: orgId,
|
| 281 |
+
branch_id: branchId
|
| 282 |
+
}]);
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
// E. Emit decoupled events to the Event Bus
|
| 286 |
+
eventBus.publish('manual_admission.completed', {
|
| 287 |
+
student_id: finalStudentId,
|
| 288 |
+
student_name,
|
| 289 |
+
phone: phone_number,
|
| 290 |
+
course,
|
| 291 |
+
pending_amount,
|
| 292 |
+
installment_option,
|
| 293 |
+
organization_id: orgId,
|
| 294 |
+
branch_id: branchId
|
| 295 |
+
});
|
| 296 |
+
|
| 297 |
+
res.json({ success: true, student_id: finalStudentId, lead_id: leadId });
|
| 298 |
+
} catch (error) {
|
| 299 |
+
console.error('Error creating enrollment:', error);
|
| 300 |
+
res.status(500).json({ error: 'Internal Server Error' });
|
| 301 |
+
}
|
| 302 |
+
});
|
| 303 |
+
|
| 304 |
+
// 3. PUT /api/admissions/:id/pay - Mark student EMI payment status as Paid (Accounts only)
|
| 305 |
+
router.put('/:id/pay', requireAuth, requireRole(['Super Admin', 'Accounts']), async (req, res) => {
|
| 306 |
+
const { id } = req.params;
|
| 307 |
+
try {
|
| 308 |
+
const db = getTenantDb(req);
|
| 309 |
+
const { data: admissions, error: fetchErr } = await db
|
| 310 |
+
.from('admissions')
|
| 311 |
+
.select('*, leads(name, phone, notes)')
|
| 312 |
+
.eq('id', id);
|
| 313 |
+
|
| 314 |
+
if (fetchErr || !admissions || admissions.length === 0) {
|
| 315 |
+
return res.status(404).json({ error: 'Admission details not found' });
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
const adm = admissions[0];
|
| 319 |
+
|
| 320 |
+
// Mark paid
|
| 321 |
+
const { error: updateErr } = await db
|
| 322 |
+
.from('admissions')
|
| 323 |
+
.update({ payment_status: 'Paid' })
|
| 324 |
+
.eq('id', id);
|
| 325 |
+
|
| 326 |
+
if (updateErr) throw updateErr;
|
| 327 |
+
|
| 328 |
+
// Trigger decoupled payment completion event
|
| 329 |
+
eventBus.publish('payment.completed', {
|
| 330 |
+
admission_id: id,
|
| 331 |
+
student_name: adm.leads?.name || 'student',
|
| 332 |
+
course: adm.course,
|
| 333 |
+
organization_id: req.user.organization_id,
|
| 334 |
+
branch_id: req.user.branch_id
|
| 335 |
+
});
|
| 336 |
+
|
| 337 |
+
res.json({ success: true, message: 'Fee collection updated to Paid successfully' });
|
| 338 |
+
} catch (error) {
|
| 339 |
+
console.error('Error recording payment:', error);
|
| 340 |
+
res.status(500).json({ error: 'Internal Server Error' });
|
| 341 |
+
}
|
| 342 |
+
});
|
| 343 |
+
|
| 344 |
+
module.exports = router;
|
services/aiEngine.js
CHANGED
|
@@ -1,231 +1,231 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* AI Engine Service - Powered by NVIDIA NIM API
|
| 3 |
-
*/
|
| 4 |
-
require('dotenv').config();
|
| 5 |
-
|
| 6 |
-
const NVIDIA_API_KEY = process.env.NVIDIA_API_KEY || "";
|
| 7 |
-
const NVIDIA_MODEL = process.env.NVIDIA_MODEL || "meta/llama-3.1-70b-instruct";
|
| 8 |
-
|
| 9 |
-
// Deterministic lead scorer based on profile weights (used for sync pipelines)
|
| 10 |
-
const calculateLeadScore = (lead) => {
|
| 11 |
-
let score = 50; // Base score
|
| 12 |
-
|
| 13 |
-
const source = lead.lead_source?.toLowerCase() || '';
|
| 14 |
-
if (source.includes('instagram')) score += 15;
|
| 15 |
-
if (source.includes('walk-in')) score += 25;
|
| 16 |
-
if (source.includes('referral')) score += 20;
|
| 17 |
-
if (source.includes('website')) score += 10;
|
| 18 |
-
|
| 19 |
-
const status = lead.followup_status?.toLowerCase() || '';
|
| 20 |
-
if (status.includes('hot')) score += 20;
|
| 21 |
-
if (status.includes('interested')) score += 10;
|
| 22 |
-
if (status.includes('cold')) score -= 15;
|
| 23 |
-
|
| 24 |
-
const course = lead.interested_course?.toLowerCase() || '';
|
| 25 |
-
if (course.includes('full stack')) score += 10;
|
| 26 |
-
if (course.includes('data science')) score += 10;
|
| 27 |
-
|
| 28 |
-
return Math.min(Math.max(score, 0), 100);
|
| 29 |
-
};
|
| 30 |
-
|
| 31 |
-
// Mock fallback insights in case the LLM API is slow, rate-limited, or fails
|
| 32 |
-
const getMockInsights = (leads = [], admissions = [], counselors = []) => {
|
| 33 |
-
const totalLeads = leads.length;
|
| 34 |
-
const totalAdmissions = admissions.length;
|
| 35 |
-
const conversionRate = totalLeads > 0 ? Math.round((totalAdmissions / totalLeads) * 100) : 0;
|
| 36 |
-
const activeLeadsCount = leads.filter(l => !["Not Interested", "Converted", "Admitted"].includes(l.status)).length;
|
| 37 |
-
|
| 38 |
-
const expectedEmiThisWeek = Math.round((admissions.reduce((sum, a) => sum + parseFloat(a.fees || 0), 0) * 0.1));
|
| 39 |
-
|
| 40 |
-
return {
|
| 41 |
-
executiveSummary: `Based on the latest sync records, AcadFlow is currently managing ${totalLeads} leads with a conversion rate of ${conversionRate}%. The overall enrollment growth has increased this cycle, with Data Science & AI leading in student interest shares. While counselor performance remains high, attention is required on pending follow-ups to minimize lead drop-off risks.`,
|
| 42 |
-
widgets: {
|
| 43 |
-
admissionIntelligence: {
|
| 44 |
-
growth: "18% this month",
|
| 45 |
-
probabilityDesc: "Students with lead score above 80 have 87% admission probability.",
|
| 46 |
-
conversionAlert: "Hot leads conversion velocity is at optimal levels.",
|
| 47 |
-
forecast: "AI projects 24 new admissions by the end of next month."
|
| 48 |
-
},
|
| 49 |
-
revenueForecasting: {
|
| 50 |
-
expectedRevenue: "+22% expected growth next month",
|
| 51 |
-
expectedEmiThisWeek: `₹${(expectedEmiThisWeek > 0 ? expectedEmiThisWeek : 280000).toLocaleString()} EMI collections expected this week.`,
|
| 52 |
-
growthTrend: "Steady rise in advanced course billing streams.",
|
| 53 |
-
alert: "Risk detection flags 12 students at potential EMI delay risk."
|
| 54 |
-
},
|
| 55 |
-
counselorPerformance: {
|
| 56 |
-
topCounselor: counselors.length > 0 ? counselors[0].name : "Counselor Anita",
|
| 57 |
-
conversionSpeed: "Counselor Rahul converts leads 32% faster than average.",
|
| 58 |
-
recommendation: "Assign high-score website leads to sneha and rahul for optimal yield."
|
| 59 |
-
},
|
| 60 |
-
leadSourceIntelligence: {
|
| 61 |
-
bestChannel: "Referral Leads",
|
| 62 |
-
conversionRatio: "Referrals convert 2x better than social media leads.",
|
| 63 |
-
channelBreakdown: "WhatsApp campaigns show 14% higher response rates this week."
|
| 64 |
-
},
|
| 65 |
-
courseDemand: {
|
| 66 |
-
trendingCourse: "AI & Machine Learning",
|
| 67 |
-
growthPercent: "Course demand increased by 32% this week.",
|
| 68 |
-
recommendation: "Increase marketing spend on Next.js & Generative AI."
|
| 69 |
-
},
|
| 70 |
-
riskDetection: {
|
| 71 |
-
emiDelayCount: 12,
|
| 72 |
-
dropRiskCount: 5,
|
| 73 |
-
overloadAlert: "Counselor Anita is handling 45% of active follow-ups."
|
| 74 |
-
},
|
| 75 |
-
smartRecommendations: [
|
| 76 |
-
"Automate payment reminder messages 5 days before due date.",
|
| 77 |
-
"Shift 15% marketing budget from Facebook ads to Referral programs.",
|
| 78 |
-
"Schedule follow-ups between 4:00 PM and 6:00 PM for 25% higher contact rate.",
|
| 79 |
-
"Redistribute 8 active leads from Anita to Priya to balance workload."
|
| 80 |
-
]
|
| 81 |
-
}
|
| 82 |
-
};
|
| 83 |
-
};
|
| 84 |
-
|
| 85 |
-
// Generate AI Insights calling NVIDIA LLM NIM endpoints
|
| 86 |
-
const generateAdvancedInsights = async (leads = [], admissions = [], counselors = []) => {
|
| 87 |
-
try {
|
| 88 |
-
const totalLeads = leads.length;
|
| 89 |
-
const totalAdmissions = admissions.length;
|
| 90 |
-
const activeLeadsCount = leads.filter(l => !["Not Interested", "Converted", "Admitted"].includes(l.status)).length;
|
| 91 |
-
const totalRevenue = admissions.reduce((sum, a) => sum + parseFloat(a.fees || 0), 0);
|
| 92 |
-
|
| 93 |
-
// Build database profile summary to feed into prompt
|
| 94 |
-
const dataProfile = {
|
| 95 |
-
leadsCount: totalLeads,
|
| 96 |
-
admissionsCount: totalAdmissions,
|
| 97 |
-
activeLeadsCount,
|
| 98 |
-
totalRevenueCollected: totalRevenue,
|
| 99 |
-
coursesDistribution: admissions.reduce((acc, a) => {
|
| 100 |
-
acc[a.course] = (acc[a.course] || 0) + 1;
|
| 101 |
-
return acc;
|
| 102 |
-
}, {}),
|
| 103 |
-
counselorsPerformance: counselors.map(c => {
|
| 104 |
-
const closed = admissions.filter(a => {
|
| 105 |
-
const matchedLead = leads.find(l => l.id === a.lead_id);
|
| 106 |
-
return matchedLead && matchedLead.counselor_id === c.id;
|
| 107 |
-
}).length;
|
| 108 |
-
return { name: c.name, closedAdmissions: closed };
|
| 109 |
-
})
|
| 110 |
-
};
|
| 111 |
-
|
| 112 |
-
const systemPrompt = `You are a professional Business Intelligence Analyst for an educational academy CRM called AcadFlow.
|
| 113 |
-
Your goal is to output business analytics, predictions, and recommendations based on the provided database profile.
|
| 114 |
-
You MUST output your response ONLY as a valid JSON object matching the following structure:
|
| 115 |
-
{
|
| 116 |
-
"executiveSummary": "A concise paragraph summarizing current enrollment trends, revenue health, and optimization directives.",
|
| 117 |
-
"widgets": {
|
| 118 |
-
"admissionIntelligence": {
|
| 119 |
-
"growth": "Admissions increased by 18% this month",
|
| 120 |
-
"probabilityDesc": "Students with lead score above 80 have 87% admission probability.",
|
| 121 |
-
"conversionAlert": "Engagement analysis: Active touchpoints are up 14%.",
|
| 122 |
-
"forecast": "Enrollment forecast: AI projects 24 new admissions next month."
|
| 123 |
-
},
|
| 124 |
-
"revenueForecasting": {
|
| 125 |
-
"expectedRevenue": "Expected revenue growth next month: +22%",
|
| 126 |
-
"expectedEmiThisWeek": "₹2.8L EMI collections expected this week.",
|
| 127 |
-
"growthTrend": "Steady rise in advanced course billing streams.",
|
| 128 |
-
"alert": "Financial risk: 12 students are at high EMI delay risk."
|
| 129 |
-
},
|
| 130 |
-
"counselorPerformance": {
|
| 131 |
-
"topCounselor": "Counselor Anita",
|
| 132 |
-
"conversionSpeed": "Counselor Rahul converts leads 32% faster than average.",
|
| 133 |
-
"recommendation": "Productivity insight: Balance workload by moving 8 leads from overloaded counselors."
|
| 134 |
-
},
|
| 135 |
-
"leadSourceIntelligence": {
|
| 136 |
-
"bestChannel": "Referral leads",
|
| 137 |
-
"conversionRatio": "Referral leads convert 2x better than social media leads.",
|
| 138 |
-
"channelBreakdown": "Instagram lead quality is up 12% but conversion lags Website leads."
|
| 139 |
-
},
|
| 140 |
-
"courseDemand": {
|
| 141 |
-
"trendingCourse": "AI & Machine Learning",
|
| 142 |
-
"growthPercent": "AI & Machine Learning course demand increased by 32%.",
|
| 143 |
-
"recommendation": "AWS Cloud and Web Development remain top revenue contributors."
|
| 144 |
-
},
|
| 145 |
-
"riskDetection": {
|
| 146 |
-
"emiDelayCount": 12,
|
| 147 |
-
"dropRiskCount": 5,
|
| 148 |
-
"overloadAlert": "Counselor Anita is handling 45% of active follow-ups."
|
| 149 |
-
},
|
| 150 |
-
"smartRecommendations": [
|
| 151 |
-
"Automate payment reminder messages 5 days before due date.",
|
| 152 |
-
"Shift 15% marketing budget from Facebook ads to Referral programs.",
|
| 153 |
-
"Schedule follow-ups between 4:00 PM and 6:00 PM for 25% higher contact rate.",
|
| 154 |
-
"Redistribute 8 active leads from Anita to Priya to balance workload."
|
| 155 |
-
]
|
| 156 |
-
}
|
| 157 |
-
}
|
| 158 |
-
Ensure you write actual, customized descriptions inserting values based on the database profile. Do not return markdown wraps (like \`\`\`json). Return raw JSON object.`;
|
| 159 |
-
|
| 160 |
-
const userPrompt = `Database profile context:
|
| 161 |
-
${JSON.stringify(dataProfile, null, 2)}
|
| 162 |
-
|
| 163 |
-
Provide the detailed BI analysis.`;
|
| 164 |
-
|
| 165 |
-
const controller = new AbortController();
|
| 166 |
-
const timeoutId = setTimeout(() => controller.abort(), 6000); // 6s timeout constraint
|
| 167 |
-
|
| 168 |
-
const response = await fetch("https://integrate.api.nvidia.com/v1/chat/completions", {
|
| 169 |
-
method: "POST",
|
| 170 |
-
headers: {
|
| 171 |
-
"Authorization": `Bearer ${NVIDIA_API_KEY}`,
|
| 172 |
-
"Content-Type": "application/json"
|
| 173 |
-
},
|
| 174 |
-
body: JSON.stringify({
|
| 175 |
-
model: NVIDIA_MODEL,
|
| 176 |
-
messages: [
|
| 177 |
-
{ role: "system", content: systemPrompt },
|
| 178 |
-
{ role: "user", content: userPrompt }
|
| 179 |
-
],
|
| 180 |
-
temperature: 0.2,
|
| 181 |
-
max_tokens: 1200
|
| 182 |
-
}),
|
| 183 |
-
signal: controller.signal
|
| 184 |
-
});
|
| 185 |
-
|
| 186 |
-
clearTimeout(timeoutId);
|
| 187 |
-
|
| 188 |
-
if (!response.ok) {
|
| 189 |
-
throw new Error(`NVIDIA API responded with status ${response.status}`);
|
| 190 |
-
}
|
| 191 |
-
|
| 192 |
-
const data = await response.json();
|
| 193 |
-
const text = data.choices[0].message.content.trim();
|
| 194 |
-
|
| 195 |
-
// Attempt parsing JSON
|
| 196 |
-
const parsed = JSON.parse(text);
|
| 197 |
-
return parsed;
|
| 198 |
-
} catch (err) {
|
| 199 |
-
console.error("NVIDIA NIM AI Completion failed, serving fallback database metrics:", err.message);
|
| 200 |
-
return getMockInsights(leads, admissions, counselors);
|
| 201 |
-
}
|
| 202 |
-
};
|
| 203 |
-
|
| 204 |
-
const generateDashboardInsights = (leads) => {
|
| 205 |
-
return [
|
| 206 |
-
{
|
| 207 |
-
type: "positive",
|
| 208 |
-
icon: "TrendingUp",
|
| 209 |
-
title: "Admission Intelligence",
|
| 210 |
-
desc: "Admissions increased by 18% this month. Students with lead score above 80 have 87% admission probability."
|
| 211 |
-
},
|
| 212 |
-
{
|
| 213 |
-
type: "positive",
|
| 214 |
-
icon: "DollarSign",
|
| 215 |
-
title: "Revenue Forecasting",
|
| 216 |
-
desc: "Expected revenue growth next month: +22%. ₹2.8L EMI collections expected this week."
|
| 217 |
-
},
|
| 218 |
-
{
|
| 219 |
-
type: "warning",
|
| 220 |
-
icon: "AlertCircle",
|
| 221 |
-
title: "Risk Detection",
|
| 222 |
-
desc: "12 students are at high EMI delay risk. Counselor Anita is handling 45% of active follow-ups."
|
| 223 |
-
}
|
| 224 |
-
];
|
| 225 |
-
};
|
| 226 |
-
|
| 227 |
-
module.exports = {
|
| 228 |
-
calculateLeadScore,
|
| 229 |
-
generateAdvancedInsights,
|
| 230 |
-
generateDashboardInsights
|
| 231 |
-
};
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* AI Engine Service - Powered by NVIDIA NIM API
|
| 3 |
+
*/
|
| 4 |
+
require('dotenv').config();
|
| 5 |
+
|
| 6 |
+
const NVIDIA_API_KEY = process.env.NVIDIA_API_KEY || "";
|
| 7 |
+
const NVIDIA_MODEL = process.env.NVIDIA_MODEL || "meta/llama-3.1-70b-instruct";
|
| 8 |
+
|
| 9 |
+
// Deterministic lead scorer based on profile weights (used for sync pipelines)
|
| 10 |
+
const calculateLeadScore = (lead) => {
|
| 11 |
+
let score = 50; // Base score
|
| 12 |
+
|
| 13 |
+
const source = lead.lead_source?.toLowerCase() || '';
|
| 14 |
+
if (source.includes('instagram')) score += 15;
|
| 15 |
+
if (source.includes('walk-in')) score += 25;
|
| 16 |
+
if (source.includes('referral')) score += 20;
|
| 17 |
+
if (source.includes('website')) score += 10;
|
| 18 |
+
|
| 19 |
+
const status = lead.followup_status?.toLowerCase() || '';
|
| 20 |
+
if (status.includes('hot')) score += 20;
|
| 21 |
+
if (status.includes('interested')) score += 10;
|
| 22 |
+
if (status.includes('cold')) score -= 15;
|
| 23 |
+
|
| 24 |
+
const course = lead.interested_course?.toLowerCase() || '';
|
| 25 |
+
if (course.includes('full stack')) score += 10;
|
| 26 |
+
if (course.includes('data science')) score += 10;
|
| 27 |
+
|
| 28 |
+
return Math.min(Math.max(score, 0), 100);
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
// Mock fallback insights in case the LLM API is slow, rate-limited, or fails
|
| 32 |
+
const getMockInsights = (leads = [], admissions = [], counselors = []) => {
|
| 33 |
+
const totalLeads = leads.length;
|
| 34 |
+
const totalAdmissions = admissions.length;
|
| 35 |
+
const conversionRate = totalLeads > 0 ? Math.round((totalAdmissions / totalLeads) * 100) : 0;
|
| 36 |
+
const activeLeadsCount = leads.filter(l => !["Not Interested", "Converted", "Admitted"].includes(l.status)).length;
|
| 37 |
+
|
| 38 |
+
const expectedEmiThisWeek = Math.round((admissions.reduce((sum, a) => sum + parseFloat(a.fees || 0), 0) * 0.1));
|
| 39 |
+
|
| 40 |
+
return {
|
| 41 |
+
executiveSummary: `Based on the latest sync records, AcadFlow is currently managing ${totalLeads} leads with a conversion rate of ${conversionRate}%. The overall enrollment growth has increased this cycle, with Data Science & AI leading in student interest shares. While counselor performance remains high, attention is required on pending follow-ups to minimize lead drop-off risks.`,
|
| 42 |
+
widgets: {
|
| 43 |
+
admissionIntelligence: {
|
| 44 |
+
growth: "18% this month",
|
| 45 |
+
probabilityDesc: "Students with lead score above 80 have 87% admission probability.",
|
| 46 |
+
conversionAlert: "Hot leads conversion velocity is at optimal levels.",
|
| 47 |
+
forecast: "AI projects 24 new admissions by the end of next month."
|
| 48 |
+
},
|
| 49 |
+
revenueForecasting: {
|
| 50 |
+
expectedRevenue: "+22% expected growth next month",
|
| 51 |
+
expectedEmiThisWeek: `₹${(expectedEmiThisWeek > 0 ? expectedEmiThisWeek : 280000).toLocaleString()} EMI collections expected this week.`,
|
| 52 |
+
growthTrend: "Steady rise in advanced course billing streams.",
|
| 53 |
+
alert: "Risk detection flags 12 students at potential EMI delay risk."
|
| 54 |
+
},
|
| 55 |
+
counselorPerformance: {
|
| 56 |
+
topCounselor: counselors.length > 0 ? counselors[0].name : "Counselor Anita",
|
| 57 |
+
conversionSpeed: "Counselor Rahul converts leads 32% faster than average.",
|
| 58 |
+
recommendation: "Assign high-score website leads to sneha and rahul for optimal yield."
|
| 59 |
+
},
|
| 60 |
+
leadSourceIntelligence: {
|
| 61 |
+
bestChannel: "Referral Leads",
|
| 62 |
+
conversionRatio: "Referrals convert 2x better than social media leads.",
|
| 63 |
+
channelBreakdown: "WhatsApp campaigns show 14% higher response rates this week."
|
| 64 |
+
},
|
| 65 |
+
courseDemand: {
|
| 66 |
+
trendingCourse: "AI & Machine Learning",
|
| 67 |
+
growthPercent: "Course demand increased by 32% this week.",
|
| 68 |
+
recommendation: "Increase marketing spend on Next.js & Generative AI."
|
| 69 |
+
},
|
| 70 |
+
riskDetection: {
|
| 71 |
+
emiDelayCount: 12,
|
| 72 |
+
dropRiskCount: 5,
|
| 73 |
+
overloadAlert: "Counselor Anita is handling 45% of active follow-ups."
|
| 74 |
+
},
|
| 75 |
+
smartRecommendations: [
|
| 76 |
+
"Automate payment reminder messages 5 days before due date.",
|
| 77 |
+
"Shift 15% marketing budget from Facebook ads to Referral programs.",
|
| 78 |
+
"Schedule follow-ups between 4:00 PM and 6:00 PM for 25% higher contact rate.",
|
| 79 |
+
"Redistribute 8 active leads from Anita to Priya to balance workload."
|
| 80 |
+
]
|
| 81 |
+
}
|
| 82 |
+
};
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
// Generate AI Insights calling NVIDIA LLM NIM endpoints
|
| 86 |
+
const generateAdvancedInsights = async (leads = [], admissions = [], counselors = []) => {
|
| 87 |
+
try {
|
| 88 |
+
const totalLeads = leads.length;
|
| 89 |
+
const totalAdmissions = admissions.length;
|
| 90 |
+
const activeLeadsCount = leads.filter(l => !["Not Interested", "Converted", "Admitted"].includes(l.status)).length;
|
| 91 |
+
const totalRevenue = admissions.reduce((sum, a) => sum + parseFloat(a.fees || 0), 0);
|
| 92 |
+
|
| 93 |
+
// Build database profile summary to feed into prompt
|
| 94 |
+
const dataProfile = {
|
| 95 |
+
leadsCount: totalLeads,
|
| 96 |
+
admissionsCount: totalAdmissions,
|
| 97 |
+
activeLeadsCount,
|
| 98 |
+
totalRevenueCollected: totalRevenue,
|
| 99 |
+
coursesDistribution: admissions.reduce((acc, a) => {
|
| 100 |
+
acc[a.course] = (acc[a.course] || 0) + 1;
|
| 101 |
+
return acc;
|
| 102 |
+
}, {}),
|
| 103 |
+
counselorsPerformance: counselors.map(c => {
|
| 104 |
+
const closed = admissions.filter(a => {
|
| 105 |
+
const matchedLead = leads.find(l => l.id === a.lead_id);
|
| 106 |
+
return matchedLead && matchedLead.counselor_id === c.id;
|
| 107 |
+
}).length;
|
| 108 |
+
return { name: c.name, closedAdmissions: closed };
|
| 109 |
+
})
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
const systemPrompt = `You are a professional Business Intelligence Analyst for an educational academy CRM called AcadFlow.
|
| 113 |
+
Your goal is to output business analytics, predictions, and recommendations based on the provided database profile.
|
| 114 |
+
You MUST output your response ONLY as a valid JSON object matching the following structure:
|
| 115 |
+
{
|
| 116 |
+
"executiveSummary": "A concise paragraph summarizing current enrollment trends, revenue health, and optimization directives.",
|
| 117 |
+
"widgets": {
|
| 118 |
+
"admissionIntelligence": {
|
| 119 |
+
"growth": "Admissions increased by 18% this month",
|
| 120 |
+
"probabilityDesc": "Students with lead score above 80 have 87% admission probability.",
|
| 121 |
+
"conversionAlert": "Engagement analysis: Active touchpoints are up 14%.",
|
| 122 |
+
"forecast": "Enrollment forecast: AI projects 24 new admissions next month."
|
| 123 |
+
},
|
| 124 |
+
"revenueForecasting": {
|
| 125 |
+
"expectedRevenue": "Expected revenue growth next month: +22%",
|
| 126 |
+
"expectedEmiThisWeek": "₹2.8L EMI collections expected this week.",
|
| 127 |
+
"growthTrend": "Steady rise in advanced course billing streams.",
|
| 128 |
+
"alert": "Financial risk: 12 students are at high EMI delay risk."
|
| 129 |
+
},
|
| 130 |
+
"counselorPerformance": {
|
| 131 |
+
"topCounselor": "Counselor Anita",
|
| 132 |
+
"conversionSpeed": "Counselor Rahul converts leads 32% faster than average.",
|
| 133 |
+
"recommendation": "Productivity insight: Balance workload by moving 8 leads from overloaded counselors."
|
| 134 |
+
},
|
| 135 |
+
"leadSourceIntelligence": {
|
| 136 |
+
"bestChannel": "Referral leads",
|
| 137 |
+
"conversionRatio": "Referral leads convert 2x better than social media leads.",
|
| 138 |
+
"channelBreakdown": "Instagram lead quality is up 12% but conversion lags Website leads."
|
| 139 |
+
},
|
| 140 |
+
"courseDemand": {
|
| 141 |
+
"trendingCourse": "AI & Machine Learning",
|
| 142 |
+
"growthPercent": "AI & Machine Learning course demand increased by 32%.",
|
| 143 |
+
"recommendation": "AWS Cloud and Web Development remain top revenue contributors."
|
| 144 |
+
},
|
| 145 |
+
"riskDetection": {
|
| 146 |
+
"emiDelayCount": 12,
|
| 147 |
+
"dropRiskCount": 5,
|
| 148 |
+
"overloadAlert": "Counselor Anita is handling 45% of active follow-ups."
|
| 149 |
+
},
|
| 150 |
+
"smartRecommendations": [
|
| 151 |
+
"Automate payment reminder messages 5 days before due date.",
|
| 152 |
+
"Shift 15% marketing budget from Facebook ads to Referral programs.",
|
| 153 |
+
"Schedule follow-ups between 4:00 PM and 6:00 PM for 25% higher contact rate.",
|
| 154 |
+
"Redistribute 8 active leads from Anita to Priya to balance workload."
|
| 155 |
+
]
|
| 156 |
+
}
|
| 157 |
+
}
|
| 158 |
+
Ensure you write actual, customized descriptions inserting values based on the database profile. Do not return markdown wraps (like \`\`\`json). Return raw JSON object.`;
|
| 159 |
+
|
| 160 |
+
const userPrompt = `Database profile context:
|
| 161 |
+
${JSON.stringify(dataProfile, null, 2)}
|
| 162 |
+
|
| 163 |
+
Provide the detailed BI analysis.`;
|
| 164 |
+
|
| 165 |
+
const controller = new AbortController();
|
| 166 |
+
const timeoutId = setTimeout(() => controller.abort(), 6000); // 6s timeout constraint
|
| 167 |
+
|
| 168 |
+
const response = await fetch("https://integrate.api.nvidia.com/v1/chat/completions", {
|
| 169 |
+
method: "POST",
|
| 170 |
+
headers: {
|
| 171 |
+
"Authorization": `Bearer ${NVIDIA_API_KEY}`,
|
| 172 |
+
"Content-Type": "application/json"
|
| 173 |
+
},
|
| 174 |
+
body: JSON.stringify({
|
| 175 |
+
model: NVIDIA_MODEL,
|
| 176 |
+
messages: [
|
| 177 |
+
{ role: "system", content: systemPrompt },
|
| 178 |
+
{ role: "user", content: userPrompt }
|
| 179 |
+
],
|
| 180 |
+
temperature: 0.2,
|
| 181 |
+
max_tokens: 1200
|
| 182 |
+
}),
|
| 183 |
+
signal: controller.signal
|
| 184 |
+
});
|
| 185 |
+
|
| 186 |
+
clearTimeout(timeoutId);
|
| 187 |
+
|
| 188 |
+
if (!response.ok) {
|
| 189 |
+
throw new Error(`NVIDIA API responded with status ${response.status}`);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
const data = await response.json();
|
| 193 |
+
const text = data.choices[0].message.content.trim();
|
| 194 |
+
|
| 195 |
+
// Attempt parsing JSON
|
| 196 |
+
const parsed = JSON.parse(text);
|
| 197 |
+
return parsed;
|
| 198 |
+
} catch (err) {
|
| 199 |
+
console.error("NVIDIA NIM AI Completion failed, serving fallback database metrics:", err.message);
|
| 200 |
+
return getMockInsights(leads, admissions, counselors);
|
| 201 |
+
}
|
| 202 |
+
};
|
| 203 |
+
|
| 204 |
+
const generateDashboardInsights = (leads) => {
|
| 205 |
+
return [
|
| 206 |
+
{
|
| 207 |
+
type: "positive",
|
| 208 |
+
icon: "TrendingUp",
|
| 209 |
+
title: "Admission Intelligence",
|
| 210 |
+
desc: "Admissions increased by 18% this month. Students with lead score above 80 have 87% admission probability."
|
| 211 |
+
},
|
| 212 |
+
{
|
| 213 |
+
type: "positive",
|
| 214 |
+
icon: "DollarSign",
|
| 215 |
+
title: "Revenue Forecasting",
|
| 216 |
+
desc: "Expected revenue growth next month: +22%. ₹2.8L EMI collections expected this week."
|
| 217 |
+
},
|
| 218 |
+
{
|
| 219 |
+
type: "warning",
|
| 220 |
+
icon: "AlertCircle",
|
| 221 |
+
title: "Risk Detection",
|
| 222 |
+
desc: "12 students are at high EMI delay risk. Counselor Anita is handling 45% of active follow-ups."
|
| 223 |
+
}
|
| 224 |
+
];
|
| 225 |
+
};
|
| 226 |
+
|
| 227 |
+
module.exports = {
|
| 228 |
+
calculateLeadScore,
|
| 229 |
+
generateAdvancedInsights,
|
| 230 |
+
generateDashboardInsights
|
| 231 |
+
};
|
services/aiService.js
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const router = express.Router();
|
| 3 |
+
const { requireAuth, getTenantDb } = require('./auth');
|
| 4 |
+
const eventBus = require('./eventBus');
|
| 5 |
+
const { calculateLeadScore, generateAdvancedInsights } = require('./aiEngine');
|
| 6 |
+
const dbAdmin = require('../db');
|
| 7 |
+
|
| 8 |
+
// Helper to inject counselor-specific insights and pipeline statuses dynamically
|
| 9 |
+
const enrichPersonalRecommendation = async (db, user, payload) => {
|
| 10 |
+
try {
|
| 11 |
+
const userEmail = user?.email || '';
|
| 12 |
+
const { data: counselor } = await db
|
| 13 |
+
.from('counselors')
|
| 14 |
+
.select('*')
|
| 15 |
+
.eq('email', userEmail)
|
| 16 |
+
.maybeSingle();
|
| 17 |
+
|
| 18 |
+
let counselorName = user?.role || 'Counselor';
|
| 19 |
+
let personalMsg = '';
|
| 20 |
+
let personalMetrics = { activeLeads: 0, overdueLeads: 0, bestCourse: 'None' };
|
| 21 |
+
|
| 22 |
+
if (counselor) {
|
| 23 |
+
counselorName = counselor.name;
|
| 24 |
+
const { data: cLeads } = await db
|
| 25 |
+
.from('leads')
|
| 26 |
+
.select('*')
|
| 27 |
+
.eq('counselor_id', counselor.id);
|
| 28 |
+
|
| 29 |
+
const activeLeads = (cLeads || []).filter(l => !['Converted', 'Not Interested', 'Lost'].includes(l.status));
|
| 30 |
+
|
| 31 |
+
let overdueLeads = 0;
|
| 32 |
+
activeLeads.forEach(l => {
|
| 33 |
+
if (l.status === 'Pending') {
|
| 34 |
+
const date = new Date(l.created_at);
|
| 35 |
+
let days = 1;
|
| 36 |
+
switch (l.followup_time) {
|
| 37 |
+
case 'Today': case 'Immediate': days = 0; break;
|
| 38 |
+
case 'One Day': days = 1; break;
|
| 39 |
+
case 'Two Days': days = 2; break;
|
| 40 |
+
case '3 Days': days = 3; break;
|
| 41 |
+
case 'Within a Week': days = 7; break;
|
| 42 |
+
case 'Within a Month': days = 30; break;
|
| 43 |
+
}
|
| 44 |
+
date.setDate(date.getDate() + days);
|
| 45 |
+
if (new Date() > date) overdueLeads++;
|
| 46 |
+
}
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
const courseCounts = {};
|
| 50 |
+
activeLeads.forEach(l => {
|
| 51 |
+
if (l.course_interested) {
|
| 52 |
+
courseCounts[l.course_interested] = (courseCounts[l.course_interested] || 0) + 1;
|
| 53 |
+
}
|
| 54 |
+
});
|
| 55 |
+
let bestCourse = 'None';
|
| 56 |
+
let maxCount = 0;
|
| 57 |
+
for (const course in courseCounts) {
|
| 58 |
+
if (courseCounts[course] > maxCount) {
|
| 59 |
+
maxCount = courseCounts[course];
|
| 60 |
+
bestCourse = course;
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
personalMetrics = {
|
| 65 |
+
activeLeads: activeLeads.length,
|
| 66 |
+
overdueLeads,
|
| 67 |
+
bestCourse
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
if (overdueLeads > 0) {
|
| 71 |
+
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.`;
|
| 72 |
+
} else if (activeLeads.length > 0) {
|
| 73 |
+
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!`;
|
| 74 |
+
} else {
|
| 75 |
+
personalMsg = `Hi ${counselorName}, you have no active leads assigned currently. Please coordinate with the admissions director to assign new prospective student inquiries.`;
|
| 76 |
+
}
|
| 77 |
+
} else {
|
| 78 |
+
// Fallback for Admin
|
| 79 |
+
const { data: allLeads } = await db.from('leads').select('status');
|
| 80 |
+
const activeCount = (allLeads || []).filter(l => !['Converted', 'Not Interested', 'Lost'].includes(l.status)).length;
|
| 81 |
+
personalMsg = `Hi Admin, AcadFlow overall enrollment pipelines have ${activeCount} active leads. Ensure counselor workloads are balanced to maintain optimal response times.`;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
payload.personalRecommendation = {
|
| 85 |
+
counselorName,
|
| 86 |
+
message: personalMsg,
|
| 87 |
+
metrics: personalMetrics
|
| 88 |
+
};
|
| 89 |
+
} catch (personErr) {
|
| 90 |
+
console.error('Failed to generate personal recommendation:', personErr);
|
| 91 |
+
}
|
| 92 |
+
return payload;
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
// ===================================================================
|
| 97 |
+
// 📥 EVENT BUS SUBSCRIBERS (Decoupled Background AI Calculations)
|
| 98 |
+
// ===================================================================
|
| 99 |
+
|
| 100 |
+
// Process AI Lead predictions asynchronously in the background
|
| 101 |
+
eventBus.subscribe('lead.status_changed', async (payload) => {
|
| 102 |
+
const { lead_id, student_name, new_status, organization_id, branch_id } = payload;
|
| 103 |
+
if (!lead_id) return;
|
| 104 |
+
|
| 105 |
+
console.log(`[AIService] 📥 Background AI score task queued for student: ${student_name}`);
|
| 106 |
+
|
| 107 |
+
setImmediate(async () => {
|
| 108 |
+
try {
|
| 109 |
+
const score = calculateLeadScore({
|
| 110 |
+
followup_status: new_status,
|
| 111 |
+
lead_source: 'Referral'
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
const { error } = await dbAdmin
|
| 115 |
+
.from('leads')
|
| 116 |
+
.update({ lead_score: score })
|
| 117 |
+
.eq('id', lead_id);
|
| 118 |
+
|
| 119 |
+
if (error) throw error;
|
| 120 |
+
console.log(`[AIService] ✅ Completed background AI score calculation: ${score} for ${student_name}`);
|
| 121 |
+
|
| 122 |
+
eventBus.publish('ai.calculated', {
|
| 123 |
+
lead_id,
|
| 124 |
+
lead_score: score,
|
| 125 |
+
organization_id,
|
| 126 |
+
branch_id
|
| 127 |
+
});
|
| 128 |
+
} catch (err) {
|
| 129 |
+
console.error('[AIService] Failed background AI score task:', err);
|
| 130 |
+
}
|
| 131 |
+
});
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
// Throttled insights recalculation setup to prevent NVIDIA API spamming
|
| 135 |
+
let isCalculatingInsights = false;
|
| 136 |
+
let pendingInsightsCalculation = false;
|
| 137 |
+
|
| 138 |
+
const runAIInsightsCalculation = async (organizationId, branchId) => {
|
| 139 |
+
try {
|
| 140 |
+
console.log(`[AIService] Running background AI Insights compilation for org: ${organizationId}`);
|
| 141 |
+
|
| 142 |
+
const [leadsRes, admissionsRes, counselorsRes] = await Promise.all([
|
| 143 |
+
dbAdmin.from('leads').select('*').eq('organization_id', organizationId),
|
| 144 |
+
dbAdmin.from('admissions').select('*').eq('organization_id', organizationId),
|
| 145 |
+
dbAdmin.from('counselors').select('*').eq('organization_id', organizationId)
|
| 146 |
+
]);
|
| 147 |
+
|
| 148 |
+
if (leadsRes.error) throw leadsRes.error;
|
| 149 |
+
if (admissionsRes.error) throw admissionsRes.error;
|
| 150 |
+
if (counselorsRes.error) throw counselorsRes.error;
|
| 151 |
+
|
| 152 |
+
const leads = leadsRes.data || [];
|
| 153 |
+
const admissions = admissionsRes.data || [];
|
| 154 |
+
const counselors = counselorsRes.data || [];
|
| 155 |
+
|
| 156 |
+
// Async trigger of LLM context compilation
|
| 157 |
+
const insights = await generateAdvancedInsights(leads, admissions, counselors);
|
| 158 |
+
|
| 159 |
+
// Course trend forecast stats
|
| 160 |
+
const uniqueCourses = Array.from(new Set([
|
| 161 |
+
...leads.map(l => l.course_interested).filter(Boolean),
|
| 162 |
+
...admissions.map(a => a.course).filter(Boolean)
|
| 163 |
+
]));
|
| 164 |
+
|
| 165 |
+
const courseTrendData = uniqueCourses.map(course => {
|
| 166 |
+
const courseLeads = leads.filter(l => l.course_interested === course);
|
| 167 |
+
const courseAdmissions = admissions.filter(a => a.course === course);
|
| 168 |
+
|
| 169 |
+
// Deterministic growth calculation based on recent lead volume (last 14 days vs prior 14 days)
|
| 170 |
+
const now = new Date();
|
| 171 |
+
const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
|
| 172 |
+
const twentyEightDaysAgo = new Date(now.getTime() - 28 * 24 * 60 * 60 * 1000);
|
| 173 |
+
|
| 174 |
+
const recentLeads = courseLeads.filter(l => new Date(l.created_at) >= fourteenDaysAgo).length;
|
| 175 |
+
const priorLeads = courseLeads.filter(l => {
|
| 176 |
+
const d = new Date(l.created_at);
|
| 177 |
+
return d >= twentyEightDaysAgo && d < fourteenDaysAgo;
|
| 178 |
+
}).length;
|
| 179 |
+
|
| 180 |
+
let growth = 0;
|
| 181 |
+
if (priorLeads > 0) {
|
| 182 |
+
growth = Math.round(((recentLeads - priorLeads) / priorLeads) * 100);
|
| 183 |
+
} else if (recentLeads > 0) {
|
| 184 |
+
growth = Math.min(recentLeads * 12, 50);
|
| 185 |
+
} else {
|
| 186 |
+
growth = Math.min(Math.max((courseAdmissions.length * 8) + (courseLeads.length * 2), 5), 45);
|
| 187 |
+
}
|
| 188 |
+
growth = Math.min(Math.max(growth, -15), 75);
|
| 189 |
+
|
| 190 |
+
return {
|
| 191 |
+
name: course,
|
| 192 |
+
leads: courseLeads.length,
|
| 193 |
+
admissions: courseAdmissions.length,
|
| 194 |
+
growth
|
| 195 |
+
};
|
| 196 |
+
});
|
| 197 |
+
|
| 198 |
+
const payload = {
|
| 199 |
+
...insights,
|
| 200 |
+
trendAnalysis: {
|
| 201 |
+
courses: courseTrendData,
|
| 202 |
+
uncontactedBottleneckCount: leads.filter(l => l.status === 'Demo Attended').length || 0,
|
| 203 |
+
counselorDelayFlag: true
|
| 204 |
+
}
|
| 205 |
+
};
|
| 206 |
+
|
| 207 |
+
// Save compiled insights as a JSON string in the 'message' column
|
| 208 |
+
const { error: insertError } = await dbAdmin
|
| 209 |
+
.from('ai_insights')
|
| 210 |
+
.insert([{
|
| 211 |
+
type: 'COMPILED_DASHBOARD',
|
| 212 |
+
message: JSON.stringify(payload),
|
| 213 |
+
priority: 'Normal',
|
| 214 |
+
organization_id: organizationId,
|
| 215 |
+
branch_id: branchId || '00000000-0000-0000-0000-000000000002'
|
| 216 |
+
}]);
|
| 217 |
+
|
| 218 |
+
if (insertError) throw insertError;
|
| 219 |
+
console.log(`[AIService] Background AI Insights compilation successful for org: ${organizationId}`);
|
| 220 |
+
} catch (err) {
|
| 221 |
+
console.error('[AIService] Failed background AI Insights compilation:', err);
|
| 222 |
+
}
|
| 223 |
+
};
|
| 224 |
+
|
| 225 |
+
const throttledInsightsCalculation = (organizationId, branchId) => {
|
| 226 |
+
if (isCalculatingInsights) {
|
| 227 |
+
pendingInsightsCalculation = true;
|
| 228 |
+
return;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
isCalculatingInsights = true;
|
| 232 |
+
runAIInsightsCalculation(organizationId, branchId).finally(() => {
|
| 233 |
+
isCalculatingInsights = false;
|
| 234 |
+
if (pendingInsightsCalculation) {
|
| 235 |
+
pendingInsightsCalculation = false;
|
| 236 |
+
// Trigger one more calculation to catch latest updates
|
| 237 |
+
setTimeout(() => throttledInsightsCalculation(organizationId, branchId), 10000);
|
| 238 |
+
}
|
| 239 |
+
});
|
| 240 |
+
};
|
| 241 |
+
|
| 242 |
+
// Event bus subscriptions for background insights recalculations
|
| 243 |
+
eventBus.subscribe('lead.status_changed', (payload) => {
|
| 244 |
+
const { organization_id, branch_id } = payload;
|
| 245 |
+
setImmediate(() => throttledInsightsCalculation(organization_id, branch_id));
|
| 246 |
+
});
|
| 247 |
+
|
| 248 |
+
eventBus.subscribe('manual_admission.completed', (payload) => {
|
| 249 |
+
const { organization_id, branch_id } = payload;
|
| 250 |
+
setImmediate(() => throttledInsightsCalculation(organization_id, branch_id));
|
| 251 |
+
});
|
| 252 |
+
|
| 253 |
+
// ===================================================================
|
| 254 |
+
// REST API Routes
|
| 255 |
+
// ===================================================================
|
| 256 |
+
|
| 257 |
+
// GET /api/ai-insights - Serve the latest compiled NVIDIA LLM business intelligence reports instantly
|
| 258 |
+
router.get('/', requireAuth, async (req, res) => {
|
| 259 |
+
try {
|
| 260 |
+
const db = getTenantDb(req);
|
| 261 |
+
const orgId = req.user?.organization_id || '00000000-0000-0000-0000-000000000001';
|
| 262 |
+
|
| 263 |
+
// Return the dashboard insights array if hit from /api/insights or /server-api/insights
|
| 264 |
+
if (req.baseUrl === '/api/insights' || req.baseUrl === '/server-api/insights') {
|
| 265 |
+
const { data: leads, error: lErr } = await db.from('leads').select('*');
|
| 266 |
+
if (lErr) throw lErr;
|
| 267 |
+
const { generateDashboardInsights } = require('./aiEngine');
|
| 268 |
+
const dashboardInsights = generateDashboardInsights(leads || []);
|
| 269 |
+
return res.json(dashboardInsights);
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
// Fetch the latest compiled dashboard row from database
|
| 273 |
+
const { data, error } = await db
|
| 274 |
+
.from('ai_insights')
|
| 275 |
+
.select('*')
|
| 276 |
+
.eq('type', 'COMPILED_DASHBOARD')
|
| 277 |
+
.order('created_at', { ascending: false })
|
| 278 |
+
.limit(1);
|
| 279 |
+
|
| 280 |
+
if (error) throw error;
|
| 281 |
+
|
| 282 |
+
if (data && data.length > 0) {
|
| 283 |
+
try {
|
| 284 |
+
const payload = JSON.parse(data[0].message);
|
| 285 |
+
|
| 286 |
+
// Dynamic, on-the-fly personalization based on current requesting user
|
| 287 |
+
const enriched = await enrichPersonalRecommendation(db, req.user, payload);
|
| 288 |
+
return res.json(enriched);
|
| 289 |
+
} catch (parseErr) {
|
| 290 |
+
console.error('Failed to parse compiled dashboard JSON:', parseErr);
|
| 291 |
+
}
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
// Fallback: If no compiled row is in the database, calculate synchronously once and return it
|
| 295 |
+
console.log('[AIService] No pre-compiled AI Insights found. Running synchronous fallback...');
|
| 296 |
+
|
| 297 |
+
const [leadsRes, admissionsRes, counselorsRes] = await Promise.all([
|
| 298 |
+
db.from('leads').select('*'),
|
| 299 |
+
db.from('admissions').select('*'),
|
| 300 |
+
db.from('counselors').select('*')
|
| 301 |
+
]);
|
| 302 |
+
|
| 303 |
+
if (leadsRes.error) throw leadsRes.error;
|
| 304 |
+
if (admissionsRes.error) throw admissionsRes.error;
|
| 305 |
+
if (counselorsRes.error) throw counselorsRes.error;
|
| 306 |
+
|
| 307 |
+
const leads = leadsRes.data || [];
|
| 308 |
+
const admissions = admissionsRes.data || [];
|
| 309 |
+
const counselors = counselorsRes.data || [];
|
| 310 |
+
|
| 311 |
+
const insights = await generateAdvancedInsights(leads, admissions, counselors);
|
| 312 |
+
|
| 313 |
+
const uniqueCourses = Array.from(new Set([
|
| 314 |
+
...leads.map(l => l.course_interested).filter(Boolean),
|
| 315 |
+
...admissions.map(a => a.course).filter(Boolean)
|
| 316 |
+
]));
|
| 317 |
+
|
| 318 |
+
const courseTrendData = uniqueCourses.map(course => {
|
| 319 |
+
const courseLeads = leads.filter(l => l.course_interested === course);
|
| 320 |
+
const courseAdmissions = admissions.filter(a => a.course === course);
|
| 321 |
+
|
| 322 |
+
// Deterministic growth calculation based on recent lead volume
|
| 323 |
+
const now = new Date();
|
| 324 |
+
const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
|
| 325 |
+
const twentyEightDaysAgo = new Date(now.getTime() - 28 * 24 * 60 * 60 * 1000);
|
| 326 |
+
|
| 327 |
+
const recentLeads = courseLeads.filter(l => new Date(l.created_at) >= fourteenDaysAgo).length;
|
| 328 |
+
const priorLeads = courseLeads.filter(l => {
|
| 329 |
+
const d = new Date(l.created_at);
|
| 330 |
+
return d >= twentyEightDaysAgo && d < fourteenDaysAgo;
|
| 331 |
+
}).length;
|
| 332 |
+
|
| 333 |
+
let growth = 0;
|
| 334 |
+
if (priorLeads > 0) {
|
| 335 |
+
growth = Math.round(((recentLeads - priorLeads) / priorLeads) * 100);
|
| 336 |
+
} else if (recentLeads > 0) {
|
| 337 |
+
growth = Math.min(recentLeads * 12, 50);
|
| 338 |
+
} else {
|
| 339 |
+
growth = Math.min(Math.max((courseAdmissions.length * 8) + (courseLeads.length * 2), 5), 45);
|
| 340 |
+
}
|
| 341 |
+
growth = Math.min(Math.max(growth, -15), 75);
|
| 342 |
+
|
| 343 |
+
return {
|
| 344 |
+
name: course,
|
| 345 |
+
leads: courseLeads.length,
|
| 346 |
+
admissions: courseAdmissions.length,
|
| 347 |
+
growth
|
| 348 |
+
};
|
| 349 |
+
});
|
| 350 |
+
|
| 351 |
+
const payload = {
|
| 352 |
+
...insights,
|
| 353 |
+
trendAnalysis: {
|
| 354 |
+
courses: courseTrendData,
|
| 355 |
+
uncontactedBottleneckCount: leads.filter(l => l.status === 'Demo Attended').length || 25,
|
| 356 |
+
counselorDelayFlag: true
|
| 357 |
+
}
|
| 358 |
+
};
|
| 359 |
+
|
| 360 |
+
// Save it so next hits are fast
|
| 361 |
+
await db.from('ai_insights').insert([{
|
| 362 |
+
type: 'COMPILED_DASHBOARD',
|
| 363 |
+
message: JSON.stringify(payload),
|
| 364 |
+
priority: 'Normal',
|
| 365 |
+
organization_id: orgId,
|
| 366 |
+
branch_id: req.user?.branch_id || '00000000-0000-0000-0000-000000000002'
|
| 367 |
+
}]);
|
| 368 |
+
|
| 369 |
+
const enriched = await enrichPersonalRecommendation(db, req.user, payload);
|
| 370 |
+
res.json(enriched);
|
| 371 |
+
} catch (error) {
|
| 372 |
+
console.error('Error compiling advanced AI reports:', error);
|
| 373 |
+
res.status(500).json({ error: 'Internal Server Error' });
|
| 374 |
+
}
|
| 375 |
+
});
|
| 376 |
+
|
| 377 |
+
module.exports = router;
|
services/auth.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { createClient } = require('@supabase/supabase-js');
|
| 2 |
+
const ws = require('ws');
|
| 3 |
+
const supabase = require('../db');
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Helper to generate a tenant-scoped Supabase client dynamically per request.
|
| 7 |
+
* This injects the active user JWT token into the client request headers,
|
| 8 |
+
* which triggers Row-Level Security (RLS) policies inside PostgreSQL natively.
|
| 9 |
+
*/
|
| 10 |
+
const getTenantDb = (req) => {
|
| 11 |
+
const authHeader = req.headers.authorization;
|
| 12 |
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
| 13 |
+
return supabase; // Fallback to global admin client
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const token = authHeader.split(' ')[1];
|
| 17 |
+
return createClient(process.env.SUPABASE_URL, process.env.SUPABASE_KEY, {
|
| 18 |
+
auth: {
|
| 19 |
+
persistSession: false
|
| 20 |
+
},
|
| 21 |
+
realtime: {
|
| 22 |
+
transport: ws
|
| 23 |
+
},
|
| 24 |
+
global: {
|
| 25 |
+
headers: {
|
| 26 |
+
Authorization: `Bearer ${token}`
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
});
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Express Middleware to validate Supabase JWT session and extract tenant/role metadata.
|
| 34 |
+
*/
|
| 35 |
+
const requireAuth = async (req, res, next) => {
|
| 36 |
+
const authHeader = req.headers.authorization;
|
| 37 |
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
| 38 |
+
// For local development without token, we can mock a default user
|
| 39 |
+
req.user = {
|
| 40 |
+
id: null,
|
| 41 |
+
email: 'local-dev@acadflow.com',
|
| 42 |
+
role: 'Super Admin',
|
| 43 |
+
organization_id: '00000000-0000-0000-0000-000000000001', // Fallback to Default Org
|
| 44 |
+
branch_id: '00000000-0000-0000-0000-000000000002', // Fallback to Default Branch
|
| 45 |
+
};
|
| 46 |
+
return next();
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
const token = authHeader.split(' ')[1];
|
| 50 |
+
try {
|
| 51 |
+
const { data: { user }, error } = await supabase.auth.getUser(token);
|
| 52 |
+
|
| 53 |
+
if (error || !user) {
|
| 54 |
+
return res.status(401).json({ error: 'Invalid or expired auth session' });
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const userMetadata = user.user_metadata || {};
|
| 58 |
+
|
| 59 |
+
req.user = {
|
| 60 |
+
id: user.id,
|
| 61 |
+
email: user.email,
|
| 62 |
+
role: userMetadata.role || 'Counselor',
|
| 63 |
+
organization_id: userMetadata.organization_id || '00000000-0000-0000-0000-000000000001',
|
| 64 |
+
branch_id: userMetadata.branch_id || '00000000-0000-0000-0000-000000000002',
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
next();
|
| 68 |
+
} catch (err) {
|
| 69 |
+
console.error('Authentication check failed:', err);
|
| 70 |
+
return res.status(500).json({ error: 'Internal Auth Validation Server Error' });
|
| 71 |
+
}
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
/**
|
| 75 |
+
* Express Middleware to restrict endpoints based on counselor roles.
|
| 76 |
+
*/
|
| 77 |
+
const requireRole = (allowedRoles) => {
|
| 78 |
+
return (req, res, next) => {
|
| 79 |
+
if (!req.user) {
|
| 80 |
+
return res.status(401).json({ error: 'User context is unauthenticated' });
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
if (!allowedRoles.includes(req.user.role)) {
|
| 84 |
+
return res.status(403).json({ error: 'Forbidden: Role privileges exceeded' });
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
next();
|
| 88 |
+
};
|
| 89 |
+
};
|
| 90 |
+
|
| 91 |
+
module.exports = {
|
| 92 |
+
getTenantDb,
|
| 93 |
+
requireAuth,
|
| 94 |
+
requireRole
|
| 95 |
+
};
|
services/counselorService.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const router = express.Router();
|
| 3 |
+
const { getTenantDb, requireAuth, requireRole } = require('./auth');
|
| 4 |
+
|
| 5 |
+
// 1. GET /api/counselors - Fetch active counselors scoped by organization
|
| 6 |
+
router.get('/', requireAuth, async (req, res) => {
|
| 7 |
+
try {
|
| 8 |
+
const db = getTenantDb(req);
|
| 9 |
+
const { data: counselors, error } = await db
|
| 10 |
+
.from('active_counselors')
|
| 11 |
+
.select('*')
|
| 12 |
+
.order('name');
|
| 13 |
+
|
| 14 |
+
if (error) throw error;
|
| 15 |
+
res.json(counselors || []);
|
| 16 |
+
} catch (error) {
|
| 17 |
+
console.error('Error fetching counselors:', error);
|
| 18 |
+
res.status(500).json({ error: 'Internal Server Error' });
|
| 19 |
+
}
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
// POST /api/counselors/login - Authenticate a counselor
|
| 23 |
+
router.post('/login', async (req, res) => {
|
| 24 |
+
const { username, password } = req.body;
|
| 25 |
+
if (!username || !password) {
|
| 26 |
+
return res.status(400).json({ error: 'Username and password are required' });
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
try {
|
| 30 |
+
const db = require('../db');
|
| 31 |
+
const { data: counselor, error } = await db
|
| 32 |
+
.from('counselors')
|
| 33 |
+
.select('*')
|
| 34 |
+
.eq('email', username.toLowerCase().trim())
|
| 35 |
+
.single();
|
| 36 |
+
|
| 37 |
+
if (error || !counselor || counselor.password !== password) {
|
| 38 |
+
return res.status(401).json({ error: 'Invalid username or password' });
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
res.json({
|
| 42 |
+
success: true,
|
| 43 |
+
user: {
|
| 44 |
+
id: counselor.id,
|
| 45 |
+
name: counselor.name,
|
| 46 |
+
email: counselor.email,
|
| 47 |
+
role: counselor.role
|
| 48 |
+
}
|
| 49 |
+
});
|
| 50 |
+
} catch (error) {
|
| 51 |
+
console.error('Login error:', error);
|
| 52 |
+
res.status(500).json({ error: 'Internal Server Error' });
|
| 53 |
+
}
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
// 2. POST /api/counselors - Add a new counselor member
|
| 57 |
+
router.post('/', requireAuth, requireRole(['Admin', 'Super Admin']), async (req, res) => {
|
| 58 |
+
const { name, email, phone, branch, role, password } = req.body;
|
| 59 |
+
if (!name || !email || !password) {
|
| 60 |
+
return res.status(400).json({ error: 'Name, Email, and Password are required fields' });
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
try {
|
| 64 |
+
const db = getTenantDb(req);
|
| 65 |
+
const orgId = req.user?.organization_id || '00000000-0000-0000-0000-000000000001';
|
| 66 |
+
const branchId = req.user?.branch_id || '00000000-0000-0000-0000-000000000002';
|
| 67 |
+
|
| 68 |
+
const newCounselor = {
|
| 69 |
+
name,
|
| 70 |
+
email,
|
| 71 |
+
password,
|
| 72 |
+
phone: phone || null,
|
| 73 |
+
branch: branch || null,
|
| 74 |
+
role: role || 'Counselor',
|
| 75 |
+
organization_id: orgId,
|
| 76 |
+
branch_id: branchId,
|
| 77 |
+
deleted_at: null
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
// Use upsert to handle cases where the counselor was previously soft-deleted
|
| 81 |
+
const { data, error } = await db
|
| 82 |
+
.from('counselors')
|
| 83 |
+
.upsert(newCounselor, { onConflict: 'email' })
|
| 84 |
+
.select();
|
| 85 |
+
|
| 86 |
+
if (error) {
|
| 87 |
+
if (error.code === '23505') {
|
| 88 |
+
return res.status(400).json({ error: 'A member with this email address already exists' });
|
| 89 |
+
}
|
| 90 |
+
throw error;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
res.status(201).json({ message: 'Counselor created successfully', counselor: data[0] });
|
| 94 |
+
} catch (error) {
|
| 95 |
+
console.error('Error creating counselor:', error);
|
| 96 |
+
res.status(500).json({ error: 'Internal Server Error' });
|
| 97 |
+
}
|
| 98 |
+
});
|
| 99 |
+
|
| 100 |
+
// 3. PUT /api/counselors/:id - Update counselor details
|
| 101 |
+
router.put('/:id', requireAuth, requireRole(['Admin', 'Super Admin']), async (req, res) => {
|
| 102 |
+
const { id } = req.params;
|
| 103 |
+
const { name, email, phone, branch, role } = req.body;
|
| 104 |
+
|
| 105 |
+
try {
|
| 106 |
+
const db = getTenantDb(req);
|
| 107 |
+
|
| 108 |
+
const updates = {};
|
| 109 |
+
if (name !== undefined) updates.name = name;
|
| 110 |
+
if (email !== undefined) updates.email = email;
|
| 111 |
+
if (phone !== undefined) updates.phone = phone;
|
| 112 |
+
if (branch !== undefined) updates.branch = branch;
|
| 113 |
+
if (role !== undefined) updates.role = role;
|
| 114 |
+
|
| 115 |
+
const { data, error } = await db
|
| 116 |
+
.from('counselors')
|
| 117 |
+
.update(updates)
|
| 118 |
+
.eq('id', id)
|
| 119 |
+
.select();
|
| 120 |
+
|
| 121 |
+
if (error) {
|
| 122 |
+
if (error.code === '23505') {
|
| 123 |
+
return res.status(400).json({ error: 'A member with this email address already exists' });
|
| 124 |
+
}
|
| 125 |
+
throw error;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
res.json({ message: 'Counselor updated successfully', counselor: data[0] });
|
| 129 |
+
} catch (error) {
|
| 130 |
+
console.error('Error updating counselor:', error);
|
| 131 |
+
res.status(500).json({ error: 'Internal Server Error' });
|
| 132 |
+
}
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
// 4. DELETE /api/counselors/:id - Soft delete a counselor (sets deleted_at)
|
| 136 |
+
router.delete('/:id', requireAuth, requireRole(['Admin', 'Super Admin']), async (req, res) => {
|
| 137 |
+
const { id } = req.params;
|
| 138 |
+
|
| 139 |
+
try {
|
| 140 |
+
const db = getTenantDb(req);
|
| 141 |
+
const { error } = await db
|
| 142 |
+
.from('counselors')
|
| 143 |
+
.update({ deleted_at: new Date().toISOString() })
|
| 144 |
+
.eq('id', id);
|
| 145 |
+
|
| 146 |
+
if (error) throw error;
|
| 147 |
+
res.json({ message: 'Counselor removed successfully' });
|
| 148 |
+
} catch (error) {
|
| 149 |
+
console.error('Error removing counselor:', error);
|
| 150 |
+
res.status(500).json({ error: 'Internal Server Error' });
|
| 151 |
+
}
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
module.exports = router;
|
services/courseService.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const { requireAuth, getTenantDb } = require('./auth');
|
| 3 |
+
|
| 4 |
+
const router = express.Router();
|
| 5 |
+
|
| 6 |
+
// GET /api/courses
|
| 7 |
+
router.get('/', requireAuth, async (req, res) => {
|
| 8 |
+
try {
|
| 9 |
+
const db = getTenantDb(req);
|
| 10 |
+
const { data, error } = await db
|
| 11 |
+
.from('courses')
|
| 12 |
+
.select('*')
|
| 13 |
+
.is('deleted_at', null)
|
| 14 |
+
.order('created_at', { ascending: false });
|
| 15 |
+
|
| 16 |
+
if (error) {
|
| 17 |
+
// If table doesn't exist yet, return empty array gracefully
|
| 18 |
+
if (error.code === '42P01') {
|
| 19 |
+
return res.json([]);
|
| 20 |
+
}
|
| 21 |
+
throw error;
|
| 22 |
+
}
|
| 23 |
+
res.json(data || []);
|
| 24 |
+
} catch (error) {
|
| 25 |
+
console.error('Error fetching courses:', error);
|
| 26 |
+
res.status(500).json({ error: 'Failed to fetch courses' });
|
| 27 |
+
}
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
// POST /api/courses
|
| 31 |
+
router.post('/', requireAuth, async (req, res) => {
|
| 32 |
+
try {
|
| 33 |
+
const {
|
| 34 |
+
title, category, duration, fee, max_price, discount, mode, trainer_name, admission_status,
|
| 35 |
+
trainer_experience, placement_percentage, rating, syllabus,
|
| 36 |
+
batch_timings, placement_partners, emi_options, ai_insight
|
| 37 |
+
} = req.body;
|
| 38 |
+
|
| 39 |
+
if (!title || !category || !fee) {
|
| 40 |
+
return res.status(400).json({ error: 'Missing required fields (title, category, fee)' });
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const db = getTenantDb(req);
|
| 44 |
+
const orgId = req.user?.user_metadata?.organization_id || '00000000-0000-0000-0000-000000000001';
|
| 45 |
+
|
| 46 |
+
const { data, error } = await db.from('courses').insert([
|
| 47 |
+
{
|
| 48 |
+
organization_id: orgId,
|
| 49 |
+
title,
|
| 50 |
+
category,
|
| 51 |
+
duration: duration || '',
|
| 52 |
+
fee,
|
| 53 |
+
max_price: max_price || fee,
|
| 54 |
+
discount: discount || 0,
|
| 55 |
+
mode: mode || 'Online / Offline',
|
| 56 |
+
admission_status: admission_status || 'Open',
|
| 57 |
+
trainer_name: trainer_name || 'TBD',
|
| 58 |
+
trainer_experience: trainer_experience || '5+ Years',
|
| 59 |
+
placement_percentage: placement_percentage || 80,
|
| 60 |
+
rating: rating || 5.0,
|
| 61 |
+
syllabus: syllabus || [],
|
| 62 |
+
batch_timings: batch_timings || [],
|
| 63 |
+
placement_partners: placement_partners || [],
|
| 64 |
+
emi_options: emi_options || 'Available on request',
|
| 65 |
+
ai_insight: ai_insight || 'New course offering.'
|
| 66 |
+
}
|
| 67 |
+
]).select();
|
| 68 |
+
|
| 69 |
+
if (error) throw error;
|
| 70 |
+
|
| 71 |
+
res.json(data[0]);
|
| 72 |
+
} catch (error) {
|
| 73 |
+
console.error('Error creating course:', error);
|
| 74 |
+
res.status(500).json({ error: 'Failed to create course' });
|
| 75 |
+
}
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
// PUT /api/courses/:id
|
| 79 |
+
router.put('/:id', requireAuth, async (req, res) => {
|
| 80 |
+
try {
|
| 81 |
+
const { id } = req.params;
|
| 82 |
+
const {
|
| 83 |
+
title, category, duration, fee, max_price, discount, mode, trainer_name, admission_status,
|
| 84 |
+
trainer_experience, placement_percentage, rating, syllabus,
|
| 85 |
+
batch_timings, placement_partners, emi_options, ai_insight
|
| 86 |
+
} = req.body;
|
| 87 |
+
|
| 88 |
+
if (!title || !category || !fee) {
|
| 89 |
+
return res.status(400).json({ error: 'Missing required fields (title, category, fee)' });
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
const db = getTenantDb(req);
|
| 93 |
+
|
| 94 |
+
const { data, error } = await db.from('courses').update({
|
| 95 |
+
title,
|
| 96 |
+
category,
|
| 97 |
+
duration: duration || '',
|
| 98 |
+
fee,
|
| 99 |
+
max_price: max_price || fee,
|
| 100 |
+
discount: discount || 0,
|
| 101 |
+
mode: mode || 'Online / Offline',
|
| 102 |
+
admission_status: admission_status || 'Open',
|
| 103 |
+
trainer_name: trainer_name || 'TBD',
|
| 104 |
+
trainer_experience: trainer_experience || '5+ Years',
|
| 105 |
+
placement_percentage: placement_percentage || 80,
|
| 106 |
+
rating: rating || 5.0,
|
| 107 |
+
syllabus: syllabus || [],
|
| 108 |
+
batch_timings: batch_timings || [],
|
| 109 |
+
placement_partners: placement_partners || [],
|
| 110 |
+
emi_options: emi_options || 'Available on request',
|
| 111 |
+
ai_insight: ai_insight || 'Course updated.'
|
| 112 |
+
}).eq('id', id).select();
|
| 113 |
+
|
| 114 |
+
if (error) throw error;
|
| 115 |
+
|
| 116 |
+
res.json(data[0]);
|
| 117 |
+
} catch (error) {
|
| 118 |
+
console.error('Error updating course:', error);
|
| 119 |
+
res.status(500).json({ error: 'Failed to update course' });
|
| 120 |
+
}
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
// DELETE /api/courses/:id
|
| 124 |
+
router.delete('/:id', requireAuth, async (req, res) => {
|
| 125 |
+
try {
|
| 126 |
+
const { id } = req.params;
|
| 127 |
+
const db = getTenantDb(req);
|
| 128 |
+
|
| 129 |
+
// Soft delete
|
| 130 |
+
const { error } = await db.from('courses').update({ deleted_at: new Date().toISOString() }).eq('id', id);
|
| 131 |
+
|
| 132 |
+
if (error) throw error;
|
| 133 |
+
res.json({ success: true });
|
| 134 |
+
} catch (error) {
|
| 135 |
+
console.error('Error deleting course:', error);
|
| 136 |
+
res.status(500).json({ error: 'Failed to delete course' });
|
| 137 |
+
}
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
module.exports = router;
|
services/eventBus.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const EventEmitter = require('events');
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Enterprise Decoupled EventBus
|
| 5 |
+
* Utilizes Node's native high-performance EventEmitter. Can be easily swapped
|
| 6 |
+
* with a Redis-backed Pub/Sub mechanism when scaling across physical container boundaries.
|
| 7 |
+
*/
|
| 8 |
+
class EventBus extends EventEmitter {
|
| 9 |
+
constructor() {
|
| 10 |
+
super();
|
| 11 |
+
this.logger = console;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Emits an event onto the bus with an associated payload
|
| 16 |
+
* @param {string} eventName
|
| 17 |
+
* @param {object} payload
|
| 18 |
+
*/
|
| 19 |
+
publish(eventName, payload) {
|
| 20 |
+
this.logger.log(`[EventBus] 📢 Publish topic "${eventName}":`, JSON.stringify(payload, null, 2));
|
| 21 |
+
this.emit(eventName, payload);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* Registers a subscriber for a specific event topic
|
| 26 |
+
* @param {string} eventName
|
| 27 |
+
* @param {function} handler
|
| 28 |
+
*/
|
| 29 |
+
subscribe(eventName, handler) {
|
| 30 |
+
this.logger.log(`[EventBus] 📥 Subscribed to topic "${eventName}"`);
|
| 31 |
+
this.on(eventName, handler);
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const eventBus = new EventBus();
|
| 36 |
+
module.exports = eventBus;
|
services/googleSheets.js
CHANGED
|
@@ -1,358 +1,447 @@
|
|
| 1 |
-
const { google } = require('googleapis');
|
| 2 |
-
const db = require('../db');
|
| 3 |
-
const { calculateLeadScore } = require('./aiEngine');
|
| 4 |
-
require('
|
| 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 |
-
const
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
*/
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
const
|
| 43 |
-
const
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
let
|
| 47 |
-
let
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
}
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
}
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
}
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
}
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { google } = require('googleapis');
|
| 2 |
+
const db = require('../db');
|
| 3 |
+
const { calculateLeadScore } = require('./aiEngine');
|
| 4 |
+
const { generateNextStudentId } = require('./idGenerator');
|
| 5 |
+
require('dotenv').config();
|
| 6 |
+
|
| 7 |
+
// Initialize Google Auth
|
| 8 |
+
let auth;
|
| 9 |
+
if (process.env.GOOGLE_CREDS_JSON) {
|
| 10 |
+
try {
|
| 11 |
+
const credentials = JSON.parse(process.env.GOOGLE_CREDS_JSON);
|
| 12 |
+
auth = new google.auth.GoogleAuth({
|
| 13 |
+
credentials,
|
| 14 |
+
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
| 15 |
+
});
|
| 16 |
+
} catch (e) {
|
| 17 |
+
console.error('Failed to parse GOOGLE_CREDS_JSON environment variable:', e);
|
| 18 |
+
auth = new google.auth.GoogleAuth({
|
| 19 |
+
keyFile: 'credentials.json',
|
| 20 |
+
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
| 21 |
+
});
|
| 22 |
+
}
|
| 23 |
+
} else {
|
| 24 |
+
auth = new google.auth.GoogleAuth({
|
| 25 |
+
keyFile: 'credentials.json', // Path to your service account key file for local dev
|
| 26 |
+
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
| 27 |
+
});
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const sheets = google.sheets({ version: 'v4', auth });
|
| 31 |
+
const SPREADSHEET_ID = process.env.SPREADSHEET_ID;
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* Parses date strings in DD/MM/YYYY hh:mm:ss format or other fallback formats
|
| 35 |
+
*/
|
| 36 |
+
const parseSheetDate = (dateStr) => {
|
| 37 |
+
if (!dateStr) return new Date().toISOString();
|
| 38 |
+
|
| 39 |
+
// Handle DD/MM/YYYY or MM/DD/YYYY format with potential time
|
| 40 |
+
const parts = dateStr.trim().split(/[\/\s:]/);
|
| 41 |
+
if (parts.length >= 3) {
|
| 42 |
+
const firstVal = parseInt(parts[0], 10);
|
| 43 |
+
const secondVal = parseInt(parts[1], 10);
|
| 44 |
+
const year = parseInt(parts[2], 10);
|
| 45 |
+
|
| 46 |
+
let hour = 0;
|
| 47 |
+
let minute = 0;
|
| 48 |
+
let second = 0;
|
| 49 |
+
|
| 50 |
+
if (parts.length >= 6) {
|
| 51 |
+
hour = parseInt(parts[3], 10);
|
| 52 |
+
minute = parseInt(parts[4], 10);
|
| 53 |
+
second = parseInt(parts[5], 10);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
let month = firstVal - 1; // 0-indexed month
|
| 57 |
+
let day = secondVal;
|
| 58 |
+
|
| 59 |
+
if (firstVal > 12 && secondVal <= 12) {
|
| 60 |
+
month = secondVal - 1;
|
| 61 |
+
day = firstVal;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
if (day > 0 && day <= 31 && month >= 0 && month < 12 && year > 1900) {
|
| 65 |
+
const date = new Date(year, month, day, hour, minute, second);
|
| 66 |
+
if (!isNaN(date.getTime())) {
|
| 67 |
+
return date.toISOString();
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
const fallback = new Date(dateStr);
|
| 73 |
+
return !isNaN(fallback.getTime()) ? fallback.toISOString() : new Date().toISOString();
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
let isSyncingDB = false;
|
| 77 |
+
|
| 78 |
+
/**
|
| 79 |
+
* Fetch all rows from Google Sheets and sync to PostgreSQL relational tables
|
| 80 |
+
*/
|
| 81 |
+
const syncSheetsToDB = async (io) => {
|
| 82 |
+
if (isSyncingDB) {
|
| 83 |
+
console.log('Sync already in progress, skipping...');
|
| 84 |
+
return;
|
| 85 |
+
}
|
| 86 |
+
if (!SPREADSHEET_ID || SPREADSHEET_ID === 'YOUR_SPREADSHEET_ID_HERE') {
|
| 87 |
+
console.log('Skipping sync: SPREADSHEET_ID not configured in .env');
|
| 88 |
+
return;
|
| 89 |
+
}
|
| 90 |
+
isSyncingDB = true;
|
| 91 |
+
try {
|
| 92 |
+
const response = await sheets.spreadsheets.values.get({
|
| 93 |
+
spreadsheetId: SPREADSHEET_ID,
|
| 94 |
+
range: 'Sheet1!A2:M',
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
const rows = response.data.values;
|
| 98 |
+
if (!rows || rows.length === 0) {
|
| 99 |
+
console.log('No data found in Google Sheets.');
|
| 100 |
+
return;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
let newlyAdded = 0;
|
| 104 |
+
let updated = 0;
|
| 105 |
+
|
| 106 |
+
const COURSE_MAPPING = {
|
| 107 |
+
'DA': 'Data Analytics',
|
| 108 |
+
'DS': 'Data Science & AI',
|
| 109 |
+
'DM': 'Digital Marketing',
|
| 110 |
+
'TALLY': 'Tally ERP & GST',
|
| 111 |
+
'UI&UX-Digital Painting': 'UI/UX Design',
|
| 112 |
+
'Digital marketing': 'Digital Marketing'
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
const COURSE_FEES = {
|
| 116 |
+
'Data Analytics': 65000,
|
| 117 |
+
'Data Science & AI': 95000,
|
| 118 |
+
'Tally ERP & GST': 25000,
|
| 119 |
+
'UI/UX Design': 48000,
|
| 120 |
+
'Digital Marketing': 40000,
|
| 121 |
+
'Full Stack Development': 85000,
|
| 122 |
+
'Python Programming': 35000
|
| 123 |
+
};
|
| 124 |
+
|
| 125 |
+
for (const row of rows) {
|
| 126 |
+
const student_name = row[1] ? row[1].trim() : '';
|
| 127 |
+
const phone = row[2] ? row[2].trim() : '';
|
| 128 |
+
|
| 129 |
+
if (!phone || phone === 'Phone Number' || phone.toLowerCase().includes('contact') || student_name === 'Student Name' || student_name.toLowerCase().includes('name')) continue;
|
| 130 |
+
|
| 131 |
+
const name = student_name || ('Lead - ' + phone);
|
| 132 |
+
const email = row[3] ? row[3].trim() : '';
|
| 133 |
+
const course_interested_raw = row[4] ? row[4].trim() : '';
|
| 134 |
+
const course_interested = COURSE_MAPPING[course_interested_raw] || course_interested_raw;
|
| 135 |
+
|
| 136 |
+
const sheet_fees = row[5] ? parseFloat(row[5].toString().replace(/[^0-9.]/g, '')) : 0;
|
| 137 |
+
const fees = sheet_fees || COURSE_FEES[course_interested] || 35000;
|
| 138 |
+
|
| 139 |
+
const source = row[6] ? row[6].trim() : 'Google Sheet';
|
| 140 |
+
|
| 141 |
+
let raw_status = row[7] ? row[7].trim() : 'Pending';
|
| 142 |
+
let followup_status = 'Pending';
|
| 143 |
+
if (raw_status.toLowerCase() === 'not interested' || raw_status.toLowerCase() === 'lost') {
|
| 144 |
+
followup_status = 'Not Interested';
|
| 145 |
+
} else if (raw_status.toLowerCase() === 'done') {
|
| 146 |
+
followup_status = 'Done';
|
| 147 |
+
} else if (raw_status.toLowerCase() === 'converted' || raw_status.toLowerCase() === 'admitted') {
|
| 148 |
+
followup_status = 'Converted';
|
| 149 |
+
} else {
|
| 150 |
+
followup_status = 'Pending';
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
const admission_status = (row[8] && row[8].trim() === 'Admitted') || followup_status === 'Converted' ? 'Admitted' : 'Not Admitted';
|
| 154 |
+
if (admission_status === 'Admitted' && followup_status !== 'Converted') {
|
| 155 |
+
followup_status = 'Converted';
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
const counselor_name = row[9] ? row[9].trim() : '';
|
| 159 |
+
const last_contacted_str = row[11] || row[10] || '';
|
| 160 |
+
|
| 161 |
+
const manual_student_id = row[0] ? row[0].trim() : null;
|
| 162 |
+
|
| 163 |
+
let counselorId = null;
|
| 164 |
+
if (counselor_name) {
|
| 165 |
+
const { data: existingCounselors, error: cErr } = await db
|
| 166 |
+
.from('counselors')
|
| 167 |
+
.select('id')
|
| 168 |
+
.eq('name', counselor_name);
|
| 169 |
+
|
| 170 |
+
if (cErr) {
|
| 171 |
+
console.error('Error fetching counselor:', cErr);
|
| 172 |
+
} else if (existingCounselors && existingCounselors.length > 0) {
|
| 173 |
+
counselorId = existingCounselors[0].id;
|
| 174 |
+
} else {
|
| 175 |
+
const cEmail = `${counselor_name.toLowerCase().replace(/[^a-z0-9]/g, '')}@acadflow.com`;
|
| 176 |
+
const { data: newCounselors, error: createCErr } = await db
|
| 177 |
+
.from('counselors')
|
| 178 |
+
.insert([{
|
| 179 |
+
name: counselor_name,
|
| 180 |
+
email: cEmail,
|
| 181 |
+
role: 'Counselor',
|
| 182 |
+
organization_id: '00000000-0000-0000-0000-000000000001',
|
| 183 |
+
branch_id: '00000000-0000-0000-0000-000000000002'
|
| 184 |
+
}])
|
| 185 |
+
.select('id');
|
| 186 |
+
|
| 187 |
+
if (createCErr) {
|
| 188 |
+
console.error('Error creating counselor:', createCErr);
|
| 189 |
+
} else if (newCounselors && newCounselors.length > 0) {
|
| 190 |
+
counselorId = newCounselors[0].id;
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
const { data: existingLeads, error: selectError } = await db
|
| 196 |
+
.from('leads')
|
| 197 |
+
.select('*')
|
| 198 |
+
.eq('phone', phone);
|
| 199 |
+
|
| 200 |
+
if (selectError) {
|
| 201 |
+
console.error('Error fetching lead from Supabase:', selectError);
|
| 202 |
+
continue;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
const created_at = parseSheetDate(last_contacted_str);
|
| 206 |
+
|
| 207 |
+
if (existingLeads && existingLeads.length === 0) {
|
| 208 |
+
const lead_score = calculateLeadScore({
|
| 209 |
+
lead_source: source,
|
| 210 |
+
followup_status: followup_status,
|
| 211 |
+
interested_course: course_interested
|
| 212 |
+
});
|
| 213 |
+
|
| 214 |
+
const nextStudentId = manual_student_id || await generateNextStudentId();
|
| 215 |
+
|
| 216 |
+
const { data: newLeads, error: insertError } = await db
|
| 217 |
+
.from('leads')
|
| 218 |
+
.insert([{
|
| 219 |
+
name,
|
| 220 |
+
student_id: nextStudentId,
|
| 221 |
+
phone,
|
| 222 |
+
email,
|
| 223 |
+
course_interested,
|
| 224 |
+
source,
|
| 225 |
+
status: followup_status,
|
| 226 |
+
lead_score,
|
| 227 |
+
counselor_id: counselorId,
|
| 228 |
+
created_at,
|
| 229 |
+
updated_at: created_at,
|
| 230 |
+
organization_id: '00000000-0000-0000-0000-000000000001',
|
| 231 |
+
branch_id: '00000000-0000-0000-0000-000000000002'
|
| 232 |
+
}])
|
| 233 |
+
.select('id');
|
| 234 |
+
|
| 235 |
+
if (insertError) {
|
| 236 |
+
console.error('Error inserting lead into Supabase:', insertError);
|
| 237 |
+
} else if (newLeads && newLeads.length > 0) {
|
| 238 |
+
const leadId = newLeads[0].id;
|
| 239 |
+
newlyAdded++;
|
| 240 |
+
|
| 241 |
+
if (io && io.addNotification) {
|
| 242 |
+
io.addNotification({
|
| 243 |
+
title: lead_score > 80 ? "New hot lead assigned 🔥" : "New lead assigned 👤",
|
| 244 |
+
message: `${name} is interested in ${course_interested} (Score: ${lead_score}%).`,
|
| 245 |
+
type: "LEAD_ALERT",
|
| 246 |
+
priority: lead_score > 80 ? "High" : "Low",
|
| 247 |
+
action_url: `/leads?search=${name}`
|
| 248 |
+
});
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
const { error: followUpError } = await db
|
| 252 |
+
.from('follow_ups')
|
| 253 |
+
.insert([{
|
| 254 |
+
lead_id: leadId,
|
| 255 |
+
followup_date: created_at,
|
| 256 |
+
followup_type: 'Call',
|
| 257 |
+
status: followup_status === 'Pending' ? 'Pending' : 'Completed',
|
| 258 |
+
remarks: 'Initial sync from Google Sheets',
|
| 259 |
+
created_by: counselorId,
|
| 260 |
+
organization_id: '00000000-0000-0000-0000-000000000001',
|
| 261 |
+
branch_id: '00000000-0000-0000-0000-000000000002'
|
| 262 |
+
}]);
|
| 263 |
+
|
| 264 |
+
if (followUpError) {
|
| 265 |
+
console.error('Error creating follow-up:', followUpError);
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
if (admission_status === 'Admitted') {
|
| 269 |
+
const { error: admissionError } = await db
|
| 270 |
+
.from('admissions')
|
| 271 |
+
.insert([{
|
| 272 |
+
lead_id: leadId,
|
| 273 |
+
course: course_interested,
|
| 274 |
+
fees,
|
| 275 |
+
payment_status: 'Pending',
|
| 276 |
+
joined_date: created_at,
|
| 277 |
+
organization_id: '00000000-0000-0000-0000-000000000001',
|
| 278 |
+
branch_id: '00000000-0000-0000-0000-000000000002'
|
| 279 |
+
}]);
|
| 280 |
+
|
| 281 |
+
if (admissionError) {
|
| 282 |
+
console.error('Error creating admission:', admissionError);
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
}
|
| 286 |
+
} else {
|
| 287 |
+
const lead = existingLeads[0];
|
| 288 |
+
const hasBasicChanges = lead.name !== name || lead.course_interested !== course_interested || lead.counselor_id !== counselorId || lead.email !== email || (manual_student_id && lead.student_id !== manual_student_id);
|
| 289 |
+
const statusChanged = lead.status !== followup_status;
|
| 290 |
+
|
| 291 |
+
if (hasBasicChanges || statusChanged) {
|
| 292 |
+
const updates = {};
|
| 293 |
+
if (lead.name !== name) updates.name = name;
|
| 294 |
+
if (lead.email !== email) updates.email = email;
|
| 295 |
+
if (lead.course_interested !== course_interested) updates.course_interested = course_interested;
|
| 296 |
+
if (lead.counselor_id !== counselorId) updates.counselor_id = counselorId;
|
| 297 |
+
if (manual_student_id && lead.student_id !== manual_student_id) updates.student_id = manual_student_id;
|
| 298 |
+
if (statusChanged) updates.status = followup_status;
|
| 299 |
+
|
| 300 |
+
const { error: updateError } = await db
|
| 301 |
+
.from('leads')
|
| 302 |
+
.update(updates)
|
| 303 |
+
.eq('id', lead.id);
|
| 304 |
+
|
| 305 |
+
if (updateError) {
|
| 306 |
+
console.error('Error updating lead in DB:', updateError);
|
| 307 |
+
} else {
|
| 308 |
+
updated++;
|
| 309 |
+
}
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
const { data: existingAdmissions, error: admSelectError } = await db
|
| 313 |
+
.from('admissions')
|
| 314 |
+
.select('id')
|
| 315 |
+
.eq('lead_id', lead.id);
|
| 316 |
+
|
| 317 |
+
if (!admSelectError) {
|
| 318 |
+
const hasAdmissionRecord = existingAdmissions && existingAdmissions.length > 0;
|
| 319 |
+
if (admission_status === 'Admitted' && !hasAdmissionRecord) {
|
| 320 |
+
await db.from('admissions').insert([{
|
| 321 |
+
lead_id: lead.id,
|
| 322 |
+
course: course_interested,
|
| 323 |
+
fees,
|
| 324 |
+
payment_status: 'Pending',
|
| 325 |
+
joined_date: created_at,
|
| 326 |
+
organization_id: '00000000-0000-0000-0000-000000000001',
|
| 327 |
+
branch_id: '00000000-0000-0000-0000-000000000002'
|
| 328 |
+
}]);
|
| 329 |
+
} else if (admission_status !== 'Admitted' && hasAdmissionRecord) {
|
| 330 |
+
await db.from('admissions').delete().eq('lead_id', lead.id);
|
| 331 |
+
}
|
| 332 |
+
}
|
| 333 |
+
}
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
if (newlyAdded > 0 || updated > 0) {
|
| 337 |
+
console.log(`Successfully synced: ${newlyAdded} new leads, ${updated} updated leads.`);
|
| 338 |
+
if (io) {
|
| 339 |
+
io.emit('leads_updated', { message: 'New leads synced from Google Sheets', count: newlyAdded + updated });
|
| 340 |
+
if (io.addNotification) {
|
| 341 |
+
io.addNotification({
|
| 342 |
+
title: "Google Sheets synced successfully 🔄",
|
| 343 |
+
message: `Synced ${newlyAdded} new leads, ${updated} updated leads from external database.`,
|
| 344 |
+
type: "SYSTEM_ALERT",
|
| 345 |
+
priority: "Low",
|
| 346 |
+
action_url: "/"
|
| 347 |
+
});
|
| 348 |
+
}
|
| 349 |
+
}
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
} catch (error) {
|
| 353 |
+
console.error('Error syncing Google Sheets to DB:', error);
|
| 354 |
+
} finally {
|
| 355 |
+
isSyncingDB = false;
|
| 356 |
+
}
|
| 357 |
+
};
|
| 358 |
+
|
| 359 |
+
/**
|
| 360 |
+
* Update a specific lead in Google Sheets (DB to Sheet)
|
| 361 |
+
*/
|
| 362 |
+
const updateLeadInSheet = async (phone, statusData) => {
|
| 363 |
+
if (!SPREADSHEET_ID) return;
|
| 364 |
+
|
| 365 |
+
try {
|
| 366 |
+
const response = await sheets.spreadsheets.values.get({
|
| 367 |
+
spreadsheetId: SPREADSHEET_ID,
|
| 368 |
+
range: 'Sheet1!A:L',
|
| 369 |
+
});
|
| 370 |
+
|
| 371 |
+
const rows = response.data.values;
|
| 372 |
+
if (!rows) return;
|
| 373 |
+
|
| 374 |
+
const rowIndex = rows.findIndex(row => row[2] && row[2].trim() === phone.trim());
|
| 375 |
+
|
| 376 |
+
if (rowIndex !== -1) {
|
| 377 |
+
const sheetRowNumber = rowIndex + 1;
|
| 378 |
+
|
| 379 |
+
let statusText = statusData.followup_status;
|
| 380 |
+
if (statusData.followup_status === 'Pending') {
|
| 381 |
+
statusText = 'Follow up';
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
await sheets.spreadsheets.values.update({
|
| 385 |
+
spreadsheetId: SPREADSHEET_ID,
|
| 386 |
+
range: `Sheet1!H${sheetRowNumber}:I${sheetRowNumber}`,
|
| 387 |
+
valueInputOption: 'USER_ENTERED',
|
| 388 |
+
resource: {
|
| 389 |
+
values: [
|
| 390 |
+
[statusText, statusData.admission_status]
|
| 391 |
+
]
|
| 392 |
+
}
|
| 393 |
+
});
|
| 394 |
+
console.log(`Successfully updated lead ${phone} in Google Sheet.`);
|
| 395 |
+
}
|
| 396 |
+
} catch (error) {
|
| 397 |
+
console.error('Error updating Google Sheet:', error);
|
| 398 |
+
}
|
| 399 |
+
};
|
| 400 |
+
|
| 401 |
+
/**
|
| 402 |
+
* Append a new lead to Google Sheets (DB to Sheet)
|
| 403 |
+
*/
|
| 404 |
+
const appendLeadToSheet = async (leadData) => {
|
| 405 |
+
if (!SPREADSHEET_ID || SPREADSHEET_ID === 'YOUR_SPREADSHEET_ID_HERE') return;
|
| 406 |
+
|
| 407 |
+
try {
|
| 408 |
+
const timestamp = new Date().toLocaleString();
|
| 409 |
+
const values = [
|
| 410 |
+
[
|
| 411 |
+
leadData.student_id || '', // A (0): STUDENT ID
|
| 412 |
+
timestamp, // B (1): DATE ADDED
|
| 413 |
+
leadData.name || '', // C (2): STUDENT PROFILE
|
| 414 |
+
leadData.phone || '', // D (3): PHONE NUMBER
|
| 415 |
+
leadData.email || '', // E (4): EMAIL
|
| 416 |
+
leadData.course_interested || '', // F (5): TARGET PROGRAM
|
| 417 |
+
'', // G (6): COURSE FEE
|
| 418 |
+
leadData.source || '', // H (7): LEAD SOURCE
|
| 419 |
+
leadData.status || 'Pending', // I (8): LEAD STATUS
|
| 420 |
+
'Not Admitted', // J (9): ENROLLMENT
|
| 421 |
+
leadData.counselor_name || '', // K (10): ASSIGN
|
| 422 |
+
leadData.followup_time || '', // L (11): TIMEFRAME
|
| 423 |
+
timestamp, // M (12): LAST CONTACTED
|
| 424 |
+
leadData.lead_score || '', // N (13): SCORE
|
| 425 |
+
leadData.city || '', // O (14): CITY
|
| 426 |
+
'' // P (15): STATE
|
| 427 |
+
]
|
| 428 |
+
];
|
| 429 |
+
|
| 430 |
+
await sheets.spreadsheets.values.append({
|
| 431 |
+
spreadsheetId: SPREADSHEET_ID,
|
| 432 |
+
range: 'Sheet1!A:Q',
|
| 433 |
+
valueInputOption: 'USER_ENTERED',
|
| 434 |
+
insertDataOption: 'INSERT_ROWS',
|
| 435 |
+
resource: { values }
|
| 436 |
+
});
|
| 437 |
+
console.log(`Successfully appended lead ${leadData.name} to Google Sheet.`);
|
| 438 |
+
} catch (error) {
|
| 439 |
+
console.error('Error appending lead to Google Sheet:', error);
|
| 440 |
+
}
|
| 441 |
+
};
|
| 442 |
+
|
| 443 |
+
module.exports = {
|
| 444 |
+
syncSheetsToDB,
|
| 445 |
+
updateLeadInSheet,
|
| 446 |
+
appendLeadToSheet
|
| 447 |
+
};
|
services/idGenerator.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const db = require('../db');
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Generate the next sequential Student ID (e.g., SKUEDU0701)
|
| 5 |
+
*/
|
| 6 |
+
async function generateNextStudentId() {
|
| 7 |
+
try {
|
| 8 |
+
const now = new Date();
|
| 9 |
+
const currentMonth = String(now.getMonth() + 1).padStart(2, '0');
|
| 10 |
+
const prefix = `SKUEDU${currentMonth}`;
|
| 11 |
+
|
| 12 |
+
let maxNum = 0;
|
| 13 |
+
|
| 14 |
+
// Fetch all leads that have an ID starting with the current prefix
|
| 15 |
+
const { data: allLeads, error: maxIdError } = await db
|
| 16 |
+
.from('leads')
|
| 17 |
+
.select('student_id')
|
| 18 |
+
.not('student_id', 'is', null)
|
| 19 |
+
.like('student_id', `${prefix}%`);
|
| 20 |
+
|
| 21 |
+
if (!maxIdError && allLeads && allLeads.length > 0) {
|
| 22 |
+
allLeads.forEach(lead => {
|
| 23 |
+
const id = lead.student_id.toUpperCase();
|
| 24 |
+
if (id.startsWith(prefix)) {
|
| 25 |
+
const numPart = parseInt(id.replace(prefix, ''), 10);
|
| 26 |
+
if (!isNaN(numPart) && numPart > maxNum) {
|
| 27 |
+
maxNum = numPart;
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
});
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const nextNum = String(maxNum + 1).padStart(2, '0'); // at least 2 digits e.g. 01, 02
|
| 34 |
+
return `${prefix}${nextNum}`;
|
| 35 |
+
} catch (err) {
|
| 36 |
+
console.error('Error generating student ID:', err);
|
| 37 |
+
// Fallback if DB fails
|
| 38 |
+
const currentMonth = String(new Date().getMonth() + 1).padStart(2, '0');
|
| 39 |
+
return `SKUEDU${currentMonth}${String(Math.floor(Math.random() * 90) + 10)}`;
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
module.exports = {
|
| 44 |
+
generateNextStudentId
|
| 45 |
+
};
|
services/leadService.js
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const router = express.Router();
|
| 3 |
+
const { getTenantDb, requireAuth } = require('./auth');
|
| 4 |
+
const eventBus = require('./eventBus');
|
| 5 |
+
const { appendLeadToSheet, updateLeadInSheet } = require('./googleSheets');
|
| 6 |
+
|
| 7 |
+
// 1. GET /api/leads - Fetch active leads scoped by tenant organization
|
| 8 |
+
router.get('/', requireAuth, async (req, res) => {
|
| 9 |
+
try {
|
| 10 |
+
const db = getTenantDb(req);
|
| 11 |
+
|
| 12 |
+
const { data: leads, error } = await db
|
| 13 |
+
.from('active_leads')
|
| 14 |
+
.select(`
|
| 15 |
+
*,
|
| 16 |
+
student_id,
|
| 17 |
+
counselors (
|
| 18 |
+
name
|
| 19 |
+
),
|
| 20 |
+
admissions (
|
| 21 |
+
course,
|
| 22 |
+
fees,
|
| 23 |
+
payment_status
|
| 24 |
+
)
|
| 25 |
+
`)
|
| 26 |
+
.order('created_at', { ascending: false });
|
| 27 |
+
|
| 28 |
+
if (error) throw error;
|
| 29 |
+
|
| 30 |
+
const formattedLeads = (leads || []).map(lead => {
|
| 31 |
+
const hasAdmission = lead.admissions && lead.admissions.length > 0;
|
| 32 |
+
const admissionRecord = hasAdmission ? lead.admissions[0] : null;
|
| 33 |
+
|
| 34 |
+
return {
|
| 35 |
+
id: lead.id,
|
| 36 |
+
student_name: lead.name,
|
| 37 |
+
phone_number: lead.phone,
|
| 38 |
+
email: lead.email || '',
|
| 39 |
+
interested_course: lead.course_interested,
|
| 40 |
+
lead_source: lead.source,
|
| 41 |
+
counselor_name: lead.counselors ? lead.counselors.name : '',
|
| 42 |
+
counselor_id: lead.counselor_id,
|
| 43 |
+
followup_status: lead.status || 'Pending',
|
| 44 |
+
followup_time: lead.followup_time || 'One Day',
|
| 45 |
+
admission_status: hasAdmission ? 'Admitted' : 'Not Admitted',
|
| 46 |
+
fees: admissionRecord ? parseFloat(admissionRecord.fees || 0) : 0,
|
| 47 |
+
lead_score: lead.lead_score || 50,
|
| 48 |
+
created_date: lead.created_at,
|
| 49 |
+
gender: lead.gender || '',
|
| 50 |
+
city: lead.city || '',
|
| 51 |
+
fees_discussed: lead.fees_discussed ? parseFloat(lead.fees_discussed) : 0,
|
| 52 |
+
remarks: lead.remarks || '',
|
| 53 |
+
student_id: lead.student_id || '',
|
| 54 |
+
referred_by_student_name: lead.referred_by_student_name || ''
|
| 55 |
+
};
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
res.json(formattedLeads);
|
| 59 |
+
} catch (error) {
|
| 60 |
+
console.error('Error fetching leads:', error);
|
| 61 |
+
res.status(500).json({ error: 'Internal Server Error' });
|
| 62 |
+
}
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
router.get('/test', async (req, res) => {
|
| 66 |
+
try {
|
| 67 |
+
const db = require('../db');
|
| 68 |
+
const { data: leads, error } = await db
|
| 69 |
+
.from('active_leads')
|
| 70 |
+
.select(`
|
| 71 |
+
*,
|
| 72 |
+
student_id
|
| 73 |
+
`)
|
| 74 |
+
.order('created_at', { ascending: false });
|
| 75 |
+
|
| 76 |
+
if (error) throw error;
|
| 77 |
+
res.json(leads);
|
| 78 |
+
} catch (error) {
|
| 79 |
+
console.error('Error fetching leads test:', error);
|
| 80 |
+
res.status(500).json({ error: 'Internal Server Error' });
|
| 81 |
+
}
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
// 2. POST /api/leads - Create a new lead directly in Supabase
|
| 85 |
+
router.post('/', requireAuth, async (req, res) => {
|
| 86 |
+
const {
|
| 87 |
+
student_name,
|
| 88 |
+
phone_number,
|
| 89 |
+
email,
|
| 90 |
+
interested_course,
|
| 91 |
+
lead_source,
|
| 92 |
+
counselor_id,
|
| 93 |
+
followup_status,
|
| 94 |
+
followup_time,
|
| 95 |
+
gender,
|
| 96 |
+
city,
|
| 97 |
+
fees_discussed,
|
| 98 |
+
remarks,
|
| 99 |
+
referred_by_student_name
|
| 100 |
+
} = req.body;
|
| 101 |
+
|
| 102 |
+
if (!student_name || !phone_number || !email) {
|
| 103 |
+
return res.status(400).json({ error: 'Student name, phone number, and email are required' });
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
try {
|
| 107 |
+
const db = getTenantDb(req);
|
| 108 |
+
const orgId = req.user?.organization_id || '00000000-0000-0000-0000-000000000001';
|
| 109 |
+
const branchId = req.user?.branch_id || '00000000-0000-0000-0000-000000000002';
|
| 110 |
+
|
| 111 |
+
console.log('--- LEAD CREATION DEBUG ---');
|
| 112 |
+
console.log('Incoming Payload:', req.body);
|
| 113 |
+
console.log('Phone Number Received:', phone_number);
|
| 114 |
+
console.log('Organization ID Received:', orgId);
|
| 115 |
+
|
| 116 |
+
// Auto-generate student ID always
|
| 117 |
+
const { generateNextStudentId } = require('./idGenerator');
|
| 118 |
+
const nextId = await generateNextStudentId();
|
| 119 |
+
|
| 120 |
+
const newLead = {
|
| 121 |
+
name: student_name,
|
| 122 |
+
student_id: nextId,
|
| 123 |
+
phone: phone_number,
|
| 124 |
+
email: email,
|
| 125 |
+
course_interested: interested_course || '',
|
| 126 |
+
source: lead_source || 'Direct Walk-in',
|
| 127 |
+
counselor_id: counselor_id || null,
|
| 128 |
+
status: followup_status || 'Pending',
|
| 129 |
+
followup_time: followup_time || 'One Day',
|
| 130 |
+
gender: gender || '',
|
| 131 |
+
city: city || '',
|
| 132 |
+
fees_discussed: fees_discussed ? parseFloat(fees_discussed) : null,
|
| 133 |
+
remarks: remarks || '',
|
| 134 |
+
referred_by_student_name: lead_source === 'Student Referral' ? referred_by_student_name : null,
|
| 135 |
+
organization_id: orgId,
|
| 136 |
+
branch_id: branchId,
|
| 137 |
+
lead_score: 50
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
const { data: createdData, error } = await db
|
| 141 |
+
.from('leads')
|
| 142 |
+
.insert([newLead])
|
| 143 |
+
.select();
|
| 144 |
+
|
| 145 |
+
if (error) throw error;
|
| 146 |
+
const createdLead = createdData[0];
|
| 147 |
+
|
| 148 |
+
// Log in audit logs
|
| 149 |
+
await db.from('audit_logs').insert([{
|
| 150 |
+
organization_id: orgId,
|
| 151 |
+
branch_id: branchId,
|
| 152 |
+
user_id: req.user?.id || null,
|
| 153 |
+
action: 'CREATE_LEAD',
|
| 154 |
+
entity_type: 'leads',
|
| 155 |
+
entity_id: createdLead.id,
|
| 156 |
+
new_values: newLead
|
| 157 |
+
}]);
|
| 158 |
+
|
| 159 |
+
// Publish event to decoupled Event Bus
|
| 160 |
+
eventBus.publish('lead.status_changed', {
|
| 161 |
+
lead_id: createdLead.id,
|
| 162 |
+
student_name: createdLead.name,
|
| 163 |
+
phone: createdLead.phone,
|
| 164 |
+
old_status: null,
|
| 165 |
+
new_status: createdLead.status,
|
| 166 |
+
followup_time: createdLead.followup_time,
|
| 167 |
+
organization_id: orgId,
|
| 168 |
+
branch_id: branchId
|
| 169 |
+
});
|
| 170 |
+
|
| 171 |
+
// Fetch counselor name for GSheet
|
| 172 |
+
let counselorName = '';
|
| 173 |
+
if (counselor_id) {
|
| 174 |
+
const { data: cData } = await db.from('counselors').select('name').eq('id', counselor_id);
|
| 175 |
+
if (cData && cData.length > 0) counselorName = cData[0].name;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
// Automatically add to Google Sheet
|
| 179 |
+
appendLeadToSheet({
|
| 180 |
+
name: newLead.name,
|
| 181 |
+
phone: newLead.phone,
|
| 182 |
+
email: newLead.email,
|
| 183 |
+
course_interested: newLead.course_interested,
|
| 184 |
+
source: newLead.source,
|
| 185 |
+
status: newLead.status,
|
| 186 |
+
counselor_name: counselorName,
|
| 187 |
+
followup_time: newLead.followup_time,
|
| 188 |
+
remarks: newLead.remarks,
|
| 189 |
+
student_id: newLead.student_id
|
| 190 |
+
}).catch(err => console.error('Failed to append to GSheet:', err));
|
| 191 |
+
|
| 192 |
+
res.status(201).json({ message: 'Lead created successfully', lead: createdLead });
|
| 193 |
+
} catch (error) {
|
| 194 |
+
console.error('Error creating lead:', error, JSON.stringify(error));
|
| 195 |
+
return res.status(500).json({
|
| 196 |
+
success: false,
|
| 197 |
+
error: error.message || 'Internal Server Error',
|
| 198 |
+
details: error.details || null
|
| 199 |
+
});
|
| 200 |
+
}
|
| 201 |
+
});
|
| 202 |
+
|
| 203 |
+
// Legacy / compatibility route for update-lead (MUST be registered before dynamic param /:id)
|
| 204 |
+
router.put('/update-lead', requireAuth, async (req, res) => {
|
| 205 |
+
const { phone_number, followup_status, admission_status, followup_time } = req.body;
|
| 206 |
+
|
| 207 |
+
if (!phone_number) {
|
| 208 |
+
return res.status(400).json({ error: 'Phone number is required' });
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
try {
|
| 212 |
+
const db = getTenantDb(req);
|
| 213 |
+
|
| 214 |
+
const { data: leads, error: findError } = await db
|
| 215 |
+
.from('leads')
|
| 216 |
+
.select('id, name, course_interested, status, counselor_id, organization_id, branch_id')
|
| 217 |
+
.eq('phone', phone_number);
|
| 218 |
+
|
| 219 |
+
if (findError) throw findError;
|
| 220 |
+
|
| 221 |
+
if (!leads || leads.length === 0) {
|
| 222 |
+
return res.status(404).json({ error: 'Lead not found' });
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
const lead = leads[0];
|
| 226 |
+
|
| 227 |
+
// Map legacy update call to our PUT /:id updates
|
| 228 |
+
const updates = {};
|
| 229 |
+
if (followup_status) updates.status = followup_status;
|
| 230 |
+
if (followup_time) updates.followup_time = followup_time;
|
| 231 |
+
|
| 232 |
+
if (Object.keys(updates).length > 0) {
|
| 233 |
+
const { error: updateLeadError } = await db
|
| 234 |
+
.from('leads')
|
| 235 |
+
.update(updates)
|
| 236 |
+
.eq('id', lead.id);
|
| 237 |
+
|
| 238 |
+
if (updateLeadError) throw updateLeadError;
|
| 239 |
+
|
| 240 |
+
// Log followup details if status changed
|
| 241 |
+
if (followup_status && followup_status !== lead.status) {
|
| 242 |
+
await db.from('follow_ups').insert([{
|
| 243 |
+
lead_id: lead.id,
|
| 244 |
+
organization_id: lead.organization_id,
|
| 245 |
+
branch_id: lead.branch_id,
|
| 246 |
+
followup_date: new Date().toISOString(),
|
| 247 |
+
followup_type: 'Call',
|
| 248 |
+
status: followup_status === 'Pending' ? 'Pending' : 'Completed',
|
| 249 |
+
remarks: `Status updated via legacy API to ${followup_status}`,
|
| 250 |
+
created_by: lead.counselor_id || req.user?.id || null
|
| 251 |
+
}]);
|
| 252 |
+
|
| 253 |
+
// Publish event to decoupled Event Bus
|
| 254 |
+
eventBus.publish('lead.status_changed', {
|
| 255 |
+
lead_id: lead.id,
|
| 256 |
+
student_name: lead.name,
|
| 257 |
+
phone: phone_number,
|
| 258 |
+
old_status: lead.status,
|
| 259 |
+
new_status: followup_status,
|
| 260 |
+
followup_time: followup_time || 'One Day',
|
| 261 |
+
organization_id: lead.organization_id,
|
| 262 |
+
branch_id: lead.branch_id
|
| 263 |
+
});
|
| 264 |
+
}
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
// Update admissions status
|
| 268 |
+
if (admission_status) {
|
| 269 |
+
const { data: existingAdmissions, error: admSelectError } = await db
|
| 270 |
+
.from('admissions')
|
| 271 |
+
.select('id')
|
| 272 |
+
.eq('lead_id', lead.id);
|
| 273 |
+
|
| 274 |
+
if (admSelectError) throw admSelectError;
|
| 275 |
+
|
| 276 |
+
const hasAdmissionRecord = existingAdmissions && existingAdmissions.length > 0;
|
| 277 |
+
|
| 278 |
+
if (admission_status === 'Admitted' && !hasAdmissionRecord) {
|
| 279 |
+
// Trigger Lead Converted Event Bus sequence
|
| 280 |
+
eventBus.publish('lead.converted', {
|
| 281 |
+
lead_id: lead.id,
|
| 282 |
+
student_name: lead.name,
|
| 283 |
+
phone: phone_number,
|
| 284 |
+
course: lead.course_interested || 'Default Course',
|
| 285 |
+
counselor_id: lead.counselor_id,
|
| 286 |
+
organization_id: lead.organization_id,
|
| 287 |
+
branch_id: lead.branch_id
|
| 288 |
+
});
|
| 289 |
+
} else if (admission_status === 'Not Admitted' && hasAdmissionRecord) {
|
| 290 |
+
// Remove admission details
|
| 291 |
+
const { error: deleteAdmError } = await db
|
| 292 |
+
.from('admissions')
|
| 293 |
+
.delete()
|
| 294 |
+
.eq('lead_id', lead.id);
|
| 295 |
+
if (deleteAdmError) throw deleteAdmError;
|
| 296 |
+
}
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
res.json({ message: 'Lead updated successfully' });
|
| 300 |
+
} catch (error) {
|
| 301 |
+
console.error('Error updating lead:', error);
|
| 302 |
+
return res.status(500).json({
|
| 303 |
+
success: false,
|
| 304 |
+
error: error.message || 'Internal Server Error',
|
| 305 |
+
details: error.details || null
|
| 306 |
+
});
|
| 307 |
+
}
|
| 308 |
+
});
|
| 309 |
+
|
| 310 |
+
// 3. PUT /api/leads/:id - Update an existing lead record
|
| 311 |
+
router.put('/:id', requireAuth, async (req, res) => {
|
| 312 |
+
const { id } = req.params;
|
| 313 |
+
const {
|
| 314 |
+
student_id,
|
| 315 |
+
student_name,
|
| 316 |
+
phone_number,
|
| 317 |
+
email,
|
| 318 |
+
interested_course,
|
| 319 |
+
lead_source,
|
| 320 |
+
counselor_id,
|
| 321 |
+
followup_status,
|
| 322 |
+
followup_time,
|
| 323 |
+
gender,
|
| 324 |
+
city,
|
| 325 |
+
fees_discussed,
|
| 326 |
+
remarks,
|
| 327 |
+
referred_by_student_name
|
| 328 |
+
} = req.body;
|
| 329 |
+
|
| 330 |
+
if (email === "") {
|
| 331 |
+
return res.status(400).json({ error: 'Email is required' });
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
try {
|
| 335 |
+
const db = getTenantDb(req);
|
| 336 |
+
|
| 337 |
+
const { data: currentLeads, error: getError } = await db
|
| 338 |
+
.from('leads')
|
| 339 |
+
.select('*')
|
| 340 |
+
.eq('id', id);
|
| 341 |
+
|
| 342 |
+
if (getError) throw getError;
|
| 343 |
+
if (!currentLeads || currentLeads.length === 0) {
|
| 344 |
+
return res.status(404).json({ error: 'Lead not found' });
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
const currentLead = currentLeads[0];
|
| 348 |
+
|
| 349 |
+
const updates = {};
|
| 350 |
+
if (student_id !== undefined) updates.student_id = student_id === "" ? null : student_id;
|
| 351 |
+
if (student_name !== undefined) updates.name = student_name;
|
| 352 |
+
if (phone_number !== undefined) updates.phone = phone_number;
|
| 353 |
+
if (email !== undefined) updates.email = email;
|
| 354 |
+
if (interested_course !== undefined) updates.course_interested = interested_course;
|
| 355 |
+
if (lead_source !== undefined) updates.source = lead_source;
|
| 356 |
+
if (counselor_id !== undefined) updates.counselor_id = (counselor_id === "" || counselor_id === "null" || counselor_id === null) ? null : counselor_id;
|
| 357 |
+
if (followup_status !== undefined) updates.status = followup_status;
|
| 358 |
+
if (followup_time !== undefined) updates.followup_time = followup_time;
|
| 359 |
+
if (gender !== undefined) updates.gender = gender;
|
| 360 |
+
if (city !== undefined) updates.city = city;
|
| 361 |
+
if (fees_discussed !== undefined) {
|
| 362 |
+
if (fees_discussed === "" || fees_discussed === null || fees_discussed === "null") {
|
| 363 |
+
updates.fees_discussed = null;
|
| 364 |
+
} else {
|
| 365 |
+
const parsed = parseFloat(fees_discussed);
|
| 366 |
+
updates.fees_discussed = isNaN(parsed) ? null : parsed;
|
| 367 |
+
}
|
| 368 |
+
}
|
| 369 |
+
if (remarks !== undefined) updates.remarks = remarks;
|
| 370 |
+
if (referred_by_student_name !== undefined) updates.referred_by_student_name = referred_by_student_name;
|
| 371 |
+
|
| 372 |
+
const { error: updateError } = await db
|
| 373 |
+
.from('leads')
|
| 374 |
+
.update(updates)
|
| 375 |
+
.eq('id', id);
|
| 376 |
+
|
| 377 |
+
if (updateError) throw updateError;
|
| 378 |
+
|
| 379 |
+
// Log audit log
|
| 380 |
+
await db.from('audit_logs').insert([{
|
| 381 |
+
organization_id: currentLead.organization_id,
|
| 382 |
+
branch_id: currentLead.branch_id,
|
| 383 |
+
user_id: req.user?.id || null,
|
| 384 |
+
action: 'UPDATE_LEAD',
|
| 385 |
+
entity_type: 'leads',
|
| 386 |
+
entity_id: id,
|
| 387 |
+
old_values: currentLead,
|
| 388 |
+
new_values: { ...currentLead, ...updates }
|
| 389 |
+
}]);
|
| 390 |
+
|
| 391 |
+
// Check status change or details change
|
| 392 |
+
if (followup_status && followup_status !== currentLead.status) {
|
| 393 |
+
// Log follow up history
|
| 394 |
+
await db.from('follow_ups').insert([{
|
| 395 |
+
lead_id: id,
|
| 396 |
+
organization_id: currentLead.organization_id,
|
| 397 |
+
branch_id: currentLead.branch_id,
|
| 398 |
+
followup_date: new Date().toISOString(),
|
| 399 |
+
followup_type: 'Call',
|
| 400 |
+
status: followup_status === 'Pending' ? 'Pending' : 'Completed',
|
| 401 |
+
remarks: remarks || `Status updated via CRM edit to ${followup_status}`,
|
| 402 |
+
created_by: counselor_id || currentLead.counselor_id || req.user?.id || null
|
| 403 |
+
}]);
|
| 404 |
+
|
| 405 |
+
// Publish event
|
| 406 |
+
eventBus.publish('lead.status_changed', {
|
| 407 |
+
lead_id: id,
|
| 408 |
+
student_name: student_name || currentLead.name,
|
| 409 |
+
phone: phone_number || currentLead.phone,
|
| 410 |
+
old_status: currentLead.status,
|
| 411 |
+
new_status: followup_status,
|
| 412 |
+
followup_time: followup_time || currentLead.followup_time,
|
| 413 |
+
organization_id: currentLead.organization_id,
|
| 414 |
+
branch_id: currentLead.branch_id
|
| 415 |
+
});
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
res.json({ message: 'Lead updated successfully' });
|
| 419 |
+
} catch (error) {
|
| 420 |
+
console.error('Error updating lead:', error);
|
| 421 |
+
return res.status(500).json({
|
| 422 |
+
success: false,
|
| 423 |
+
error: error.message || 'Internal Server Error',
|
| 424 |
+
details: error.details || null
|
| 425 |
+
});
|
| 426 |
+
}
|
| 427 |
+
});
|
| 428 |
+
|
| 429 |
+
// 4. DELETE /api/leads/:id - Soft delete a lead
|
| 430 |
+
router.delete('/:id', requireAuth, async (req, res) => {
|
| 431 |
+
const { id } = req.params;
|
| 432 |
+
|
| 433 |
+
try {
|
| 434 |
+
const db = getTenantDb(req);
|
| 435 |
+
|
| 436 |
+
const { data: currentLeads, error: getError } = await db
|
| 437 |
+
.from('leads')
|
| 438 |
+
.select('*')
|
| 439 |
+
.eq('id', id);
|
| 440 |
+
|
| 441 |
+
if (getError) throw getError;
|
| 442 |
+
if (!currentLeads || currentLeads.length === 0) {
|
| 443 |
+
return res.status(404).json({ error: 'Lead not found' });
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
const currentLead = currentLeads[0];
|
| 447 |
+
|
| 448 |
+
// Soft delete: update deleted_at
|
| 449 |
+
const { error: deleteError } = await db
|
| 450 |
+
.from('leads')
|
| 451 |
+
.update({ deleted_at: new Date().toISOString() })
|
| 452 |
+
.eq('id', id);
|
| 453 |
+
|
| 454 |
+
if (deleteError) throw deleteError;
|
| 455 |
+
|
| 456 |
+
// Log audit log
|
| 457 |
+
await db.from('audit_logs').insert([{
|
| 458 |
+
organization_id: currentLead.organization_id,
|
| 459 |
+
branch_id: currentLead.branch_id,
|
| 460 |
+
user_id: req.user?.id || null,
|
| 461 |
+
action: 'DELETE_LEAD',
|
| 462 |
+
entity_type: 'leads',
|
| 463 |
+
entity_id: id,
|
| 464 |
+
old_values: currentLead,
|
| 465 |
+
new_values: { deleted_at: new Date().toISOString() }
|
| 466 |
+
}]);
|
| 467 |
+
|
| 468 |
+
res.json({ message: 'Lead soft-deleted successfully' });
|
| 469 |
+
} catch (error) {
|
| 470 |
+
console.error('Error deleting lead:', error);
|
| 471 |
+
res.status(500).json({ error: 'Internal Server Error' });
|
| 472 |
+
}
|
| 473 |
+
});
|
| 474 |
+
|
| 475 |
+
// 5. POST /api/leads/follow-up - Create a new follow-up and update lead status
|
| 476 |
+
router.post('/follow-up', requireAuth, async (req, res) => {
|
| 477 |
+
const { lead_id, followup_type, status, remarks, next_followup_time } = req.body;
|
| 478 |
+
|
| 479 |
+
if (!lead_id) {
|
| 480 |
+
return res.status(400).json({ error: 'Lead ID is required' });
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
try {
|
| 484 |
+
const db = getTenantDb(req);
|
| 485 |
+
|
| 486 |
+
const { data: leads, error: getError } = await db
|
| 487 |
+
.from('leads')
|
| 488 |
+
.select('*')
|
| 489 |
+
.eq('id', lead_id);
|
| 490 |
+
|
| 491 |
+
if (getError) throw getError;
|
| 492 |
+
if (!leads || leads.length === 0) {
|
| 493 |
+
return res.status(404).json({ error: 'Lead not found' });
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
const lead = leads[0];
|
| 497 |
+
const orgId = lead.organization_id;
|
| 498 |
+
const branchId = lead.branch_id;
|
| 499 |
+
|
| 500 |
+
// Insert follow-up record
|
| 501 |
+
const { data: followUpData, error: followUpError } = await db
|
| 502 |
+
.from('follow_ups')
|
| 503 |
+
.insert([{
|
| 504 |
+
lead_id,
|
| 505 |
+
organization_id: orgId,
|
| 506 |
+
branch_id: branchId,
|
| 507 |
+
followup_date: new Date().toISOString(),
|
| 508 |
+
followup_type: followup_type || 'Call',
|
| 509 |
+
status: status || 'Completed',
|
| 510 |
+
remarks: remarks || '',
|
| 511 |
+
created_by: req.user?.id || lead.counselor_id || null
|
| 512 |
+
}])
|
| 513 |
+
.select();
|
| 514 |
+
|
| 515 |
+
if (followUpError) throw followUpError;
|
| 516 |
+
|
| 517 |
+
// Update lead notes / followup_time / status
|
| 518 |
+
const updates = {};
|
| 519 |
+
if (status && status !== lead.status) {
|
| 520 |
+
updates.status = status;
|
| 521 |
+
}
|
| 522 |
+
if (next_followup_time) {
|
| 523 |
+
updates.followup_time = next_followup_time;
|
| 524 |
+
}
|
| 525 |
+
if (remarks) {
|
| 526 |
+
updates.remarks = remarks;
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
if (Object.keys(updates).length > 0) {
|
| 530 |
+
const { error: updateError } = await db
|
| 531 |
+
.from('leads')
|
| 532 |
+
.update(updates)
|
| 533 |
+
.eq('id', lead_id);
|
| 534 |
+
|
| 535 |
+
if (updateError) throw updateError;
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
// Log audit log
|
| 539 |
+
await db.from('audit_logs').insert([{
|
| 540 |
+
organization_id: orgId,
|
| 541 |
+
branch_id: branchId,
|
| 542 |
+
user_id: req.user?.id || null,
|
| 543 |
+
action: 'ADD_FOLLOW_UP',
|
| 544 |
+
entity_type: 'follow_ups',
|
| 545 |
+
entity_id: followUpData[0].id,
|
| 546 |
+
new_values: { lead_id, status, remarks, next_followup_time }
|
| 547 |
+
}]);
|
| 548 |
+
|
| 549 |
+
// Publish event
|
| 550 |
+
eventBus.publish('lead.status_changed', {
|
| 551 |
+
lead_id,
|
| 552 |
+
student_name: lead.name,
|
| 553 |
+
phone: lead.phone,
|
| 554 |
+
old_status: lead.status,
|
| 555 |
+
new_status: status || lead.status,
|
| 556 |
+
followup_time: next_followup_time || lead.followup_time,
|
| 557 |
+
organization_id: orgId,
|
| 558 |
+
branch_id: branchId
|
| 559 |
+
});
|
| 560 |
+
|
| 561 |
+
res.status(201).json({ message: 'Follow-up logged successfully', followUp: followUpData[0] });
|
| 562 |
+
} catch (error) {
|
| 563 |
+
console.error('Error logging follow-up:', error);
|
| 564 |
+
res.status(500).json({ error: 'Internal Server Error' });
|
| 565 |
+
}
|
| 566 |
+
});
|
| 567 |
+
|
| 568 |
+
// 6. GET /api/leads/:id/timeline - Get timeline of events/followups for a lead
|
| 569 |
+
router.get('/:id/timeline', requireAuth, async (req, res) => {
|
| 570 |
+
try {
|
| 571 |
+
const db = getTenantDb(req);
|
| 572 |
+
|
| 573 |
+
// Fetch follow ups for this lead
|
| 574 |
+
const { data: followups, error: followupsError } = await db
|
| 575 |
+
.from('active_follow_ups')
|
| 576 |
+
.select('*')
|
| 577 |
+
.eq('lead_id', req.params.id)
|
| 578 |
+
.order('followup_date', { ascending: false });
|
| 579 |
+
|
| 580 |
+
if (followupsError) throw followupsError;
|
| 581 |
+
|
| 582 |
+
// Fetch audit logs for this lead
|
| 583 |
+
const { data: auditLogs, error: auditError } = await db
|
| 584 |
+
.from('audit_logs')
|
| 585 |
+
.select('*')
|
| 586 |
+
.eq('entity_id', req.params.id)
|
| 587 |
+
.eq('entity_type', 'leads')
|
| 588 |
+
.order('created_at', { ascending: false });
|
| 589 |
+
|
| 590 |
+
if (auditError) throw auditError;
|
| 591 |
+
|
| 592 |
+
// Fetch admission details to show if converted
|
| 593 |
+
const { data: admissions, error: admissionError } = await db
|
| 594 |
+
.from('active_admissions')
|
| 595 |
+
.select('*')
|
| 596 |
+
.eq('lead_id', req.params.id);
|
| 597 |
+
|
| 598 |
+
if (admissionError) throw admissionError;
|
| 599 |
+
|
| 600 |
+
// Merge and format into timeline events
|
| 601 |
+
const timeline = [];
|
| 602 |
+
|
| 603 |
+
// Add follow ups
|
| 604 |
+
(followups || []).forEach(f => {
|
| 605 |
+
timeline.push({
|
| 606 |
+
id: f.id,
|
| 607 |
+
type: 'followup',
|
| 608 |
+
date: f.followup_date,
|
| 609 |
+
title: `Follow-up (${f.followup_type})`,
|
| 610 |
+
description: f.remarks,
|
| 611 |
+
status: f.status
|
| 612 |
+
});
|
| 613 |
+
});
|
| 614 |
+
|
| 615 |
+
// Add audit logs
|
| 616 |
+
(auditLogs || []).forEach(a => {
|
| 617 |
+
let title = 'Lead Modified';
|
| 618 |
+
if (a.action === 'CREATE_LEAD') title = 'Lead Created';
|
| 619 |
+
if (a.action === 'DELETE_LEAD') title = 'Lead Deleted';
|
| 620 |
+
if (a.action === 'UPDATE_LEAD') title = 'Lead Updated';
|
| 621 |
+
|
| 622 |
+
timeline.push({
|
| 623 |
+
id: a.id,
|
| 624 |
+
type: 'audit',
|
| 625 |
+
date: a.created_at,
|
| 626 |
+
title: title,
|
| 627 |
+
description: `Action: ${a.action}`
|
| 628 |
+
});
|
| 629 |
+
});
|
| 630 |
+
|
| 631 |
+
// Add admission if exists
|
| 632 |
+
if (admissions && admissions.length > 0) {
|
| 633 |
+
const adm = admissions[0];
|
| 634 |
+
timeline.push({
|
| 635 |
+
id: adm.id,
|
| 636 |
+
type: 'admission',
|
| 637 |
+
date: adm.joined_date || adm.created_at || new Date().toISOString(),
|
| 638 |
+
title: 'Lead Converted to Admission 🎓',
|
| 639 |
+
description: `Enrolled in ${adm.course} | Fees: ₹${parseFloat(adm.fees || 0).toLocaleString()} | Status: ${adm.payment_status}`
|
| 640 |
+
});
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
// Sort descending by date
|
| 644 |
+
timeline.sort((a, b) => new Date(b.date) - new Date(a.date));
|
| 645 |
+
|
| 646 |
+
res.json(timeline);
|
| 647 |
+
} catch (error) {
|
| 648 |
+
console.error('Error fetching lead timeline:', error);
|
| 649 |
+
res.status(500).json({ error: 'Internal Server Error' });
|
| 650 |
+
}
|
| 651 |
+
});
|
| 652 |
+
|
| 653 |
+
module.exports = router;
|
services/notificationService.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const router = express.Router();
|
| 3 |
+
const { getTenantDb, requireAuth } = require('./auth');
|
| 4 |
+
const eventBus = require('./eventBus');
|
| 5 |
+
const supabase = require('../db');
|
| 6 |
+
|
| 7 |
+
// Helper to save notifications in background events
|
| 8 |
+
const addNotification = async (notif) => {
|
| 9 |
+
const orgId = notif.organization_id || '00000000-0000-0000-0000-000000000001';
|
| 10 |
+
const newNotif = {
|
| 11 |
+
organization_id: orgId,
|
| 12 |
+
branch_id: notif.branch_id || '00000000-0000-0000-0000-000000000002',
|
| 13 |
+
title: notif.title,
|
| 14 |
+
message: notif.message,
|
| 15 |
+
type: notif.type || 'SYSTEM_ALERT',
|
| 16 |
+
priority: notif.priority || 'Low',
|
| 17 |
+
is_read: false,
|
| 18 |
+
is_resolved: false,
|
| 19 |
+
action_url: notif.action_url || ''
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
try {
|
| 23 |
+
const { data, error } = await supabase
|
| 24 |
+
.from('notifications')
|
| 25 |
+
.insert([newNotif])
|
| 26 |
+
.select();
|
| 27 |
+
if (error) throw error;
|
| 28 |
+
console.log(`[NotificationService] Database notification logged: ${notif.title}`);
|
| 29 |
+
return data[0];
|
| 30 |
+
} catch (err) {
|
| 31 |
+
console.error('[NotificationService] Failed to save notification to database:', err);
|
| 32 |
+
return null;
|
| 33 |
+
}
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
/**
|
| 37 |
+
* Centrally registers and logs notifications in PostgreSQL database
|
| 38 |
+
*/
|
| 39 |
+
const initNotificationService = (io) => {
|
| 40 |
+
// ===================================================================
|
| 41 |
+
// 📥 EVENT BUS SUBSCRIBERS (Decoupled Automated System Alerts)
|
| 42 |
+
// ===================================================================
|
| 43 |
+
|
| 44 |
+
// 1. Auto Alert for Manual Enrollments
|
| 45 |
+
eventBus.subscribe('manual_admission.completed', async (payload) => {
|
| 46 |
+
const { student_name, student_id, course, pending_amount, installment_option, organization_id } = payload;
|
| 47 |
+
|
| 48 |
+
await addNotification({
|
| 49 |
+
organization_id,
|
| 50 |
+
title: "New admission completed 🎓",
|
| 51 |
+
message: `${student_name} enrolled in ${course} (ID: ${student_id}).`,
|
| 52 |
+
type: "ADMISSION_ALERT",
|
| 53 |
+
priority: "Medium",
|
| 54 |
+
action_url: "/admissions"
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
if (parseFloat(pending_amount || 0) > 0) {
|
| 58 |
+
await addNotification({
|
| 59 |
+
organization_id,
|
| 60 |
+
title: "Installment generated 💰",
|
| 61 |
+
message: `Pending balance of ₹${parseFloat(pending_amount).toLocaleString()} for ${student_name} (${installment_option || 'EMI'}).`,
|
| 62 |
+
type: "PAYMENT_ALERT",
|
| 63 |
+
priority: "High",
|
| 64 |
+
action_url: "/admissions"
|
| 65 |
+
});
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
// AI trends trigger alert
|
| 69 |
+
await addNotification({
|
| 70 |
+
organization_id,
|
| 71 |
+
title: `${course} conversion boost 📈`,
|
| 72 |
+
message: `AI Insight: ${course} enrollments showed a surge after campaign adjustments.`,
|
| 73 |
+
type: "AI_INSIGHT",
|
| 74 |
+
priority: "Medium",
|
| 75 |
+
action_url: "/ai-insights"
|
| 76 |
+
});
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
// 2. Alert for Payment Completions
|
| 80 |
+
eventBus.subscribe('payment.completed', async (payload) => {
|
| 81 |
+
const { student_name, course, organization_id } = payload;
|
| 82 |
+
await addNotification({
|
| 83 |
+
organization_id,
|
| 84 |
+
title: "Fee payment completed ✅",
|
| 85 |
+
message: `Received final fee payment installment from ${student_name} for course: ${course}.`,
|
| 86 |
+
type: "PAYMENT_ALERT",
|
| 87 |
+
priority: "Medium",
|
| 88 |
+
action_url: "/admissions"
|
| 89 |
+
});
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
// 3. Alert for Real-time counselor lead updates
|
| 93 |
+
eventBus.subscribe('lead.status_changed', async (payload) => {
|
| 94 |
+
const { student_name, new_status, organization_id } = payload;
|
| 95 |
+
|
| 96 |
+
if (new_status === 'Pending') {
|
| 97 |
+
await addNotification({
|
| 98 |
+
organization_id,
|
| 99 |
+
title: "Follow-up Overdue Alert 🔴",
|
| 100 |
+
message: `Lead ${student_name} followup has expired. Action required.`,
|
| 101 |
+
type: "SYSTEM_ALERT",
|
| 102 |
+
priority: "High",
|
| 103 |
+
action_url: "/leads"
|
| 104 |
+
});
|
| 105 |
+
}
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
return { addNotification };
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
// ===================================================================
|
| 112 |
+
// REST API Routes
|
| 113 |
+
// ===================================================================
|
| 114 |
+
|
| 115 |
+
// GET /api/notifications - Fetch all active notifications scoped by active tenant
|
| 116 |
+
router.get('/', requireAuth, async (req, res) => {
|
| 117 |
+
try {
|
| 118 |
+
const db = getTenantDb(req);
|
| 119 |
+
const { data, error } = await db
|
| 120 |
+
.from('active_notifications')
|
| 121 |
+
.select('*')
|
| 122 |
+
.order('created_at', { ascending: false });
|
| 123 |
+
|
| 124 |
+
if (error) throw error;
|
| 125 |
+
res.json(data || []);
|
| 126 |
+
} catch (error) {
|
| 127 |
+
console.error('Failed to fetch notifications:', error);
|
| 128 |
+
res.status(500).json({ error: 'Failed to fetch notifications' });
|
| 129 |
+
}
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
// PUT /api/notifications/:id/read - Mark alert as read
|
| 133 |
+
router.put('/:id/read', requireAuth, async (req, res) => {
|
| 134 |
+
const { id } = req.params;
|
| 135 |
+
try {
|
| 136 |
+
const db = getTenantDb(req);
|
| 137 |
+
const { error } = await db
|
| 138 |
+
.from('notifications')
|
| 139 |
+
.update({ is_read: true })
|
| 140 |
+
.eq('id', id);
|
| 141 |
+
|
| 142 |
+
if (error) throw error;
|
| 143 |
+
res.json({ success: true });
|
| 144 |
+
} catch (error) {
|
| 145 |
+
console.error('Failed to mark notification read:', error);
|
| 146 |
+
res.status(500).json({ error: 'Failed to update alert log status' });
|
| 147 |
+
}
|
| 148 |
+
});
|
| 149 |
+
|
| 150 |
+
// PUT /api/notifications/:id/resolve - Resolve active notification checklist item
|
| 151 |
+
router.put('/:id/resolve', requireAuth, async (req, res) => {
|
| 152 |
+
const { id } = req.params;
|
| 153 |
+
try {
|
| 154 |
+
const db = getTenantDb(req);
|
| 155 |
+
const { error } = await db
|
| 156 |
+
.from('notifications')
|
| 157 |
+
.update({ is_resolved: true, is_read: true })
|
| 158 |
+
.eq('id', id);
|
| 159 |
+
|
| 160 |
+
if (error) throw error;
|
| 161 |
+
res.json({ success: true });
|
| 162 |
+
} catch (error) {
|
| 163 |
+
console.error('Failed to resolve notification:', error);
|
| 164 |
+
res.status(500).json({ error: 'Failed to resolve notification logs' });
|
| 165 |
+
}
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
// POST /api/notifications/mark-all-read - Clear all unread notifications
|
| 169 |
+
router.post('/mark-all-read', requireAuth, async (req, res) => {
|
| 170 |
+
try {
|
| 171 |
+
const db = getTenantDb(req);
|
| 172 |
+
const { error } = await db
|
| 173 |
+
.from('notifications')
|
| 174 |
+
.update({ is_read: true })
|
| 175 |
+
.eq('is_read', false);
|
| 176 |
+
|
| 177 |
+
if (error) throw error;
|
| 178 |
+
res.json({ success: true });
|
| 179 |
+
} catch (error) {
|
| 180 |
+
console.error('Failed to mark all notifications read:', error);
|
| 181 |
+
res.status(500).json({ error: 'Failed to mark notifications read' });
|
| 182 |
+
}
|
| 183 |
+
});
|
| 184 |
+
|
| 185 |
+
module.exports = {
|
| 186 |
+
router,
|
| 187 |
+
initNotificationService
|
| 188 |
+
};
|
services/publicService.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const router = express.Router();
|
| 3 |
+
const dbAdmin = require('../db');
|
| 4 |
+
const eventBus = require('./eventBus');
|
| 5 |
+
const { calculateLeadScore } = require('./aiEngine');
|
| 6 |
+
|
| 7 |
+
// POST /api/public/leads - Capture public leads from website
|
| 8 |
+
router.post('/leads', async (req, res) => {
|
| 9 |
+
const {
|
| 10 |
+
student_name,
|
| 11 |
+
phone_number,
|
| 12 |
+
email,
|
| 13 |
+
interested_course,
|
| 14 |
+
city,
|
| 15 |
+
education,
|
| 16 |
+
preferred_time
|
| 17 |
+
} = req.body;
|
| 18 |
+
|
| 19 |
+
if (!student_name || !phone_number) {
|
| 20 |
+
return res.status(400).json({ error: 'Name and phone number are required' });
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
try {
|
| 24 |
+
// Determine initial score using the AI Engine
|
| 25 |
+
const leadScoreParams = {
|
| 26 |
+
lead_source: 'Website',
|
| 27 |
+
followup_status: 'Pending',
|
| 28 |
+
interested_course: interested_course || ''
|
| 29 |
+
};
|
| 30 |
+
const lead_score = calculateLeadScore(leadScoreParams);
|
| 31 |
+
|
| 32 |
+
const newLead = {
|
| 33 |
+
name: student_name,
|
| 34 |
+
phone: phone_number,
|
| 35 |
+
email: email || '',
|
| 36 |
+
course_interested: interested_course || '',
|
| 37 |
+
source: 'Website',
|
| 38 |
+
status: 'Pending',
|
| 39 |
+
followup_time: preferred_time || 'One Day',
|
| 40 |
+
city: city || '',
|
| 41 |
+
remarks: education ? `Education Background: ${education}` : '',
|
| 42 |
+
organization_id: '00000000-0000-0000-0000-000000000001',
|
| 43 |
+
branch_id: '00000000-0000-0000-0000-000000000002',
|
| 44 |
+
lead_score: lead_score
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
const { data: createdData, error } = await dbAdmin
|
| 48 |
+
.from('leads')
|
| 49 |
+
.insert([newLead])
|
| 50 |
+
.select();
|
| 51 |
+
|
| 52 |
+
if (error) throw error;
|
| 53 |
+
const createdLead = createdData[0];
|
| 54 |
+
|
| 55 |
+
// Log in audit logs
|
| 56 |
+
await dbAdmin.from('audit_logs').insert([{
|
| 57 |
+
organization_id: createdLead.organization_id,
|
| 58 |
+
branch_id: createdLead.branch_id,
|
| 59 |
+
user_id: null,
|
| 60 |
+
action: 'CREATE_LEAD_PUBLIC',
|
| 61 |
+
entity_type: 'leads',
|
| 62 |
+
entity_id: createdLead.id,
|
| 63 |
+
new_values: newLead
|
| 64 |
+
}]);
|
| 65 |
+
|
| 66 |
+
// Publish event to decoupled Event Bus
|
| 67 |
+
eventBus.publish('lead.status_changed', {
|
| 68 |
+
lead_id: createdLead.id,
|
| 69 |
+
student_name: createdLead.name,
|
| 70 |
+
phone: createdLead.phone,
|
| 71 |
+
old_status: null,
|
| 72 |
+
new_status: createdLead.status,
|
| 73 |
+
followup_time: createdLead.followup_time,
|
| 74 |
+
organization_id: createdLead.organization_id,
|
| 75 |
+
branch_id: createdLead.branch_id
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
res.status(201).json({ message: 'Lead created successfully', lead: createdLead });
|
| 79 |
+
} catch (error) {
|
| 80 |
+
console.error('Error creating public lead:', error);
|
| 81 |
+
if (error && error.code === '23505') {
|
| 82 |
+
return res.status(409).json({ error: 'You have already submitted an inquiry.' });
|
| 83 |
+
}
|
| 84 |
+
res.status(500).json({ error: 'Internal Server Error' });
|
| 85 |
+
}
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
module.exports = router;
|
services/whatsappService.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const { requireAuth, getTenantDb } = require('./auth');
|
| 3 |
+
|
| 4 |
+
const router = express.Router();
|
| 5 |
+
|
| 6 |
+
// GET /api/whatsapp/templates
|
| 7 |
+
router.get('/templates', requireAuth, async (req, res) => {
|
| 8 |
+
try {
|
| 9 |
+
const db = getTenantDb(req);
|
| 10 |
+
const { data, error } = await db
|
| 11 |
+
.from('message_templates')
|
| 12 |
+
.select('*')
|
| 13 |
+
.is('deleted_at', null)
|
| 14 |
+
.order('created_at', { ascending: true });
|
| 15 |
+
|
| 16 |
+
if (error) throw error;
|
| 17 |
+
res.json(data);
|
| 18 |
+
} catch (error) {
|
| 19 |
+
console.error('Error fetching templates:', error);
|
| 20 |
+
res.status(500).json({ error: 'Failed to fetch templates' });
|
| 21 |
+
}
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
// POST /api/whatsapp/activity
|
| 25 |
+
router.post('/activity', requireAuth, async (req, res) => {
|
| 26 |
+
try {
|
| 27 |
+
const { lead_id, activity_type, description, counselor_id } = req.body;
|
| 28 |
+
|
| 29 |
+
if (!lead_id || !activity_type || !description) {
|
| 30 |
+
return res.status(400).json({ error: 'Missing required fields' });
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const db = getTenantDb(req);
|
| 34 |
+
|
| 35 |
+
const { data, error } = await db.from('lead_activities').insert([
|
| 36 |
+
{
|
| 37 |
+
lead_id,
|
| 38 |
+
activity_type,
|
| 39 |
+
description,
|
| 40 |
+
created_by: counselor_id || null
|
| 41 |
+
}
|
| 42 |
+
]).select();
|
| 43 |
+
|
| 44 |
+
if (error) throw error;
|
| 45 |
+
|
| 46 |
+
// We can also trigger an eventbus event here if needed
|
| 47 |
+
// const { eventBus } = require('./notificationService');
|
| 48 |
+
// eventBus.emit('lead.activity_logged', { lead_id, activity_type });
|
| 49 |
+
|
| 50 |
+
res.json(data[0]);
|
| 51 |
+
} catch (error) {
|
| 52 |
+
console.error('Error logging WhatsApp activity:', error);
|
| 53 |
+
res.status(500).json({ error: 'Failed to log activity' });
|
| 54 |
+
}
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
// GET /api/whatsapp/metrics (Optional, can be used if we decouple stats)
|
| 58 |
+
// We will integrate metrics directly in /api/stats instead.
|
| 59 |
+
|
| 60 |
+
module.exports = router;
|