mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
chore: relocate backend into apps/api ahead of monorepo merge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user