/*********************************************************************************************************************************************************************** * File Name: task_completion.controller.js (admin) * Type of Program: Controller * Description: Admin-level task completion management. * Admins can view all completions per task, view a single completion, * and archive/restore completions. Completions are created by clients only. * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 13, 2026 ***********************************************************************************************************************************************************************/ const { Op, Sequelize } = require('sequelize'); const sequelize = require('../../config/db.config'); const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_completion.mdl'); const { Task } = require('../../models/task/task.mdl'); const mdl_Users = require('../../models/users/users.mdl'); const { adminExclude } = require('../../models/task/task_completion.attributes'); const R = require('../../utils/response.util'); const { paginate } = require('../../utils/paginate.util'); const { archiveOne, archiveMany } = require('../../utils/courses/archive.util'); const { restoreOne, restoreMany } = require('../../utils/courses/restore.util'); const logActivity = require('../../utils/logActivity.util'); // ─── Allowed filter/sort fields ─────────────────────────────────────────────── const COMPLETION_FIELDS = ['submitted_at', 'createdAt', 'updatedAt', 'deletedAt']; // ─── Reusable include: completion files ─────────────────────────────────────── // separate: true → Sequelize fetches files in a second SELECT ... WHERE completion_id IN (...) // instead of a JOIN, which avoids the subquery alias conflict that occurs when // paginate applies LIMIT/OFFSET alongside a hasMany include. const FILES_INCLUDE = { model: TaskCompletionFile, as: 'files', attributes: { exclude: adminExclude }, paranoid: false, separate: true, order: [['createdAt', 'ASC']], }; // ─── Reusable include: submitting user ──────────────────────────────────────── const USER_INCLUDE = { model: mdl_Users, as: 'user', attributes: [ 'user_id', 'email', // ← direct column, fine as-is [ Sequelize.literal(`("user"."personal_info"->'name'->>'full_name')`), 'name', ], ], }; // ============================================================================= // ── COMPLETIONS (nested under task-list → task) ─────────────────────────────── // ============================================================================= // ─── GET ALL ────────────────────────────────────────────────────────────────── // GET /admin/task-lists/:taskListId/tasks/:taskId/completions exports.getCompletions = 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); const result = await paginate(TaskCompletion, req, { excludeAttributes: adminExclude, jsonbSchemas: {}, computedAttributes: [], auditOptions: { mdl_Users, parentAlias: 'TaskCompletion' }, allowedFields: COMPLETION_FIELDS, findOptions: { where: { task_id: taskId }, include: [USER_INCLUDE, FILES_INCLUDE], }, }); return R.success(res, 'Completions retrieved.', result); } catch (err) { console.error('[ADMIN][GET ALL COMPLETIONS]', err); return R.error(res, 'Could not retrieve completions.', 500); } }; // ─── GET ONE ────────────────────────────────────────────────────────────────── // GET /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId exports.getCompletion = async (req, res) => { try { const { taskListId, taskId, completionId } = 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); const completion = await TaskCompletion.findOne({ where: { completion_id: completionId, task_id: taskId }, attributes: { exclude: adminExclude }, paranoid: false, include: [USER_INCLUDE, FILES_INCLUDE], }); if (!completion) return R.error(res, 'Completion not found.', 404); return R.success(res, 'Completion retrieved.', completion); } catch (err) { console.error('[ADMIN][GET COMPLETION]', err); return R.error(res, 'Could not retrieve completion.', 500); } }; // ─── GET ALL BY USER ────────────────────────────────────────────────────────── // GET /admin/task-lists/:taskListId/tasks/:taskId/completions/user/:userId exports.getCompletionsByUser = async (req, res) => { try { const { taskListId, taskId, userId } = 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); const result = await paginate(TaskCompletion, req, { excludeAttributes: adminExclude, jsonbSchemas: {}, computedAttributes: [], auditOptions: { mdl_Users, parentAlias: 'TaskCompletion' }, allowedFields: COMPLETION_FIELDS, findOptions: { where: { task_id: taskId, user_id: userId }, include: [FILES_INCLUDE], }, }); return R.success(res, 'User completions retrieved.', result); } catch (err) { console.error('[ADMIN][GET COMPLETIONS BY USER]', err); return R.error(res, 'Could not retrieve user completions.', 500); } }; // ─── ARCHIVE ────────────────────────────────────────────────────────────────── // DELETE /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId exports.archiveCompletion = async (req, res) => { const t = await sequelize.transaction(); try { const { taskListId, taskId, completionId } = 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 record = await archiveOne( TaskCompletion, { completion_id: completionId, task_id: taskId }, req.user.user_id, t ); if (!record) { await t.rollback(); return R.error(res, 'Completion not found.', 404); } await t.commit(); logActivity(req.user.user_id, 'archive_completion', { entityType: 'task_completion', entityId: Number(completionId), details: { task_id: taskId } }); return R.success(res, 'Completion archived successfully.'); } catch (err) { await t.rollback(); console.error('[ADMIN][ARCHIVE COMPLETION]', err); return R.error(res, 'Could not archive completion.', 500); } }; // ─── RESTORE ────────────────────────────────────────────────────────────────── // PATCH /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId/restore exports.restoreCompletion = async (req, res) => { const t = await sequelize.transaction(); try { const { taskListId, taskId, completionId } = req.params; const record = await restoreOne( TaskCompletion, { completion_id: completionId, task_id: taskId, deletedAt: { [Op.not]: null } }, req.user.user_id, t ); if (!record) { await t.rollback(); return R.error(res, 'Completion not found or not archived.', 404); } await t.commit(); logActivity(req.user.user_id, 'restore_completion', { entityType: 'task_completion', entityId: Number(completionId), details: { task_id: taskId } }); return R.success(res, 'Completion restored successfully.', record); } catch (err) { await t.rollback(); console.error('[ADMIN][RESTORE COMPLETION]', err); return R.error(res, 'Could not restore completion.', 500); } }; // ─── BULK ARCHIVE ───────────────────────────────────────────────────────────── // POST /admin/task-lists/:taskListId/tasks/:taskId/completions/bulk-archive exports.bulkArchiveCompletions = async (req, res) => { const t = await sequelize.transaction(); try { const { taskListId, taskId } = req.params; const { ids } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No completion IDs provided.', 400); const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } }); if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); } const completions = await TaskCompletion.findAll({ where: { completion_id: ids, task_id: taskId }, }); if (!completions.length) return R.error(res, 'No completions found.', 404); const activeIds = completions .filter((c) => !c.deletedAt) .map((c) => c.completion_id); if (!activeIds.length) return R.error(res, 'All selected completions are already archived.', 400); const count = await archiveMany(TaskCompletion, 'completion_id', activeIds, req.user.user_id, t); await t.commit(); logActivity(req.user.user_id, 'bulk_archive_completions', { entityType: 'task_completion', details: { ids: activeIds, count, task_id: taskId } }); return R.success(res, `${count} completion(s) archived successfully.`, { archived_ids: activeIds, skipped_ids: ids.filter((id) => !activeIds.includes(id)), }); } catch (err) { await t.rollback(); console.error('[ADMIN][BULK ARCHIVE COMPLETIONS]', err); return R.error(res, 'Could not archive completions.', 500); } }; // ─── BULK RESTORE ───────────────────────────────────────────────────────────── // POST /admin/task-lists/:taskListId/tasks/:taskId/completions/bulk-restore exports.bulkRestoreCompletions = async (req, res) => { const t = await sequelize.transaction(); try { const { taskListId, taskId } = req.params; const { ids } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No completion IDs provided.', 400); const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } }); if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); } const completions = await TaskCompletion.findAll({ where: { completion_id: ids, task_id: taskId }, paranoid: false, }); if (!completions.length) return R.error(res, 'No completions found.', 404); const deletedIds = completions .filter((c) => c.deletedAt) .map((c) => c.completion_id); if (!deletedIds.length) return R.error(res, 'All selected completions are already active.', 400); const count = await restoreMany(TaskCompletion, 'completion_id', deletedIds, req.user.user_id, t); await t.commit(); logActivity(req.user.user_id, 'bulk_restore_completions', { entityType: 'task_completion', details: { ids: deletedIds, count, task_id: taskId } }); return R.success(res, `${count} completion(s) restored successfully.`, { restored_ids: deletedIds, skipped_ids: ids.filter((id) => !deletedIds.includes(id)), }); } catch (err) { await t.rollback(); console.error('[ADMIN][BULK RESTORE COMPLETIONS]', err); return R.error(res, 'Could not restore completions.', 500); } };