mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
241 lines
11 KiB
JavaScript
241 lines
11 KiB
JavaScript
/***********************************************************************************************************************************************************************
|
|
* 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_stream_token: avatar?.stream_token ?? 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);
|
|
}
|
|
};
|