/*********************************************************************************************************************************************************************** * 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');