mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
413 lines
18 KiB
JavaScript
413 lines
18 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 sequelize = require('../../config/db.config');
|
|
|
|
const { Task, TaskRequirement } = require('../../models/task/task.mdl');
|
|
const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl');
|
|
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
|
const logActivity = require('../../utils/logActivity.util');
|
|
const R = require('../../utils/response.util');
|
|
|
|
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.
|
|
const deriveCourseCompletion = async (userId, courseRequirementId, t) => {
|
|
const rows = await TaskProgress.findAll({
|
|
where: {
|
|
requirement_id: courseRequirementId,
|
|
user_id: userId,
|
|
type: 'read_unit',
|
|
},
|
|
transaction: t,
|
|
});
|
|
if (!rows.length) return false;
|
|
return rows.every((r) => r.completed);
|
|
};
|
|
|
|
// =============================================================================
|
|
// ── 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 [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 });
|
|
} 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 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 },
|
|
});
|
|
}
|
|
|
|
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;
|
|
|
|
// ── 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();
|
|
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, 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();
|
|
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);
|
|
// }
|
|
// };
|