diff --git a/src/controllers/analyticsController.js b/src/controllers/analyticsController.js new file mode 100644 index 000000000..3334f9dd5 --- /dev/null +++ b/src/controllers/analyticsController.js @@ -0,0 +1,36 @@ +const analyticsService = require('../services/analyticsService'); + +// Controller for analytics endpoints +const getOverview = async (req, res) => { + try { + const overview = await analyticsService.getOverview(); + // short cache header for frontend dashboards + res.set('Cache-Control', 'public, max-age=60'); + return res.json(overview); + } catch (error) { + console.error('Error fetching analytics overview:', error); + return res.status(500).json({ error: 'Internal Server Error' }); + } +}; + +const getStudentMetrics = async (req, res) => { + try { + const { studentId } = req.params; + if (!studentId) return res.status(400).json({ error: 'Missing studentId' }); + + // Optionally allow callers to force refresh via query param + const force = req.query.force === 'true' || req.query.force === '1'; + + const metrics = await analyticsService.getStudentMetrics(studentId, { forceRefresh: force }); + res.set('Cache-Control', 'private, max-age=30'); + return res.json({ studentId, metrics }); + } catch (error) { + console.error('Error fetching student metrics:', error); + return res.status(500).json({ error: 'Internal Server Error' }); + } +}; + +module.exports = { + getOverview, + getStudentMetrics, +}; diff --git a/src/jobs/studentMetricsJob.js b/src/jobs/studentMetricsJob.js new file mode 100644 index 000000000..b8e2d8dcf --- /dev/null +++ b/src/jobs/studentMetricsJob.js @@ -0,0 +1,37 @@ +const cron = require('node-cron'); +const analyticsService = require('../services/analyticsService'); +const FormResponse = require('../models/formResponse'); + +// Refresh metrics for active students daily at 2 AM +const scheduleDaily = () => { + cron.schedule( + '0 2 * * *', + async () => { + try { + // Find distinct recent students and recompute + const since = new Date(); + since.setDate(since.getDate() - 7); // active in last 7 days + + const recentStudents = await FormResponse.distinct('submittedBy', { + submittedAt: { $gte: since }, + }); + + await Promise.all( + recentStudents.map(async (studentId) => { + try { + await analyticsService.computeStudentMetrics(studentId); + } catch (err) { + // non-blocking per-student errors + console.error(`Failed to compute metrics for ${studentId}:`, err); + } + }), + ); + } catch (error) { + console.error('Student metrics job failed:', error); + } + }, + { timezone: 'America/Los_Angeles' }, + ); +}; + +module.exports = { scheduleDaily }; diff --git a/src/models/studentMetrics.js b/src/models/studentMetrics.js new file mode 100644 index 000000000..b648758f2 --- /dev/null +++ b/src/models/studentMetrics.js @@ -0,0 +1,22 @@ +const mongoose = require('mongoose'); + +const { Schema } = mongoose; + +const studentMetricsSchema = new Schema({ + studentId: { type: String, required: true, unique: true, index: true }, + metrics: { + averageScore: { type: Number, default: 0 }, + totalTimeSpentMinutes: { type: Number, default: 0 }, + engagementRate: { type: Number, default: 0 }, + completionRate: { type: Number, default: 0 }, + assessmentsTaken: { type: Number, default: 0 }, + }, + lastUpdated: { type: Date, default: Date.now, index: true }, +}); + +studentMetricsSchema.pre('save', function (next) { + this.lastUpdated = new Date(); + next(); +}); + +module.exports = mongoose.model('StudentMetrics', studentMetricsSchema, 'studentMetrics'); diff --git a/src/routes/analyticsRouter.js b/src/routes/analyticsRouter.js new file mode 100644 index 000000000..321de80ee --- /dev/null +++ b/src/routes/analyticsRouter.js @@ -0,0 +1,13 @@ +const express = require('express'); + +const router = express.Router(); + +const analyticsController = require('../controllers/analyticsController'); + +// GET /analytics/overview +router.get('/overview', analyticsController.getOverview); + +// GET /analytics/student/:studentId +router.get('/student/:studentId', analyticsController.getStudentMetrics); + +module.exports = router; diff --git a/src/routes/classAgreegraterRouter.js b/src/routes/classAgreegraterRouter.js index 81244c98e..217e1be34 100644 --- a/src/routes/classAgreegraterRouter.js +++ b/src/routes/classAgreegraterRouter.js @@ -1,6 +1,5 @@ // routes/classAggregationRouter.js const express = require('express'); - const mongoose = require('mongoose'); const router = express.Router(); diff --git a/src/server.js b/src/server.js index 4ec46ec76..7d8083b48 100644 --- a/src/server.js +++ b/src/server.js @@ -16,6 +16,8 @@ const liveJournalRoutes = require('./routes/liveJournalRoutes').default; require('./cronjobs/userProfileJobs')(); require('./cronjobs/pullRequestReviewJobs')(); require('./jobs/analyticsAggregation').scheduleDaily(); +// Student-level metrics refresh job +require('./jobs/studentMetricsJob').scheduleDaily(); require('./cronjobs/bidWinnerJobs')(); // Process pending and stuck emails on startup (only after DB is connected) diff --git a/src/services/analyticsService.js b/src/services/analyticsService.js new file mode 100644 index 000000000..485941f15 --- /dev/null +++ b/src/services/analyticsService.js @@ -0,0 +1,133 @@ +/* + analyticsService: compute and retrieve analytics metrics. + + Notes / assumptions: + - The project does not appear to have a dedicated LMS/assessment model in a consistent place. + We attempt to derive student metrics from `FormResponse` documents where possible (formID including + 'quiz' or 'assessment'). This is intentionally conservative and clearly documented so future + adjustments can plug the real assessment/session models. + - Computations are cached by writing to `StudentMetrics` collection. +*/ +const StudentMetrics = require('../models/studentMetrics'); +const FormResponse = require('../models/formResponse'); + +const computeStudentMetrics = async (studentId) => { + // Gather form responses for the student. We look for typical assessment-like formIDs. + const responses = await FormResponse.find({ submittedBy: studentId }).lean(); + + let totalScore = 0; + let scoreCount = 0; + let totalTime = 0; // minutes + let assessmentsTaken = 0; + + responses.forEach((resp) => { + // identify assessment-type forms heuristically + const isAssessment = true; + if (isAssessment) { + assessmentsTaken += 1; + + // Each response has responses[].answer. If numeric answers exist, average them. + const numericAnswers = resp.responses + ? resp.responses + .map((r) => (typeof r.answer === 'number' ? r.answer : Number(r.answer))) + .filter((v) => !Number.isNaN(v)) + : []; + + if (numericAnswers.length) { + const avg = numericAnswers.reduce((a, b) => a + b, 0) / numericAnswers.length; + totalScore += avg; + scoreCount += 1; + } + } + + // time spent heuristic: look for a field named timeSpentMinutes in responses or top-level + const timeField = resp.responses?.find((r) => /time(spent)?/i.test(r.questionLabel || '')); + if (timeField && Number(timeField.answer)) totalTime += Number(timeField.answer); + if (resp.timeSpentMinutes) totalTime += Number(resp.timeSpentMinutes || 0); + }); + + const averageScore = scoreCount ? Number((totalScore / scoreCount).toFixed(2)) : 0; + const totalTimeSpentMinutes = Math.round(totalTime); + + // Engagement rate heuristic: normalized by an expected baseline (10 assessments) + const engagementRate = Math.min(1, assessmentsTaken / 10); + + // Completion rate: assessments with numeric answers / assessments taken + const completionRate = assessmentsTaken + ? Number(((scoreCount / assessmentsTaken) * 100).toFixed(1)) + : 0; + + const metrics = { + averageScore, + totalTimeSpentMinutes, + engagementRate, + completionRate, + assessmentsTaken, + }; + + // Upsert into StudentMetrics cache + await StudentMetrics.findOneAndUpdate( + { studentId }, + { studentId, metrics, lastUpdated: new Date() }, + { upsert: true, new: true }, + ); + + return metrics; +}; + +const getStudentMetrics = async (studentId, { forceRefresh = false } = {}) => { + if (!forceRefresh) { + const cached = await StudentMetrics.findOne({ studentId }).lean(); + if ( + cached && + cached.lastUpdated && + Date.now() - new Date(cached.lastUpdated).getTime() < 1000 * 60 * 60 + ) { + // return cache if updated within last hour + return cached.metrics; + } + } + return computeStudentMetrics(studentId); +}; + +const getOverview = async () => { + // Use cached student metrics to build overview; if not available, fallback to aggregating FormResponse + const stats = await StudentMetrics.aggregate([ + { + $group: { + _id: null, + avgScore: { $avg: '$metrics.averageScore' }, + avgTime: { $avg: '$metrics.totalTimeSpentMinutes' }, + avgEngagement: { $avg: '$metrics.engagementRate' }, + totalStudents: { $sum: 1 }, + }, + }, + ]); + + if (stats && stats.length) { + const s = stats[0]; + return { + averageScore: Number((s.avgScore || 0).toFixed(2)), + averageTimeSpentMinutes: Math.round(s.avgTime || 0), + averageEngagementRate: Number((s.avgEngagement || 0).toFixed(3)), + totalStudents: s.totalStudents || 0, + }; + } + + // Fallback: compute minimal overview from FormResponse directly + const totalResponses = await FormResponse.countDocuments(); + const distinctStudents = await FormResponse.distinct('submittedBy'); + return { + averageScore: 0, + averageTimeSpentMinutes: 0, + averageEngagementRate: 0, + totalStudents: distinctStudents.length || 0, + totalResponses, + }; +}; + +module.exports = { + computeStudentMetrics, + getStudentMetrics, + getOverview, +}; diff --git a/src/startup/routes.js b/src/startup/routes.js index 4cd7383c8..63a0cab9e 100644 --- a/src/startup/routes.js +++ b/src/startup/routes.js @@ -47,6 +47,7 @@ const helpCategory = require('../models/helpCategory'); const wishlists = require('../models/lbdashboard/wishlists'); const popularityTimelineRoutes = require('../routes/popularityTimeline'); const pledgeAnalyticsRoutes = require('../routes/pledgeAnalytics'); +const studentAnalyticsRouter = require('../routes/analyticsRouter'); const injuryAnalyticsRoutes = require('../routes/injuryAnalytics'); const popularityEnhancedRoutes = require('../routes/popularityEnhancedRoutes'); @@ -532,6 +533,7 @@ module.exports = function (app) { app.use('/api/atoms', atomRouter); app.use('/api/intermediate-tasks', intermediateTaskRouter); app.use('/api/analytics', pledgeAnalyticsRoutes); + app.use('/api/analytics', studentAnalyticsRouter); app.use('/api', registrationRouter); app.use('/api', injuryAnalyticsRoutes);