From 4e142fd90d556d04577b8443a130a6c39973f8e9 Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Sat, 18 Jul 2026 14:30:13 +0800 Subject: [PATCH] add more things Signed-off-by: Kenneth Obsequio --- .../course_reading_progress.controller.js | 135 ++++++++++++++++++ routes/client/courses.routes.js | 3 + 2 files changed, 138 insertions(+) diff --git a/controllers/client/course_reading_progress.controller.js b/controllers/client/course_reading_progress.controller.js index e91bf51..3efbe93 100644 --- a/controllers/client/course_reading_progress.controller.js +++ b/controllers/client/course_reading_progress.controller.js @@ -6,6 +6,10 @@ * GET /client/courses/in-progress * → 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 * → 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 { recordPlaybackPosition } = require('../../services/playback_position.service'); 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 { 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 ────────────────────────────────────────────────────── // ============================================================================= diff --git a/routes/client/courses.routes.js b/routes/client/courses.routes.js index d4cdbb3..187597d 100644 --- a/routes/client/courses.routes.js +++ b/routes/client/courses.routes.js @@ -9,6 +9,9 @@ router.get('/categories', ctrl.getCategories); // Profile learning progress card — must come before /:courseId 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) router.get('/uuid/:uuid', ctrl.getCourseByUuid); router.get('/unit/uuid/:uuid/lessons', ctrl.getLessonsByUnitUuid);