diff --git a/controllers/admin/task.controller.js b/controllers/admin/task.controller.js index 9d3024e..543e294 100644 --- a/controllers/admin/task.controller.js +++ b/controllers/admin/task.controller.js @@ -23,10 +23,10 @@ const { filterableFields } = require('../../models/task/task.attributes'); 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, + TaskList: TaskList, + Task: Task, TaskRequirement: TaskRequirement, - TaskListGroup: TaskListGroup, + TaskListGroup: TaskListGroup, }; // ─── Reusable group include for getTaskList / getArchivedTaskList ───────────── @@ -773,21 +773,21 @@ 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); + 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"]; + 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 (!allowedFields.includes(field) && !dateFields.includes(field)) + return R.error(res, "Invalid or restricted field.", 400); - if (auditByFields.includes(field)) { - const [rows] = await sequelize.query(` + 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}" @@ -795,28 +795,78 @@ exports.getTaskFieldValues = async (req, res) => { 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)); + 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); } +}; - 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)); +// ─── 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); } - - 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 index 66d966e..9bc2b72 100644 --- a/models/task/task.attributes.js +++ b/models/task/task.attributes.js @@ -8,13 +8,13 @@ const adminExclude = [ const userExclude = [ ...excludeAttributes, // regular users cannot see audit trails - + "createdBy", "updatedBy", "deletedBy", "deletedAt", ]; 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 index 67e4e7d..080b442 100644 --- a/models/task/task.mdl.js +++ b/models/task/task.mdl.js @@ -5,7 +5,7 @@ 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 }, + description: { type: DataTypes.TEXT, allowNull: true, hidden: true, filterable: false }, // ── Audit trails ──────────────────────────────────────────────────────── createdBy: { type: DataTypes.INTEGER, allowNull: true }, @@ -18,8 +18,6 @@ const TaskList = sequelize.define('TaskList', { }); // ─── 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', }, @@ -39,9 +37,9 @@ const TaskListGroup = sequelize.define('TaskListGroup', { 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 }, + task_list_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'task_lists', key: 'task_list_id' }, hidden: true, filterable: false }, name: { type: DataTypes.STRING, allowNull: false, validate: { notEmpty: true }, order: 1, filterable: true }, - description: { type: DataTypes.TEXT, allowNull: true, hidden: true, }, + description: { type: DataTypes.TEXT, allowNull: true, hidden: true, filterable: false }, 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 }, @@ -61,16 +59,16 @@ const TaskRequirement = sequelize.define('TaskRequirement', { 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 }, + link_url: { type: DataTypes.STRING, allowNull: true, filterable: false }, + link_label: { type: DataTypes.STRING, allowNull: true, filterable: false }, // ── upload_file ───────────────────────────────────────────────────────── - allowed_file_types: { type: DataTypes.JSONB, allowNull: true }, - max_file_count: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 1 }, + allowed_file_types: { type: DataTypes.JSONB, allowNull: true, filterable: false }, + max_file_count: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 1, filterable: false }, // ── 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' }, + reference_id: { type: DataTypes.UUID, allowNull: true, comment: 'course_id | unit_id | lesson_id depending on type', filterable: false }, + reference_label: { type: DataTypes.STRING, allowNull: true, comment: 'Cached display name so we do not always join', filterable: false }, order: { type: DataTypes.INTEGER, defaultValue: 0, filterable: true }, // ── Audit trails ──────────────────────────────────────────────────────── @@ -87,13 +85,9 @@ 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', -}); - +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/task.routes.js b/routes/admin/task.routes.js index 92ede9f..0ddebba 100644 --- a/routes/admin/task.routes.js +++ b/routes/admin/task.routes.js @@ -14,6 +14,7 @@ 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);