/*********************************************************************************************************************************************************************** * 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 * → returns all progress rows for this user + course (flat, frontend builds the map) * * POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/progress * → UPSERT lesson + derives + UPSERTs parent unit + course in one transaction * * Body (POST): * { status: 'in_progress' | 'completed' } * Defaults to 'in_progress' if omitted (on first visit). * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 21, 2026 ***********************************************************************************************************************************************************************/ 'use strict'; const R = require('../../utils/response.util'); const logActivity = require('../../utils/logActivity.util'); const { upsertLessonRead } = require('../../services/course_reading_progress.service'); const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl'); const Certificate = require('../../models/courses/certificate.mdl'); const { Course, Unit, Lesson, UnitQuiz, CourseAssessment, QuizAttempt, } = require('../../models/courses/courses.associations'); const notDeleted = { deletedAt: null }; // ============================================================================= // ── IN-PROGRESS COURSES — profile learning progress card ────────────────────── // ============================================================================= // GET /client/courses/in-progress // Returns courses the user has started but not yet completed (no certificate). // Includes both: // • reading in_progress → still working through lessons // • reading completed → finished lessons but quiz / assessment still pending // Excludes any course where the user already holds a certificate. exports.getMyInProgressCourses = async (req, res) => { try { const userId = req.user.user_id; // All course-level progress rows for this user (any reading status) 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.', []); // Courses the user has already earned a certificate for — exclude these 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 readingDone = row.status === 'completed'; const [lessons_total, lessons_completed] = await Promise.all([ Lesson.count({ include: [{ model: Unit, as: 'unit', where: { course_id: courseId, ...notDeleted }, required: true, }], where: notDeleted, }), CourseReadingProgress.count({ where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' }, }), ]); // Only compute pending quiz/assessment detail when all lessons are read let pending_quizzes = []; let pending_assessment = null; if (readingDone) { // All unit quizzes in this course const unitQuizzes = await UnitQuiz.findAll({ attributes: ['quiz_id', 'title', 'is_required', 'passing_score'], include: [{ model: Unit, as: 'unit', attributes: ['unit_id', 'title'], where: { course_id: courseId, ...notDeleted }, required: true, }], where: 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, }); } } // Course assessment const assessment = await CourseAssessment.findOne({ attributes: ['assessment_id', 'title', 'is_required', 'passing_score'], where: { course_id: courseId }, }); 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: row.status, 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 ────────────────────────────────────────────────────── // ============================================================================= // GET /client/courses/:courseId/progress/summary // Returns a compact progress snapshot: lesson counts + percentage + course status. // Used by the ReadCourse block to render the inline progress bar without needing // the full flat row list. 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([ Lesson.count({ include: [{ model: Unit, as: 'unit', where: { course_id: courseId, ...notDeleted }, required: true, }], where: notDeleted, }), 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 ───────────────────────────────────────────────────── // ============================================================================= // GET /client/courses/:courseId/progress // Returns all progress rows for this user+course. // Frontend uses this to decorate the sidebar (completed checkmarks, locked states, etc.) 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); } }; // ============================================================================= // ── 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 — handles lesson + unit + course in one tx 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] = await Promise.all([ Course.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: ['course_id', 'uuid'], }), Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted }, attributes: ['unit_id', 'uuid'], }), Lesson.findOne({ where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted }, attributes: ['lesson_id', 'uuid'], }), ]); if (!course) return R.error(res, 'Course not found.', 404); if (!unit) return R.error(res, 'Unit not found.', 404); if (!lesson) return R.error(res, 'Lesson not found.', 404); const result = await upsertLessonRead(userId, { courseId: course.course_id, courseUuid: course.uuid, unitId: unit.unit_id, unitUuid: unit.uuid, lessonUuid: lesson.uuid, lessonStatus: status, }); logActivity(userId, 'lesson_read', { entityType: 'lesson', entityId: lesson.lesson_id, details: { lesson_uuid: lesson.uuid, status }, }); return R.success(res, 'Progress updated.', result, 200); } catch (err) { console.error('[CLIENT][COURSE READING PROGRESS][UPSERT]', err); return R.error(res, 'Could not update progress.', 500); } };