mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
add: tasks func()
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
@@ -25,6 +27,7 @@ const dashboardRoutes = require('./dashboard.routes');
|
||||
const usersRoutes = require('./users.routes');
|
||||
const groupsRoutes = require('./groups.routes');
|
||||
const assetsRoutes = require('./assets.routes');
|
||||
const taskRoutes = require('./task.routes');
|
||||
|
||||
// ── Guards — applied to ALL admin routes ──────────────────────────────────────
|
||||
router.use(authenticate, requireAdmin(), adminLimiter);
|
||||
@@ -34,5 +37,6 @@ router.use('/dashboard', dashboardRoutes);
|
||||
router.use('/users', usersRoutes);
|
||||
router.use('/groups', groupsRoutes);
|
||||
router.use('/assets', assetsRoutes);
|
||||
router.use('/task-lists', taskRoutes);
|
||||
|
||||
module.exports = router;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user