diff --git a/controllers/admin/task.controller.js b/controllers/admin/task.controller.js new file mode 100644 index 0000000..9d3024e --- /dev/null +++ b/controllers/admin/task.controller.js @@ -0,0 +1,822 @@ +/*********************************************************************************************************************************************************************** + * 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); + } +}; \ No newline at end of file diff --git a/models/task/task.attributes.js b/models/task/task.attributes.js new file mode 100644 index 0000000..66d966e --- /dev/null +++ b/models/task/task.attributes.js @@ -0,0 +1,20 @@ +const excludeAttributes = []; + +const adminExclude = [ + ...excludeAttributes, + // admins can see audit fields, so nothing extra excluded +]; + +const userExclude = [ + ...excludeAttributes, + // regular users cannot see audit trails + +]; + +const jsonbSchemas = {}; + +const computedAttributes = []; + +const filterableFields = {} + +module.exports = { adminExclude, userExclude, excludeAttributes, jsonbSchemas, computedAttributes, filterableFields }; \ No newline at end of file diff --git a/models/task/task.mdl.js b/models/task/task.mdl.js new file mode 100644 index 0000000..67e4e7d --- /dev/null +++ b/models/task/task.mdl.js @@ -0,0 +1,99 @@ +const { DataTypes } = require("sequelize"); +const sequelize = require("../../config/db.config"); +const { mdl_UserGroups } = require('../users/user_groups.mdl'); + +const TaskList = sequelize.define('TaskList', { + task_list_id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true, hidden: true }, + name: { type: DataTypes.STRING, allowNull: false, validate: { notEmpty: true }, filterable: true }, + description: { type: DataTypes.TEXT, allowNull: true, hidden: true }, + + // ── Audit trails ──────────────────────────────────────────────────────── + createdBy: { type: DataTypes.INTEGER, allowNull: true }, + updatedBy: { type: DataTypes.INTEGER, allowNull: true }, + deletedBy: { type: DataTypes.INTEGER, allowNull: true }, +}, { + tableName: 'task_lists', + paranoid: true, + timestamps: true, +}); + +// ─── Junction: TaskList ↔ UserGroups ────────────────────────────────────────── +// Tracks which groups a task list is visible to / assigned to, plus who assigned +// it and when. Stored in `task_list_groups` so it lives alongside `task_lists`. +const TaskListGroup = sequelize.define('TaskListGroup', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + task_list_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'task_lists', key: 'task_list_id' }, onDelete: 'CASCADE', }, + group_id: { type: DataTypes.BIGINT, allowNull: false, references: { model: 'user_groups', key: 'group_id' }, onDelete: 'CASCADE', }, + assignedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW, allowNull: false, filterable: true }, + assignedBy: { type: DataTypes.BIGINT, allowNull: true, filterable: true }, +}, { + tableName: 'task_list_groups', + timestamps: false, + indexes: [ + { unique: true, fields: ['task_list_id', 'group_id'], name: 'uq_task_list_group' }, + { fields: ['task_list_id'], name: 'idx_tlg_task_list_id' }, + { fields: ['group_id'], name: 'idx_tlg_group_id' }, + ], +}); + + +const Task = sequelize.define('Task', { + task_id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true, hidden: true }, + task_list_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'task_lists', key: 'task_list_id' }, hidden: true }, + name: { type: DataTypes.STRING, allowNull: false, validate: { notEmpty: true }, order: 1, filterable: true }, + description: { type: DataTypes.TEXT, allowNull: true, hidden: true, }, + deadline: { type: DataTypes.DATE, allowNull: true, order: 2, filterable: true }, + status: { type: DataTypes.ENUM('pending', 'in_progress', 'completed', 'overdue'), defaultValue: 'pending', allowNull: false, filterable: true }, + + // ── Audit trails ──────────────────────────────────────────────────────── + createdBy: { type: DataTypes.INTEGER, allowNull: true }, + updatedBy: { type: DataTypes.INTEGER, allowNull: true }, + deletedBy: { type: DataTypes.INTEGER, allowNull: true }, +}, { + tableName: 'tasks', + paranoid: true, + timestamps: true, +}); + +const TaskRequirement = sequelize.define('TaskRequirement', { + requirement_id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + task_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' } }, + type: { type: DataTypes.ENUM('visit_link', 'upload_file', 'read_course', 'read_unit', 'read_lesson'), allowNull: false, filterable: true }, + + // ── visit_link ────────────────────────────────────────────────────────── + link_url: { type: DataTypes.STRING, allowNull: true }, + link_label: { type: DataTypes.STRING, allowNull: true }, + + // ── upload_file ───────────────────────────────────────────────────────── + allowed_file_types: { type: DataTypes.JSONB, allowNull: true }, + max_file_count: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 1 }, + + // ── read_course / read_unit / read_lesson ──────────────────────────────── + reference_id: { type: DataTypes.UUID, allowNull: true, comment: 'course_id | unit_id | lesson_id depending on type' }, + reference_label: { type: DataTypes.STRING, allowNull: true, comment: 'Cached display name so we do not always join' }, + order: { type: DataTypes.INTEGER, defaultValue: 0, filterable: true }, + + // ── Audit trails ──────────────────────────────────────────────────────── + createdBy: { type: DataTypes.INTEGER, allowNull: true }, + updatedBy: { type: DataTypes.INTEGER, allowNull: true }, + deletedBy: { type: DataTypes.INTEGER, allowNull: true }, +}, { + tableName: 'task_requirements', + paranoid: true, + timestamps: true, +}); + +TaskList.hasMany(Task, { foreignKey: 'task_list_id', as: 'tasks' }); +Task.belongsTo(TaskList, { foreignKey: 'task_list_id', as: 'taskList' }); +Task.hasMany(TaskRequirement, { foreignKey: 'task_id', as: 'requirements' }); +TaskRequirement.belongsTo(Task, { foreignKey: 'task_id', as: 'task' }); +// TaskList ↔ UserGroups (many-to-many through TaskListGroup) +TaskList.belongsToMany(mdl_UserGroups, { through: TaskListGroup, foreignKey: 'task_list_id', otherKey: 'group_id', as: 'groups', }); +mdl_UserGroups.belongsToMany(TaskList, { + through: TaskListGroup, foreignKey: 'group_id', + otherKey: 'task_list_id', + as: 'taskLists', +}); + + +module.exports = { Task, TaskList, TaskRequirement, TaskListGroup, mdl_UserGroups }; \ No newline at end of file diff --git a/routes/admin/admin.routes.js b/routes/admin/admin.routes.js index 693a9d9..5bf065c 100644 --- a/routes/admin/admin.routes.js +++ b/routes/admin/admin.routes.js @@ -11,8 +11,10 @@ * * Guards: authenticate → requireAdmin() → adminLimiter * - * Author: rgrgogu + * Author: rgrgogu and Kenneth Obsequio (@lash0000) * Date Created: Oct. 6, 2025 + * Date Modified: May 5, 2026 + * Latest: May 19, 2026 ***********************************************************************************************************************************************************************/ const express = require('express'); const router = express.Router(); @@ -26,6 +28,7 @@ const usersRoutes = require('./users.routes'); const groupsRoutes = require('./groups.routes'); const assetsRoutes = require('./assets.routes'); const coursesRoutes = require('./courses.routes') +const taskRoutes = require('./task.routes'); // ── Guards — applied to ALL admin routes ────────────────────────────────────── router.use(authenticate, requireAdmin(), adminLimiter); @@ -36,5 +39,6 @@ router.use('/users', usersRoutes); router.use('/groups', groupsRoutes); router.use('/assets', assetsRoutes); router.use('/courses', coursesRoutes); +router.use('/task-lists', taskRoutes); module.exports = router; \ No newline at end of file diff --git a/routes/admin/task.routes.js b/routes/admin/task.routes.js new file mode 100644 index 0000000..92ede9f --- /dev/null +++ b/routes/admin/task.routes.js @@ -0,0 +1,34 @@ +const router = require("express").Router(); +const controller = require("../../controllers/admin/task.controller"); +const { sensitiveOpsLimiter } = require("../../middleware/rateLimiter.middleware"); + +router.get("/field-values", controller.getTaskFieldValues); + +// ─── Task Lists ─────────────────────────────────────────────────────────────── +router.get("/", controller.getTaskLists); +router.get("/archived", controller.getArchivedTaskLists); +router.post("/", sensitiveOpsLimiter, controller.createTaskList); +router.post("/bulk-archive", sensitiveOpsLimiter, controller.bulkArchiveTaskLists); +router.post("/bulk-restore", sensitiveOpsLimiter, controller.bulkRestoreTaskLists); +router.get("/:taskListId", controller.getTaskList); +router.patch("/:taskListId", sensitiveOpsLimiter, controller.updateTaskList); +router.delete("/:taskListId", sensitiveOpsLimiter, controller.archiveTaskList); +router.patch("/:taskListId/restore", sensitiveOpsLimiter, controller.restoreTaskList); + +// ─── Task List Groups ───────────────────────────────────────────────────────── +router.get("/:taskListId/groups", controller.getTaskListGroups); +router.post("/:taskListId/groups/assign", sensitiveOpsLimiter, controller.assignGroups); +router.post("/:taskListId/groups/unassign", sensitiveOpsLimiter, controller.unassignGroups); + +// ─── Tasks (nested under task-list) ────────────────────────────────────────── +router.get("/:taskListId/tasks", controller.getTasks); +router.get("/:taskListId/tasks/archived", controller.getArchivedTasks); +router.post("/:taskListId/tasks", sensitiveOpsLimiter, controller.createTask); +router.post("/:taskListId/tasks/bulk-archive", sensitiveOpsLimiter, controller.bulkArchiveTasks); +router.post("/:taskListId/tasks/bulk-restore", sensitiveOpsLimiter, controller.bulkRestoreTasks); +router.get("/:taskListId/tasks/:taskId", controller.getTask); +router.patch("/:taskListId/tasks/:taskId", sensitiveOpsLimiter, controller.updateTask); +router.delete("/:taskListId/tasks/:taskId", sensitiveOpsLimiter, controller.archiveTask); +router.patch("/:taskListId/tasks/:taskId/restore", sensitiveOpsLimiter, controller.restoreTask); + +module.exports = router; \ No newline at end of file