From 463a8d397836b86e4a7c08ec9f62f8dfe17c9600 Mon Sep 17 00:00:00 2001 From: rgrgogu Date: Wed, 24 Jun 2026 13:43:16 +0800 Subject: [PATCH] added --- controllers/staff/groups.ctrl.js | 267 ++++++++++ controllers/staff/scores.ctrl.js | 263 ++++++++++ controllers/staff/tasks.ctrl.js | 813 +++++++++++++++++++++++++++++++ controllers/staff/users.ctrl.js | 152 ++++++ package-lock.json | 4 + 5 files changed, 1499 insertions(+) create mode 100644 controllers/staff/groups.ctrl.js create mode 100644 controllers/staff/scores.ctrl.js create mode 100644 controllers/staff/tasks.ctrl.js create mode 100644 controllers/staff/users.ctrl.js diff --git a/controllers/staff/groups.ctrl.js b/controllers/staff/groups.ctrl.js new file mode 100644 index 0000000..05feb9f --- /dev/null +++ b/controllers/staff/groups.ctrl.js @@ -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 }; diff --git a/controllers/staff/scores.ctrl.js b/controllers/staff/scores.ctrl.js new file mode 100644 index 0000000..2b8e29d --- /dev/null +++ b/controllers/staff/scores.ctrl.js @@ -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, +}; diff --git a/controllers/staff/tasks.ctrl.js b/controllers/staff/tasks.ctrl.js new file mode 100644 index 0000000..a63190c --- /dev/null +++ b/controllers/staff/tasks.ctrl.js @@ -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'); \ No newline at end of file diff --git a/controllers/staff/users.ctrl.js b/controllers/staff/users.ctrl.js new file mode 100644 index 0000000..8173ac0 --- /dev/null +++ b/controllers/staff/users.ctrl.js @@ -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 }; diff --git a/package-lock.json b/package-lock.json index 0e9e94c..f6aa416 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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",