/*********************************************************************************************************************************************************************** * File Name: scores.ctrl.js (staff) * Type of Program: Controller * Description: Staff-scoped scores and progress endpoints. * Staff can view: * - Task completion per user per task list * - Quiz attempt scores (UnitQuiz) * - Assessment attempt scores (CourseAssessment) * - A combined progress summary per group/task list ***********************************************************************************************************************************************************************/ const { Op } = require('sequelize'); const sequelize = require('../../config/db.config'); const mdl_Users = require('../../models/users/users.mdl'); const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); const { Task, TaskList, TaskListGroup } = require('../../models/task/task.mdl'); const TaskCompletion = require('../../models/scores/task_completions.mdl'); const QuizAttempt = require('../../models/scores/quiz_attempts.mdl'); const AssessmentAttempt = require('../../models/scores/assessment_attempts.mdl'); const UnitQuiz = require('../../models/courses/unit_quiz.mdl'); const CourseAssessment = require('../../models/courses/course_assessment.mdl'); const { Unit } = require('../../models/courses/units.mdl'); const { Course } = require('../../models/courses/courses.mdl'); // ─── Helper ─────────────────────────────────────────────────────────────────── async function getStaffGroupIds(staffUserId) { const memberships = await mdl_UserGroupMembers.findAll({ where: { user_id: staffUserId }, attributes: ['group_id'], raw: true, }); return memberships.map(m => m.group_id); } // ════════════════════════════════════════════════════════════════════════════════ // TASK COMPLETION TRACKING // ════════════════════════════════════════════════════════════════════════════════ /** * GET /api/staff/progress/task-list/:task_list_id * Returns completion status for ALL members in the groups attached to this task list. * Response shape: { members: [{ user, tasks: [{ task, completion }] }] } */ const getTaskListProgress = async (req, res) => { try { const staffUserId = req.user.user_id; const { task_list_id } = req.params; const scopedGroupIds = await getStaffGroupIds(staffUserId); // Verify staff has access to this task list const access = await TaskListGroup.findOne({ where: { task_list_id, group_id: { [Op.in]: scopedGroupIds } }, }); if (!access) return res.status(403).json({ success: false, message: 'No access to this task list.' }); // Get all groups linked to this task list (within staff's scope) const linkedGroups = await TaskListGroup.findAll({ where: { task_list_id, group_id: { [Op.in]: scopedGroupIds } }, attributes: ['group_id'], raw: true, }); const linkedGroupIds = linkedGroups.map(g => g.group_id); // Get all unique members in those groups const memberships = await mdl_UserGroupMembers.findAll({ where: { group_id: { [Op.in]: linkedGroupIds } }, attributes: ['user_id'], raw: true, }); const memberIds = [...new Set(memberships.map(m => m.user_id))]; // Get all tasks in the task list const tasks = await Task.findAll({ where: { task_list_id }, attributes: ['task_id', 'name', 'deadline', 'status'] }); // Get all completions for these tasks + members const completions = await TaskCompletion.findAll({ where: { task_id: { [Op.in]: tasks.map(t => t.task_id) }, user_id: { [Op.in]: memberIds }, }, attributes: ['task_id', 'user_id', 'status', 'completed_at'], raw: true, }); // Index completions: { user_id: { task_id: completion } } const completionIndex = {}; completions.forEach(c => { if (!completionIndex[c.user_id]) completionIndex[c.user_id] = {}; completionIndex[c.user_id][c.task_id] = c; }); // Fetch user info const users = await mdl_Users.findAll({ where: { user_id: { [Op.in]: memberIds } }, attributes: ['user_id', 'email', 'personal_info'], }); // Build response const data = users.map(u => ({ user: u, tasks: tasks.map(t => ({ task: t, completion: completionIndex[u.user_id]?.[t.task_id] ?? { status: 'pending', completed_at: null }, })), completed_count: tasks.filter(t => completionIndex[u.user_id]?.[t.task_id]?.status === 'completed').length, total_tasks: tasks.length, })); return res.status(200).json({ success: true, task_list_id, data }); } catch (err) { console.error('[staff/scores] getTaskListProgress error:', err); return res.status(500).json({ success: false, message: 'Internal server error.' }); } }; // ════════════════════════════════════════════════════════════════════════════════ // QUIZ SCORES // ════════════════════════════════════════════════════════════════════════════════ /** * GET /api/staff/scores/quiz/:quiz_id * Returns all users' quiz attempts for a given UnitQuiz. * Shows latest attempt + best score per user. */ const getQuizScores = async (req, res) => { try { const staffUserId = req.user.user_id; const quizId = parseInt(req.params.quiz_id); const scopedGroupIds = await getStaffGroupIds(staffUserId); // Get all members in the staff's groups const memberships = await mdl_UserGroupMembers.findAll({ where: { group_id: { [Op.in]: scopedGroupIds } }, attributes: ['user_id'], raw: true, }); const memberIds = [...new Set(memberships.map(m => m.user_id))]; const quiz = await UnitQuiz.findByPk(quizId, { include: [{ model: Unit, as: 'unit', attributes: ['unit_id', 'title'] }], }); if (!quiz) return res.status(404).json({ success: false, message: 'Quiz not found.' }); // All attempts by members const attempts = await QuizAttempt.findAll({ where: { quiz_id: quizId, user_id: { [Op.in]: memberIds } }, include: [ { model: mdl_Users, as: 'user', attributes: ['user_id', 'email', 'personal_info'] }, ], order: [['user_id', 'ASC'], ['attempt_number', 'DESC']], }); // Group by user, extract best + latest const byUser = {}; attempts.forEach(a => { const uid = a.user_id; if (!byUser[uid]) byUser[uid] = { user: a.user, attempts: [], best_score: null, latest: null }; byUser[uid].attempts.push(a); if (byUser[uid].best_score === null || a.score > byUser[uid].best_score) { byUser[uid].best_score = a.score; } if (!byUser[uid].latest || a.attempt_number > byUser[uid].latest.attempt_number) { byUser[uid].latest = a; } }); // Include members who haven't attempted yet const attemptedIds = new Set(Object.keys(byUser).map(Number)); const nonAttempted = memberIds.filter(id => !attemptedIds.has(id)); const nonAttemptedUsers = await mdl_Users.findAll({ where: { user_id: { [Op.in]: nonAttempted } }, attributes: ['user_id', 'email', 'personal_info'], }); nonAttemptedUsers.forEach(u => { byUser[u.user_id] = { user: u, attempts: [], best_score: null, latest: null }; }); return res.status(200).json({ success: true, quiz: { quiz_id: quiz.quiz_id, title: quiz.title, passing_score: quiz.passing_score, unit: quiz.unit }, data: Object.values(byUser), }); } catch (err) { console.error('[staff/scores] getQuizScores error:', err); return res.status(500).json({ success: false, message: 'Internal server error.' }); } }; // ════════════════════════════════════════════════════════════════════════════════ // ASSESSMENT SCORES // ════════════════════════════════════════════════════════════════════════════════ /** * GET /api/staff/scores/assessment/:assessment_id * Returns all users' assessment attempts for a CourseAssessment. */ const getAssessmentScores = async (req, res) => { try { const staffUserId = req.user.user_id; const assessmentId = parseInt(req.params.assessment_id); const scopedGroupIds = await getStaffGroupIds(staffUserId); const memberships = await mdl_UserGroupMembers.findAll({ where: { group_id: { [Op.in]: scopedGroupIds } }, attributes: ['user_id'], raw: true, }); const memberIds = [...new Set(memberships.map(m => m.user_id))]; const assessment = await CourseAssessment.findByPk(assessmentId, { include: [{ model: Course, as: 'course', attributes: ['title', 'course_code'] }], }); if (!assessment) return res.status(404).json({ success: false, message: 'Assessment not found.' }); const attempts = await AssessmentAttempt.findAll({ where: { assessment_id: assessmentId, user_id: { [Op.in]: memberIds } }, include: [ { model: mdl_Users, as: 'user', attributes: ['user_id', 'email', 'personal_info'] }, ], order: [['user_id', 'ASC'], ['attempt_number', 'DESC']], }); const byUser = {}; attempts.forEach(a => { const uid = a.user_id; if (!byUser[uid]) byUser[uid] = { user: a.user, attempts: [], best_score: null, latest: null }; byUser[uid].attempts.push(a); if (byUser[uid].best_score === null || a.score > byUser[uid].best_score) byUser[uid].best_score = a.score; if (!byUser[uid].latest || a.attempt_number > byUser[uid].latest.attempt_number) byUser[uid].latest = a; }); const attemptedIds = new Set(Object.keys(byUser).map(Number)); const nonAttempted = memberIds.filter(id => !attemptedIds.has(id)); const nonAttemptedUsers = await mdl_Users.findAll({ where: { user_id: { [Op.in]: nonAttempted } }, attributes: ['user_id', 'email', 'personal_info'], }); nonAttemptedUsers.forEach(u => { byUser[u.user_id] = { user: u, attempts: [], best_score: null, latest: null }; }); return res.status(200).json({ success: true, assessment: { assessment_id: assessment.assessment_id, title: assessment.title, passing_score: assessment.passing_score, course: assessment.course, }, data: Object.values(byUser), }); } catch (err) { console.error('[staff/scores] getAssessmentScores error:', err); return res.status(500).json({ success: false, message: 'Internal server error.' }); } }; module.exports = { getTaskListProgress, getQuizScores, getAssessmentScores, };