mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
528 lines
24 KiB
JavaScript
528 lines
24 KiB
JavaScript
/***********************************************************************************************************************************************************************
|
|
* File Name: course_reading_progress.controller.js (client)
|
|
* Type of Program: Controller
|
|
* Description: Tracks user reading progress through a course hierarchy (course → unit → lesson).
|
|
*
|
|
* GET /client/courses/in-progress
|
|
* → courses where the current user has status = 'in_progress', with lesson counts
|
|
*
|
|
* GET /client/courses/:courseId/progress/summary
|
|
* → compact snapshot: lesson counts + percentage + course status
|
|
*
|
|
* GET /client/courses/:courseId/progress
|
|
* → all progress rows for this user + course (flat, frontend builds the map)
|
|
*
|
|
* GET /client/courses/:courseId/task-context
|
|
* → all pending task requirements (read_*) for this course's UUIDs that the user is assigned to
|
|
*
|
|
* POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/progress
|
|
* → UPSERT lesson + derives + UPSERTs parent unit + course in one transaction
|
|
* → side-effects: writes to lesson_reading_progress / unit_reading_progress,
|
|
* syncs task_progress for matching task requirements,
|
|
* returns completed_tasks for any task whose read requirements are now all done
|
|
*
|
|
* Author: Kenneth Obsequio (@lash0000)
|
|
* Date Created: Jun. 21, 2026
|
|
***********************************************************************************************************************************************************************/
|
|
'use strict';
|
|
|
|
const { Op } = require('sequelize');
|
|
const R = require('../../utils/response.util');
|
|
const logActivity = require('../../utils/logActivity.util');
|
|
const { upsertLessonRead } = require('../../services/course_reading_progress.service');
|
|
const { upsertLessonRead: upsertReadingProgress } = require('../../services/reading_progress.service');
|
|
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
|
|
const Certificate = require('../../models/courses/certificate.mdl');
|
|
const {
|
|
Course, Unit, Lesson,
|
|
CourseUnit, UnitLesson,
|
|
UnitQuiz, CourseAssessment, QuizAttempt,
|
|
} = require('../../models/courses/courses.associations');
|
|
const { countCourseLessons, getCourseUnitIds, flattenUnits } = require('../../utils/courses/hierarchy.util');
|
|
|
|
const { Task, TaskRequirement, TaskListGroup } = require('../../models/task/task.mdl');
|
|
const { TaskProgress } = require('../../models/task/task_progress.mdl');
|
|
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
|
|
|
const notDeleted = { deletedAt: null };
|
|
|
|
// ─── Internal helper: get task list IDs accessible to a user ─────────────────
|
|
async function getAccessibleTaskListIds(userId) {
|
|
const memberships = await mdl_UserGroupMembers.findAll({
|
|
where: { user_id: userId, deletedAt: null },
|
|
attributes: ['group_id'],
|
|
});
|
|
const groupIds = memberships.map((m) => m.group_id);
|
|
if (!groupIds.length) return { taskListIds: [], taskListToGroup: {} };
|
|
|
|
const taskListGroups = await TaskListGroup.findAll({
|
|
where: { group_id: groupIds },
|
|
attributes: ['task_list_id', 'group_id'],
|
|
});
|
|
const taskListToGroup = Object.fromEntries(taskListGroups.map((tlg) => [tlg.task_list_id, tlg.group_id]));
|
|
return { taskListIds: Object.keys(taskListToGroup), taskListToGroup };
|
|
}
|
|
|
|
// ─── Internal helper: sync task_progress after a lesson read ─────────────────
|
|
// Finds TaskRequirement rows whose reference_id matches the lesson/unit/course UUID
|
|
// (only for tasks the user is assigned to) and marks them completed in task_progress.
|
|
// Returns an array of { task_id, task_name } for tasks where ALL read-only requirements
|
|
// are now satisfied — these are eligible for display as "auto turned-in" on the frontend.
|
|
async function syncTaskProgress(userId, { lessonUuid, unitUuid, courseUuid, lessonStatus, unitStatus, courseStatus }) {
|
|
if (lessonStatus !== 'completed') return [];
|
|
|
|
const { taskListIds } = await getAccessibleTaskListIds(userId);
|
|
if (!taskListIds.length) return [];
|
|
|
|
// Collect UUIDs to match based on what became completed
|
|
const matchUuids = [lessonUuid];
|
|
if (unitStatus === 'completed') matchUuids.push(unitUuid);
|
|
if (courseStatus === 'completed') matchUuids.push(courseUuid);
|
|
|
|
const requirements = await TaskRequirement.findAll({
|
|
where: {
|
|
reference_id: { [Op.in]: matchUuids },
|
|
type: { [Op.in]: ['read_lesson', 'read_unit', 'read_course'] },
|
|
deletedAt: null,
|
|
},
|
|
include: [{
|
|
model: Task,
|
|
as: 'task',
|
|
where: { task_list_id: { [Op.in]: taskListIds }, deletedAt: null },
|
|
required: true,
|
|
attributes: ['task_id', 'name', 'task_list_id'],
|
|
}],
|
|
attributes: ['requirement_id', 'task_id', 'type', 'reference_id'],
|
|
});
|
|
|
|
if (!requirements.length) return [];
|
|
|
|
// Filter to (type, reference_id) pairs that actually became completed this call
|
|
const toComplete = requirements.filter((req) => {
|
|
if (req.type === 'read_lesson' && req.reference_id === lessonUuid) return true;
|
|
if (req.type === 'read_unit' && req.reference_id === unitUuid && unitStatus === 'completed') return true;
|
|
if (req.type === 'read_course' && req.reference_id === courseUuid && courseStatus === 'completed') return true;
|
|
return false;
|
|
});
|
|
if (!toComplete.length) return [];
|
|
|
|
const now = new Date();
|
|
|
|
// Upsert TaskProgress as completed for each matching requirement
|
|
await Promise.all(toComplete.map((req) =>
|
|
TaskProgress.upsert(
|
|
{
|
|
task_id: req.task_id,
|
|
requirement_id: req.requirement_id,
|
|
user_id: userId,
|
|
reference_id: req.reference_id,
|
|
type: req.type,
|
|
completed: true,
|
|
completed_at: now,
|
|
createdBy: userId,
|
|
updatedBy: userId,
|
|
},
|
|
{ conflictFields: ['requirement_id', 'user_id', 'reference_id'] }
|
|
)
|
|
));
|
|
|
|
// Check if any impacted task now has ALL its read requirements done
|
|
// (only auto-turn-in pure read tasks — tasks with upload_file/visit_link need manual submission)
|
|
const taskIds = [...new Set(toComplete.map((r) => r.task_id))];
|
|
const completedTasks = [];
|
|
|
|
for (const taskId of taskIds) {
|
|
const allReqs = await TaskRequirement.findAll({
|
|
where: { task_id: taskId, deletedAt: null },
|
|
attributes: ['requirement_id', 'type', 'reference_id'],
|
|
});
|
|
|
|
const hasNonReadReqs = allReqs.some((r) => !['read_course', 'read_unit', 'read_lesson'].includes(r.type));
|
|
if (hasNonReadReqs) continue; // let the user manually submit
|
|
|
|
const readReqs = allReqs; // all are read-type at this point
|
|
|
|
const doneProgress = await TaskProgress.findAll({
|
|
where: { task_id: taskId, user_id: userId, completed: true },
|
|
attributes: ['requirement_id', 'reference_id'],
|
|
});
|
|
const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`));
|
|
const allDone = readReqs.every((r) => doneSet.has(`${r.requirement_id}:${r.reference_id}`));
|
|
|
|
if (allDone) {
|
|
const taskName = toComplete.find((r) => r.task_id === taskId)?.task?.name ?? '';
|
|
completedTasks.push({ task_id: taskId, task_name: taskName });
|
|
}
|
|
}
|
|
|
|
return completedTasks;
|
|
}
|
|
|
|
// =============================================================================
|
|
// ── IN-PROGRESS COURSES — profile learning progress card ──────────────────────
|
|
// =============================================================================
|
|
|
|
exports.getMyInProgressCourses = async (req, res) => {
|
|
try {
|
|
const userId = req.user.user_id;
|
|
|
|
const courseRows = await CourseReadingProgress.findAll({
|
|
where: { user_id: userId, type: 'course' },
|
|
attributes: ['course_id', 'status', 'last_accessed_at'],
|
|
include: [{
|
|
model: Course,
|
|
as: 'course',
|
|
attributes: ['course_id', 'title'],
|
|
where: notDeleted,
|
|
required: true,
|
|
}],
|
|
order: [['last_accessed_at', 'DESC']],
|
|
});
|
|
|
|
if (!courseRows.length) return R.success(res, 'No courses in progress.', []);
|
|
|
|
const certificates = await Certificate.findAll({
|
|
where: { user_id: userId },
|
|
attributes: ['course_id'],
|
|
});
|
|
const certSet = new Set(certificates.map((c) => String(c.course_id)));
|
|
|
|
const pending = courseRows.filter((r) => !certSet.has(String(r.course_id)));
|
|
if (!pending.length) return R.success(res, 'No courses in progress.', []);
|
|
|
|
const result = await Promise.all(pending.map(async (row) => {
|
|
const courseId = row.course_id;
|
|
|
|
const [lessons_total, lessons_completed] = await Promise.all([
|
|
countCourseLessons(courseId),
|
|
CourseReadingProgress.count({
|
|
where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' },
|
|
}),
|
|
]);
|
|
|
|
// "Reading done" is derived independently from lesson counts — row.status now also
|
|
// requires the course assessment to be passed, so it can't be used as the reading gate.
|
|
const readingDone = lessons_total > 0 && lessons_completed === lessons_total;
|
|
|
|
let pending_quizzes = [];
|
|
let pending_assessment = null;
|
|
let assessment_configured = true;
|
|
|
|
if (readingDone) {
|
|
const courseUnitIds = await getCourseUnitIds(courseId);
|
|
const unitQuizzes = courseUnitIds.length ? await UnitQuiz.findAll({
|
|
attributes: ['quiz_id', 'title', 'is_required', 'passing_score'],
|
|
include: [{
|
|
model: Unit,
|
|
as: 'unit',
|
|
attributes: ['unit_id', 'title'],
|
|
where: notDeleted,
|
|
required: true,
|
|
}],
|
|
where: { unit_id: courseUnitIds, ...notDeleted },
|
|
}) : [];
|
|
|
|
for (const quiz of unitQuizzes) {
|
|
const [hasPassed, attemptCount] = await Promise.all([
|
|
QuizAttempt.findOne({ where: { user_id: userId, quiz_id: quiz.quiz_id, passed: true } }),
|
|
QuizAttempt.count({ where: { user_id: userId, quiz_id: quiz.quiz_id } }),
|
|
]);
|
|
if (!hasPassed) {
|
|
pending_quizzes.push({
|
|
quiz_id: quiz.quiz_id,
|
|
title: quiz.title,
|
|
unit_title: quiz.unit.title,
|
|
is_required: quiz.is_required,
|
|
passing_score: quiz.passing_score,
|
|
attempt_count: attemptCount,
|
|
});
|
|
}
|
|
}
|
|
|
|
const assessment = await CourseAssessment.findOne({
|
|
attributes: ['assessment_id', 'title', 'is_required', 'passing_score'],
|
|
where: { course_id: courseId },
|
|
});
|
|
assessment_configured = !!assessment;
|
|
if (assessment) {
|
|
const [hasPassed, attemptCount] = await Promise.all([
|
|
QuizAttempt.findOne({ where: { user_id: userId, assessment_id: assessment.assessment_id, passed: true } }),
|
|
QuizAttempt.count({ where: { user_id: userId, assessment_id: assessment.assessment_id } }),
|
|
]);
|
|
if (!hasPassed) {
|
|
pending_assessment = {
|
|
assessment_id: assessment.assessment_id,
|
|
title: assessment.title,
|
|
is_required: assessment.is_required,
|
|
passing_score: assessment.passing_score,
|
|
attempt_count: attemptCount,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
course_id: courseId,
|
|
title: row.course.title,
|
|
reading_status: readingDone ? 'completed' : 'in_progress',
|
|
assessment_configured,
|
|
lessons_total,
|
|
lessons_completed,
|
|
last_accessed_at: row.last_accessed_at,
|
|
pending_quizzes,
|
|
pending_assessment,
|
|
};
|
|
}));
|
|
|
|
return R.success(res, 'In-progress courses retrieved.', result);
|
|
} catch (err) {
|
|
console.error('[CLIENT][COURSE READING PROGRESS][IN PROGRESS]', err);
|
|
return R.error(res, 'Could not retrieve in-progress courses.', 500);
|
|
}
|
|
};
|
|
|
|
// =============================================================================
|
|
// ── GET PROGRESS SUMMARY ──────────────────────────────────────────────────────
|
|
// =============================================================================
|
|
|
|
exports.getCourseProgressSummary = async (req, res) => {
|
|
try {
|
|
const { courseId } = req.params;
|
|
const userId = req.user.user_id;
|
|
|
|
const course = await Course.findOne({
|
|
where: { course_id: courseId, ...notDeleted },
|
|
attributes: ['course_id'],
|
|
});
|
|
if (!course) return R.error(res, 'Course not found.', 404);
|
|
|
|
const [lessons_total, lessons_completed, courseRow] = await Promise.all([
|
|
countCourseLessons(courseId),
|
|
CourseReadingProgress.count({
|
|
where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' },
|
|
}),
|
|
CourseReadingProgress.findOne({
|
|
where: { user_id: userId, course_id: courseId, type: 'course' },
|
|
attributes: ['status'],
|
|
}),
|
|
]);
|
|
|
|
const percent = lessons_total > 0 ? Math.round((lessons_completed / lessons_total) * 100) : 0;
|
|
|
|
return R.success(res, 'Progress summary retrieved.', {
|
|
lessons_total,
|
|
lessons_completed,
|
|
percent,
|
|
status: courseRow?.status ?? null,
|
|
});
|
|
} catch (err) {
|
|
console.error('[CLIENT][COURSE READING PROGRESS][SUMMARY]', err);
|
|
return R.error(res, 'Could not retrieve progress summary.', 500);
|
|
}
|
|
};
|
|
|
|
// =============================================================================
|
|
// ── GET PROGRESS SNAPSHOT ─────────────────────────────────────────────────────
|
|
// =============================================================================
|
|
|
|
exports.getCourseProgress = async (req, res) => {
|
|
try {
|
|
const { courseId } = req.params;
|
|
const userId = req.user.user_id;
|
|
|
|
const course = await Course.findOne({
|
|
where: { course_id: courseId, ...notDeleted },
|
|
attributes: ['course_id'],
|
|
});
|
|
if (!course) return R.error(res, 'Course not found.', 404);
|
|
|
|
const rows = await CourseReadingProgress.findAll({
|
|
where: { user_id: userId, course_id: courseId },
|
|
attributes: ['progress_id', 'reference_id', 'type', 'status', 'completed_at', 'last_accessed_at'],
|
|
});
|
|
|
|
return R.success(res, 'Course progress retrieved.', rows);
|
|
} catch (err) {
|
|
console.error('[CLIENT][COURSE READING PROGRESS][GET]', err);
|
|
return R.error(res, 'Could not retrieve course progress.', 500);
|
|
}
|
|
};
|
|
|
|
// =============================================================================
|
|
// ── TASK CONTEXT FOR A COURSE ─────────────────────────────────────────────────
|
|
// =============================================================================
|
|
|
|
// GET /client/courses/:courseId/task-context
|
|
// Returns all pending task requirements (read_course / read_unit / read_lesson)
|
|
// whose reference_id matches this course, any of its units, or any of its lessons,
|
|
// filtered to tasks the current user is actually assigned to (via group membership).
|
|
// UnitList calls this on mount when no task context is passed via navigation state.
|
|
|
|
exports.getCourseTaskContext = async (req, res) => {
|
|
try {
|
|
const { courseId } = req.params;
|
|
const userId = req.user.user_id;
|
|
|
|
const course = await Course.findOne({
|
|
where: { course_id: courseId, ...notDeleted },
|
|
attributes: ['course_id', 'uuid'],
|
|
include: [{
|
|
model: Unit,
|
|
as: 'units',
|
|
where: notDeleted,
|
|
required: false,
|
|
attributes: ['unit_id', 'uuid'],
|
|
through: { attributes: [] },
|
|
include: [{
|
|
model: Lesson,
|
|
as: 'lessons',
|
|
where: notDeleted,
|
|
required: false,
|
|
attributes: ['lesson_id', 'uuid'],
|
|
through: { attributes: [] },
|
|
}],
|
|
}],
|
|
});
|
|
if (!course) return R.error(res, 'Course not found.', 404);
|
|
|
|
const units = course.units ?? [];
|
|
const allUuids = [
|
|
course.uuid,
|
|
...units.map((u) => u.uuid),
|
|
...units.flatMap((u) => (u.lessons ?? []).map((l) => l.uuid)),
|
|
];
|
|
|
|
const { taskListIds, taskListToGroup } = await getAccessibleTaskListIds(userId);
|
|
if (!taskListIds.length) {
|
|
return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] });
|
|
}
|
|
|
|
const requirements = await TaskRequirement.findAll({
|
|
where: {
|
|
type: { [Op.in]: ['read_course', 'read_unit', 'read_lesson'] },
|
|
reference_id: { [Op.in]: allUuids },
|
|
deletedAt: null,
|
|
},
|
|
include: [{
|
|
model: Task,
|
|
as: 'task',
|
|
where: { task_list_id: { [Op.in]: taskListIds }, deletedAt: null },
|
|
required: true,
|
|
attributes: ['task_id', 'name', 'task_list_id'],
|
|
}],
|
|
attributes: ['requirement_id', 'task_id', 'type', 'reference_id', 'reference_label'],
|
|
});
|
|
|
|
if (!requirements.length) {
|
|
return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] });
|
|
}
|
|
|
|
// Mark which requirements are already completed
|
|
const taskIds = [...new Set(requirements.map((r) => r.task_id))];
|
|
const doneProgress = await TaskProgress.findAll({
|
|
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
|
|
attributes: ['requirement_id', 'reference_id'],
|
|
});
|
|
const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`));
|
|
|
|
const contexts = requirements.map((req) => ({
|
|
task_id: req.task_id,
|
|
task_name: req.task.name,
|
|
task_list_id: req.task.task_list_id,
|
|
group_id: taskListToGroup[req.task.task_list_id],
|
|
requirement_id: req.requirement_id,
|
|
type: req.type,
|
|
reference_id: req.reference_id,
|
|
reference_label: req.reference_label,
|
|
already_completed: doneSet.has(`${req.requirement_id}:${req.reference_id}`),
|
|
}));
|
|
|
|
return R.success(res, 'Task context retrieved.', { has_task: true, contexts });
|
|
} catch (err) {
|
|
console.error('[CLIENT][COURSE TASK CONTEXT]', err);
|
|
return R.error(res, 'Could not retrieve task context.', 500);
|
|
}
|
|
};
|
|
|
|
// =============================================================================
|
|
// ── UPSERT LESSON PROGRESS ────────────────────────────────────────────────────
|
|
// =============================================================================
|
|
|
|
// POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/progress
|
|
// Body: { status: 'in_progress' | 'completed' }
|
|
//
|
|
// Flow:
|
|
// 1. Resolve course / unit / lesson to get their UUIDs
|
|
// 2. Delegate to upsertLessonRead (course_reading_progress service) — lesson + unit + course in one tx
|
|
// 3. Side-effect A: write to lesson_reading_progress + unit_reading_progress (new dedicated tables)
|
|
// 4. Side-effect B: sync task_progress for matching task requirements
|
|
// 5. Return result + completed_tasks (tasks whose all read requirements are now satisfied)
|
|
|
|
exports.upsertLessonProgress = async (req, res) => {
|
|
try {
|
|
const { courseId, unitId, lessonId } = req.params;
|
|
const userId = req.user.user_id;
|
|
const status = req.body.status === 'completed' ? 'completed' : 'in_progress';
|
|
|
|
const [course, unit, lesson, courseLink, lessonLink] = await Promise.all([
|
|
Course.findOne({
|
|
where: { course_id: courseId, ...notDeleted },
|
|
attributes: ['course_id', 'uuid'],
|
|
}),
|
|
Unit.findOne({
|
|
where: { unit_id: unitId, ...notDeleted },
|
|
attributes: ['unit_id', 'uuid'],
|
|
}),
|
|
Lesson.findOne({
|
|
where: { lesson_id: lessonId, ...notDeleted },
|
|
attributes: ['lesson_id', 'uuid'],
|
|
}),
|
|
CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }),
|
|
UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId } }),
|
|
]);
|
|
|
|
if (!course) return R.error(res, 'Course not found.', 404);
|
|
if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404);
|
|
if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404);
|
|
|
|
// ── 1. Primary write: course_reading_progress ─────────────────────────
|
|
const result = await upsertLessonRead(userId, {
|
|
courseId: course.course_id,
|
|
courseUuid: course.uuid,
|
|
unitId: unit.unit_id,
|
|
unitUuid: unit.uuid,
|
|
lessonUuid: lesson.uuid,
|
|
lessonStatus: status,
|
|
});
|
|
|
|
// ── 2. Side-effect A: write to new dedicated tables (fire-and-forget) ──
|
|
upsertReadingProgress(userId, {
|
|
courseId: course.course_id,
|
|
unitId: unit.unit_id,
|
|
lessonId: lesson.lesson_id,
|
|
lessonStatus: status,
|
|
}).catch((e) => console.error('[READING PROGRESS] piggyback write failed:', e));
|
|
|
|
// ── 3. Side-effect B: sync task_progress ──────────────────────────────
|
|
const completedTasks = await syncTaskProgress(userId, {
|
|
lessonUuid: lesson.uuid,
|
|
unitUuid: unit.uuid,
|
|
courseUuid: course.uuid,
|
|
lessonStatus: status,
|
|
unitStatus: result.unit.status,
|
|
courseStatus: result.course.status,
|
|
});
|
|
|
|
logActivity(userId, 'lesson_read', {
|
|
entityType: 'lesson',
|
|
entityId: lesson.lesson_id,
|
|
details: { lesson_uuid: lesson.uuid, status },
|
|
});
|
|
|
|
return R.success(res, 'Progress updated.', { ...result, completed_tasks: completedTasks }, 200);
|
|
} catch (err) {
|
|
console.error('[CLIENT][COURSE READING PROGRESS][UPSERT]', err);
|
|
return R.error(res, 'Could not update progress.', 500);
|
|
}
|
|
};
|