/*********************************************************************************************************************************************************************** * File Name: course_reading_progress.controller.js (admin) * Type of Program: Controller * Description: Admin-facing endpoints for viewing course reading progress. * * GET /:courseId/reading-progress * → one entry per user who has touched the course, with lesson/unit counts aggregated * * GET /:courseId/reading-progress/users/:userId * → full lesson + unit breakdown for a single user (loaded on row expand) * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 21, 2026 ***********************************************************************************************************************************************************************/ 'use strict'; const { Op } = require('sequelize'); const R = require('../../utils/response.util'); const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl'); const { Course, Unit, Lesson, CourseUnit, UnitQuiz, CourseAssessment, QuizAttempt, } = require('../../models/courses/courses.associations'); const mdl_Users = require('../../models/users/users.mdl'); const { countCourseLessons, getCourseUnitIds, flattenUnits } = require('../../utils/courses/hierarchy.util'); const { resolveAvatarUrl } = require('../../utils/resolveAvatar.util'); const notDeleted = { deletedAt: null }; // ============================================================================= // ── LIST — all users who touched this course ────────────────────────────────── // ============================================================================= // GET /admin/courses/:courseId/reading-progress // Returns one summary row per user. Lesson + unit counts are derived by querying // the course structure server-side so the totals are always accurate. exports.getCourseReadingProgress = async (req, res) => { try { const { courseId } = req.params; const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: ['course_id'], }); if (!course) return R.error(res, 'Course not found.', 404); // Count total lessons and units in the course (structure totals via junctions) const [unitIds, lessons_total] = await Promise.all([ getCourseUnitIds(courseId), countCourseLessons(courseId), ]); const units_total = unitIds.length; // All progress rows for this course, grouped per user const rows = await CourseReadingProgress.findAll({ where: { course_id: courseId }, attributes: ['user_id', 'reference_id', 'type', 'status', 'last_accessed_at'], order: [['last_accessed_at', 'DESC']], }); if (!rows.length) return R.success(res, 'No reading progress for this course yet.', []); // Aggregate per user const userIds = [...new Set(rows.map((r) => r.user_id))]; const users = await mdl_Users.findAll({ where: { user_id: userIds }, attributes: ['user_id', 'email', 'personal_info'], }); const userMap = Object.fromEntries(users.map((u) => [u.user_id, u])); // Quiz/assessment gating — mirrors controllers/client/course_reading_progress // .controller.js#getMyInProgressCourses: a unit quiz or course assessment that // exists but hasn't been passed yet is why a user with 1/1 lessons read can still // be 'in_progress'. Batched once per course across all users in this list. const unitQuizzes = unitIds.length ? await UnitQuiz.findAll({ where: { unit_id: unitIds, ...notDeleted }, attributes: ['quiz_id'] }) : []; const quizIds = unitQuizzes.map((q) => q.quiz_id); const assessment = await CourseAssessment.findOne({ where: { course_id: courseId }, attributes: ['assessment_id'], }); const passedQuizIdsByUser = new Map(); const passedAssessmentUserIds = new Set(); if (quizIds.length || assessment) { const passedAttempts = await QuizAttempt.findAll({ where: { user_id: userIds, passed: true, [Op.or]: [ ...(quizIds.length ? [{ quiz_id: quizIds }] : []), ...(assessment ? [{ assessment_id: assessment.assessment_id }] : []), ], }, attributes: ['user_id', 'quiz_id', 'assessment_id'], }); for (const attempt of passedAttempts) { if (attempt.quiz_id) { if (!passedQuizIdsByUser.has(attempt.user_id)) passedQuizIdsByUser.set(attempt.user_id, new Set()); passedQuizIdsByUser.get(attempt.user_id).add(attempt.quiz_id); } if (attempt.assessment_id) passedAssessmentUserIds.add(attempt.user_id); } } // Build per-user summary const summaryMap = {}; for (const row of rows) { if (!summaryMap[row.user_id]) { summaryMap[row.user_id] = { user_id: row.user_id, course_status: null, last_accessed_at: null, lessons_completed: 0, units_completed: 0, }; } const entry = summaryMap[row.user_id]; // Track latest access across all rows for this user if (!entry.last_accessed_at || new Date(row.last_accessed_at) > new Date(entry.last_accessed_at)) { entry.last_accessed_at = row.last_accessed_at; } if (row.type === 'course') entry.course_status = row.status; if (row.type === 'unit' && row.status === 'completed') entry.units_completed++; if (row.type === 'lesson' && row.status === 'completed') entry.lessons_completed++; } const result = await Promise.all(Object.values(summaryMap).map(async (entry) => { const u = userMap[entry.user_id]; const avatar = await resolveAvatarUrl(u?.personal_info?.avatar); return { ...entry, user: { email: u?.email ?? null, full_name: u?.personal_info?.name?.full_name ?? null, avatar_url: avatar?.url ?? null, }, units_total, lessons_total, // Fall back to in_progress if the course row hasn't been written yet course_status: entry.course_status ?? 'in_progress', quizzes_pending: quizIds.length - (passedQuizIdsByUser.get(entry.user_id)?.size ?? 0), assessment_pending: !!assessment && !passedAssessmentUserIds.has(entry.user_id), }; })); // Sort: completed last, most recent first within each group result.sort((a, b) => { if (a.course_status !== b.course_status) { return a.course_status === 'completed' ? 1 : -1; } return new Date(b.last_accessed_at) - new Date(a.last_accessed_at); }); return R.success(res, 'Course reading progress retrieved.', result); } catch (err) { console.error('[ADMIN][COURSE READING PROGRESS][LIST]', err); return R.error(res, 'Could not retrieve reading progress.', 500); } }; // ============================================================================= // ── DETAIL — single user's full lesson/unit breakdown ───────────────────────── // ============================================================================= // GET /admin/courses/:courseId/reading-progress/users/:userId // Loaded lazily when the admin expands a user row. // Returns units with their lessons and the progress status per item. exports.getUserReadingProgress = async (req, res) => { try { const { courseId, userId } = req.params; const [unitRows, progressRows] = await Promise.all([ Unit.findAll({ where: notDeleted, attributes: ['unit_id', 'uuid', 'title'], include: [ { model: CourseUnit, as: 'courseLinks', where: { course_id: courseId }, required: true, attributes: ['order_index'], }, { model: Lesson, as: 'lessons', where: notDeleted, required: false, attributes: ['lesson_id', 'uuid', 'title'], through: { attributes: ['order_index'] }, }, ], }), CourseReadingProgress.findAll({ where: { course_id: courseId, user_id: userId }, attributes: ['reference_id', 'type', 'status', 'completed_at', 'last_accessed_at'], }), ]); // Sort by junction order (course-level, then unit-level for lessons) const units = flattenUnits(unitRows.map((u) => { const plain = u.toJSON(); plain.CourseUnit = { order_index: plain.courseLinks?.[0]?.order_index ?? 0 }; delete plain.courseLinks; return plain; })); // Build a quick lookup: { [reference_id (uuid)]: status } const progressMap = Object.fromEntries( progressRows.map((r) => [r.reference_id, { status: r.status, completed_at: r.completed_at }]) ); const breakdown = units.map((unit) => ({ unit_id: unit.unit_id, uuid: unit.uuid, title: unit.title, status: progressMap[unit.uuid]?.status ?? null, lessons: (unit.lessons ?? []).map((lesson) => ({ lesson_id: lesson.lesson_id, uuid: lesson.uuid, title: lesson.title, status: progressMap[lesson.uuid]?.status ?? null, completed_at: progressMap[lesson.uuid]?.completed_at ?? null, })), })); return R.success(res, 'User reading progress retrieved.', breakdown); } catch (err) { console.error('[ADMIN][COURSE READING PROGRESS][USER DETAIL]', err); return R.error(res, 'Could not retrieve user reading progress.', 500); } };