diff --git a/controllers/admin/task.controller.js b/controllers/admin/task.controller.js index 543e294..16c2608 100644 --- a/controllers/admin/task.controller.js +++ b/controllers/admin/task.controller.js @@ -11,13 +11,15 @@ 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'); +const { getFieldValues } = require('../../utils/fieldValues.util'); +const { archiveOne, archiveMany } = require("../../utils/courses/archive.util"); +const { restoreOne, restoreMany } = require("../../utils/courses/restore.util"); // ─── Allowed filter/sort fields ─────────────────────────────────────────────── const TASK_LIST_FIELDS = ['name', 'description', 'createdAt', 'updatedAt', 'deletedAt']; @@ -146,17 +148,19 @@ exports.updateTaskList = async (req, res) => { // ─── ARCHIVE (soft-delete) ──────────────────────────────────────────────────── exports.archiveTaskList = async (req, res) => { + const t = await sequelize.transaction(); 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(); + const record = await archiveOne(TaskList, { task_list_id: taskListId }, + req.user.user_id, 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('[ADMIN][ARCHIVE TASK LIST]', err); return R.error(res, 'Could not archive task list.', 500); } @@ -165,18 +169,17 @@ exports.archiveTaskList = async (req, res) => { // ─── RESTORE ────────────────────────────────────────────────────────────────── exports.restoreTaskList = async (req, res) => { + const t = await sequelize.transaction(); 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); + const record = await restoreOne(TaskList, { task_list_id: taskListId, deletedAt: { [Op.not]: null } }, req.user.user_id, t); + if (!record) { await t.rollback(); return R.error(res, 'Task list not found or not archived.', 404); } - await taskList.restore(); - await taskList.update({ deletedBy: null, updatedBy: req.user.user_id }); - - return R.success(res, 'Task list restored successfully.', taskList); + await t.commit(); + return R.success(res, 'Task list restored successfully.', record); } catch (err) { + await t.rollback(); console.error('[ADMIN][RESTORE TASK LIST]', err); return R.error(res, 'Could not restore task list.', 500); } @@ -198,17 +201,9 @@ exports.bulkArchiveTaskLists = async (req, res) => { 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, - }); - + const count = await archiveMany(TaskList, 'task_list_id', activeIds, req.user.user_id, t); await t.commit(); - return R.success(res, `${activeIds.length} task list(s) archived successfully.`, { + return R.success(res, `${count} task list(s) archived successfully.`, { archived_ids: activeIds, skipped_ids: ids.filter((id) => !activeIds.includes(id)), }); @@ -235,14 +230,9 @@ exports.bulkRestoreTaskLists = async (req, res) => { 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 } - ); - + const count = await restoreMany(TaskList, 'task_list_id', deletedIds, req.user.user_id, t); await t.commit(); - return R.success(res, `${deletedIds.length} task list(s) restored successfully.`, { + return R.success(res, `${count} task list(s) restored successfully.`, { restored_ids: deletedIds, skipped_ids: ids.filter((id) => !deletedIds.includes(id)), }); @@ -651,17 +641,22 @@ exports.getArchivedTasks = async (req, res) => { // ─── ARCHIVE (soft-delete) ──────────────────────────────────────────────────── exports.archiveTask = 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 } }); - if (!task) return R.error(res, 'Task not found.', 404); - - await task.update({ deletedBy: req.user.user_id }); - await task.destroy(); + const record = await archiveOne( + Task, + { task_id: taskId, task_list_id: taskListId }, + req.user.user_id, + 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('[ADMIN][ARCHIVE TASK]', err); return R.error(res, 'Could not archive task.', 500); } @@ -670,21 +665,22 @@ exports.archiveTask = async (req, res) => { // ─── RESTORE ────────────────────────────────────────────────────────────────── exports.restoreTask = 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 }, - paranoid: false, - }); - if (!task) return R.error(res, 'Task not found.', 404); - if (!task.deletedAt) return R.error(res, 'Task is not archived.', 400); + const record = await restoreOne( + Task, + { task_id: taskId, task_list_id: taskListId, deletedAt: { [Op.not]: null } }, + req.user.user_id, + t + ); + if (!record) { await t.rollback(); return R.error(res, 'Task not found or not archived.', 404); } - await task.restore(); - await task.update({ deletedBy: null, updatedBy: req.user.user_id }); - - return R.success(res, 'Task restored successfully.', task); + await t.commit(); + return R.success(res, 'Task restored successfully.', record); } catch (err) { + await t.rollback(); console.error('[ADMIN][RESTORE TASK]', err); return R.error(res, 'Could not restore task.', 500); } @@ -700,26 +696,16 @@ exports.bulkArchiveTasks = async (req, res) => { 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 }, - }); + 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, - }); - + const count = await archiveMany(Task, 'task_id', activeIds, req.user.user_id, t); await t.commit(); - return R.success(res, `${activeIds.length} task(s) archived successfully.`, { + return R.success(res, `${count} task(s) archived successfully.`, { archived_ids: activeIds, skipped_ids: ids.filter((id) => !activeIds.includes(id)), }); @@ -750,17 +736,9 @@ exports.bulkRestoreTasks = async (req, res) => { 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 } - ); - + const count = await restoreMany(Task, 'task_id', deletedIds, req.user.user_id, t); await t.commit(); - return R.success(res, `${deletedIds.length} task(s) restored successfully.`, { + return R.success(res, `${count} task(s) restored successfully.`, { restored_ids: deletedIds, skipped_ids: ids.filter((id) => !deletedIds.includes(id)), }); @@ -771,102 +749,5 @@ exports.bulkRestoreTasks = async (req, res) => { } }; -// ─── 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) && !dateFields.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); - } -}; - -// ─── GET TASK LIST FIELD VALUES ─────────────────────────────────────────────── -exports.getTaskListFieldValues = async (req, res) => { - try { - const { field } = req.query; - if (!field) return R.error(res, 'Field is required.', 400); - - const allowedFields = Object.keys(TaskList.rawAttributes).filter( - (f) => TaskList.rawAttributes[f].filterable - ); - const auditByFields = ['createdBy', 'updatedBy', 'deletedBy']; - const dateFields = ['createdAt', 'updatedAt', 'deletedAt']; - - if (!allowedFields.includes(field) && !dateFields.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 task_lists tl - JOIN users u ON u.user_id = tl."${field}" - WHERE tl."${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 TaskList.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 TaskList.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 LIST][GET FIELD VALUES]', err); - return R.error(res, 'Could not retrieve field values.', 500); - } -}; \ No newline at end of file +exports.getTaskFieldValues = getFieldValues(Task, "TASK"); +exports.getTaskListFieldValues = getFieldValues(TaskList, "TASKLIST"); \ No newline at end of file diff --git a/models/task/task.mdl.js b/models/task/task.mdl.js index 080b442..8638b82 100644 --- a/models/task/task.mdl.js +++ b/models/task/task.mdl.js @@ -8,9 +8,9 @@ const TaskList = sequelize.define('TaskList', { description: { type: DataTypes.TEXT, allowNull: true, hidden: true, filterable: false }, // ── Audit trails ──────────────────────────────────────────────────────── - createdBy: { type: DataTypes.INTEGER, allowNull: true }, - updatedBy: { type: DataTypes.INTEGER, allowNull: true }, - deletedBy: { type: DataTypes.INTEGER, allowNull: true }, + createdBy: { type: DataTypes.INTEGER, allowNull: true, filterable: true }, + updatedBy: { type: DataTypes.INTEGER, allowNull: true, filterable: true }, + deletedBy: { type: DataTypes.INTEGER, allowNull: true, filterable: true }, }, { tableName: 'task_lists', paranoid: true, @@ -44,9 +44,9 @@ const Task = sequelize.define('Task', { 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 }, + createdBy: { type: DataTypes.INTEGER, allowNull: true, filterable: true }, + updatedBy: { type: DataTypes.INTEGER, allowNull: true, filterable: true }, + deletedBy: { type: DataTypes.INTEGER, allowNull: true, filterable: true }, }, { tableName: 'tasks', paranoid: true, diff --git a/routes/admin/task.routes.js b/routes/admin/task.routes.js index 0ddebba..ece15f7 100644 --- a/routes/admin/task.routes.js +++ b/routes/admin/task.routes.js @@ -2,19 +2,19 @@ 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.get("/field-values", controller.getTaskListFieldValues); router.post("/", sensitiveOpsLimiter, controller.createTaskList); router.post("/bulk-archive", sensitiveOpsLimiter, controller.bulkArchiveTaskLists); router.post("/bulk-restore", sensitiveOpsLimiter, controller.bulkRestoreTaskLists); + +// ─── Task List (single) ─────────────────────────────────────────────────────── router.get("/:taskListId", controller.getTaskList); router.patch("/:taskListId", sensitiveOpsLimiter, controller.updateTaskList); router.delete("/:taskListId", sensitiveOpsLimiter, controller.archiveTaskList); router.patch("/:taskListId/restore", sensitiveOpsLimiter, controller.restoreTaskList); -router.get("/:taskListId/tasks/field-values", controller.getTaskFieldValues); // ─── Task List Groups ───────────────────────────────────────────────────────── router.get("/:taskListId/groups", controller.getTaskListGroups); @@ -24,9 +24,12 @@ router.post("/:taskListId/groups/unassign", sensitiveOpsLimiter, controller.unas // ─── Tasks (nested under task-list) ────────────────────────────────────────── router.get("/:taskListId/tasks", controller.getTasks); router.get("/:taskListId/tasks/archived", controller.getArchivedTasks); +router.get("/:taskListId/tasks/field-values", controller.getTaskFieldValues); 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); + +// ─── Tasks (single) ─────────────────────────────────────────────────────────── router.get("/:taskListId/tasks/:taskId", controller.getTask); router.patch("/:taskListId/tasks/:taskId", sensitiveOpsLimiter, controller.updateTask); router.delete("/:taskListId/tasks/:taskId", sensitiveOpsLimiter, controller.archiveTask);