mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
added
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: groups.ctrl.js (staff)
|
||||
* Type of Program: Controller
|
||||
* Description: Staff-scoped group endpoints.
|
||||
* Returns only the groups the logged-in staff member belongs to,
|
||||
* along with their members and associated task lists.
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Op } = require('sequelize');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
const { TaskList, Task, TaskRequirement, TaskListGroup } = require('../../models/task/task.mdl');
|
||||
const { getFieldValues } = require("../../utils/fieldValues.util");
|
||||
const { paginate } = require('../../utils/paginate.util');
|
||||
const R = require('../../utils/response.util');
|
||||
const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas, computedAttributes: userComputed } = require('../../models/users/users.attributes');
|
||||
|
||||
/**
|
||||
* GET /api/staff/groups
|
||||
* Returns all groups the staff member belongs to,
|
||||
* with member count and associated task lists.
|
||||
*/
|
||||
const getMyGroups = async (req, res) => {
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
|
||||
const groups = await mdl_UserGroups.findAll({
|
||||
include: [
|
||||
{
|
||||
model: mdl_UserGroupMembers,
|
||||
required: true, // INNER JOIN — only groups the staff member is in
|
||||
where: { user_id: staffUserId },
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
model: mdl_Users,
|
||||
as: 'members',
|
||||
attributes: ['user_id', 'email', 'personal_info', 'is_active'],
|
||||
through: { attributes: ['joined_at'] },
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: TaskList,
|
||||
as: 'taskLists',
|
||||
attributes: ['task_list_id', 'name', 'description', 'createdAt'],
|
||||
through: { attributes: ['assignedAt'] },
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: Task,
|
||||
as: 'tasks',
|
||||
attributes: ['task_id', 'name', 'deadline', 'status'],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
where: { is_active: true },
|
||||
order: [['name', 'ASC']],
|
||||
});
|
||||
|
||||
// Add member_count computed field
|
||||
const data = groups.map(g => ({
|
||||
...g.toJSON(),
|
||||
member_count: g.members?.length ?? 0,
|
||||
}));
|
||||
|
||||
return res.status(200).json({ success: true, data });
|
||||
} catch (err) {
|
||||
console.error('[staff/groups] getMyGroups error:', err);
|
||||
return res.status(500).json({ success: false, message: 'Internal server error.' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/staff/groups/:group_id
|
||||
* Returns a single group's full details — members + task lists.
|
||||
* Staff must be a member of this group.
|
||||
*/
|
||||
const getGroupById = async (req, res) => {
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const groupId = parseInt(req.params.group_id);
|
||||
|
||||
// Verify staff membership
|
||||
const membership = await mdl_UserGroupMembers.findOne({
|
||||
where: { user_id: staffUserId, group_id: groupId },
|
||||
});
|
||||
if (!membership) {
|
||||
return res.status(403).json({ success: false, message: 'You are not a member of this group.' });
|
||||
}
|
||||
|
||||
const group = await mdl_UserGroups.findOne({
|
||||
where: { group_id: groupId, is_active: true },
|
||||
include: [
|
||||
{
|
||||
model: mdl_Users,
|
||||
as: 'members',
|
||||
attributes: ['user_id', 'email', 'personal_info', 'is_active', 'acc_type'],
|
||||
through: { attributes: ['joined_at'] },
|
||||
},
|
||||
{
|
||||
model: TaskList,
|
||||
as: 'taskLists',
|
||||
attributes: ['task_list_id', 'name', 'description', 'createdAt'],
|
||||
through: { attributes: ['assignedAt'] },
|
||||
include: [
|
||||
{
|
||||
model: Task,
|
||||
as: 'tasks',
|
||||
attributes: ['task_id', 'name', 'description', 'deadline', 'status'],
|
||||
include: [
|
||||
{
|
||||
model: TaskRequirement,
|
||||
as: 'requirements',
|
||||
attributes: ['requirement_id', 'type', 'link_url', 'link_label', 'reference_id', 'reference_label', 'order'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!group) return res.status(404).json({ success: false, message: 'Group not found.' });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: { ...group.toJSON(), member_count: group.members?.length ?? 0 },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[staff/groups] getGroupById error:', err);
|
||||
return res.status(500).json({ success: false, message: 'Internal server error.' });
|
||||
}
|
||||
};
|
||||
|
||||
// GET /staff/groups/:group_id/members
|
||||
// Paginated, searchable, filterable — mirrors getUsers pattern
|
||||
const getGroupMembers = async (req, res) => {
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const groupId = parseInt(req.params.group_id);
|
||||
|
||||
// Verify staff is a member of this group
|
||||
const membership = await mdl_UserGroupMembers.findOne({
|
||||
where: { user_id: staffUserId, group_id: groupId },
|
||||
});
|
||||
if (!membership) {
|
||||
return res.status(403).json({ success: false, message: 'You are not a member of this group.' });
|
||||
}
|
||||
|
||||
// Confirm group exists
|
||||
const groupExists = await mdl_UserGroups.findOne({
|
||||
where: { group_id: groupId, is_active: true },
|
||||
attributes: ['group_id'],
|
||||
});
|
||||
if (!groupExists) {
|
||||
return res.status(404).json({ success: false, message: 'Group not found.' });
|
||||
}
|
||||
|
||||
const result = await paginate(mdl_Users, req, {
|
||||
excludeAttributes: usersExclude,
|
||||
jsonbSchemas: usersSchemas,
|
||||
jsonbColumn: 'personal_info',
|
||||
computedAttributes: userComputed,
|
||||
context: "list",
|
||||
findOptions: {
|
||||
include: [
|
||||
{
|
||||
model: mdl_UserGroups,
|
||||
as: 'groups',
|
||||
through: { attributes: [] },
|
||||
attributes: ['group_id', 'name', 'group_code'],
|
||||
required: true, // INNER JOIN — only users in this group
|
||||
where: { group_id: groupId },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Group members retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[staff/groups] getGroupMembers error:', err);
|
||||
return R.error(res, 'Could not retrieve group members.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/staff/groups/by-code/:group_code
|
||||
* Convenience endpoint — look up a group by its invite code.
|
||||
* Staff must still be a member.
|
||||
*/
|
||||
const getGroupByCode = async (req, res) => {
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const groupCode = req.params.group_code.toUpperCase().trim();
|
||||
|
||||
const group = await mdl_UserGroups.findOne({
|
||||
where: { group_code: groupCode, is_active: true },
|
||||
});
|
||||
|
||||
if (!group) return res.status(404).json({ success: false, message: 'Group not found.' });
|
||||
|
||||
// Verify staff membership
|
||||
const membership = await mdl_UserGroupMembers.findOne({
|
||||
where: { user_id: staffUserId, group_id: group.group_id },
|
||||
});
|
||||
if (!membership) {
|
||||
return res.status(403).json({ success: false, message: 'You are not a member of this group.' });
|
||||
}
|
||||
|
||||
// Reuse getGroupById logic by patching req.params
|
||||
req.params.group_id = group.group_id;
|
||||
return getGroupById(req, res);
|
||||
} catch (err) {
|
||||
console.error('[staff/groups] getGroupByCode error:', err);
|
||||
return res.status(500).json({ success: false, message: 'Internal server error.' });
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET /staff/groups/:group_id/members/field-values ────────────────────────
|
||||
const getGroupMemberFieldValues = async (req, res) => {
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const groupId = parseInt(req.params.group_id);
|
||||
|
||||
const membership = await mdl_UserGroupMembers.findOne({
|
||||
where: { user_id: staffUserId, group_id: groupId },
|
||||
});
|
||||
if (!membership) {
|
||||
return res.status(403).json({ success: false, message: "You are not a member of this group." });
|
||||
}
|
||||
|
||||
const groupExists = await mdl_UserGroups.findOne({
|
||||
where: { group_id: groupId, is_active: true },
|
||||
attributes: ["group_id"],
|
||||
});
|
||||
if (!groupExists) {
|
||||
return res.status(404).json({ success: false, message: "Group not found." });
|
||||
}
|
||||
|
||||
return getFieldValues(mdl_Users, "USER", {
|
||||
blockedFields: ["password", "otp_code", "otp_expires_at", "personal_info"],
|
||||
extraDateFields: ["modifiedAt"],
|
||||
allowJsonb: true,
|
||||
selfJoin: true,
|
||||
// Scope distinct values to this group only
|
||||
baseWhere: {
|
||||
"$groups.group_id$": groupId,
|
||||
},
|
||||
associations: {
|
||||
groups: {
|
||||
model: mdl_UserGroups,
|
||||
labelField: "name",
|
||||
valueField: "name",
|
||||
// INNER JOIN so values are scoped to group members only
|
||||
required: true,
|
||||
where: { group_id: groupId },
|
||||
},
|
||||
},
|
||||
})(req, res);
|
||||
} catch (err) {
|
||||
console.error("[staff/groups] getGroupMemberFieldValues error:", err);
|
||||
return R.error(res, "Could not retrieve field values.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getMyGroups, getGroupById, getGroupByCode, getGroupMembers, getGroupMemberFieldValues };
|
||||
@@ -0,0 +1,263 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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: ['title', 'course_id'] }],
|
||||
});
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,813 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: tasks.ctrl.js (staff)
|
||||
* Type of Program: Controller
|
||||
* Description: Staff-level task management — Task Lists and Tasks scoped to the
|
||||
* staff member's groups. Aligned with admin task.controller.js:
|
||||
* uses transactions, archiveOne/archiveMany, restoreOne/restoreMany,
|
||||
* paginate, and consistent R response helpers throughout.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: May 17, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Op } = require('sequelize');
|
||||
const sequelize = require('../../config/db.config');
|
||||
|
||||
const { Task, TaskList, TaskRequirement, TaskListGroup } = require('../../models/task/task.mdl');
|
||||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const { staffExclude, jsonbSchemas, computedAttributes } = require('../../models/task/task.attributes');
|
||||
|
||||
const R = require('../../utils/response.util');
|
||||
const { paginate } = require('../../utils/paginate.util');
|
||||
const { archiveOne, archiveMany } = require('../../utils/courses/archive.util');
|
||||
const { restoreOne, restoreMany } = require('../../utils/courses/restore.util');
|
||||
const { getFieldValues } = require('../../utils/fieldValues.util');
|
||||
|
||||
// ─── Allowed filter/sort fields ───────────────────────────────────────────────
|
||||
const TASK_LIST_FIELDS = ['name', 'description', 'createdAt', 'updatedAt', 'deletedAt'];
|
||||
const TASK_FIELDS = ['name', 'description', 'deadline', 'status', 'createdAt', 'updatedAt', 'deletedAt'];
|
||||
|
||||
// ─── Reusable group include ───────────────────────────────────────────────────
|
||||
const GROUP_INCLUDE = {
|
||||
model: mdl_UserGroups,
|
||||
as: 'groups',
|
||||
attributes: ['group_id', 'name', 'group_code'],
|
||||
through: {
|
||||
model: TaskListGroup,
|
||||
as: 'assignment',
|
||||
attributes: ['assignedAt'],
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Helper: get group IDs scoped to this staff member ───────────────────────
|
||||
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);
|
||||
}
|
||||
|
||||
// ─── Helper: assert staff has access to at least one group of a task list ────
|
||||
async function assertTaskListAccess(staffUserId, taskListId, t) {
|
||||
const scopedGroupIds = await getStaffGroupIds(staffUserId);
|
||||
if (!scopedGroupIds.length) return false;
|
||||
const access = await TaskListGroup.findOne({
|
||||
where: { task_list_id: taskListId, group_id: { [Op.in]: scopedGroupIds } },
|
||||
transaction: t,
|
||||
});
|
||||
return !!access;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ── TASK LISTS ────────────────────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getTaskLists = async (req, res) => {
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const { group_id } = req.query;
|
||||
|
||||
let scopedGroupIds = await getStaffGroupIds(staffUserId);
|
||||
if (!scopedGroupIds.length)
|
||||
return R.success(res, 'Task lists retrieved.', { rows: [], count: 0 });
|
||||
|
||||
if (group_id) {
|
||||
const gid = Number(group_id);
|
||||
if (!scopedGroupIds.map(Number).includes(gid))
|
||||
return R.error(res, 'No access to this group.', 403);
|
||||
scopedGroupIds = [gid];
|
||||
}
|
||||
|
||||
// Find task list IDs accessible to this staff member
|
||||
const assignments = await TaskListGroup.findAll({
|
||||
where: { group_id: { [Op.in]: scopedGroupIds } },
|
||||
attributes: ['task_list_id'],
|
||||
raw: true,
|
||||
});
|
||||
const accessibleIds = [...new Set(assignments.map((a) => a.task_list_id))];
|
||||
|
||||
const result = await paginate(TaskList, req, {
|
||||
excludeAttributes: staffExclude,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
auditOptions: { mdl_Users, parentAlias: 'TaskList' },
|
||||
allowedFields: TASK_LIST_FIELDS,
|
||||
findOptions: {
|
||||
distinct: true, // ← count parent rows, not JOIN rows
|
||||
col: 'task_list_id', // ← count on PK, not inflated join tuples
|
||||
where: { task_list_id: { [Op.in]: accessibleIds } },
|
||||
include: [GROUP_INCLUDE],
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Task lists retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[STAFF][GET ALL TASK LISTS]', err);
|
||||
return R.error(res, 'Could not retrieve task lists.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET ONE ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getTaskList = async (req, res) => {
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId)))
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
|
||||
const taskList = await TaskList.findByPk(taskListId, {
|
||||
attributes: { exclude: staffExclude },
|
||||
paranoid: false,
|
||||
include: [
|
||||
{
|
||||
model: Task, as: 'tasks', paranoid: false,
|
||||
include: [{
|
||||
model: TaskRequirement,
|
||||
as: 'requirements',
|
||||
paranoid: false,
|
||||
attributes: { exclude: staffExclude },
|
||||
order: [['order', 'ASC']],
|
||||
}],
|
||||
},
|
||||
GROUP_INCLUDE,
|
||||
],
|
||||
});
|
||||
|
||||
if (!taskList) return R.error(res, 'Task list not found.', 404);
|
||||
|
||||
const data = taskList.toJSON();
|
||||
data.group_count = data.groups?.length ?? 0;
|
||||
|
||||
return R.success(res, 'Task list retrieved.', data);
|
||||
} catch (err) {
|
||||
console.error('[STAFF][GET TASK LIST]', err);
|
||||
return R.error(res, 'Could not retrieve task list.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── CREATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.createTaskList = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const { name, description, group_ids } = req.body;
|
||||
|
||||
if (!name) { await t.rollback(); return R.error(res, 'Name is required.', 400); }
|
||||
if (!Array.isArray(group_ids) || !group_ids.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'group_ids is required.', 400);
|
||||
}
|
||||
|
||||
// Verify staff has access to all requested groups
|
||||
const scopedGroupIds = (await getStaffGroupIds(staffUserId)).map(String);
|
||||
const normalizedIds = group_ids.map(String);
|
||||
const unauthorized = normalizedIds.filter((id) => !scopedGroupIds.includes(id));
|
||||
if (unauthorized.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, `No access to group(s): ${unauthorized.join(', ')}`, 403);
|
||||
}
|
||||
|
||||
const taskList = await TaskList.create(
|
||||
{ name, description, createdBy: staffUserId, updatedBy: staffUserId },
|
||||
{ transaction: t }
|
||||
);
|
||||
|
||||
await TaskListGroup.bulkCreate(
|
||||
normalizedIds.map((gid) => ({
|
||||
task_list_id: taskList.task_list_id,
|
||||
group_id: gid,
|
||||
assignedAt: new Date(),
|
||||
assignedBy: staffUserId,
|
||||
})),
|
||||
{ transaction: t }
|
||||
);
|
||||
|
||||
await t.commit();
|
||||
|
||||
const full = await TaskList.findByPk(taskList.task_list_id, {
|
||||
attributes: { exclude: staffExclude },
|
||||
include: [GROUP_INCLUDE],
|
||||
});
|
||||
|
||||
return R.success(res, 'Task list created successfully.', full, 201);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][CREATE TASK LIST]', err);
|
||||
return R.error(res, 'Could not create task list.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.updateTaskList = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId, t))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
}
|
||||
|
||||
const taskList = await TaskList.findByPk(taskListId, { transaction: t });
|
||||
if (!taskList) { await t.rollback(); return R.error(res, 'Task list not found.', 404); }
|
||||
|
||||
const { name, description } = req.body;
|
||||
await taskList.update({ name, description, updatedBy: staffUserId }, { transaction: t });
|
||||
|
||||
await t.commit();
|
||||
return R.success(res, 'Task list updated successfully.', taskList);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][UPDATE TASK LIST]', err);
|
||||
return R.error(res, 'Could not update task list.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVE (soft-delete) ────────────────────────────────────────────────────
|
||||
|
||||
exports.archiveTaskList = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId, t))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
}
|
||||
|
||||
const record = await archiveOne(TaskList, { task_list_id: taskListId }, staffUserId, t);
|
||||
if (!record) { await t.rollback(); return R.error(res, 'Task list not found.', 404); }
|
||||
|
||||
await t.commit();
|
||||
return R.success(res, 'Task list archived successfully.');
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][ARCHIVE TASK LIST]', err);
|
||||
return R.error(res, 'Could not archive task list.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── RESTORE ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.restoreTaskList = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId, t))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
}
|
||||
|
||||
const record = await restoreOne(
|
||||
TaskList,
|
||||
{ task_list_id: taskListId, deletedAt: { [Op.not]: null } },
|
||||
staffUserId,
|
||||
t
|
||||
);
|
||||
if (!record) { await t.rollback(); return R.error(res, 'Task list not found or not archived.', 404); }
|
||||
|
||||
await t.commit();
|
||||
return R.success(res, 'Task list restored successfully.', record);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][RESTORE TASK LIST]', err);
|
||||
return R.error(res, 'Could not restore task list.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK ARCHIVE ─────────────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkArchiveTaskLists = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const { ids } = req.body;
|
||||
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No task list IDs provided.', 400);
|
||||
|
||||
// Scope: only IDs the staff can access
|
||||
const scopedGroupIds = await getStaffGroupIds(staffUserId);
|
||||
const assignments = await TaskListGroup.findAll({
|
||||
where: { task_list_id: { [Op.in]: ids }, group_id: { [Op.in]: scopedGroupIds } },
|
||||
attributes: ['task_list_id'],
|
||||
raw: true,
|
||||
});
|
||||
const accessibleIds = [...new Set(assignments.map((a) => String(a.task_list_id)))];
|
||||
const requestedIds = ids.map(String);
|
||||
const unauthorizedIds = requestedIds.filter((id) => !accessibleIds.includes(id));
|
||||
|
||||
if (!accessibleIds.length)
|
||||
return R.error(res, 'No accessible task lists found.', 404);
|
||||
|
||||
const taskLists = await TaskList.findAll({ where: { task_list_id: accessibleIds } });
|
||||
const activeIds = taskLists.filter((tl) => !tl.deletedAt).map((tl) => String(tl.task_list_id));
|
||||
|
||||
if (!activeIds.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'All selected task lists are already archived.', 400);
|
||||
}
|
||||
|
||||
const count = await archiveMany(TaskList, 'task_list_id', activeIds, staffUserId, t);
|
||||
await t.commit();
|
||||
return R.success(res, `${count} task list(s) archived successfully.`, {
|
||||
archived_ids: activeIds,
|
||||
skipped_ids: requestedIds.filter((id) => !activeIds.includes(id)),
|
||||
unauthorized_ids: unauthorizedIds,
|
||||
});
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][BULK ARCHIVE TASK LISTS]', err);
|
||||
return R.error(res, 'Could not archive task lists.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK RESTORE ─────────────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkRestoreTaskLists = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const { ids } = req.body;
|
||||
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No task list IDs provided.', 400);
|
||||
|
||||
// Scope: only IDs the staff can access
|
||||
const scopedGroupIds = await getStaffGroupIds(staffUserId);
|
||||
const assignments = await TaskListGroup.findAll({
|
||||
where: { task_list_id: { [Op.in]: ids }, group_id: { [Op.in]: scopedGroupIds } },
|
||||
attributes: ['task_list_id'],
|
||||
raw: true,
|
||||
});
|
||||
const accessibleIds = [...new Set(assignments.map((a) => String(a.task_list_id)))];
|
||||
const requestedIds = ids.map(String);
|
||||
const unauthorizedIds = requestedIds.filter((id) => !accessibleIds.includes(id));
|
||||
|
||||
if (!accessibleIds.length)
|
||||
return R.error(res, 'No accessible task lists found.', 404);
|
||||
|
||||
const taskLists = await TaskList.findAll({
|
||||
where: { task_list_id: accessibleIds },
|
||||
paranoid: false,
|
||||
});
|
||||
const deletedIds = taskLists.filter((tl) => tl.deletedAt).map((tl) => String(tl.task_list_id));
|
||||
|
||||
if (!deletedIds.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'All selected task lists are already active.', 400);
|
||||
}
|
||||
|
||||
const count = await restoreMany(TaskList, 'task_list_id', deletedIds, staffUserId, t);
|
||||
await t.commit();
|
||||
return R.success(res, `${count} task list(s) restored successfully.`, {
|
||||
restored_ids: deletedIds,
|
||||
skipped_ids: requestedIds.filter((id) => !deletedIds.includes(id)),
|
||||
unauthorized_ids: unauthorizedIds,
|
||||
});
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][BULK RESTORE TASK LISTS]', err);
|
||||
return R.error(res, 'Could not restore task lists.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET ARCHIVED TASK LISTS ──────────────────────────────────────────────────
|
||||
|
||||
exports.getArchivedTaskLists = async (req, res) => {
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const scopedGroupIds = await getStaffGroupIds(staffUserId);
|
||||
|
||||
if (!scopedGroupIds.length)
|
||||
return R.success(res, 'Archived task lists retrieved.', { rows: [], count: 0 });
|
||||
|
||||
const assignments = await TaskListGroup.findAll({
|
||||
where: { group_id: { [Op.in]: scopedGroupIds } },
|
||||
attributes: ['task_list_id'],
|
||||
raw: true,
|
||||
});
|
||||
const accessibleIds = [...new Set(assignments.map((a) => a.task_list_id))];
|
||||
|
||||
const result = await paginate(TaskList, req, {
|
||||
excludeAttributes: staffExclude,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
auditOptions: { mdl_Users, parentAlias: 'TaskList' },
|
||||
allowedFields: TASK_LIST_FIELDS,
|
||||
findOptions: {
|
||||
paranoid: false,
|
||||
where: {
|
||||
task_list_id: { [Op.in]: accessibleIds },
|
||||
deletedAt: { [Op.not]: null },
|
||||
},
|
||||
include: [GROUP_INCLUDE],
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Archived task lists retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[STAFF][GET ARCHIVED TASK LISTS]', err);
|
||||
return R.error(res, 'Could not retrieve archived task lists.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── TASKS ─────────────────────────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getTasks = async (req, res) => {
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId)))
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
|
||||
const result = await paginate(Task, req, {
|
||||
excludeAttributes: staffExclude,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
auditOptions: { mdl_Users, parentAlias: 'Task' },
|
||||
allowedFields: TASK_FIELDS,
|
||||
findOptions: {
|
||||
where: { task_list_id: taskListId },
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Tasks retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[STAFF][GET ALL TASKS]', err);
|
||||
return R.error(res, 'Could not retrieve tasks.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET ONE ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getTask = async (req, res) => {
|
||||
try {
|
||||
const { taskListId, taskId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId)))
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
|
||||
const task = await Task.findOne({
|
||||
where: { task_id: taskId, task_list_id: taskListId },
|
||||
attributes: { exclude: staffExclude },
|
||||
paranoid: false,
|
||||
include: [
|
||||
{
|
||||
model: TaskRequirement,
|
||||
as: 'requirements',
|
||||
paranoid: false,
|
||||
attributes: { exclude: staffExclude },
|
||||
order: [['order', 'ASC']],
|
||||
},
|
||||
{
|
||||
model: TaskList,
|
||||
as: 'taskList',
|
||||
paranoid: false,
|
||||
attributes: { exclude: staffExclude },
|
||||
include: [GROUP_INCLUDE],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!task) return R.error(res, 'Task not found.', 404);
|
||||
|
||||
return R.success(res, 'Task retrieved.', task);
|
||||
} catch (err) {
|
||||
console.error('[STAFF][GET TASK]', err);
|
||||
return R.error(res, 'Could not retrieve task.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── CREATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.createTask = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
const { name, description, deadline, requirements = [] } = req.body;
|
||||
|
||||
if (!name) { await t.rollback(); return R.error(res, 'Task name is required.', 400); }
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId, t))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
}
|
||||
|
||||
const taskList = await TaskList.findByPk(taskListId, { transaction: t });
|
||||
if (!taskList) { await t.rollback(); return R.error(res, 'Task list not found.', 404); }
|
||||
|
||||
const task = await Task.create(
|
||||
{
|
||||
task_list_id: taskListId,
|
||||
name,
|
||||
description,
|
||||
deadline: deadline || null,
|
||||
status: 'pending',
|
||||
createdBy: staffUserId,
|
||||
updatedBy: staffUserId,
|
||||
},
|
||||
{ transaction: t }
|
||||
);
|
||||
|
||||
if (requirements.length) {
|
||||
const reqRows = requirements.map((r, i) => ({
|
||||
...r,
|
||||
task_id: task.task_id,
|
||||
order: r.order ?? i,
|
||||
reference_id: r.reference_id || null,
|
||||
reference_label: r.reference_label || null,
|
||||
link_url: r.link_url || null,
|
||||
link_label: r.link_label || null,
|
||||
createdBy: staffUserId,
|
||||
updatedBy: staffUserId,
|
||||
}));
|
||||
await TaskRequirement.bulkCreate(reqRows, { transaction: t });
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
const full = await Task.findByPk(task.task_id, {
|
||||
attributes: { exclude: staffExclude },
|
||||
include: [{
|
||||
model: TaskRequirement,
|
||||
as: 'requirements',
|
||||
attributes: { exclude: staffExclude },
|
||||
order: [['order', 'ASC']],
|
||||
}],
|
||||
});
|
||||
|
||||
return R.success(res, 'Task created successfully.', full, 201);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][CREATE TASK]', err);
|
||||
return R.error(res, 'Could not create task.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.updateTask = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId, taskId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId, t))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
}
|
||||
|
||||
const task = await Task.findOne({
|
||||
where: { task_id: taskId, task_list_id: taskListId },
|
||||
transaction: t,
|
||||
});
|
||||
if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
|
||||
|
||||
const { name, description, deadline, status, requirements } = req.body;
|
||||
|
||||
await task.update(
|
||||
{ name, description, deadline: deadline || null, status, updatedBy: staffUserId },
|
||||
{ transaction: t }
|
||||
);
|
||||
|
||||
if (Array.isArray(requirements)) {
|
||||
await TaskRequirement.destroy({
|
||||
where: { task_id: taskId },
|
||||
force: false,
|
||||
transaction: t,
|
||||
});
|
||||
|
||||
if (requirements.length) {
|
||||
const reqRows = requirements.map((r, i) => ({
|
||||
...r,
|
||||
task_id: task.task_id,
|
||||
order: r.order ?? i,
|
||||
reference_id: r.reference_id || null,
|
||||
reference_label: r.reference_label || null,
|
||||
link_url: r.link_url || null,
|
||||
link_label: r.link_label || null,
|
||||
createdBy: staffUserId,
|
||||
updatedBy: staffUserId,
|
||||
}));
|
||||
await TaskRequirement.bulkCreate(reqRows, { transaction: t });
|
||||
}
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
const full = await Task.findByPk(taskId, {
|
||||
attributes: { exclude: staffExclude },
|
||||
include: [{
|
||||
model: TaskRequirement,
|
||||
as: 'requirements',
|
||||
attributes: { exclude: staffExclude },
|
||||
order: [['order', 'ASC']],
|
||||
}],
|
||||
});
|
||||
|
||||
return R.success(res, 'Task updated successfully.', full);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][UPDATE TASK]', err);
|
||||
return R.error(res, 'Could not update task.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVE (soft-delete) ────────────────────────────────────────────────────
|
||||
|
||||
exports.archiveTask = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId, taskId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId, t))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
}
|
||||
|
||||
const record = await archiveOne(
|
||||
Task,
|
||||
{ task_id: taskId, task_list_id: taskListId },
|
||||
staffUserId,
|
||||
t
|
||||
);
|
||||
if (!record) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
|
||||
|
||||
await t.commit();
|
||||
return R.success(res, 'Task archived successfully.');
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][ARCHIVE TASK]', err);
|
||||
return R.error(res, 'Could not archive task.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── RESTORE ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.restoreTask = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId, taskId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId, t))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
}
|
||||
|
||||
const record = await restoreOne(
|
||||
Task,
|
||||
{ task_id: taskId, task_list_id: taskListId, deletedAt: { [Op.not]: null } },
|
||||
staffUserId,
|
||||
t
|
||||
);
|
||||
if (!record) { await t.rollback(); return R.error(res, 'Task not found or not archived.', 404); }
|
||||
|
||||
await t.commit();
|
||||
return R.success(res, 'Task restored successfully.', record);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][RESTORE TASK]', err);
|
||||
return R.error(res, 'Could not restore task.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK ARCHIVE ─────────────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkArchiveTasks = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
const { ids } = req.body;
|
||||
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No task IDs provided.', 400);
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId, t))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
}
|
||||
|
||||
const tasks = await Task.findAll({
|
||||
where: { task_id: ids, task_list_id: taskListId },
|
||||
});
|
||||
if (!tasks.length) return R.error(res, 'No tasks found.', 404);
|
||||
|
||||
const activeIds = tasks.filter((task) => !task.deletedAt).map((task) => task.task_id);
|
||||
if (!activeIds.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'All selected tasks are already archived.', 400);
|
||||
}
|
||||
|
||||
const count = await archiveMany(Task, 'task_id', activeIds, staffUserId, t);
|
||||
await t.commit();
|
||||
return R.success(res, `${count} task(s) archived successfully.`, {
|
||||
archived_ids: activeIds,
|
||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][BULK ARCHIVE TASKS]', err);
|
||||
return R.error(res, 'Could not archive tasks.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK RESTORE ─────────────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkRestoreTasks = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
const { ids } = req.body;
|
||||
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No task IDs provided.', 400);
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId, t))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
}
|
||||
|
||||
const tasks = await Task.findAll({
|
||||
where: { task_id: ids, task_list_id: taskListId },
|
||||
paranoid: false,
|
||||
});
|
||||
if (!tasks.length) return R.error(res, 'No tasks found.', 404);
|
||||
|
||||
const deletedIds = tasks.filter((task) => task.deletedAt).map((task) => task.task_id);
|
||||
if (!deletedIds.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'All selected tasks are already active.', 400);
|
||||
}
|
||||
|
||||
const count = await restoreMany(Task, 'task_id', deletedIds, staffUserId, t);
|
||||
await t.commit();
|
||||
return R.success(res, `${count} task(s) restored successfully.`, {
|
||||
restored_ids: deletedIds,
|
||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][BULK RESTORE TASKS]', err);
|
||||
return R.error(res, 'Could not restore tasks.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET ARCHIVED TASKS ───────────────────────────────────────────────────────
|
||||
|
||||
exports.getArchivedTasks = async (req, res) => {
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId)))
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
|
||||
const result = await paginate(Task, req, {
|
||||
excludeAttributes: staffExclude,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
auditOptions: { mdl_Users, parentAlias: 'Task' },
|
||||
allowedFields: TASK_FIELDS,
|
||||
findOptions: {
|
||||
paranoid: false,
|
||||
where: {
|
||||
task_list_id: taskListId,
|
||||
deletedAt: { [Op.not]: null },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Archived tasks retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[STAFF][GET ARCHIVED TASKS]', err);
|
||||
return R.error(res, 'Could not retrieve archived tasks.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Field value helpers (for filter dropdowns) ───────────────────────────────
|
||||
exports.getTaskFieldValues = getFieldValues(Task, 'TASK');
|
||||
exports.getTaskListFieldValues = getFieldValues(TaskList, 'TASKLIST');
|
||||
@@ -0,0 +1,152 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: users.ctrl.js (staff)
|
||||
* Type of Program: Controller
|
||||
* Description: Staff-scoped user endpoints.
|
||||
* A staff member can only see users who belong to at least one
|
||||
* of the same groups the staff member belongs to.
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Op } = require('sequelize');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
|
||||
/**
|
||||
* GET /api/staff/users
|
||||
* Returns all users who share at least one group with the requesting staff member.
|
||||
* Supports optional query: ?group_id=27 or ?group_code=CG2027
|
||||
*/
|
||||
const getUsers = async (req, res) => {
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const { group_id, group_code, page = 1, limit = 20 } = req.query;
|
||||
const offset = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
// 1. Find the groups the staff member belongs to
|
||||
const staffMemberships = await mdl_UserGroupMembers.findAll({
|
||||
where: { user_id: staffUserId },
|
||||
attributes: ['group_id'],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
let scopedGroupIds = staffMemberships.map(m => m.group_id);
|
||||
|
||||
if (scopedGroupIds.length === 0) {
|
||||
return res.status(200).json({ success: true, data: [], total: 0, page, limit });
|
||||
}
|
||||
|
||||
// 2. Filter down to a specific group if requested
|
||||
if (group_id) {
|
||||
const gid = parseInt(group_id);
|
||||
if (!scopedGroupIds.includes(gid)) {
|
||||
return res.status(403).json({ success: false, message: 'You do not have access to this group.' });
|
||||
}
|
||||
scopedGroupIds = [gid];
|
||||
}
|
||||
|
||||
if (group_code) {
|
||||
const group = await mdl_UserGroups.findOne({
|
||||
where: { group_code: group_code.toUpperCase().trim(), deletedAt: null },
|
||||
attributes: ['group_id'],
|
||||
raw: true,
|
||||
});
|
||||
if (!group || !scopedGroupIds.includes(group.group_id)) {
|
||||
return res.status(403).json({ success: false, message: 'You do not have access to this group.' });
|
||||
}
|
||||
scopedGroupIds = [group.group_id];
|
||||
}
|
||||
|
||||
// 3. Find all user_ids in those groups (excluding the staff member themselves)
|
||||
const memberships = await mdl_UserGroupMembers.findAll({
|
||||
where: { group_id: { [Op.in]: scopedGroupIds } },
|
||||
attributes: ['user_id'],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
const userIds = [...new Set(memberships.map(m => m.user_id))].filter(id => id !== staffUserId);
|
||||
|
||||
// 4. Fetch users
|
||||
const { count, rows } = await mdl_Users.findAndCountAll({
|
||||
where: { user_id: { [Op.in]: userIds } },
|
||||
attributes: [
|
||||
'user_id', 'email', 'is_active', 'is_verified',
|
||||
'acc_type', 'personal_info', 'createdAt',
|
||||
],
|
||||
include: [
|
||||
{
|
||||
model: mdl_UserGroups,
|
||||
as: 'groups',
|
||||
attributes: ['group_id', 'name', 'group_code'],
|
||||
through: { attributes: ['joined_at'] },
|
||||
where: { group_id: { [Op.in]: scopedGroupIds } },
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
limit: parseInt(limit),
|
||||
offset,
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: rows,
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(count / parseInt(limit)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[staff/users] getUsers error:', err);
|
||||
return res.status(500).json({ success: false, message: 'Internal server error.' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/staff/users/:user_id
|
||||
* Get a single user's profile — only if they share a group with the staff member.
|
||||
*/
|
||||
const getUserById = async (req, res) => {
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const targetUserId = parseInt(req.params.user_id);
|
||||
|
||||
// Get staff's groups
|
||||
const staffMemberships = await mdl_UserGroupMembers.findAll({
|
||||
where: { user_id: staffUserId },
|
||||
attributes: ['group_id'],
|
||||
raw: true,
|
||||
});
|
||||
const scopedGroupIds = staffMemberships.map(m => m.group_id);
|
||||
|
||||
// Check target user shares a group
|
||||
const sharedMembership = await mdl_UserGroupMembers.findOne({
|
||||
where: {
|
||||
user_id: targetUserId,
|
||||
group_id: { [Op.in]: scopedGroupIds },
|
||||
},
|
||||
});
|
||||
|
||||
if (!sharedMembership) {
|
||||
return res.status(403).json({ success: false, message: 'User not in your scope.' });
|
||||
}
|
||||
|
||||
const user = await mdl_Users.findByPk(targetUserId, {
|
||||
attributes: ['user_id', 'email', 'is_active', 'is_verified', 'acc_type', 'personal_info', 'createdAt'],
|
||||
include: [
|
||||
{
|
||||
model: mdl_UserGroups,
|
||||
as: 'groups',
|
||||
attributes: ['group_id', 'name', 'group_code'],
|
||||
through: { attributes: ['joined_at'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!user) return res.status(404).json({ success: false, message: 'User not found.' });
|
||||
|
||||
return res.status(200).json({ success: true, data: user });
|
||||
} catch (err) {
|
||||
console.error('[staff/users] getUserById error:', err);
|
||||
return res.status(500).json({ success: false, message: 'Internal server error.' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getUsers, getUserById };
|
||||
Generated
+4
@@ -553,6 +553,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cluster-key-slot": "1.1.2",
|
||||
"generic-pool": "3.9.0",
|
||||
@@ -1717,6 +1718,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
@@ -1763,6 +1765,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.11.2.tgz",
|
||||
"integrity": "sha512-a7uwwfNTh1U60ssiIkuLFWHt4hAC5yxlLGU2VP0X4YNlyEDZAqF4tK3GD3NSitVBrCQmQ0++0uOyFOgC2y4DDw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
},
|
||||
@@ -3127,6 +3130,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.12.0",
|
||||
"pg-pool": "^3.13.0",
|
||||
|
||||
Reference in New Issue
Block a user