Files
starr-philproperties/controllers/client/task_progress.controller.js
T
2026-07-20 11:28:21 +08:00

513 lines
23 KiB
JavaScript

/***********************************************************************************************************************************************************************
* 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, 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);
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 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, taskId))) {
await t.rollback();
return R.error(res, 'Complete this task\'s prerequisite tasks first.', 409);
}
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);
// }
// };