/*********************************************************************************************************************************************************************** * File Name: task_progress.controller.js (client) * Type of Program: Controller * Description: Client-side progress tracking via UPSERT for all requirement types. * * GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/progress * → returns full progress snapshot: { link_visits, progress } * * POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit * → UPSERT TaskLinkVisit (visit_link) * DELETE /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit * → DELETE TaskLinkVisit (unsubmit) * * POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/progress * → UPSERT TaskProgress (read_lesson) + derives read_unit + read_course * * UPSERT keys: * TaskLinkVisit : (requirement_id, user_id) * TaskProgress : (requirement_id, user_id, reference_id) * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 13, 2026 ***********************************************************************************************************************************************************************/ const { Op } = require('sequelize'); const sequelize = require('../../config/db.config'); const { Task, TaskRequirement } = require('../../models/task/task.mdl'); const { assertTaskUnlocked, checkTaskCompletion, fireTaskCompletedEvent } = require('./task.controller'); const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl'); const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); const { Course } = require('../../models/courses/courses.mdl'); const CourseAssessment = require('../../models/courses/course_assessment.mdl'); const QuizAttempt = require('../../models/courses/quiz_attempt.mdl'); const UnitQuiz = require('../../models/courses/unit_quiz.mdl'); const logActivity = require('../../utils/logActivity.util'); const R = require('../../utils/response.util'); const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.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); // ─── 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: verify requirement belongs to task ─────────────────────────────── const getRequirement = async (requirementId, taskId) => { return TaskRequirement.findOne({ where: { requirement_id: requirementId, task_id: taskId }, }); }; // ─── Helper: derive unit completion ────────────────────────────────────────── // Unit is complete when ALL read_lesson progress rows under this unit requirement // for this user are marked completed. const deriveUnitCompletion = async (userId, unitRequirementId, t) => { const rows = await TaskProgress.findAll({ where: { requirement_id: unitRequirementId, user_id: userId, type: 'read_lesson', }, transaction: t, }); if (!rows.length) return false; return rows.every((r) => r.completed); }; // ─── Helper: derive course completion ──────────────────────────────────────── // Course is complete when ALL read_unit progress rows under this course requirement // for this user are marked completed AND, if the course has a built assessment, // the user has passed it. A course with no assessment yet can never be "complete" — // finishing the reading alone isn't course completion. const deriveCourseCompletion = async (userId, courseRequirementId, courseUuid, t) => { const rows = await TaskProgress.findAll({ where: { requirement_id: courseRequirementId, user_id: userId, type: 'read_unit', }, transaction: t, }); if (!rows.length) return false; const allUnitsRead = rows.every((r) => r.completed); if (!allUnitsRead) return false; const course = await Course.findOne({ where: { uuid: courseUuid }, attributes: ['course_id'], transaction: t, }); if (!course) return false; const assessment = await CourseAssessment.findOne({ where: { course_id: course.course_id }, attributes: ['assessment_id'], transaction: t, }); if (!assessment) return false; const passedAttempt = await QuizAttempt.findOne({ where: { user_id: userId, assessment_id: assessment.assessment_id, passed: true }, transaction: t, }); return !!passedAttempt; }; // ============================================================================= // ── GET FULL PROGRESS SNAPSHOT ──────────────────────────────────────────────── // ============================================================================= // GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/progress // Called once on ViewTaskDetails mount. // Returns { link_visits: [], progress: [] } — frontend builds lookup maps from these. exports.getTaskProgress = 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 }, }); if (!task) return R.error(res, 'Task not found.', 404); const readRequirements = await TaskRequirement.findAll({ where: { task_id: taskId, type: { [Op.in]: ['read_course', 'read_unit', 'read_lesson'] }, }, attributes: ['task_id', 'requirement_id', 'reference_id', 'type'], }); await hydrateReadTaskProgress(req.user.user_id, readRequirements); // pass_quiz completion is computed live from QuizAttempt (same source // has_passed already uses for unit quizzes) rather than a synced // TaskProgress row — reference_id on the requirement is the quiz's // uuid, so resolve to quiz_id first. const quizRequirements = await TaskRequirement.findAll({ where: { task_id: taskId, type: 'pass_quiz' }, attributes: ['requirement_id', 'reference_id'], }); const quizPassedRows = []; if (quizRequirements.length) { const quizUuids = [...new Set(quizRequirements.map((r) => r.reference_id).filter(Boolean))]; const quizzes = await UnitQuiz.findAll({ where: { uuid: quizUuids }, attributes: ['quiz_id', 'uuid'] }); const quizIdByUuid = new Map(quizzes.map((q) => [q.uuid, q.quiz_id])); const quizIds = quizzes.map((q) => q.quiz_id); const passedAttempts = quizIds.length ? await QuizAttempt.findAll({ where: { quiz_id: { [Op.in]: quizIds }, user_id: req.user.user_id, passed: true }, attributes: ['quiz_id'] }) : []; const passedQuizIds = new Set(passedAttempts.map((a) => String(a.quiz_id))); for (const r of quizRequirements) { const quizId = quizIdByUuid.get(r.reference_id); quizPassedRows.push({ requirement_id: r.requirement_id, reference_id: r.reference_id, completed: quizId ? passedQuizIds.has(String(quizId)) : false, completed_at: null, }); } } const [linkVisits, progress] = await Promise.all([ TaskLinkVisit.findAll({ where: { task_id: taskId, user_id: req.user.user_id }, attributes: ['visit_id', 'requirement_id', 'visited_at'], }), TaskProgress.findAll({ where: { task_id: taskId, user_id: req.user.user_id }, attributes: ['progress_id', 'requirement_id', 'reference_id', 'type', 'completed', 'completed_at'], }), ]); return R.success(res, 'Task progress retrieved.', { link_visits: linkVisits, progress: [...progress, ...quizPassedRows], }); } catch (err) { console.error('[CLIENT][GET TASK PROGRESS]', err); return R.error(res, 'Could not retrieve task progress.', 500); } }; // ============================================================================= // ── VISIT LINK (UPSERT) ─────────────────────────────────────────────────────── // ============================================================================= // POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit // // UPSERT on (requirement_id, user_id): // First visit → INSERT new row // Revisit → UPDATE visited_at to NOW() exports.visitLink = async (req, res) => { const t = await sequelize.transaction(); try { const { groupId, taskListId, taskId, requirementId } = req.params; 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 requirement = await getRequirement(requirementId, taskId); if (!requirement) { await t.rollback(); return R.error(res, 'Requirement not found.', 404); } if (requirement.type !== 'visit_link') { await t.rollback(); return R.error(res, 'Requirement is not a visit_link type.', 400); } 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 (!(await assertTaskUnlocked(req.user.user_id, taskListId, task.order_index))) { await t.rollback(); return R.error(res, 'Complete the earlier required tasks in this list first.', 409); } const wasComplete = await checkTaskCompletion(req.user.user_id, taskId); const now = new Date(); const [record, created] = await TaskLinkVisit.upsert( { task_id: taskId, requirement_id: requirementId, user_id: req.user.user_id, visited_at: now, createdBy: req.user.user_id, updatedBy: req.user.user_id, }, { conflictFields: ['requirement_id', 'user_id'], returning: true, transaction: t, } ); await t.commit(); if (created) { logActivity(req.user.user_id, 'visit_link', { entityType: 'task', entityId: Number(taskId), details: { requirement_id: requirementId }, }); } if (!wasComplete && await checkTaskCompletion(req.user.user_id, taskId)) { fireTaskCompletedEvent(req.user.user_id, taskId); // fire-and-forget } return R.success( res, created ? 'Link visited.' : 'Link visit updated.', { requirement_id: requirementId, visited_at: now }, created ? 201 : 200 ); } catch (err) { await t.rollback(); console.error('[CLIENT][VISIT LINK]', err); return R.error(res, 'Could not record link visit.', 500); } }; // ============================================================================= // ── UNVISIT LINK (DELETE TaskLinkVisit) ────────────────────────────────────── // ============================================================================= // DELETE /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit exports.unvisitLink = async (req, res) => { const t = await sequelize.transaction(); try { const { groupId, taskListId, taskId, requirementId } = req.params; 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 requirement = await getRequirement(requirementId, taskId); if (!requirement) { await t.rollback(); return R.error(res, 'Requirement not found.', 404); } if (requirement.type !== 'visit_link') { await t.rollback(); return R.error(res, 'Requirement is not a visit_link type.', 400); } await TaskLinkVisit.destroy({ where: { requirement_id: requirementId, user_id: req.user.user_id, task_id: taskId }, transaction: t, }); await t.commit(); return R.success(res, 'Link visit removed.', { requirement_id: requirementId }); } catch (err) { await t.rollback(); console.error('[CLIENT][UNVISIT LINK]', err); return R.error(res, 'Could not remove link visit.', 500); } }; // ============================================================================= // ── UPDATE LESSON PROGRESS (UPSERT — derives unit + course) ────────────────── // ============================================================================= // POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/progress // // Body: // { // reference_id : UUID — lesson_id being marked // completed : boolean // unit_requirement_id? : UUID — read_unit requirement this lesson belongs to // course_requirement_id?: UUID — read_course requirement this unit belongs to // } // // Flow: // 1. UPSERT lesson progress row // 2. If unit_requirement_id provided → derive unit completion → UPSERT unit row // 3. If course_requirement_id provided → derive course completion → UPSERT course row exports.updateProgress = async (req, res) => { const t = await sequelize.transaction(); try { const { groupId, taskListId, taskId, requirementId } = req.params; const { reference_id, completed, unit_requirement_id, course_requirement_id } = req.body; if (!reference_id || completed === undefined) { await t.rollback(); return R.error(res, 'reference_id and completed are required.', 400); } 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 requirement = await getRequirement(requirementId, taskId); if (!requirement) { await t.rollback(); return R.error(res, 'Requirement not found.', 404); } const ALLOWED = ['read_lesson', 'read_unit', 'read_course']; if (!ALLOWED.includes(requirement.type)) { await t.rollback(); return R.error(res, `Cannot update progress for requirement type: ${requirement.type}.`, 400); } const now = new Date(); const userId = req.user.user_id; const wasComplete = await checkTaskCompletion(userId, taskId); // ── Direct UPSERT for read_unit / read_course ───────────────────────── if (requirement.type === 'read_unit' || requirement.type === 'read_course') { await TaskProgress.upsert( { task_id: taskId, requirement_id: requirementId, user_id: userId, reference_id, type: requirement.type, completed: !!completed, completed_at: completed ? now : null, createdBy: userId, updatedBy: userId, }, { conflictFields: ['requirement_id', 'user_id', 'reference_id'], transaction: t, } ); await t.commit(); if (!wasComplete && await checkTaskCompletion(userId, taskId)) { fireTaskCompletedEvent(userId, taskId); // fire-and-forget } return R.success(res, 'Progress updated.', { requirement_id: requirementId, reference_id, completed: !!completed, completed_at: completed ? now : null, }); } // ── 1. UPSERT lesson ────────────────────────────────────────────────── await TaskProgress.upsert( { task_id: taskId, requirement_id: requirementId, user_id: userId, reference_id, type: 'read_lesson', completed: !!completed, completed_at: completed ? now : null, createdBy: userId, updatedBy: userId, }, { conflictFields: ['requirement_id', 'user_id', 'reference_id'], transaction: t, } ); // ── 2. Derive + UPSERT unit ─────────────────────────────────────────── if (unit_requirement_id) { const unitReq = await getRequirement(unit_requirement_id, taskId); if (unitReq && unitReq.type === 'read_unit') { const unitDone = await deriveUnitCompletion(userId, unit_requirement_id, t); await TaskProgress.upsert( { task_id: taskId, requirement_id: unit_requirement_id, user_id: userId, reference_id: unitReq.reference_id, type: 'read_unit', completed: unitDone, completed_at: unitDone ? now : null, createdBy: userId, updatedBy: userId, }, { conflictFields: ['requirement_id', 'user_id', 'reference_id'], transaction: t, } ); // ── 3. Derive + UPSERT course ───────────────────────────────── if (course_requirement_id) { const courseReq = await getRequirement(course_requirement_id, taskId); if (courseReq && courseReq.type === 'read_course') { const courseDone = await deriveCourseCompletion(userId, course_requirement_id, courseReq.reference_id, t); await TaskProgress.upsert( { task_id: taskId, requirement_id: course_requirement_id, user_id: userId, reference_id: courseReq.reference_id, type: 'read_course', completed: courseDone, completed_at: courseDone ? now : null, createdBy: userId, updatedBy: userId, }, { conflictFields: ['requirement_id', 'user_id', 'reference_id'], transaction: t, } ); } } } } await t.commit(); if (!wasComplete && await checkTaskCompletion(userId, taskId)) { fireTaskCompletedEvent(userId, taskId); // fire-and-forget } return R.success(res, 'Progress updated.', { requirement_id: requirementId, reference_id, completed: !!completed, completed_at: completed ? now : null, }); } catch (err) { await t.rollback(); console.error('[CLIENT][UPDATE PROGRESS]', err); return R.error(res, 'Could not update progress.', 500); } }; // ───────────────────────────────────────────────────────────────────────────── // NOTE: getLatestCompletion lives in task.controller.js as it shares // the isMember + Task lookup pattern already established there. // Add this function to the BOTTOM of task.controller.js: // // 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); // } // };