/*********************************************************************************************************************************************************************** * File Name: course_reading_progress.controller.js (client) * Type of Program: Controller * Description: Tracks user reading progress through a course hierarchy (course → unit → lesson). * * 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 * * GET /client/courses/:courseId/progress * → all progress rows for this user + course (flat, frontend builds the map) * * GET /client/courses/:courseId/task-context * → all pending task requirements (read_*) for this course's UUIDs that the user is assigned to * * POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/progress * → UPSERT lesson + derives + UPSERTs parent unit + course in one transaction * → side-effects: writes to lesson_reading_progress / unit_reading_progress, * syncs task_progress for matching task requirements, * returns completed_tasks for any task whose read requirements are now all done * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 21, 2026 ***********************************************************************************************************************************************************************/ 'use strict'; const { Op } = require('sequelize'); const R = require('../../utils/response.util'); 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, CourseUnit, UnitLesson, UnitQuiz, CourseAssessment, QuizAttempt, } = require('../../models/courses/courses.associations'); const { countCourseLessons, getCourseUnitIds, flattenUnits } = require('../../utils/courses/hierarchy.util'); const { Task, TaskRequirement, TaskListGroup } = require('../../models/task/task.mdl'); const { TaskProgress } = require('../../models/task/task_progress.mdl'); const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); const notDeleted = { deletedAt: null }; // ─── Internal helper: get task list IDs accessible to a user ───────────────── async function getAccessibleTaskListIds(userId) { const memberships = await mdl_UserGroupMembers.findAll({ where: { user_id: userId, deletedAt: null }, attributes: ['group_id'], }); const groupIds = memberships.map((m) => m.group_id); if (!groupIds.length) return { taskListIds: [], taskListToGroup: {} }; const taskListGroups = await TaskListGroup.findAll({ where: { group_id: groupIds }, attributes: ['task_list_id', 'group_id'], }); const taskListToGroup = Object.fromEntries(taskListGroups.map((tlg) => [tlg.task_list_id, tlg.group_id])); return { taskListIds: Object.keys(taskListToGroup), taskListToGroup }; } // Task-progress auto-sync (read_lesson/read_unit/read_course requirements) now lives in // services/task_reading_progress_sync.service.js#syncCompletedEntitiesToTaskProgress, called // directly from completion_requirements.service.js's cascade/recompute functions — covers every // completion trigger (scroll, watch_percent, manual_complete, pass_quiz, assessment), not just // this endpoint. getAccessibleTaskListIds stays here (below) since getCourseTaskContext still // needs its richer { taskListIds, taskListToGroup } shape. // ============================================================================= // ── IN-PROGRESS COURSES — profile learning progress card ────────────────────── // ============================================================================= exports.getMyInProgressCourses = async (req, res) => { try { const userId = req.user.user_id; const courseRows = await CourseReadingProgress.findAll({ where: { user_id: userId, type: 'course' }, attributes: ['course_id', 'status', 'last_accessed_at'], include: [{ model: Course, as: 'course', attributes: ['course_id', 'title'], where: notDeleted, required: true, }], order: [['last_accessed_at', 'DESC']], }); if (!courseRows.length) return R.success(res, 'No courses in progress.', []); const certificates = await Certificate.findAll({ where: { user_id: userId }, attributes: ['course_id'], }); const certSet = new Set(certificates.map((c) => String(c.course_id))); const pending = courseRows.filter((r) => !certSet.has(String(r.course_id))); if (!pending.length) return R.success(res, 'No courses in progress.', []); const result = await Promise.all(pending.map(async (row) => { const courseId = row.course_id; const [lessons_total, lessons_completed] = await Promise.all([ countCourseLessons(courseId), CourseReadingProgress.count({ where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' }, }), ]); // "Reading done" is derived independently from lesson counts — row.status now also // requires the course assessment to be passed, so it can't be used as the reading gate. const readingDone = lessons_total > 0 && lessons_completed === lessons_total; let pending_quizzes = []; let pending_assessment = null; let assessment_configured = true; if (readingDone) { const courseUnitIds = await getCourseUnitIds(courseId); const unitQuizzes = courseUnitIds.length ? await UnitQuiz.findAll({ attributes: ['quiz_id', 'title', 'is_required', 'passing_score'], include: [{ model: Unit, as: 'unit', attributes: ['unit_id', 'title'], where: notDeleted, required: true, }], where: { unit_id: courseUnitIds, ...notDeleted }, }) : []; for (const quiz of unitQuizzes) { const [hasPassed, attemptCount] = await Promise.all([ QuizAttempt.findOne({ where: { user_id: userId, quiz_id: quiz.quiz_id, passed: true } }), QuizAttempt.count({ where: { user_id: userId, quiz_id: quiz.quiz_id } }), ]); if (!hasPassed) { pending_quizzes.push({ quiz_id: quiz.quiz_id, title: quiz.title, unit_title: quiz.unit.title, is_required: quiz.is_required, passing_score: quiz.passing_score, attempt_count: attemptCount, }); } } const assessment = await CourseAssessment.findOne({ attributes: ['assessment_id', 'title', 'is_required', 'passing_score'], where: { course_id: courseId }, }); assessment_configured = !!assessment; if (assessment) { const [hasPassed, attemptCount] = await Promise.all([ QuizAttempt.findOne({ where: { user_id: userId, assessment_id: assessment.assessment_id, passed: true } }), QuizAttempt.count({ where: { user_id: userId, assessment_id: assessment.assessment_id } }), ]); if (!hasPassed) { pending_assessment = { assessment_id: assessment.assessment_id, title: assessment.title, is_required: assessment.is_required, passing_score: assessment.passing_score, attempt_count: attemptCount, }; } } } return { course_id: courseId, title: row.course.title, reading_status: readingDone ? 'completed' : 'in_progress', assessment_configured, lessons_total, lessons_completed, last_accessed_at: row.last_accessed_at, pending_quizzes, pending_assessment, }; })); return R.success(res, 'In-progress courses retrieved.', result); } catch (err) { console.error('[CLIENT][COURSE READING PROGRESS][IN PROGRESS]', err); return R.error(res, 'Could not retrieve in-progress courses.', 500); } }; // ============================================================================= // ── 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 ────────────────────────────────────────────────────── // ============================================================================= exports.getCourseProgressSummary = async (req, res) => { try { const { courseId } = req.params; const userId = req.user.user_id; const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: ['course_id'], }); if (!course) return R.error(res, 'Course not found.', 404); const [lessons_total, lessons_completed, courseRow] = await Promise.all([ countCourseLessons(courseId), CourseReadingProgress.count({ where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' }, }), CourseReadingProgress.findOne({ where: { user_id: userId, course_id: courseId, type: 'course' }, attributes: ['status'], }), ]); const percent = lessons_total > 0 ? Math.round((lessons_completed / lessons_total) * 100) : 0; return R.success(res, 'Progress summary retrieved.', { lessons_total, lessons_completed, percent, status: courseRow?.status ?? null, }); } catch (err) { console.error('[CLIENT][COURSE READING PROGRESS][SUMMARY]', err); return R.error(res, 'Could not retrieve progress summary.', 500); } }; // ============================================================================= // ── GET PROGRESS SNAPSHOT ───────────────────────────────────────────────────── // ============================================================================= exports.getCourseProgress = async (req, res) => { try { const { courseId } = req.params; const userId = req.user.user_id; const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: ['course_id'], }); if (!course) return R.error(res, 'Course not found.', 404); const rows = await CourseReadingProgress.findAll({ where: { user_id: userId, course_id: courseId }, attributes: ['progress_id', 'reference_id', 'type', 'status', 'completed_at', 'last_accessed_at'], }); return R.success(res, 'Course progress retrieved.', rows); } catch (err) { console.error('[CLIENT][COURSE READING PROGRESS][GET]', err); return R.error(res, 'Could not retrieve course progress.', 500); } }; // ============================================================================= // ── TASK CONTEXT FOR A COURSE ───────────────────────────────────────────────── // ============================================================================= // GET /client/courses/:courseId/task-context // Returns all pending task requirements (read_course / read_unit / read_lesson) // whose reference_id matches this course, any of its units, or any of its lessons, // filtered to tasks the current user is actually assigned to (via group membership). // UnitList calls this on mount when no task context is passed via navigation state. exports.getCourseTaskContext = async (req, res) => { try { const { courseId } = req.params; const userId = req.user.user_id; const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: ['course_id', 'uuid'], include: [{ model: Unit, as: 'units', where: notDeleted, required: false, attributes: ['unit_id', 'uuid'], through: { attributes: [] }, include: [{ model: Lesson, as: 'lessons', where: notDeleted, required: false, attributes: ['lesson_id', 'uuid'], through: { attributes: [] }, }], }], }); if (!course) return R.error(res, 'Course not found.', 404); const units = course.units ?? []; const allUuids = [ course.uuid, ...units.map((u) => u.uuid), ...units.flatMap((u) => (u.lessons ?? []).map((l) => l.uuid)), ]; const { taskListIds, taskListToGroup } = await getAccessibleTaskListIds(userId); if (!taskListIds.length) { return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] }); } const requirements = await TaskRequirement.findAll({ where: { type: { [Op.in]: ['read_course', 'read_unit', 'read_lesson'] }, reference_id: { [Op.in]: allUuids }, deletedAt: null, }, include: [{ model: Task, as: 'task', where: { task_list_id: { [Op.in]: taskListIds }, deletedAt: null }, required: true, attributes: ['task_id', 'name', 'task_list_id'], }], attributes: ['requirement_id', 'task_id', 'type', 'reference_id', 'reference_label'], }); if (!requirements.length) { return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] }); } // Mark which requirements are already completed const taskIds = [...new Set(requirements.map((r) => r.task_id))]; const doneProgress = await TaskProgress.findAll({ where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true }, attributes: ['requirement_id', 'reference_id'], }); const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`)); const contexts = requirements.map((req) => ({ task_id: req.task_id, task_name: req.task.name, task_list_id: req.task.task_list_id, group_id: taskListToGroup[req.task.task_list_id], requirement_id: req.requirement_id, type: req.type, reference_id: req.reference_id, reference_label: req.reference_label, already_completed: doneSet.has(`${req.requirement_id}:${req.reference_id}`), })); return R.success(res, 'Task context retrieved.', { has_task: true, contexts }); } catch (err) { console.error('[CLIENT][COURSE TASK CONTEXT]', err); return R.error(res, 'Could not retrieve task context.', 500); } }; // ============================================================================= // ── TASK CONTEXT FOR A STANDALONE LESSON / UNIT ─────────────────────────────── // ============================================================================= // // Same idea as getCourseTaskContext, but scoped to a single lesson/unit UUID // rather than a whole course tree — the fallback source for LessonDetails.jsx/ // UnitReader.jsx (the standalone/library readers reached via /lessons/:uuid and // /units/:uuid/read) when the page is opened directly rather than navigated to // from a task's requirement card, so the "Task mode" banner still shows up. // GET /client/courses/lesson/uuid/:uuid/task-context exports.getLessonTaskContext = async (req, res) => { try { const { uuid } = req.params; const userId = req.user.user_id; const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ['lesson_id', 'uuid'] }); if (!lesson) return R.error(res, 'Lesson not found.', 404); const { taskListIds, taskListToGroup } = await getAccessibleTaskListIds(userId); if (!taskListIds.length) return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] }); const requirements = await TaskRequirement.findAll({ where: { type: 'read_lesson', reference_id: uuid, deletedAt: null }, include: [{ model: Task, as: 'task', where: { task_list_id: { [Op.in]: taskListIds }, deletedAt: null }, required: true, attributes: ['task_id', 'name', 'task_list_id'], }], attributes: ['requirement_id', 'task_id', 'reference_id'], }); if (!requirements.length) return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] }); const taskIds = [...new Set(requirements.map((r) => r.task_id))]; const doneProgress = await TaskProgress.findAll({ where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true }, attributes: ['requirement_id', 'reference_id'], }); const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`)); const contexts = requirements.map((req) => ({ task_id: req.task_id, task_name: req.task.name, task_list_id: req.task.task_list_id, group_id: taskListToGroup[req.task.task_list_id], requirement_id: req.requirement_id, already_completed: doneSet.has(`${req.requirement_id}:${req.reference_id}`), })); return R.success(res, 'Task context retrieved.', { has_task: true, contexts }); } catch (err) { console.error('[CLIENT][LESSON TASK CONTEXT]', err); return R.error(res, 'Could not retrieve task context.', 500); } }; // GET /client/courses/unit/uuid/:uuid/task-context exports.getUnitTaskContext = async (req, res) => { try { const { uuid } = req.params; const userId = req.user.user_id; const unit = await Unit.findOne({ where: { uuid, ...notDeleted }, attributes: ['unit_id', 'uuid'] }); if (!unit) return R.error(res, 'Unit not found.', 404); const { taskListIds, taskListToGroup } = await getAccessibleTaskListIds(userId); if (!taskListIds.length) return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] }); const requirements = await TaskRequirement.findAll({ where: { type: 'read_unit', reference_id: uuid, deletedAt: null }, include: [{ model: Task, as: 'task', where: { task_list_id: { [Op.in]: taskListIds }, deletedAt: null }, required: true, attributes: ['task_id', 'name', 'task_list_id'], }], attributes: ['requirement_id', 'task_id', 'reference_id'], }); if (!requirements.length) return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] }); const taskIds = [...new Set(requirements.map((r) => r.task_id))]; const doneProgress = await TaskProgress.findAll({ where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true }, attributes: ['requirement_id', 'reference_id'], }); const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`)); const contexts = requirements.map((req) => ({ task_id: req.task_id, task_name: req.task.name, task_list_id: req.task.task_list_id, group_id: taskListToGroup[req.task.task_list_id], requirement_id: req.requirement_id, already_completed: doneSet.has(`${req.requirement_id}:${req.reference_id}`), })); return R.success(res, 'Task context retrieved.', { has_task: true, contexts }); } catch (err) { console.error('[CLIENT][UNIT TASK CONTEXT]', err); return R.error(res, 'Could not retrieve task context.', 500); } }; // ============================================================================= // ── UPSERT LESSON PROGRESS ──────────────────────────────────────────────────── // ============================================================================= // POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/progress // Body: { status: 'in_progress' | 'completed' } // // Flow: // 1. Resolve course / unit / lesson to get their UUIDs // 2. Delegate to recomputeCascade (completion_requirements service) — lesson + unit + course // evaluated against any configured CompletionRequirement rows (or the default implicit rule), // all in one transaction // 3. Side-effect: sync task_progress for matching task requirements // 4. Return result + completed_tasks (tasks whose all read requirements are now satisfied) exports.upsertLessonProgress = async (req, res) => { try { const { courseId, unitId, lessonId } = req.params; const userId = req.user.user_id; const status = req.body.status === 'completed' ? 'completed' : 'in_progress'; const [course, unit, lesson, courseLink, lessonLink] = await Promise.all([ Course.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: ['course_id', 'uuid'], }), Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, attributes: ['unit_id', 'uuid'], }), Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted }, attributes: ['lesson_id', 'uuid'], }), CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }), UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId } }), ]); if (!course) return R.error(res, 'Course not found.', 404); if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404); if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404); // ── 1. Consolidated evaluation + persistence: lesson → unit → course ── const result = await recomputeCascade(userId, { courseId: course.course_id, courseUuid: course.uuid, unitId: unit.unit_id, unitUuid: unit.uuid, lessonId: lesson.lesson_id, lessonUuid: lesson.uuid, lessonStatus: status, }); // Task-progress sync (read_lesson/read_unit/read_course auto-complete) already ran // inside recomputeCascade — result.completed_tasks reflects it directly. logActivity(userId, 'lesson_read', { entityType: 'lesson', entityId: lesson.lesson_id, details: { lesson_uuid: lesson.uuid, status }, }); return R.success(res, 'Progress updated.', result, 200); } catch (err) { console.error('[CLIENT][COURSE READING PROGRESS][UPSERT]', err); return R.error(res, 'Could not update progress.', 500); } }; // ============================================================================= // ── WATCH PROGRESS (watch_percent completion requirement) ──────────────────── // ============================================================================= // POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/watch-progress // Body: { percent, block_id?, block_type? } — running max % of video/audio watched, 0-100. // block_id/block_type identify which block on the lesson's page sent this update — needed // to drive watch_video/listen_audio (every block of that type must individually reach 100); // omit them and only the aggregate watch_percent requirement (if configured) is touched. // No-ops (still 200s) if the lesson has neither requirement type configured. exports.upsertWatchProgress = async (req, res) => { try { const { courseId, unitId, lessonId } = req.params; const userId = req.user.user_id; const percent = Number(req.body.percent); if (!Number.isFinite(percent)) return R.error(res, 'percent must be a number.', 400); const blockId = req.body.block_id ?? null; const blockType = req.body.block_type ?? null; const [course, unit, lesson, courseLink, lessonLink] = await Promise.all([ Course.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: ['course_id', 'uuid'] }), Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, attributes: ['unit_id', 'uuid'] }), Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted }, attributes: ['lesson_id', 'uuid'] }), CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }), UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId } }), ]); if (!course) return R.error(res, 'Course not found.', 404); if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404); if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404); // Resume-position tracking is unconditional — every block gets it regardless of // whether a completion requirement is configured. recordWatchProgress, below, is // the anti-cheat-validated path and stays a no-op when nothing's configured. await recordPlaybackPosition(userId, { lessonId: lesson.lesson_id, blockId, percent }); const result = await recordWatchProgress(userId, { lessonId: lesson.lesson_id, lessonUuid: lesson.uuid, unitId: unit.unit_id, unitUuid: unit.uuid, courseId: course.course_id, courseUuid: course.uuid, percent, blockId, blockType, }); return R.success(res, 'Watch progress updated.', result, 200); } catch (err) { console.error('[CLIENT][COURSE READING PROGRESS][WATCH PROGRESS]', err); return R.error(res, 'Could not update watch progress.', 500); } }; // ============================================================================= // ── MARK COMPLETE (manual_complete completion requirement) ─────────────────── // ============================================================================= // POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/mark-complete // No-ops (still 200s) if the lesson has no configured manual_complete requirement. exports.markLessonComplete = async (req, res) => { try { const { courseId, unitId, lessonId } = req.params; const userId = req.user.user_id; const [course, unit, lesson, courseLink, lessonLink] = await Promise.all([ Course.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: ['course_id', 'uuid'] }), Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, attributes: ['unit_id', 'uuid'] }), Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted }, attributes: ['lesson_id', 'uuid'] }), CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }), UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId } }), ]); if (!course) return R.error(res, 'Course not found.', 404); if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404); if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404); const result = await recordManualComplete(userId, { entityType: 'lesson', entityId: lesson.lesson_id, lessonUuid: lesson.uuid, unitId: unit.unit_id, unitUuid: unit.uuid, courseId: course.course_id, courseUuid: course.uuid, }); return R.success(res, 'Lesson marked complete.', result, 200); } catch (err) { console.error('[CLIENT][COURSE READING PROGRESS][MARK COMPLETE]', err); return R.error(res, 'Could not mark lesson complete.', 500); } };