mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -6,6 +6,10 @@
|
|||||||
* GET /client/courses/in-progress
|
* GET /client/courses/in-progress
|
||||||
* → courses where the current user has status = 'in_progress', with lesson counts
|
* → courses where the current user has status = 'in_progress', with lesson counts
|
||||||
*
|
*
|
||||||
|
* GET /client/courses/completed
|
||||||
|
* → every completed lesson/unit/course for the current user, course-scoped and
|
||||||
|
* standalone reads unioned together, most-recently-completed first
|
||||||
|
*
|
||||||
* GET /client/courses/:courseId/progress/summary
|
* GET /client/courses/:courseId/progress/summary
|
||||||
* → compact snapshot: lesson counts + percentage + course status
|
* → compact snapshot: lesson counts + percentage + course status
|
||||||
*
|
*
|
||||||
@@ -32,6 +36,8 @@ const logActivity = require('../../utils/logActivity.util');
|
|||||||
const { recomputeCascade, recordWatchProgress, recordManualComplete } = require('../../services/completion_requirements.service');
|
const { recomputeCascade, recordWatchProgress, recordManualComplete } = require('../../services/completion_requirements.service');
|
||||||
const { recordPlaybackPosition } = require('../../services/playback_position.service');
|
const { recordPlaybackPosition } = require('../../services/playback_position.service');
|
||||||
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
|
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
|
||||||
|
const UnitReadingProgress = require('../../models/courses/unit_reading_progress.mdl');
|
||||||
|
const LessonReadingProgress = require('../../models/courses/lesson_reading_progress.mdl');
|
||||||
const Certificate = require('../../models/courses/certificate.mdl');
|
const Certificate = require('../../models/courses/certificate.mdl');
|
||||||
const {
|
const {
|
||||||
Course, Unit, Lesson,
|
Course, Unit, Lesson,
|
||||||
@@ -193,6 +199,135 @@ exports.getMyInProgressCourses = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ── COMPLETED CONTENT — "live view" of every finished lesson/unit/course ──────
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// GET /client/courses/completed
|
||||||
|
// Unions the two completion systems (see completion_requirements.service.js header):
|
||||||
|
// - CourseReadingProgress — course-scoped lessons/units/courses (course_id NOT NULL)
|
||||||
|
// - Unit/LessonReadingProgress, filtered to course_id IS NULL — genuinely standalone
|
||||||
|
// reads. Course-scoped reads also get a best-effort mirror written into these same
|
||||||
|
// tables (see recomputeCascade's mirrorLessonRead call) but that mirror always
|
||||||
|
// carries a course_id, so the IS NULL filter here excludes it and avoids double-
|
||||||
|
// counting the same completion from both systems.
|
||||||
|
exports.getMyCompletedContent = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.user_id;
|
||||||
|
|
||||||
|
const courseScoped = await CourseReadingProgress.findAll({
|
||||||
|
where: { user_id: userId, status: 'completed' },
|
||||||
|
attributes: ['reference_id', 'type', 'completed_at'],
|
||||||
|
include: [{
|
||||||
|
model: Course,
|
||||||
|
as: 'course',
|
||||||
|
attributes: ['course_id', 'uuid', 'title'],
|
||||||
|
where: notDeleted,
|
||||||
|
required: true,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedCourseRows = courseScoped.filter((r) => r.type === 'course');
|
||||||
|
const completedUnitRows = courseScoped.filter((r) => r.type === 'unit');
|
||||||
|
const completedLessonRows = courseScoped.filter((r) => r.type === 'lesson');
|
||||||
|
|
||||||
|
const unitUuids = completedUnitRows.map((r) => r.reference_id);
|
||||||
|
const lessonUuids = completedLessonRows.map((r) => r.reference_id);
|
||||||
|
|
||||||
|
const [unitRows, lessonRows, standaloneUnits, standaloneLessons] = await Promise.all([
|
||||||
|
unitUuids.length
|
||||||
|
? Unit.findAll({ where: { uuid: unitUuids, ...notDeleted }, attributes: ['unit_id', 'uuid', 'title'] })
|
||||||
|
: [],
|
||||||
|
lessonUuids.length
|
||||||
|
? Lesson.findAll({ where: { uuid: lessonUuids, ...notDeleted }, attributes: ['lesson_id', 'uuid', 'title'] })
|
||||||
|
: [],
|
||||||
|
UnitReadingProgress.findAll({
|
||||||
|
where: { user_id: userId, status: 'completed', course_id: null },
|
||||||
|
attributes: ['completed_at'],
|
||||||
|
include: [{
|
||||||
|
model: Unit, as: 'unit', attributes: ['unit_id', 'uuid', 'title'], where: notDeleted, required: true,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
LessonReadingProgress.findAll({
|
||||||
|
where: { user_id: userId, status: 'completed', course_id: null },
|
||||||
|
attributes: ['completed_at'],
|
||||||
|
include: [{
|
||||||
|
model: Lesson, as: 'lesson', attributes: ['lesson_id', 'uuid', 'title'], where: notDeleted, required: true,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const unitByUuid = Object.fromEntries(unitRows.map((u) => [u.uuid, u]));
|
||||||
|
const lessonByUuid = Object.fromEntries(lessonRows.map((l) => [l.uuid, l]));
|
||||||
|
|
||||||
|
const courseIds = completedCourseRows.map((r) => r.course.course_id);
|
||||||
|
const certificates = courseIds.length
|
||||||
|
? await Certificate.findAll({
|
||||||
|
where: { user_id: userId, course_id: courseIds },
|
||||||
|
attributes: ['uuid', 'cert_no', 'issued_at', 'score', 'course_id'],
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const certByCourseId = Object.fromEntries(certificates.map((c) => [String(c.course_id), c]));
|
||||||
|
|
||||||
|
const courses = completedCourseRows.map((r) => {
|
||||||
|
const cert = certByCourseId[String(r.course.course_id)] ?? null;
|
||||||
|
return {
|
||||||
|
course_id: r.course.course_id,
|
||||||
|
uuid: r.course.uuid,
|
||||||
|
title: r.course.title,
|
||||||
|
completed_at: r.completed_at,
|
||||||
|
certificate: cert ? { uuid: cert.uuid, cert_no: cert.cert_no, issued_at: cert.issued_at, score: cert.score } : null,
|
||||||
|
};
|
||||||
|
}).sort((a, b) => new Date(b.completed_at) - new Date(a.completed_at));
|
||||||
|
|
||||||
|
const units = [
|
||||||
|
...completedUnitRows
|
||||||
|
.filter((r) => unitByUuid[r.reference_id])
|
||||||
|
.map((r) => ({
|
||||||
|
unit_id: unitByUuid[r.reference_id].unit_id,
|
||||||
|
uuid: r.reference_id,
|
||||||
|
title: unitByUuid[r.reference_id].title,
|
||||||
|
completed_at: r.completed_at,
|
||||||
|
course: { course_id: r.course.course_id, title: r.course.title },
|
||||||
|
})),
|
||||||
|
...standaloneUnits.map((r) => ({
|
||||||
|
unit_id: r.unit.unit_id,
|
||||||
|
uuid: r.unit.uuid,
|
||||||
|
title: r.unit.title,
|
||||||
|
completed_at: r.completed_at,
|
||||||
|
course: null,
|
||||||
|
})),
|
||||||
|
].sort((a, b) => new Date(b.completed_at) - new Date(a.completed_at));
|
||||||
|
|
||||||
|
const lessons = [
|
||||||
|
...completedLessonRows
|
||||||
|
.filter((r) => lessonByUuid[r.reference_id])
|
||||||
|
.map((r) => ({
|
||||||
|
lesson_id: lessonByUuid[r.reference_id].lesson_id,
|
||||||
|
uuid: r.reference_id,
|
||||||
|
title: lessonByUuid[r.reference_id].title,
|
||||||
|
completed_at: r.completed_at,
|
||||||
|
course: { course_id: r.course.course_id, title: r.course.title },
|
||||||
|
})),
|
||||||
|
...standaloneLessons.map((r) => ({
|
||||||
|
lesson_id: r.lesson.lesson_id,
|
||||||
|
uuid: r.lesson.uuid,
|
||||||
|
title: r.lesson.title,
|
||||||
|
completed_at: r.completed_at,
|
||||||
|
course: null,
|
||||||
|
})),
|
||||||
|
].sort((a, b) => new Date(b.completed_at) - new Date(a.completed_at));
|
||||||
|
|
||||||
|
return R.success(res, 'Completed content retrieved.', {
|
||||||
|
courses, units, lessons,
|
||||||
|
counts: { courses: courses.length, units: units.length, lessons: lessons.length },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CLIENT][COURSE READING PROGRESS][COMPLETED CONTENT]', err);
|
||||||
|
return R.error(res, 'Could not retrieve completed content.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// ── GET PROGRESS SUMMARY ──────────────────────────────────────────────────────
|
// ── GET PROGRESS SUMMARY ──────────────────────────────────────────────────────
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ router.get('/categories', ctrl.getCategories);
|
|||||||
// Profile learning progress card — must come before /:courseId
|
// Profile learning progress card — must come before /:courseId
|
||||||
router.get('/in-progress', progressCtrl.getMyInProgressCourses);
|
router.get('/in-progress', progressCtrl.getMyInProgressCourses);
|
||||||
|
|
||||||
|
// Live view of every completed lesson/unit/course — must come before /:courseId
|
||||||
|
router.get('/completed', progressCtrl.getMyCompletedContent);
|
||||||
|
|
||||||
// UUID lookups (task requirement blocks — must come before /:courseId)
|
// UUID lookups (task requirement blocks — must come before /:courseId)
|
||||||
router.get('/uuid/:uuid', ctrl.getCourseByUuid);
|
router.get('/uuid/:uuid', ctrl.getCourseByUuid);
|
||||||
router.get('/unit/uuid/:uuid/lessons', ctrl.getLessonsByUnitUuid);
|
router.get('/unit/uuid/:uuid/lessons', ctrl.getLessonsByUnitUuid);
|
||||||
|
|||||||
Reference in New Issue
Block a user