Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/controllers/analyticsController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
const analyticsService = require('../services/analyticsService');

const parseOverviewDate = (value, isEndDate = false) => {
if (value === undefined) return null;
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;

const date = new Date(`${value}T00:00:00.000Z`);
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) return undefined;
if (isEndDate) date.setUTCHours(23, 59, 59, 999);
return date;
};

// Controller for analytics endpoints
const getOverview = async (req, res) => {
try {
const startDate = parseOverviewDate(req.query.startDate);
const endDate = parseOverviewDate(req.query.endDate, true);
if (startDate === undefined || endDate === undefined) {
return res.status(400).json({ error: 'Dates must use YYYY-MM-DD format' });
}
if (startDate && endDate && startDate > endDate) {
return res.status(400).json({ error: 'startDate must be before or equal to endDate' });
}

const overview = await analyticsService.getOverview({
studentId: req.query.studentId,
classId: req.query.classId,
startDate,
endDate,
});
// 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' });
}
};

const refreshStudentMetrics = async (req, res) => {
try {
const { studentId } = req.params;
if (!studentId) return res.status(400).json({ error: 'Missing studentId' });

const metrics = await analyticsService.refreshStudentMetrics(studentId);
return res.json({ studentId, metrics });
} catch (error) {
console.error('Error refreshing student metrics:', error);
return res.status(500).json({ error: 'Internal Server Error' });
}
};

module.exports = {
getOverview,
getStudentMetrics,
refreshStudentMetrics,
};
89 changes: 89 additions & 0 deletions src/controllers/analyticsController.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
jest.mock('../services/analyticsService');

const analyticsService = require('../services/analyticsService');
const { getOverview, getStudentMetrics, refreshStudentMetrics } = require('./analyticsController');

const response = () => ({
set: jest.fn(),
json: jest.fn(),
status: jest.fn().mockReturnThis(),
});

describe('analyticsController student metrics', () => {
beforeEach(() => jest.clearAllMocks());

test('GET returns metrics without calling the persistent refresh path', async () => {
const res = response();
analyticsService.getStudentMetrics.mockResolvedValue({ averageScore: 90 });

await getStudentMetrics({ params: { studentId: 'student-1' }, query: {} }, res);

expect(analyticsService.getStudentMetrics).toHaveBeenCalledWith('student-1', {
forceRefresh: false,
});
expect(res.json).toHaveBeenCalledWith({
studentId: 'student-1',
metrics: { averageScore: 90 },
});
expect(analyticsService.refreshStudentMetrics).not.toHaveBeenCalled();
});

test('POST refresh returns explicitly persisted metrics', async () => {
const res = response();
analyticsService.refreshStudentMetrics.mockResolvedValue({ averageScore: 95 });

await refreshStudentMetrics({ params: { studentId: 'student-1' } }, res);

expect(analyticsService.refreshStudentMetrics).toHaveBeenCalledWith('student-1');
expect(res.json).toHaveBeenCalledWith({
studentId: 'student-1',
metrics: { averageScore: 95 },
});
});

test('GET overview passes validated filters with an inclusive end date', async () => {
const res = response();
analyticsService.getOverview.mockResolvedValue({ totalStudents: 1 });

await getOverview(
{
query: {
studentId: 'student-1',
classId: 'group-1',
startDate: '2026-09-02',
endDate: '2026-09-02',
},
},
res,
);

expect(analyticsService.getOverview).toHaveBeenCalledWith({
studentId: 'student-1',
classId: 'group-1',
startDate: new Date('2026-09-02T00:00:00.000Z'),
endDate: new Date('2026-09-02T23:59:59.999Z'),
});
expect(res.json).toHaveBeenCalledWith({ totalStudents: 1 });
});

test.each(['not-a-date', '2026-02-30', '09-02-2026'])(
'GET overview rejects invalid date %s',
async (startDate) => {
const res = response();

await getOverview({ query: { startDate } }, res);

expect(res.status).toHaveBeenCalledWith(400);
expect(analyticsService.getOverview).not.toHaveBeenCalled();
},
);

test('GET overview rejects reversed date ranges', async () => {
const res = response();

await getOverview({ query: { startDate: '2026-09-03', endDate: '2026-09-02' } }, res);

expect(res.status).toHaveBeenCalledWith(400);
expect(analyticsService.getOverview).not.toHaveBeenCalled();
});
});
37 changes: 37 additions & 0 deletions src/jobs/studentMetricsJob.js
Original file line number Diff line number Diff line change
@@ -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 };
22 changes: 22 additions & 0 deletions src/models/studentMetrics.js
Original file line number Diff line number Diff line change
@@ -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');
16 changes: 16 additions & 0 deletions src/routes/analyticsRouter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
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);

// POST /analytics/student/:studentId/refresh
router.post('/student/:studentId/refresh', analyticsController.refreshStudentMetrics);

module.exports = router;
2 changes: 2 additions & 0 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading