/*********************************************************************************************************************************************************************** * 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, TaskPrerequisite, 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'); const { wouldCreateCycle } = require('../../utils/courses/taskPrerequisites.util'); const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl'); const LessonReadingProgress = require('../../models/courses/lesson_reading_progress.mdl'); const UnitReadingProgress = require('../../models/courses/unit_reading_progress.mdl'); const Lesson = require('../../models/courses/lessons.mdl'); const Unit = require('../../models/courses/units.mdl'); // ─── 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}`; }; // Deduped { user_id, group_id } rows for every user in a group assigned to this // task list — shared by the requirements-changed and submissions-toggled notify // blocks in updateTask. First group membership wins if a user is in more than one. async function getTaskListMembers(taskListId) { const groupRows = await TaskListGroup.findAll({ where: { task_list_id: taskListId }, attributes: ['group_id'], }); const groupIds = groupRows.map((r) => r.group_id); if (!groupIds.length) return []; const memberRows = await mdl_UserGroupMembers.findAll({ where: { group_id: groupIds }, attributes: ['user_id', 'group_id'], }); const seenUsers = new Set(); return memberRows.filter(({ user_id }) => { if (seenUsers.has(user_id)) return false; seenUsers.add(user_id); return true; }); } // ─── Allowed filter/sort fields ─────────────────────────────────────────────── const TASK_LIST_FIELDS = ['name', 'description', 'createdAt', 'updatedAt', 'deletedAt']; const TASK_FIELDS = ['name', 'description', 'deadline', 'order_index', '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', // Requirements are replaced via soft-delete on task update (see // updateTask). paranoid: false here would resurrect the superseded // rows alongside the current set, double-counting "Requirements". attributes: { exclude: adminExclude }, order: [['order', 'ASC']], }, PREREQUISITE_INCLUDE], }, 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); } }; // ─── SYNC GROUPS ────────────────────────────────────────────────────────────── // PUT /admin/task-lists/:taskListId/groups // Body: { group_ids: [1, 2, 3] } // // Replaces the full assigned-group set in one request/one transaction, so an // edit that both adds and removes groups is a single atomic operation instead // of two separate calls (assign + unassign). exports.syncGroups = async (req, res) => { const t = await sequelize.transaction(); try { const { taskListId } = req.params; const { group_ids } = req.body; if (!Array.isArray(group_ids)) return R.error(res, 'group_ids must be an array.', 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)); const currentRows = await TaskListGroup.findAll({ where: { task_list_id: taskListId }, attributes: ['group_id'], transaction: t, }); const currentIds = currentRows.map((r) => r.group_id); const newIds = validIds.filter((id) => !currentIds.includes(id)); const removedIds = currentIds.filter((id) => !validIds.includes(id)); if (newIds.length) { await TaskListGroup.bulkCreate( newIds.map((group_id) => ({ task_list_id: taskListId, group_id, assignedAt: new Date(), assignedBy: req.user.user_id, })), { transaction: t } ); } if (removedIds.length) { await TaskListGroup.destroy({ where: { task_list_id: taskListId, group_id: { [Op.in]: removedIds } }, transaction: t, }); } await t.commit(); logActivity(req.user.user_id, 'sync_groups', { entityType: 'task_list', entityId: Number(taskListId), details: { assigned_ids: newIds, unassigned_ids: removedIds }, }); // ── 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][SYNC GROUPS][NOTIFY]', notifyErr); } } return R.success(res, 'Task list groups updated.', { assigned_ids: newIds, unassigned_ids: removedIds, invalid_ids: invalidIds, }); } catch (err) { await t.rollback(); console.error('[ADMIN][SYNC GROUPS]', err); return R.error(res, 'Could not update task list groups.', 500); } }; // ============================================================================= // ── TASKS ───────────────────────────────────────────────────────────────────── // ============================================================================= // ─── Prerequisite include — reused by getTask / getTaskList ─────────────────── const PREREQUISITE_INCLUDE = { model: Task, as: 'prerequisites', attributes: ['task_id', 'name'], through: { attributes: [] }, }; class TaskValidationError extends Error {} // ─── Pre-completed assignees check ──────────────────────────────────────────── // A read_course/read_unit/read_lesson requirement can reference content // that a task list's assignees already finished BEFORE this requirement existed. // That's not an error — task_reading_progress_sync.service.js's hydrateReadTaskProgress // (client-side, on task list load) already auto-marks it done for them — but the // admin creating/editing the task has no visibility into it otherwise. This is a // heads-up, not a validator: it never blocks create/update, only informs. const READ_TYPE_TO_PROGRESS_TYPE = { read_course: 'course', read_unit: 'unit', read_lesson: 'lesson' }; const progressKeyType = (reqType) => READ_TYPE_TO_PROGRESS_TYPE[reqType]; async function getPreCompletedAssignees(taskListId, requirements, transaction) { const contentReqs = (requirements ?? []).filter( (r) => r.reference_id && progressKeyType(r.type) ); if (!contentReqs.length) return []; const members = await getTaskListMembers(taskListId); if (!members.length) return []; const userIds = members.map((m) => m.user_id); const completedByKey = new Map(); // `${progressType}:${reference_id}` -> Set(user_id) const markCompleted = (progressType, referenceId, userId) => { const key = `${progressType}:${referenceId}`; if (!completedByKey.has(key)) completedByKey.set(key, new Set()); completedByKey.get(key).add(userId); }; // ── read_course / read_unit / read_lesson ─────────────────────────────── const readReqs = contentReqs.filter((r) => READ_TYPE_TO_PROGRESS_TYPE[r.type]); if (readReqs.length) { const refsByProgressType = readReqs.reduce((acc, r) => { const progressType = READ_TYPE_TO_PROGRESS_TYPE[r.type]; if (!acc[progressType]) acc[progressType] = new Set(); acc[progressType].add(r.reference_id); return acc; }, {}); const courseRows = await CourseReadingProgress.findAll({ where: { user_id: { [Op.in]: userIds }, status: 'completed', [Op.or]: Object.entries(refsByProgressType).map(([type, refs]) => ({ type, reference_id: { [Op.in]: [...refs] }, })), }, attributes: ['user_id', 'type', 'reference_id'], transaction, }); for (const row of courseRows) markCompleted(row.type, row.reference_id, row.user_id); // Standalone lesson/unit (no parent course) — resolve uuid -> numeric PK first. const lessonUuids = [...(refsByProgressType.lesson ?? [])]; if (lessonUuids.length) { const lessons = await Lesson.findAll({ where: { uuid: { [Op.in]: lessonUuids } }, attributes: ['lesson_id', 'uuid'], transaction, }); if (lessons.length) { const uuidByLessonId = new Map(lessons.map((l) => [l.lesson_id, l.uuid])); const rows = await LessonReadingProgress.findAll({ where: { user_id: { [Op.in]: userIds }, lesson_id: { [Op.in]: [...uuidByLessonId.keys()] }, status: 'completed' }, attributes: ['user_id', 'lesson_id'], transaction, }); for (const row of rows) { const uuid = uuidByLessonId.get(row.lesson_id); if (uuid) markCompleted('lesson', uuid, row.user_id); } } } const unitUuids = [...(refsByProgressType.unit ?? [])]; if (unitUuids.length) { const units = await Unit.findAll({ where: { uuid: { [Op.in]: unitUuids } }, attributes: ['unit_id', 'uuid'], transaction, }); if (units.length) { const uuidByUnitId = new Map(units.map((u) => [u.unit_id, u.uuid])); const rows = await UnitReadingProgress.findAll({ where: { user_id: { [Op.in]: userIds }, unit_id: { [Op.in]: [...uuidByUnitId.keys()] }, status: 'completed' }, attributes: ['user_id', 'unit_id'], transaction, }); for (const row of rows) { const uuid = uuidByUnitId.get(row.unit_id); if (uuid) markCompleted('unit', uuid, row.user_id); } } } } // ── One entry per requirement that has at least one already-completed assignee ── const results = []; for (const r of contentReqs) { const key = `${progressKeyType(r.type)}:${r.reference_id}`; const completedCount = completedByKey.get(key)?.size ?? 0; if (completedCount > 0) { results.push({ type: r.type, reference_id: r.reference_id, reference_label: r.reference_label ?? null, completedCount, totalAssignees: userIds.length, }); } } return results; } // ─── Active-dependent lookup — guards archive/permanent-delete ──────────────── // paranoid: false on the base query so this still works when checking an // already-archived task (permanent-delete path) — the nested 'dependents' // include is left at its default (paranoid: true, independent of the base // query's setting), so only non-archived dependents ever get counted. async function getActiveDependents(taskIds, transaction) { if (!taskIds.length) return new Map(); const rows = await Task.findAll({ where: { task_id: { [Op.in]: taskIds } }, paranoid: false, include: [{ model: Task, as: 'dependents', attributes: ['task_id', 'name'], through: { attributes: [] }, required: false, }], transaction, }); const map = new Map(); for (const row of rows) { const names = (row.dependents ?? []).map((d) => d.name); if (names.length) map.set(row.task_id, names); } return map; } // ─── Validate + replace a task's prerequisite set ────────────────────────────── // Shared by createTask/updateTask. Throws TaskValidationError (→ 400) on: // - self-reference // - a prerequisite_task_id that isn't a sibling task in the same task list // - a prerequisite task with zero requirements (can never be "completed", // so it would permanently deadlock the dependent task) // - a proposed edge set that would introduce a cycle // On success, hard-deletes the task's existing TaskPrerequisite rows and // bulkCreates the new set (junction rows — not audit content, so unlike // TaskRequirement this is a real delete, not soft-delete). async function syncTaskPrerequisites(taskId, taskListId, prerequisiteIds, transaction) { const ids = [...new Set(prerequisiteIds)]; if (ids.includes(taskId)) { throw new TaskValidationError('A task cannot be its own prerequisite.'); } if (ids.length) { const siblingTasks = await Task.findAll({ where: { task_id: { [Op.in]: ids }, task_list_id: taskListId }, include: [{ model: TaskRequirement, as: 'requirements', attributes: ['requirement_id'] }], transaction, }); const foundIds = siblingTasks.map((t) => t.task_id); const invalidIds = ids.filter((id) => !foundIds.includes(id)); if (invalidIds.length) { throw new TaskValidationError(`Some selected prerequisites do not belong to this task list: ${invalidIds.join(', ')}.`); } const emptyTasks = siblingTasks.filter((t) => !(t.requirements ?? []).length); if (emptyTasks.length) { throw new TaskValidationError( `These tasks have no requirements yet and can never be marked complete, so they can't be used as a prerequisite: ${emptyTasks.map((t) => t.name).join(', ')}.` ); } if (await wouldCreateCycle(taskId, ids, taskListId, transaction)) { throw new TaskValidationError('That selection would create a circular dependency between tasks.'); } } await TaskPrerequisite.destroy({ where: { task_id: taskId }, transaction }); if (ids.length) { await TaskPrerequisite.bulkCreate( ids.map((prerequisite_task_id) => ({ task_id: taskId, prerequisite_task_id })), { transaction } ); } } // ─── 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); } }; // ─── REQUIREMENT COMPLETION CHECK ──────────────────────────────────────────── // GET /:taskListId/tasks/requirement-completion-check?type=&reference_id=&reference_label= // // Live check used by RequirementBuilder.jsx the moment a content item is picked — // reports whether any of this task list's assignees already completed it. Purely // informational (see getPreCompletedAssignees above); returns null when nobody has. exports.checkRequirementCompletion = async (req, res) => { try { const { taskListId } = req.params; const { type, reference_id, reference_label } = req.query; if (!type || !reference_id) return R.error(res, 'type and reference_id are required.', 400); const [result] = await getPreCompletedAssignees( taskListId, [{ type, reference_id, reference_label }], ); return R.success(res, 'Requirement completion check complete.', result ?? null); } catch (err) { console.error('[ADMIN][CHECK REQUIREMENT COMPLETION]', err); return R.error(res, 'Could not check requirement completion.', 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']], }, PREREQUISITE_INCLUDE, { 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 = [], prerequisite_task_ids = [] } = 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, 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 }); } if (prerequisite_task_ids.length) { await syncTaskPrerequisites(task.task_id, taskListId, prerequisite_task_ids, 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']], }, PREREQUISITE_INCLUDE], }); // Heads-up only — never blocks creation. See getPreCompletedAssignees. const warnings = await getPreCompletedAssignees( taskListId, (full.requirements ?? []).map((r) => ({ type: r.type, reference_id: r.reference_id, reference_label: r.reference_label })), ); 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.toJSON(), warnings }, 201); } catch (err) { await t.rollback(); if (err instanceof TaskValidationError) return R.error(res, err.message, 400); console.error('[ADMIN][CREATE TASK]', err); return R.error(res, 'Could not create task.', 500); } }; // ─── BULK CREATE ────────────────────────────────────────────────────────────── // POST /:taskListId/tasks/bulk // Body: { tasks: [{ name, description, deadline, requirements }, ...] } // // Batches Task + TaskRequirement creation into one transaction/request — used // by the Create Task List wizard so queuing N tasks costs one request instead // of N sequential POST .../tasks calls (see CreateTaskList.jsx handleCreate). // Prerequisite wiring is intentionally not supported here: queued tasks can't // reference a not-yet-created sibling task's id, same as createTask today // when called from this flow. exports.createTasksBulk = async (req, res) => { const t = await sequelize.transaction(); try { const { taskListId } = req.params; const { tasks } = req.body; if (!Array.isArray(tasks) || !tasks.length) return R.error(res, 'No tasks provided.', 400); if (tasks.some((task) => !task.name)) { return R.error(res, 'Every task requires a name.', 400); } const taskList = await TaskList.findByPk(taskListId, { transaction: t }); if (!taskList) { await t.rollback(); return R.error(res, 'Task list not found.', 404); } const startOrderIndex = await nextOrderIndex(Task, { task_list_id: taskListId }, t); const createdTasks = await Task.bulkCreate( tasks.map((task, i) => ({ task_list_id: taskListId, name: task.name, description: task.description, deadline: task.deadline || null, order_index: startOrderIndex + i, createdBy: req.user.user_id, updatedBy: req.user.user_id, })), { transaction: t } ); const reqRows = createdTasks.flatMap((task, i) => (tasks[i].requirements ?? []).map((r, j) => ({ ...r, task_id: task.task_id, order: r.order ?? j, 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, })) ); if (reqRows.length) await TaskRequirement.bulkCreate(reqRows, { transaction: t }); await t.commit(); const taskIds = createdTasks.map((task) => task.task_id); const full = await Task.findAll({ where: { task_id: { [Op.in]: taskIds } }, attributes: { exclude: adminExclude }, include: [{ model: TaskRequirement, as: 'requirements', attributes: { exclude: adminExclude }, order: [['order', 'ASC']], }, PREREQUISITE_INCLUDE], order: [['order_index', 'ASC']], }); // Heads-up only — never blocks creation. See getPreCompletedAssignees. const allRequirements = full.flatMap((task) => (task.requirements ?? []).map((r) => ({ type: r.type, reference_id: r.reference_id, reference_label: r.reference_label })) ); const warnings = await getPreCompletedAssignees(taskListId, allRequirements); logActivity(req.user.user_id, 'bulk_create_tasks', { entityType: 'task_list', entityId: Number(taskListId), details: { count: createdTasks.length, task_ids: taskIds }, }); return R.success(res, `${createdTasks.length} task(s) created successfully.`, { tasks: full.map((task) => task.toJSON()), warnings, }, 201); } catch (err) { await t.rollback(); console.error('[ADMIN][BULK CREATE TASKS]', err); return R.error(res, 'Could not create tasks.', 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, accepts_submissions, requirements, prerequisite_task_ids } = req.body; const wasAccepting = task.accepts_submissions; const updates = { updatedBy: req.user.user_id }; if (name !== undefined) updates.name = name; if (description !== undefined) updates.description = description; if (deadline !== undefined) updates.deadline = deadline || null; if (status !== undefined) updates.status = status; if (accepts_submissions !== undefined) updates.accepts_submissions = accepts_submissions; await task.update(updates, { 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 }); } } if (Array.isArray(prerequisite_task_ids)) { await syncTaskPrerequisites(taskId, taskListId, prerequisite_task_ids, t); } await t.commit(); const full = await Task.findByPk(taskId, { attributes: { exclude: adminExclude }, include: [{ model: TaskRequirement, as: 'requirements', attributes: { exclude: adminExclude }, order: [['order', 'ASC']], }, PREREQUISITE_INCLUDE], }); logActivity(req.user.user_id, 'update_task', { entityType: 'task', entityId: Number(taskId) }); // Heads-up only — never blocks the update. See getPreCompletedAssignees. const warnings = Array.isArray(requirements) ? await getPreCompletedAssignees( taskListId, (full.requirements ?? []).map((r) => ({ type: r.type, reference_id: r.reference_id, reference_label: r.reference_label })), ) : []; // ── Notify assigned users when requirements changed ──────────────────── if (Array.isArray(requirements)) { try { const members = await getTaskListMembers(task.task_list_id); 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, taskId: task.task_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); } } // ── Notify assigned users when accepting-submissions toggled ─────────── if (accepts_submissions !== undefined && accepts_submissions !== wasAccepting) { try { const members = await getTaskListMembers(task.task_list_id); if (members.length) { const now = new Date(); const registryKey = accepts_submissions ? 'task_submissions_reopened' : 'task_submissions_closed'; const notify = NOTIFICATION_REGISTRY[registryKey].build({ taskName: full.name, taskListId: task.task_list_id, taskId: task.task_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 SUBMISSIONS]', notifyErr); } } return R.success(res, 'Task updated successfully.', { ...full.toJSON(), warnings }); } catch (err) { await t.rollback(); if (err instanceof TaskValidationError) return R.error(res, err.message, 400); 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 dependents = await getActiveDependents([taskId], t); if (dependents.has(taskId)) { await t.rollback(); return R.error(res, `${dependents.get(taskId).length} task(s) depend on this as a prerequisite: ${dependents.get(taskId).join(', ')}. Remove that dependency first.`, 400); } 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); // Exclude tasks that other (still-active) tasks depend on as a prerequisite — // archiving them would silently break those dependents' unlock logic. const dependents = await getActiveDependents(activeIds, t); const blockedIds = activeIds.filter((id) => dependents.has(id)); const archivableIds = activeIds.filter((id) => !dependents.has(id)); if (!archivableIds.length) { await t.rollback(); return R.error(res, `All selected tasks are depended on as a prerequisite by another task: ${[...dependents.values()].flat().join(', ')}. Remove those dependencies first.`, 400); } const count = await archiveMany(Task, 'task_id', archivableIds, req.user.user_id, t); await t.commit(); logActivity(req.user.user_id, 'bulk_archive_tasks', { entityType: 'task', details: { ids: archivableIds, count } }); return R.success(res, `${count} task(s) archived successfully.`, { archived_ids: archivableIds, skipped_ids: ids.filter((id) => !archivableIds.includes(id)), blocked_ids: blockedIds, }); } 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 dependents = await getActiveDependents([taskId], t); if (dependents.has(taskId)) { await t.rollback(); return R.error(res, `${dependents.get(taskId).length} task(s) depend on this as a prerequisite: ${dependents.get(taskId).join(', ')}. Remove that dependency first.`, 400); } 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 dependents = await getActiveDependents(deletedIds, t); const blockedIds = deletedIds.filter((id) => dependents.has(id)); const deletableIds = deletedIds.filter((id) => !dependents.has(id)); if (!deletableIds.length) { await t.rollback(); return R.error(res, `All selected tasks are depended on as a prerequisite by another task: ${[...dependents.values()].flat().join(', ')}. Remove those dependencies first.`, 400); } const count = await permanentDeleteMany(Task, 'task_id', deletableIds, t); await t.commit(); logActivity(req.user.user_id, 'bulk_permanently_delete_tasks', { entityType: 'task', details: { ids: deletableIds, count } }); return R.success(res, `${count} task(s) permanently deleted.`, { deleted_ids: deletableIds, skipped_ids: ids.filter((id) => !deletableIds.includes(id)), blocked_ids: blockedIds, }); } 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");