/*********************************************************************************************************************************************************************** * File Name: task.controller.js (client) * Type of Program: Controller * Description: Client-level task access. * Users can view groups they belong to, task lists assigned to those * groups, tasks within those lists, and submit work for tasks. * Read-only except for completions. * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 13, 2026 ***********************************************************************************************************************************************************************/ const { Op } = require('sequelize'); const sequelize = require('../../config/db.config'); const { Task, TaskList, TaskRequirement, TaskListGroup } = require('../../models/task/task.mdl'); const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_completion.mdl'); const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl'); const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); const mdl_Users = require('../../models/users/users.mdl'); const { userExclude } = require('../../models/task/task.attributes'); const { clientExclude } = require('../../models/task/task_completion.attributes'); const logActivity = require('../../utils/logActivity.util'); const R = require('../../utils/response.util'); // ============================================================================= // ── GROUPS ──────────────────────────────────────────────────────────────────── // ============================================================================= // ─── GET MY GROUPS ──────────────────────────────────────────────────────────── // GET /client/groups // Returns all active groups the authenticated user belongs to. exports.getMyGroups = async (req, res) => { try { const groups = await mdl_UserGroups.findAll({ include: [ { model: mdl_Users, as: 'members', where: { user_id: req.user.user_id }, attributes: [], through: { model: mdl_UserGroupMembers, attributes: ['joined_at'], where: { deletedAt: null }, }, }, ], where: { is_active: true }, attributes: ['group_id', 'name', 'group_code', 'description'], order: [['name', 'ASC']], }); return R.success(res, 'Groups retrieved.', groups); } catch (err) { console.error('[CLIENT][GET MY GROUPS]', err); return R.error(res, 'Could not retrieve groups.', 500); } }; // ─── GET ONE GROUP ──────────────────────────────────────────────────────────── // GET /client/groups/:groupId // Returns group info — verifies the user is a member before responding. exports.getMyGroup = async (req, res) => { try { const { groupId } = req.params; const group = await mdl_UserGroups.findOne({ where: { group_id: groupId, is_active: true }, attributes: ['group_id', 'name', 'group_code', 'description'], include: [ { model: mdl_Users, as: 'members', where: { user_id: req.user.user_id }, attributes: [], through: { model: mdl_UserGroupMembers, attributes: [], where: { deletedAt: null }, }, }, ], }); if (!group) return R.error(res, 'Group not found or you are not a member.', 404); return R.success(res, 'Group retrieved.', group); } catch (err) { console.error('[CLIENT][GET MY GROUP]', err); return R.error(res, 'Could not retrieve group.', 500); } }; // ============================================================================= // ── TASK LISTS ──────────────────────────────────────────────────────────────── // ============================================================================= // ─── Helper: verify user is member of group ─────────────────────────────────── const isMember = async (userId, groupId) => { const membership = await mdl_UserGroupMembers.findOne({ where: { user_id: userId, group_id: groupId, deletedAt: null }, }); return !!membership; }; // ───────────────────────────────────────────────────────────────────────────── // REPLACEMENT: getGroupTaskList in task.controller.js (client) // // GET /client/groups/:groupId/task-lists/:taskListId?status=ongoing|done|overdue // // has_completed is now computed per-task as: ALL of the task's requirements // individually have a completion signal — matching RequirementsStatusPanel's // "Overall progress: X / Y done" logic exactly. // // Per-requirement-type completion: // upload_file → task has at least one TaskCompletion (binary, task-level) // visit_link → a TaskLinkVisit exists for THIS requirement_id // read_course/ // read_unit/ // read_lesson → a TaskProgress with completed=true exists for THIS // requirement_id (+ reference_id) // // Task bucket: // done → every requirement passes its check above // (a task with zero requirements is vacuously "ongoing", per // earlier spec — zero requirements should not normally happen) // overdue → not done AND task.deadline < now // ongoing → otherwise // ───────────────────────────────────────────────────────────────────────────── exports.getGroupTaskList = async (req, res) => { try { const { groupId, taskListId } = req.params; const { status } = req.query; // optional: 'ongoing' | 'done' | 'overdue' const userId = req.user.user_id; const member = await isMember(userId, groupId); if (!member) return R.error(res, 'Group not found or you are not a member.', 403); const taskList = await TaskList.findOne({ where: { task_list_id: taskListId }, attributes: { exclude: userExclude }, include: [ { model: mdl_UserGroups, as: 'groups', where: { group_id: groupId }, attributes: [], through: { model: TaskListGroup, attributes: [] }, }, { model: Task, as: 'tasks', required: false, attributes: { exclude: userExclude }, include: [{ model: TaskRequirement, as: 'requirements', attributes: { exclude: userExclude }, order: [['order', 'ASC']], }], order: [['createdAt', 'ASC']], }, ], }); if (!taskList) return R.error(res, 'Task list not found or not assigned to your group.', 404); const json = taskList.toJSON(); const tasks = json.tasks ?? []; const taskIds = tasks.map((t) => t.task_id); // ── Fetch user's completion signals for these tasks ──────────────────── const [completions, linkVisits, progressRows] = await Promise.all([ taskIds.length ? TaskCompletion.findAll({ where: { task_id: { [Op.in]: taskIds }, user_id: userId }, attributes: ['task_id'], }) : [], taskIds.length ? TaskLinkVisit.findAll({ where: { task_id: { [Op.in]: taskIds }, user_id: userId }, attributes: ['task_id', 'requirement_id'], }) : [], taskIds.length ? TaskProgress.findAll({ where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true }, attributes: ['task_id', 'requirement_id', 'reference_id'], }) : [], ]); // ── Lookup sets/maps for per-requirement checks ───────────────────────── const tasksWithCompletion = new Set(completions.map((c) => c.task_id)); const visitedRequirementIds = new Set(linkVisits.map((v) => v.requirement_id)); const completedProgressKeys = new Set( progressRows.map((p) => `${p.requirement_id}:${p.reference_id}`) ); const now = Date.now(); // ── Bucket each task by per-requirement completion ────────────────────── const bucketedTasks = tasks.map((task) => { const requirements = task.requirements ?? []; const allRequirementsDone = requirements.length > 0 && requirements.every((r) => { switch (r.type) { case 'upload_file': return tasksWithCompletion.has(task.task_id); case 'visit_link': return visitedRequirementIds.has(r.requirement_id); case 'read_course': case 'read_unit': case 'read_lesson': return completedProgressKeys.has(`${r.requirement_id}:${r.reference_id}`); default: return true; // unknown requirement types don't block completion } }); const has_completed = allRequirementsDone; let bucket; if (has_completed) { bucket = 'done'; } else if (task.deadline && new Date(task.deadline).getTime() < now) { bucket = 'overdue'; } else { bucket = 'ongoing'; } return { ...task, has_completed, _bucket: bucket }; }); // ── Filter by requested status, strip internal _bucket field ────────── const filteredTasks = status ? bucketedTasks.filter((t) => t._bucket === status) : bucketedTasks; json.tasks = filteredTasks.map(({ _bucket, ...rest }) => rest); return R.success(res, 'Task list retrieved.', json); } catch (err) { console.error('[CLIENT][GET GROUP TASK LIST]', err); return R.error(res, 'Could not retrieve task list.', 500); } }; // ───────────────────────────────────────────────────────────────────────────── // REPLACEMENT: getGroupTaskLists in task.controller.js (client) — plural // // GET /client/groups/:groupId/task-lists?status=ongoing|done|overdue // // Updated to match getGroupTaskList (singular): has_completed per task now // means ALL of that task's requirements individually have a completion signal // (not just "any"), matching RequirementsStatusPanel's "X / Y done" logic. // // TaskList bucket (based on per-task has_completed, computed below): // TaskList has zero tasks → Ongoing (nothing to do yet) // ALL tasks have has_completed → Done // NOT all done AND any incomplete // task has deadline < now → Overdue // Otherwise → Ongoing // ───────────────────────────────────────────────────────────────────────────── exports.getGroupTaskLists = async (req, res) => { try { const { groupId } = req.params; const { status } = req.query; // optional: 'ongoing' | 'done' | 'overdue' const userId = req.user.user_id; const member = await isMember(userId, groupId); if (!member) return R.error(res, 'Group not found or you are not a member.', 403); // ── Fetch ALL task lists assigned to this group, no status filter ───── const taskLists = await TaskList.findAll({ include: [ { model: mdl_UserGroups, as: 'groups', where: { group_id: groupId }, attributes: [], through: { model: TaskListGroup, attributes: [] }, }, { model: Task, as: 'tasks', required: false, attributes: { exclude: userExclude }, include: [ { model: TaskRequirement, as: 'requirements', attributes: { exclude: userExclude }, order: [['order', 'ASC']], }, ], order: [['createdAt', 'ASC']], }, ], attributes: { exclude: userExclude }, order: [['createdAt', 'ASC']], }); // ── Gather all task_ids across the group's task lists ────────────────── const allTasks = taskLists.flatMap((tl) => tl.tasks ?? []); const taskIds = allTasks.map((t) => t.task_id); // ── Fetch user's completion signals for these tasks ──────────────────── const [completions, linkVisits, progressRows] = await Promise.all([ taskIds.length ? TaskCompletion.findAll({ where: { task_id: { [Op.in]: taskIds }, user_id: userId }, attributes: ['task_id'], }) : [], taskIds.length ? TaskLinkVisit.findAll({ where: { task_id: { [Op.in]: taskIds }, user_id: userId }, attributes: ['task_id', 'requirement_id'], }) : [], taskIds.length ? TaskProgress.findAll({ where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true }, attributes: ['task_id', 'requirement_id', 'reference_id'], }) : [], ]); // ── Lookup sets/maps for per-requirement checks ───────────────────────── const tasksWithCompletion = new Set(completions.map((c) => c.task_id)); const visitedRequirementIds = new Set(linkVisits.map((v) => v.requirement_id)); const completedProgressKeys = new Set( progressRows.map((p) => `${p.requirement_id}:${p.reference_id}`) ); const now = Date.now(); // ── Compute per-task has_completed via per-requirement checks ────────── const computeHasCompleted = (task) => { const requirements = task.requirements ?? []; if (requirements.length === 0) return false; // vacuously not done return requirements.every((r) => { switch (r.type) { case 'upload_file': return tasksWithCompletion.has(task.task_id); case 'visit_link': return visitedRequirementIds.has(r.requirement_id); case 'read_course': case 'read_unit': case 'read_lesson': return completedProgressKeys.has(`${r.requirement_id}:${r.reference_id}`); default: return true; } }); }; // ── Bucket each task list based on per-task has_completed ─────────────── const bucketed = taskLists.map((tl) => { const json = tl.toJSON(); const tasks = json.tasks ?? []; tasks.forEach((task) => { task.has_completed = computeHasCompleted(task); }); let bucket; if (tasks.length === 0) { bucket = 'ongoing'; } else { const allDone = tasks.every((t) => t.has_completed); if (allDone) { bucket = 'done'; } else { const anyOverdue = tasks.some((t) => !t.has_completed && t.deadline && new Date(t.deadline).getTime() < now ); bucket = anyOverdue ? 'overdue' : 'ongoing'; } } return { ...json, tasks, _bucket: bucket }; }); // ── Filter by requested status, then strip internal _bucket field ────── const filtered = status ? bucketed.filter((tl) => tl._bucket === status) : bucketed; const data = filtered.map(({ _bucket, ...rest }) => rest); return R.success(res, 'Task lists retrieved.', data); } catch (err) { console.error('[CLIENT][GET GROUP TASK LISTS]', err); return R.error(res, 'Could not retrieve task lists.', 500); } }; // ============================================================================= // ── TASKS ───────────────────────────────────────────────────────────────────── // ============================================================================= // ─── GET ONE TASK ───────────────────────────────────────────────────────────── // GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId // // Returns the task with its requirements + the user's latest completion. exports.getTask = async (req, res) => { try { const { groupId, taskListId, taskId } = req.params; const member = await isMember(req.user.user_id, groupId); if (!member) return R.error(res, 'Group not found or you are not a member.', 403); const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId }, attributes: { exclude: userExclude }, include: [ { model: TaskList, as: 'taskList', attributes: { exclude: userExclude }, include: [ { model: mdl_UserGroups, as: 'groups', where: { group_id: groupId }, attributes: [], through: { model: TaskListGroup, attributes: [] }, }, ], }, { model: TaskRequirement, as: 'requirements', attributes: { exclude: userExclude }, order: [['order', 'ASC']], }, { // Latest completion by this user model: TaskCompletion, as: 'completions', where: { user_id: req.user.user_id }, required: false, attributes: { exclude: clientExclude }, include: [{ model: TaskCompletionFile, as: 'files', attributes: { exclude: clientExclude }, separate: true, order: [['createdAt', 'ASC']], }], order: [['submitted_at', 'DESC']], limit: 1, }, ], }); if (!task) return R.error(res, 'Task not found or not accessible.', 404); // Flatten: expose latest_completion directly instead of array const data = task.toJSON(); data.latest_completion = data.completions?.[0] ?? null; delete data.completions; return R.success(res, 'Task retrieved.', data); } catch (err) { console.error('[CLIENT][GET TASK]', err); return R.error(res, 'Could not retrieve task.', 500); } }; // ============================================================================= // ── SUBMISSIONS ─────────────────────────────────────────────────────────────── // ============================================================================= // ─── GET MY SUBMISSIONS FOR A TASK ─────────────────────────────────────────── // GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions // Returns all past completions by this user for this task (newest first). exports.getMySubmissions = async (req, res) => { try { const { groupId, taskListId, taskId } = req.params; const member = await isMember(req.user.user_id, groupId); if (!member) return R.error(res, 'Group not found or you are not a member.', 403); const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } }); if (!task) return R.error(res, 'Task not found.', 404); const completions = await TaskCompletion.findAll({ where: { task_id: taskId, user_id: req.user.user_id }, attributes: { exclude: clientExclude }, include: [{ model: TaskCompletionFile, as: 'files', attributes: { exclude: clientExclude }, }], order: [['submitted_at', 'DESC']], }); return R.success(res, 'Completions retrieved.', completions); } catch (err) { console.error('[CLIENT][GET MY SUBMISSIONS]', err); return R.error(res, 'Could not retrieve completions.', 500); } }; // ─── SUBMIT ─────────────────────────────────────────────────────────────────── // // Adds validation against the task's `upload_file` TaskRequirement: // - allowed_file_types: array of uppercase extensions (e.g. ["PDF","DOCX",...]) // - max_file_count: integer cap on number of files per completion // // Validation happens BEFORE creating the TaskCompletion row, at submit time only // (not at /upload). If validation fails, the transaction is rolled back and a // 400 is returned — the already-uploaded files remain orphaned in S3, which is // acceptable per current design (no cleanup-on-reject requirement). exports.submitTask = async (req, res) => { const t = await sequelize.transaction(); try { const { groupId, taskListId, taskId } = req.params; const { note, files = [] } = req.body; const member = await isMember(req.user.user_id, groupId); if (!member) { await t.rollback(); return R.error(res, 'Group not found or you are not a member.', 403); } 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); } if (!files.length) { await t.rollback(); return R.error(res, 'At least one file is required to submit.', 400); } // Validate file entries have required fields const invalid = files.some((f) => !f.file_url || !f.file_name); if (invalid) { await t.rollback(); return R.error(res, 'Each file must have file_url and file_name.', 400); } // ── Validate against upload_file requirement (if defined) ────────────── const uploadRequirement = await TaskRequirement.findOne({ where: { task_id: taskId, type: 'upload_file' }, transaction: t, }); if (uploadRequirement) { // ── max_file_count ─────────────────────────────────────────────────── const maxFiles = uploadRequirement.max_file_count; if (maxFiles && files.length > maxFiles) { await t.rollback(); return R.error( res, `You can only submit up to ${maxFiles} file${maxFiles !== 1 ? 's' : ''} for this task.`, 400 ); } // ── allowed_file_types ─────────────────────────────────────────────── const allowedTypes = (uploadRequirement.allowed_file_types ?? []) .map((ext) => String(ext).toUpperCase()); if (allowedTypes.length) { const rejected = files.filter((f) => { const ext = (f.file_name.split('.').pop() ?? '').toUpperCase(); return !allowedTypes.includes(ext); }); if (rejected.length) { await t.rollback(); const rejectedNames = rejected.map((f) => f.file_name).join(', '); return R.error( res, `These files are not allowed: ${rejectedNames}. Allowed types: ${allowedTypes.join(', ')}.`, 400 ); } } } // ── Create completion ────────────────────────────────────────────────── const completion = await TaskCompletion.create({ task_id: taskId, user_id: req.user.user_id, note: note || null, submitted_at: new Date(), createdBy: req.user.user_id, updatedBy: req.user.user_id, }, { transaction: t }); const fileRows = files.map((f) => ({ completion_id: completion.completion_id, file_url: f.file_url, file_name: f.file_name, file_size: f.file_size ?? null, mime_type: f.mime_type ?? null, storage_key: f.storage_key ?? null, createdBy: req.user.user_id, updatedBy: req.user.user_id, })); await TaskCompletionFile.bulkCreate(fileRows, { transaction: t }); await t.commit(); // Return full completion with files const full = await TaskCompletion.findByPk(completion.completion_id, { attributes: { exclude: clientExclude }, include: [{ model: TaskCompletionFile, as: 'files', attributes: { exclude: clientExclude }, }], }); logActivity(req.user.user_id, 'submit_task', { entityType: 'task', entityId: Number(taskId), }); return R.success(res, 'Task submitted successfully.', full, 201); } catch (err) { await t.rollback(); console.error('[CLIENT][SUBMIT TASK]', err); return R.error(res, 'Could not submit task.', 500); } }; // ───────────────────────────────────────────────────────────────────────────── // ADD THIS to the bottom of task.controller.js (client) // ───────────────────────────────────────────────────────────────────────────── // ─── GET LATEST COMPLETION ──────────────────────────────────────────────────── // GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions/latest // Returns only the most recent completion for this user on this task. // Returns null if the user has not submitted yet — that is valid. exports.getLatestCompletion = async (req, res) => { try { const { groupId, taskListId, taskId } = req.params; const member = await isMember(req.user.user_id, groupId); if (!member) return R.error(res, 'Group not found or you are not a member.', 403); 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: { task_id: taskId, user_id: req.user.user_id }, attributes: { exclude: clientExclude }, include: [{ model: TaskCompletionFile, as: 'files', attributes: { exclude: clientExclude }, separate: true, order: [['createdAt', 'ASC']], }], order: [['submitted_at', 'DESC']], }); return R.success(res, 'Latest completion retrieved.', completion ?? null); } catch (err) { console.error('[CLIENT][GET LATEST COMPLETION]', err); return R.error(res, 'Could not retrieve latest completion.', 500); } };