Files
starr-philproperties/apps/api/controllers/staff/scores.ctrl.js
T

264 lines
11 KiB
JavaScript

/***********************************************************************************************************************************************************************
* 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,
};