Files
starr-philproperties/controllers/admin/task.controller.js
T
kennethobsequio ea3e82e54c added
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-07-12 12:39:17 +08:00

1041 lines
46 KiB
JavaScript

/***********************************************************************************************************************************************************************
* 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_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const mdl_Users = require('../../models/users/users.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
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");
const { permanentDeleteOne, permanentDeleteMany } = require("../../utils/courses/permanentDelete.util");
const { TaskCompletion } = require('../../models/task/task_completion.mdl');
const logActivity = require('../../utils/logActivity.util');
const { nextOrderIndex, reorderJunction } = require('../../utils/courses/hierarchy.util');
// ─── URL normalizer — ensures protocol prefix so link_url is never a relative path ──
const normalizeUrl = (url) => {
if (!url) return null;
if (/^https?:\/\//i.test(url)) return url;
return `https://${url}`;
};
// ─── Allowed filter/sort fields ───────────────────────────────────────────────
const TASK_LIST_FIELDS = ['name', 'description', 'createdAt', 'updatedAt', 'deletedAt'];
const TASK_FIELDS = ['name', 'description', 'deadline', 'order_index', 'is_required', '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,
});
logActivity(req.user.user_id, 'create_task_list', { entityType: 'task_list', entityId: taskList.task_list_id, details: { name: taskList.name } });
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 });
logActivity(req.user.user_id, 'update_task_list', { entityType: 'task_list', entityId: taskList.task_list_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) => {
const t = await sequelize.transaction();
try {
const { taskListId } = req.params;
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();
logActivity(req.user.user_id, 'archive_task_list', { entityType: 'task_list', entityId: Number(taskListId) });
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);
}
};
// ─── RESTORE ──────────────────────────────────────────────────────────────────
exports.restoreTaskList = async (req, res) => {
const t = await sequelize.transaction();
try {
const { taskListId } = req.params;
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 t.commit();
logActivity(req.user.user_id, 'restore_task_list', { entityType: 'task_list', entityId: Number(taskListId) });
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);
}
};
// ─── 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);
const count = await archiveMany(TaskList, 'task_list_id', activeIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_archive_task_lists', { entityType: 'task_list', details: { ids: activeIds, count } });
return R.success(res, `${count} 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);
const count = await restoreMany(TaskList, 'task_list_id', deletedIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_restore_task_lists', { entityType: 'task_list', details: { ids: deletedIds, count } });
return R.success(res, `${count} 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);
}
};
// ─── PERMANENT DELETE ─────────────────────────────────────────────────────────
exports.permanentlyDeleteTaskList = async (req, res) => {
const t = await sequelize.transaction();
try {
const { taskListId } = req.params;
const record = await permanentDeleteOne(TaskList, { task_list_id: taskListId }, t);
if (record === null) { await t.rollback(); return R.error(res, 'Task list not found.', 404); }
if (record === false) { await t.rollback(); return R.error(res, 'Task list must be archived before it can be permanently deleted.', 400); }
await t.commit();
logActivity(req.user.user_id, 'permanently_delete_task_list', { entityType: 'task_list', entityId: Number(taskListId) });
return R.success(res, 'Task list permanently deleted.');
} catch (err) {
await t.rollback();
console.error('[ADMIN][PERMANENT DELETE TASK LIST]', err);
return R.error(res, 'Could not permanently delete task list.', 500);
}
};
// ─── BULK PERMANENT DELETE ────────────────────────────────────────────────────
exports.bulkPermanentlyDeleteTaskLists = 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 must be archived before they can be permanently deleted.', 400);
const count = await permanentDeleteMany(TaskList, 'task_list_id', deletedIds, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_permanently_delete_task_lists', { entityType: 'task_list', details: { ids: deletedIds, count } });
return R.success(res, `${count} task list(s) permanently deleted.`, {
deleted_ids: deletedIds,
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
});
} catch (err) {
await t.rollback();
console.error('[ADMIN][BULK PERMANENT DELETE TASK LISTS]', err);
return R.error(res, 'Could not permanently delete task lists.', 500);
}
};
// ─── PERMANENT DELETE IMPACT ──────────────────────────────────────────────────
exports.getTaskListPermanentDeleteImpact = async (req, res) => {
try {
const { taskListId } = req.params;
const taskCount = await Task.count({ where: { task_list_id: taskListId }, paranoid: false });
const tasks = await Task.findAll({ where: { task_list_id: taskListId }, attributes: ['task_id'], paranoid: false });
const completionCount = tasks.length
? await TaskCompletion.count({ where: { task_id: tasks.map((task) => task.task_id) }, paranoid: false })
: 0;
return R.success(res, 'Impact retrieved.', { taskCount, completionCount });
} catch (err) {
console.error('[ADMIN][TASK LIST PERMANENT DELETE IMPACT]', err);
return R.error(res, 'Could not retrieve impact.', 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();
logActivity(req.user.user_id, 'assign_groups', { entityType: 'task_list', entityId: Number(taskListId), details: { group_ids: newIds } });
// ── Notify every member of the newly-assigned group(s) ─────────────────
if (newIds.length) {
try {
const taskCount = await Task.count({ where: { task_list_id: taskListId } });
const memberRows = await mdl_UserGroupMembers.findAll({
where: { group_id: newIds },
attributes: ['user_id'],
});
const seenUsers = new Set();
const userIds = memberRows.filter(({ user_id }) => {
if (seenUsers.has(user_id)) return false;
seenUsers.add(user_id);
return true;
}).map((m) => m.user_id);
if (userIds.length) {
const now = new Date();
const notify = NOTIFICATION_REGISTRY.task_assigned.build({
taskListName: taskList.name,
taskCount,
});
await UserNotification.bulkCreate(
userIds.map((user_id) => ({ user_id, ...notify, seen: false, createdAt: now, updatedAt: now })),
{ validate: false }
);
}
} catch (notifyErr) {
console.error('[ADMIN][ASSIGN GROUPS][NOTIFY]', notifyErr);
}
}
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();
logActivity(req.user.user_id, 'unassign_groups', { entityType: 'task_list', entityId: Number(taskListId), details: { group_ids: existingIds } });
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',
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, is_required, 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 order_index = await nextOrderIndex(Task, { task_list_id: taskListId }, t);
const task = await Task.create(
{
task_list_id: taskListId,
name,
description,
deadline: deadline || null,
order_index,
is_required: is_required ?? true,
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: normalizeUrl(r.link_url),
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']],
}],
});
logActivity(req.user.user_id, 'create_task', { entityType: 'task', entityId: task.task_id, details: { name: task.name } });
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, is_required, requirements } = req.body;
await task.update(
{ name, description, deadline: deadline || null, status, is_required, updatedBy: req.user.user_id },
{ transaction: t }
);
// ─────────────────────────────────────────────────────────────────────────────
// PATCH: exports.updateTask in task.controller.js (admin)
//
// BUG: TaskRequirement.destroy({ where: { task_id }, force: false }) is a
// SOFT delete (paranoid: true) — the row stays in the table with deletedAt
// set, still occupying its requirement_id primary key slot. The subsequent
// bulkCreate spread `...r`, which still carried the OLD requirement_id from
// the requirement object the frontend sent back (since RequirementBuilder.jsx
// initializes from the previously-fetched requirements, including their IDs).
// Inserting a new row with that same requirement_id collides with the
// soft-deleted row still sitting on that PK → SequelizeUniqueConstraintError.
//
// FIX: strip requirement_id (and any timestamp fields) from each incoming
// requirement before building reqRows, so bulkCreate always lets the model's
// defaultValue: DataTypes.UUIDV4 generate a fresh ID for the replacement set.
// ─────────────────────────────────────────────────────────────────────────────
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) => {
// Strip requirement_id and timestamps — these are server-owned.
// Reusing requirement_id here would collide with the soft-deleted
// row still occupying that primary key.
const { requirement_id, createdAt, updatedAt, deletedAt, ...rest } = r;
return {
...rest,
task_id: task.task_id,
order: rest.order ?? i,
reference_id: rest.reference_id || null, // '' → null (UUID column)
reference_label: rest.reference_label || null, // '' → null
link_url: normalizeUrl(rest.link_url),
link_label: rest.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']],
}],
});
logActivity(req.user.user_id, 'update_task', { entityType: 'task', entityId: Number(taskId) });
// ── Notify assigned users when requirements changed ────────────────────
if (Array.isArray(requirements)) {
try {
const groupRows = await TaskListGroup.findAll({
where: { task_list_id: task.task_list_id },
attributes: ['group_id'],
});
const groupIds = groupRows.map((r) => r.group_id);
if (groupIds.length) {
const memberRows = await mdl_UserGroupMembers.findAll({
where: { group_id: groupIds },
attributes: ['user_id', 'group_id'],
});
// One notification per user — first group membership wins if they're in more than one.
const seenUsers = new Set();
const members = memberRows.filter(({ user_id }) => {
if (seenUsers.has(user_id)) return false;
seenUsers.add(user_id);
return true;
});
if (members.length) {
const now = new Date();
// Title/message are identical for every member — render once,
// then vary only the per-member groupId in the data payload.
const notify = NOTIFICATION_REGISTRY.task_requirements_updated.build({
taskName: full.name,
taskListId: task.task_list_id,
groupId: null,
});
await UserNotification.bulkCreate(
members.map(({ user_id, group_id }) => ({
user_id,
...notify,
data: { ...notify.data, groupId: group_id },
seen: false,
createdAt: now,
updatedAt: now,
})),
{ validate: false }
);
}
}
} catch (notifyErr) {
console.error('[ADMIN][UPDATE TASK][NOTIFY]', notifyErr);
}
}
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);
}
};
// ─── REORDER ──────────────────────────────────────────────────────────────────
// PATCH /admin/task-lists/:taskListId/tasks/order { task_ids: [orderedIds] }
exports.reorderTasks = async (req, res) => {
const t = await sequelize.transaction();
try {
const { taskListId } = req.params;
const { task_ids = [] } = req.body;
if (!task_ids.length) { await t.rollback(); return R.error(res, 'task_ids 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); }
await reorderJunction(Task, 'task_list_id', taskListId, 'task_id', task_ids, t);
await t.commit();
logActivity(req.user.user_id, 'reorder_tasks', { entityType: 'task_list', entityId: taskListId, details: { task_ids } });
return R.success(res, 'Task order updated.');
} catch (err) {
await t.rollback();
console.error('[ADMIN][REORDER TASKS]', err);
return R.error(res, 'Could not reorder tasks.', 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) => {
const t = await sequelize.transaction();
try {
const { taskListId, taskId } = req.params;
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();
logActivity(req.user.user_id, 'archive_task', { entityType: 'task', entityId: Number(taskId) });
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);
}
};
// ─── RESTORE ──────────────────────────────────────────────────────────────────
exports.restoreTask = async (req, res) => {
const t = await sequelize.transaction();
try {
const { taskListId, taskId } = req.params;
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 t.commit();
logActivity(req.user.user_id, 'restore_task', { entityType: 'task', entityId: Number(taskId) });
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);
}
};
// ─── 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);
const count = await archiveMany(Task, 'task_id', activeIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_archive_tasks', { entityType: 'task', details: { ids: activeIds, count } });
return R.success(res, `${count} 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);
const count = await restoreMany(Task, 'task_id', deletedIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_restore_tasks', { entityType: 'task', details: { ids: deletedIds, count } });
return R.success(res, `${count} 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);
}
};
// ─── PERMANENT DELETE ─────────────────────────────────────────────────────────
exports.permanentlyDeleteTask = async (req, res) => {
const t = await sequelize.transaction();
try {
const { taskListId, taskId } = req.params;
const record = await permanentDeleteOne(Task, { task_id: taskId, task_list_id: taskListId }, t);
if (record === null) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
if (record === false) { await t.rollback(); return R.error(res, 'Task must be archived before it can be permanently deleted.', 400); }
await t.commit();
logActivity(req.user.user_id, 'permanently_delete_task', { entityType: 'task', entityId: Number(taskId) });
return R.success(res, 'Task permanently deleted.');
} catch (err) {
await t.rollback();
console.error('[ADMIN][PERMANENT DELETE TASK]', err);
return R.error(res, 'Could not permanently delete task.', 500);
}
};
// ─── BULK PERMANENT DELETE ────────────────────────────────────────────────────
exports.bulkPermanentlyDeleteTasks = 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 must be archived before they can be permanently deleted.', 400);
const count = await permanentDeleteMany(Task, 'task_id', deletedIds, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_permanently_delete_tasks', { entityType: 'task', details: { ids: deletedIds, count } });
return R.success(res, `${count} task(s) permanently deleted.`, {
deleted_ids: deletedIds,
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
});
} catch (err) {
await t.rollback();
console.error('[ADMIN][BULK PERMANENT DELETE TASKS]', err);
return R.error(res, 'Could not permanently delete tasks.', 500);
}
};
exports.getTaskFieldValues = getFieldValues(Task, "TASK");
exports.getTaskListFieldValues = getFieldValues(TaskList, "TASKLIST");