/*********************************************************************************************************************************************************************** * 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, TaskPrerequisite } = 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 { QuizAttempt } = require('../../models/courses/courses.associations'); 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'); const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service'); const UserNotification = require('../../models/notifications/user_notification.mdl'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const { onTaskCompleted, onTaskListCompleted } = require('../../services/achievements.service'); const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const isUUID = (v) => UUID_RE.test(v); // ============================================================================= // ── 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: [], where: { deletedAt: null }, }, }, { model: TaskList, as: 'taskLists', attributes: [], through: { attributes: [] }, required: false, }, ], where: { is_active: true }, attributes: [ 'group_id', 'name', 'group_code', 'description', [sequelize.fn('COUNT', sequelize.fn('DISTINCT', sequelize.col('taskLists.task_list_id'))), 'task_list_count'], ], group: [ 'UserGroup.group_id', 'UserGroup.name', 'UserGroup.group_code', 'UserGroup.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; }; // ─── Helper: per-user completion signals for a batch of tasks ───────────────── // Shared by getGroupTaskList/getGroupTaskLists. upload_file/submit_text share // one TaskCompletion per task (resubmit-anytime — latest by submitted_at wins); // pass_quiz is computed live from QuizAttempt, same as unit-quiz has_passed // (courses.controller.js) rather than a separately-synced TaskProgress row. const getTaskCompletionSignals = async (userId, taskIds, requirements) => { const quizIds = [...new Set( requirements.filter((r) => r.type === 'pass_quiz' && r.reference_id).map((r) => r.reference_id) )]; const [completions, linkVisits, progressRows, passedAttempts] = await Promise.all([ taskIds.length ? TaskCompletion.findAll({ where: { task_id: { [Op.in]: taskIds }, user_id: userId }, attributes: ['task_id', 'status', 'submitted_at'], order: [['submitted_at', 'DESC']], }) : [], 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'], }) : [], quizIds.length ? QuizAttempt.findAll({ where: { quiz_id: { [Op.in]: quizIds }, user_id: userId, passed: true }, attributes: ['quiz_id'], }) : [], ]); // First row per task_id wins — completions are ordered submitted_at DESC. const latestCompletionByTask = new Map(); for (const c of completions) { if (!latestCompletionByTask.has(c.task_id)) latestCompletionByTask.set(c.task_id, c); } return { latestCompletionByTask, visitedRequirementIds: new Set(linkVisits.map((v) => v.requirement_id)), completedProgressKeys: new Set(progressRows.map((p) => `${p.requirement_id}:${p.reference_id}`)), passedQuizIds: new Set(passedAttempts.map((a) => String(a.quiz_id))), }; }; // ─── Helper: has this requirement been satisfied by the user? ───────────────── const isRequirementDone = (r, signals) => { switch (r.type) { case 'upload_file': case 'submit_text': { const completion = signals.latestCompletionByTask.get(r.task_id); if (!completion) return false; return r.requires_review ? completion.status === 'approved' : true; } case 'visit_link': return signals.visitedRequirementIds.has(r.requirement_id); case 'read_course': case 'read_unit': case 'read_lesson': return signals.completedProgressKeys.has(`${r.requirement_id}:${r.reference_id}`); case 'pass_quiz': return signals.passedQuizIds.has(String(r.reference_id)); default: return true; // unknown requirement types don't block completion } }; // ─── Helper: resolve which sibling task(s) are blocking a locked task ─────── // Prerequisites are always siblings within the same task list (enforced by // admin's syncTaskPrerequisites), so names can always be resolved from `arr`. // A task with no explicit task_prerequisites rows is never locked. const resolveLockedBy = (task, i, arr, prereqsByTask, completedById) => { const prereqIds = prereqsByTask.get(task.task_id); if (!prereqIds || !prereqIds.length) return []; const blockers = arr.filter((t) => prereqIds.includes(t.task_id) && completedById.get(t.task_id) !== true); return blockers.map((t) => ({ task_id: t.task_id, name: t.name })); }; // ─── Helper: explicit prerequisite gate ───────────────────────────────────── // Returns true/false when `taskId` has explicit task_prerequisites rows — // ALL of them must be completed by this user. Returns true (unlocked) when // the task has no explicit prerequisites configured. const checkPrerequisitesUnlocked = async (userId, taskId) => { const prereqRows = await TaskPrerequisite.findAll({ where: { task_id: taskId } }); if (!prereqRows.length) return true; const results = await Promise.all(prereqRows.map((r) => checkTaskCompletion(userId, r.prerequisite_task_id))); return results.every(Boolean); }; // ─── Helper: server-side sequencing gate ─────────────────────────────────── // A task is locked only by its own explicit task_prerequisites rows — no // implicit locking based on list position. Shared by this file's submitTask // and task_progress.controller.js's visitLink/updateProgress. const assertTaskUnlocked = async (userId, taskId) => checkPrerequisitesUnlocked(userId, taskId); // ─── Helper: is this one task fully done for this user, right now? ───────── const checkTaskCompletion = async (userId, taskId) => { const reqs = await TaskRequirement.findAll({ where: { task_id: taskId } }); if (!reqs.length) return false; const plainReqs = reqs.map((r) => r.toJSON()); const signals = await getTaskCompletionSignals(userId, [taskId], plainReqs); return plainReqs.every((r) => isRequirementDone(r, signals)); }; // ─── Helper: fire task_completed (+ task_list_finisher achievement) on the // 0→1 completion transition. Callers compute `wasComplete` themselves right // before their write, then call this after, so it only fires once per task. const fireTaskCompletedEvent = async (userId, taskId) => { try { const task = await Task.findByPk(taskId); if (!task) return; try { const notify = NOTIFICATION_REGISTRY.task_completed.build({ taskName: task.name }); await UserNotification.create({ user_id: userId, ...notify, seen: false }); } catch (notifyErr) { console.error('[TASK][NOTIFY COMPLETED]', notifyErr); } await onTaskCompleted(userId, taskId, task.name); // ── Whole-list completion — check every sibling task too ─────────── const siblingTasks = await Task.findAll({ where: { task_list_id: task.task_list_id } }); const allDone = siblingTasks.length > 0 && ( await Promise.all(siblingTasks.map((t) => checkTaskCompletion(userId, t.task_id))) ).every(Boolean); if (allDone) { const taskList = await TaskList.findByPk(task.task_list_id); if (taskList) await onTaskListCompleted(userId, taskList.task_list_id, taskList.name); } } catch (err) { console.error('[TASK][FIRE COMPLETED EVENT]', err); } }; exports.getTaskCompletionSignals = getTaskCompletionSignals; exports.isRequirementDone = isRequirementDone; exports.assertTaskUnlocked = assertTaskUnlocked; exports.checkPrerequisitesUnlocked = checkPrerequisitesUnlocked; exports.checkTaskCompletion = checkTaskCompletion; exports.fireTaskCompletedEvent = fireTaskCompletedEvent; // ───────────────────────────────────────────────────────────────────────────── // REPLACEMENT: getGroupTaskList in task.controller.js (client) // // GET /client/groups/:groupId/task-lists/:taskListId?status=ongoing|completed|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: // completed → 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 completed AND task.deadline < now // ongoing → otherwise // ───────────────────────────────────────────────────────────────────────────── exports.getGroupTaskList = async (req, res) => { try { const { groupId, taskListId } = req.params; const { status } = req.query; // optional: 'ongoing' | 'completed' | '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: [['order_index', '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); const readRequirements = tasks.flatMap((task) => (task.requirements ?? []) .filter((req) => ['read_course', 'read_unit', 'read_lesson'].includes(req.type)) .map((req) => ({ ...req, task_id: task.task_id })) ); await hydrateReadTaskProgress(userId, readRequirements); // ── Fetch user's completion signals for these tasks ──────────────────── const allRequirements = tasks.flatMap((task) => task.requirements ?? []); const signals = await getTaskCompletionSignals(userId, taskIds, allRequirements); const now = Date.now(); // ── has_completed per task (needed up-front — both the bucket AND the // locked computation below depend on sibling tasks' completion) ──────── const completedById = new Map(tasks.map((task) => { const requirements = task.requirements ?? []; return [task.task_id, requirements.length > 0 && requirements.every((r) => isRequirementDone(r, signals))]; })); // ── Explicit prerequisite edges for these tasks ───────────────────────── const prereqEdges = taskIds.length ? await TaskPrerequisite.findAll({ where: { task_id: { [Op.in]: taskIds } }, attributes: ['task_id', 'prerequisite_task_id'] }) : []; const prereqsByTask = new Map(); for (const { task_id, prerequisite_task_id } of prereqEdges) { if (!prereqsByTask.has(task_id)) prereqsByTask.set(task_id, []); prereqsByTask.get(task_id).push(prerequisite_task_id); } // ── Bucket + lock each task — a task with explicit prerequisites is // locked until ALL of them are done; a task with none is never locked // (same rule assertTaskUnlocked enforces). const bucketedTasks = tasks.map((task, i, arr) => { const has_completed = completedById.get(task.task_id); let bucket; if (has_completed) { bucket = 'completed'; } else if (task.deadline && new Date(task.deadline).getTime() < now) { bucket = 'overdue'; } else { bucket = 'ongoing'; } const lockedBy = resolveLockedBy(task, i, arr, prereqsByTask, completedById); const locked = lockedBy.length > 0; return { ...task, has_completed, locked, lockedBy, _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|completed|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 → Completed // NOT all completed 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' | 'completed' | '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: [['order_index', '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); const readRequirements = allTasks.flatMap((task) => (task.requirements ?? []) .filter((req) => ['read_course', 'read_unit', 'read_lesson'].includes(req.type)) .map((req) => ({ task_id: task.task_id, requirement_id: req.requirement_id, reference_id: req.reference_id, type: req.type, })) ); await hydrateReadTaskProgress(userId, readRequirements); // ── Fetch user's completion signals for these tasks ──────────────────── const allRequirements = allTasks.flatMap((task) => task.requirements ?? []); const signals = await getTaskCompletionSignals(userId, taskIds, allRequirements); 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) => isRequirementDone(r, signals)); }; // ── Explicit prerequisite edges for these tasks ───────────────────────── const prereqEdges = taskIds.length ? await TaskPrerequisite.findAll({ where: { task_id: { [Op.in]: taskIds } }, attributes: ['task_id', 'prerequisite_task_id'] }) : []; const prereqsByTask = new Map(); for (const { task_id, prerequisite_task_id } of prereqEdges) { if (!prereqsByTask.has(task_id)) prereqsByTask.set(task_id, []); prereqsByTask.get(task_id).push(prerequisite_task_id); } // ── 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); }); // has_completed lookup scoped to THIS list's tasks (prerequisite // edges only ever point at siblings within the same list). const completedById = new Map(tasks.map((task) => [task.task_id, task.has_completed])); tasks.forEach((task, i, arr) => { task.lockedBy = resolveLockedBy(task, i, arr, prereqsByTask, completedById); task.locked = task.lockedBy.length > 0; }); let bucket; if (tasks.length === 0) { bucket = 'ongoing'; } else { const allDone = tasks.every((t) => t.has_completed); if (allDone) { bucket = 'completed'; } 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; if (!isUUID(taskId) || !isUUID(taskListId)) return R.error(res, 'Task not found.', 404); 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; data.locked = !(await assertTaskUnlocked(req.user.user_id, taskId)); 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 = [], response_text } = 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 (task.accepts_submissions === false) { await t.rollback(); return R.error(res, 'This task no longer accepts submissions.', 409); } if (!(await assertTaskUnlocked(req.user.user_id, taskId))) { await t.rollback(); return R.error(res, 'Complete this task\'s prerequisite tasks first.', 409); } const wasComplete = await checkTaskCompletion(req.user.user_id, taskId); // ── Which submission-based requirement(s) does this task have? ───────── const submissionRequirements = await TaskRequirement.findAll({ where: { task_id: taskId, type: { [Op.in]: ['upload_file', 'submit_text'] } }, transaction: t, }); const uploadRequirement = submissionRequirements.find((r) => r.type === 'upload_file'); const textRequirement = submissionRequirements.find((r) => r.type === 'submit_text'); if (!uploadRequirement && !textRequirement) { await t.rollback(); return R.error(res, 'This task has no requirement that accepts a submission.', 400); } if (uploadRequirement && !files.length) { await t.rollback(); return R.error(res, 'At least one file is required to submit.', 400); } if (!uploadRequirement && textRequirement && !(response_text ?? '').trim()) { await t.rollback(); return R.error(res, 'A response 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); } 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, response_text: response_text || null, submitted_at: new Date(), createdBy: req.user.user_id, updatedBy: req.user.user_id, }, { transaction: t }); if (files.length) { 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), }); if (!wasComplete && await checkTaskCompletion(req.user.user_id, taskId)) { fireTaskCompletedEvent(req.user.user_id, taskId); // fire-and-forget } 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); } };