/*********************************************************************************************************************************************************************** * File Name: task.controller.js (admin) * Type of Program: Controller * Description: Admin-level task management — Task Lists, Tasks, Task Requirements, * and Task List ↔ User Group assignment (visibility + tracking). * * Author: Kenneth Obsequio (@lash0000) * Date Created: May 17, 2026 ***********************************************************************************************************************************************************************/ const { Op, Sequelize } = require('sequelize'); const sequelize = require('../../config/db.config'); const { Task, TaskList, TaskRequirement, TaskListGroup, mdl_UserGroups } = require('../../models/task/task.mdl'); // const { mdl_UserGroups } = require('../../models/users/user_groups.mdl'); const mdl_Users = require('../../models/users/users.mdl'); const { adminExclude, jsonbSchemas, computedAttributes } = require('../../models/task/task.attributes'); const R = require('../../utils/response.util'); const { paginate } = require('../../utils/paginate.util'); const { filterableFields } = require('../../models/task/task.attributes'); // ─── Allowed filter/sort fields ─────────────────────────────────────────────── const TASK_LIST_FIELDS = ['name', 'description', 'createdAt', 'updatedAt', 'deletedAt']; const TASK_FIELDS = ['name', 'description', 'deadline', 'status', 'createdAt', 'updatedAt', 'deletedAt']; const FILTERABLE_MODELS = { TaskList: TaskList, Task: Task, TaskRequirement: TaskRequirement, TaskListGroup: TaskListGroup, }; // ─── Reusable group include for getTaskList / getArchivedTaskList ───────────── const GROUP_INCLUDE = { model: mdl_UserGroups, as: 'groups', attributes: ['group_id', 'name'], through: { model: TaskListGroup, as: 'assignment', attributes: [], }, }; // ============================================================================= // ── TASK LISTS ──────────────────────────────────────────────────────────────── // ============================================================================= // ─── GET ALL ────────────────────────────────────────────────────────────────── exports.getTaskLists = async (req, res) => { try { const result = await paginate(TaskList, req, { excludeAttributes: adminExclude, jsonbSchemas, computedAttributes, auditOptions: { mdl_Users, parentAlias: 'TaskList' }, allowedFields: TASK_LIST_FIELDS, }); return R.success(res, 'Task lists retrieved.', result); } catch (err) { console.error('[ADMIN][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 taskList = await TaskList.findByPk(taskListId, { attributes: { exclude: adminExclude }, paranoid: false, include: [ { model: Task, as: 'tasks', paranoid: false, include: [{ model: TaskRequirement, as: 'requirements', paranoid: false, attributes: { exclude: adminExclude }, order: [['order', 'ASC']], }], }, GROUP_INCLUDE, ], }); if (!taskList) return R.error(res, 'Task list not found.', 404); // Append group_count so the frontend doesn't need to measure the array const data = taskList.toJSON(); data.group_count = data.groups?.length ?? 0; return R.success(res, 'Task list retrieved.', data); } catch (err) { console.error('[ADMIN][GET TASK LIST]', err); return R.error(res, 'Could not retrieve task list.', 500); } }; // ─── CREATE ─────────────────────────────────────────────────────────────────── exports.createTaskList = async (req, res) => { try { const { name, description } = req.body; if (!name) return R.error(res, 'Name is required.', 400); const taskList = await TaskList.create({ name, description, createdBy: req.user.user_id, updatedBy: req.user.user_id, }); return R.success(res, 'Task list created successfully.', taskList, 201); } catch (err) { console.error('[ADMIN][CREATE TASK LIST]', err); return R.error(res, 'Could not create task list.', 500); } }; // ─── UPDATE ─────────────────────────────────────────────────────────────────── exports.updateTaskList = async (req, res) => { try { const { taskListId } = req.params; const taskList = await TaskList.findByPk(taskListId); if (!taskList) return R.error(res, 'Task list not found.', 404); const { name, description } = req.body; await taskList.update({ name, description, updatedBy: req.user.user_id }); return R.success(res, 'Task list updated successfully.', taskList); } catch (err) { console.error('[ADMIN][UPDATE TASK LIST]', err); return R.error(res, 'Could not update task list.', 500); } }; // ─── ARCHIVE (soft-delete) ──────────────────────────────────────────────────── exports.archiveTaskList = async (req, res) => { try { const { taskListId } = req.params; const taskList = await TaskList.findByPk(taskListId); if (!taskList) return R.error(res, 'Task list not found.', 404); await taskList.update({ deletedBy: req.user.user_id }); await taskList.destroy(); return R.success(res, 'Task list archived successfully.'); } catch (err) { console.error('[ADMIN][ARCHIVE TASK LIST]', err); return R.error(res, 'Could not archive task list.', 500); } }; // ─── RESTORE ────────────────────────────────────────────────────────────────── exports.restoreTaskList = async (req, res) => { try { const { taskListId } = req.params; const taskList = await TaskList.findByPk(taskListId, { paranoid: false }); if (!taskList) return R.error(res, 'Task list not found.', 404); if (!taskList.deletedAt) return R.error(res, 'Task list is not archived.', 400); await taskList.restore(); await taskList.update({ deletedBy: null, updatedBy: req.user.user_id }); return R.success(res, 'Task list restored successfully.', taskList); } catch (err) { console.error('[ADMIN][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 { ids } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No task list IDs provided.', 400); const taskLists = await TaskList.findAll({ where: { task_list_id: ids } }); if (!taskLists.length) return R.error(res, 'No task lists found.', 404); const activeIds = taskLists.filter((tl) => !tl.deletedAt).map((tl) => tl.task_list_id); if (!activeIds.length) return R.error(res, 'All selected task lists are already archived.', 400); await TaskList.update( { deletedBy: req.user.user_id }, { where: { task_list_id: { [Op.in]: activeIds } }, transaction: t } ); await TaskList.destroy({ where: { task_list_id: { [Op.in]: activeIds } }, transaction: t, }); await t.commit(); return R.success(res, `${activeIds.length} task list(s) archived successfully.`, { archived_ids: activeIds, skipped_ids: ids.filter((id) => !activeIds.includes(id)), }); } catch (err) { await t.rollback(); console.error('[ADMIN][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 { ids } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No task list IDs provided.', 400); const taskLists = await TaskList.findAll({ where: { task_list_id: ids }, paranoid: false }); if (!taskLists.length) return R.error(res, 'No task lists found.', 404); const deletedIds = taskLists.filter((tl) => tl.deletedAt).map((tl) => tl.task_list_id); if (!deletedIds.length) return R.error(res, 'All selected task lists are already active.', 400); await TaskList.restore({ where: { task_list_id: { [Op.in]: deletedIds } }, transaction: t }); await TaskList.update( { deletedBy: null, updatedBy: req.user.user_id }, { where: { task_list_id: { [Op.in]: deletedIds } }, paranoid: false, transaction: t } ); await t.commit(); return R.success(res, `${deletedIds.length} task list(s) restored successfully.`, { restored_ids: deletedIds, skipped_ids: ids.filter((id) => !deletedIds.includes(id)), }); } catch (err) { await t.rollback(); console.error('[ADMIN][BULK RESTORE TASK LISTS]', err); return R.error(res, 'Could not restore task lists.', 500); } }; // ============================================================================= // ── TASK LIST GROUPS (assign / unassign / list) ────────────────────────────── // ============================================================================= // ─── GET ASSIGNED GROUPS ────────────────────────────────────────────────────── // GET /admin/task-lists/:taskListId/groups // // Returns every UserGroup currently assigned to this task list, including // the assignment metadata (assignedAt, assignedBy). exports.getTaskListGroups = async (req, res) => { try { const { taskListId } = req.params; const taskList = await TaskList.findByPk(taskListId, { paranoid: false }); if (!taskList) return R.error(res, 'Task list not found.', 404); const groups = await TaskListGroup.findAll({ where: { task_list_id: taskListId }, include: [ { model: mdl_UserGroups, as: 'group', attributes: ['group_id', 'name'], }, ], order: [['assignedAt', 'ASC']], }); return R.success(res, 'Task list groups retrieved.', groups); } catch (err) { console.error('[ADMIN][GET TASK LIST GROUPS]', err); return R.error(res, 'Could not retrieve task list groups.', 500); } }; // ─── ASSIGN GROUPS ──────────────────────────────────────────────────────────── // POST /admin/task-lists/:taskListId/groups/assign // Body: { group_ids: [1, 2, 3] } // // Upsert-style: groups already assigned are silently skipped. // Returns a summary of newly assigned vs already-assigned IDs. exports.assignGroups = async (req, res) => { const t = await sequelize.transaction(); try { const { taskListId } = req.params; const { group_ids } = req.body; if (!Array.isArray(group_ids) || !group_ids.length) return R.error(res, 'No group IDs provided.', 400); const taskList = await TaskList.findByPk(taskListId, { transaction: t }); if (!taskList) { await t.rollback(); return R.error(res, 'Task list not found.', 404); } // Validate all provided group IDs actually exist const validGroups = await mdl_UserGroups.findAll({ where: { group_id: { [Op.in]: group_ids } }, attributes: ['group_id'], transaction: t, }); const validIds = validGroups.map((g) => g.group_id); const invalidIds = group_ids.filter((id) => !validIds.includes(id)); // Find which ones are already assigned const existing = await TaskListGroup.findAll({ where: { task_list_id: taskListId, group_id: { [Op.in]: validIds } }, attributes: ['group_id'], transaction: t, }); const existingIds = existing.map((e) => e.group_id); const newIds = validIds.filter((id) => !existingIds.includes(id)); if (newIds.length) { const rows = newIds.map((group_id) => ({ task_list_id: taskListId, group_id, assignedAt: new Date(), assignedBy: req.user.user_id, })); await TaskListGroup.bulkCreate(rows, { transaction: t }); } await t.commit(); return R.success(res, `${newIds.length} group(s) assigned.`, { assigned_ids: newIds, already_assigned_ids: existingIds, invalid_ids: invalidIds, }, 201); } catch (err) { await t.rollback(); console.error('[ADMIN][ASSIGN GROUPS]', err); return R.error(res, 'Could not assign groups.', 500); } }; // ─── UNASSIGN GROUPS ────────────────────────────────────────────────────────── // POST /admin/task-lists/:taskListId/groups/unassign // Body: { group_ids: [1, 2, 3] } // // Hard-deletes junction rows (the assignment record is gone, not soft-deleted). // Groups not currently assigned are silently skipped. exports.unassignGroups = async (req, res) => { const t = await sequelize.transaction(); try { const { taskListId } = req.params; const { group_ids } = req.body; if (!Array.isArray(group_ids) || !group_ids.length) return R.error(res, 'No group IDs provided.', 400); const taskList = await TaskList.findByPk(taskListId, { paranoid: false }); if (!taskList) { await t.rollback(); return R.error(res, 'Task list not found.', 404); } // Only remove what's actually assigned const existing = await TaskListGroup.findAll({ where: { task_list_id: taskListId, group_id: { [Op.in]: group_ids } }, attributes: ['group_id'], transaction: t, }); const existingIds = existing.map((e) => e.group_id); const skippedIds = group_ids.filter((id) => !existingIds.includes(id)); if (!existingIds.length) { await t.rollback(); return R.error(res, 'None of the provided groups are assigned to this task list.', 400); } await TaskListGroup.destroy({ where: { task_list_id: taskListId, group_id: { [Op.in]: existingIds } }, transaction: t, }); await t.commit(); return R.success(res, `${existingIds.length} group(s) unassigned.`, { unassigned_ids: existingIds, skipped_ids: skippedIds, }); } catch (err) { await t.rollback(); console.error('[ADMIN][UNASSIGN GROUPS]', err); return R.error(res, 'Could not unassign groups.', 500); } }; // ============================================================================= // ── TASKS ───────────────────────────────────────────────────────────────────── // ============================================================================= // ─── GET ALL ────────────────────────────────────────────────────────────────── exports.getTasks = async (req, res) => { try { const { taskListId } = req.params; const result = await paginate(Task, req, { excludeAttributes: adminExclude, 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('[ADMIN][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 task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId }, attributes: { exclude: adminExclude }, paranoid: false, include: [ { model: TaskRequirement, as: 'requirements', paranoid: false, attributes: { exclude: adminExclude }, order: [['order', 'ASC']], }, { model: TaskList, as: 'taskList', paranoid: false, attributes: { exclude: adminExclude }, include: [GROUP_INCLUDE], // surface parent task list's groups here too }, ], }); if (!task) return R.error(res, 'Task not found.', 404); return R.success(res, 'Task retrieved.', task); } catch (err) { console.error('[ADMIN][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 { name, description, deadline, requirements = [] } = req.body; if (!name) return R.error(res, 'Task name is required.', 400); 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, createdBy: req.user.user_id, updatedBy: req.user.user_id, }, { 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, // '' → null (UUID column) reference_label: r.reference_label || null, // '' → null link_url: r.link_url || null, link_label: r.link_label || null, createdBy: req.user.user_id, updatedBy: req.user.user_id, })); await TaskRequirement.bulkCreate(reqRows, { transaction: t }); } await t.commit(); const full = await Task.findByPk(task.task_id, { attributes: { exclude: adminExclude }, include: [{ model: TaskRequirement, as: 'requirements', attributes: { exclude: adminExclude }, order: [['order', 'ASC']], }], }); return R.success(res, 'Task created successfully.', full, 201); } catch (err) { await t.rollback(); console.error('[ADMIN][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 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: req.user.user_id }, { transaction: t } ); if (Array.isArray(requirements)) { // soft-delete existing requirements then insert fresh ones 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, // '' → null (UUID column) reference_label: r.reference_label || null, // '' → null link_url: r.link_url || null, link_label: r.link_label || null, createdBy: req.user.user_id, updatedBy: req.user.user_id, })); await TaskRequirement.bulkCreate(reqRows, { transaction: t }); } } await t.commit(); const full = await Task.findByPk(taskId, { attributes: { exclude: adminExclude }, include: [{ model: TaskRequirement, as: 'requirements', attributes: { exclude: adminExclude }, order: [['order', 'ASC']], }], }); return R.success(res, 'Task updated successfully.', full); } catch (err) { await t.rollback(); console.error('[ADMIN][UPDATE TASK]', err); return R.error(res, 'Could not update task.', 500); } }; // ─── GET ARCHIVED TASK LISTS ────────────────────────────────────────────────── exports.getArchivedTaskLists = async (req, res) => { try { const result = await paginate(TaskList, req, { excludeAttributes: adminExclude, jsonbSchemas, computedAttributes, auditOptions: { mdl_Users, parentAlias: 'TaskList' }, allowedFields: TASK_LIST_FIELDS, findOptions: { paranoid: false, where: { deletedAt: { [Op.not]: null } }, }, }); return R.success(res, 'Archived task lists retrieved.', result); } catch (err) { console.error('[ADMIN][GET ARCHIVED TASK LISTS]', err); return R.error(res, 'Could not retrieve archived task lists.', 500); } }; // ─── GET ARCHIVED TASKS ─────────────────────────────────────────────────────── exports.getArchivedTasks = async (req, res) => { try { const { taskListId } = req.params; const result = await paginate(Task, req, { excludeAttributes: adminExclude, 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('[ADMIN][GET ARCHIVED TASKS]', err); return R.error(res, 'Could not retrieve archived tasks.', 500); } }; // ─── ARCHIVE (soft-delete) ──────────────────────────────────────────────────── exports.archiveTask = async (req, res) => { try { const { taskListId, taskId } = req.params; const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } }); if (!task) return R.error(res, 'Task not found.', 404); await task.update({ deletedBy: req.user.user_id }); await task.destroy(); return R.success(res, 'Task archived successfully.'); } catch (err) { console.error('[ADMIN][ARCHIVE TASK]', err); return R.error(res, 'Could not archive task.', 500); } }; // ─── RESTORE ────────────────────────────────────────────────────────────────── exports.restoreTask = async (req, res) => { try { const { taskListId, taskId } = req.params; const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId }, paranoid: false, }); if (!task) return R.error(res, 'Task not found.', 404); if (!task.deletedAt) return R.error(res, 'Task is not archived.', 400); await task.restore(); await task.update({ deletedBy: null, updatedBy: req.user.user_id }); return R.success(res, 'Task restored successfully.', task); } catch (err) { console.error('[ADMIN][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 { ids } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No task IDs provided.', 400); 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) return R.error(res, 'All selected tasks are already archived.', 400); await Task.update( { deletedBy: req.user.user_id }, { where: { task_id: { [Op.in]: activeIds }, task_list_id: taskListId }, transaction: t } ); await Task.destroy({ where: { task_id: { [Op.in]: activeIds }, task_list_id: taskListId }, transaction: t, }); await t.commit(); return R.success(res, `${activeIds.length} task(s) archived successfully.`, { archived_ids: activeIds, skipped_ids: ids.filter((id) => !activeIds.includes(id)), }); } catch (err) { await t.rollback(); console.error('[ADMIN][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 { ids } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No task IDs provided.', 400); 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) return R.error(res, 'All selected tasks are already active.', 400); await Task.restore({ where: { task_id: { [Op.in]: deletedIds }, task_list_id: taskListId }, transaction: t, }); await Task.update( { deletedBy: null, updatedBy: req.user.user_id }, { where: { task_id: { [Op.in]: deletedIds }, task_list_id: taskListId }, paranoid: false, transaction: t } ); await t.commit(); return R.success(res, `${deletedIds.length} task(s) restored successfully.`, { restored_ids: deletedIds, skipped_ids: ids.filter((id) => !deletedIds.includes(id)), }); } catch (err) { await t.rollback(); console.error('[ADMIN][BULK RESTORE TASKS]', err); return R.error(res, 'Could not restore tasks.', 500); } }; // ─── GET TASK FIELD VALUES ─────────────────────────────────────────────────── exports.getTaskFieldValues = async (req, res) => { try { const { field } = req.query; if (!field) return R.error(res, "Field is required.", 400); const allowedFields = Object.keys(Task.rawAttributes).filter( (f) => Task.rawAttributes[f].filterable ); const auditByFields = ["createdBy", "updatedBy", "deletedBy"]; const dateFields = ["createdAt", "updatedAt", "deletedAt", "deadline"]; if (!allowedFields.includes(field)) return R.error(res, "Invalid or restricted field.", 400); if (auditByFields.includes(field)) { const [rows] = await sequelize.query(` SELECT DISTINCT u."personal_info"->'name'->>'full_name' AS value FROM tasks t JOIN users u ON u.user_id = t."${field}" WHERE t."${field}" IS NOT NULL AND u."personal_info"->'name'->>'full_name' IS NOT NULL ORDER BY value ASC `); return R.success(res, "Field values retrieved.", rows.map((r) => r.value).filter(Boolean)); } if (dateFields.includes(field)) { const results = await Task.findAll({ attributes: [[Sequelize.fn("DISTINCT", Sequelize.fn("DATE", Sequelize.col(field))), "value"]], where: { [field]: { [Op.ne]: null } }, order: [[Sequelize.fn("DATE", Sequelize.col(field)), "DESC"]], raw: true, }); return R.success(res, "Field values retrieved.", results.map((r) => r.value).filter(Boolean)); } const results = await Task.findAll({ attributes: [[Sequelize.fn("DISTINCT", Sequelize.col(field)), "value"]], where: { [field]: { [Op.ne]: null } }, raw: true, }); return R.success(res, "Field values retrieved.", results.map((r) => r.value).filter(Boolean).sort()); } catch (err) { console.error("[TASK][GET FIELD VALUES]", err); return R.error(res, "Could not retrieve field values.", 500); } };