/*********************************************************************************************************************************************************************** * File Name: units.controller.js (client) * Type of Program: Controller * Description: Standalone Unit / Lesson consumption — the junction revamp lets * learners run Units and Lessons outside any Course: * * GET /client/units → INDEPENDENT units only (no course affiliation) * GET /client/lessons → INDEPENDENT lessons only (no unit is course-affiliated) * GET /client/units/:uuid → unit metadata (shared handler) * GET /client/units/:uuid/lessons → unit + ALL lesson data (shared handler) * GET /client/units/:uuid/quiz → the unit's quiz, no course context * POST /client/units/:uuid/quiz/:quizId/submit→ graded attempt with course_id NULL * GET /client/lessons/:uuid → single lesson, runs independently (shared handler) * POST /client/lessons/:uuid/progress → standalone reading progress (course NULL, unit optional) * * Access rule: a unit attached to no course is open; otherwise the user must * be able to access at least one attached course. Lessons resolve through * their parent units the same way. * * Discovery rule (getUnits/getLessons only): ALL non-deleted units/lessons * are listed, whether or not they're attached to a course — course_count/ * courses[] (published courses only) and is_locked tell the learner whether * a given item is standalone or bound, and if bound, whether they already * have access. This does not affect the single-item endpoints above * (:uuid) — those still enforce access normally for direct links, and * course-scoped consumption runs through a separate controller entirely. * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jul. 7, 2026 ***********************************************************************************************************************************************************************/ "use strict"; const R = require("../../utils/response.util"); const logActivity = require("../../utils/logActivity.util"); const sequelize = require("../../config/db.config"); const { Unit, Lesson, CourseUnit, UnitLesson, UnitQuiz, QuizQuestion, QuizOption, QuizAttempt, QuizSession, } = require("../../models/courses/courses.associations"); const coursesCtrl = require("./courses.controller"); // canAccessUnit / canAccessLesson / shared uuid handlers const { gradeSubmission } = require("../../utils/courses/grading.util"); const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util"); const { recomputeCascade, recordWatchProgress, recordManualComplete } = require("../../services/completion_requirements.service"); const notDeleted = { deletedAt: null }; // Strip correct-answer data (same policy as course-scoped quiz endpoints) function sanitizeQuestions(questions = []) { return questions.map((q) => { const plain = q.toJSON ? q.toJSON() : { ...q }; if (plain.type === "multi_select") { plain.correct_count = (plain.options ?? []).filter((o) => o.is_correct).length; } plain.options = (plain.options ?? []).map(({ is_correct: _drop, ...o }) => o); delete plain.explanation; return plain; }); } // ─── UNIT LIBRARY (learner view) ────────────────────────────────────────────── // Client-side Units/Lessons browsing shows ALL content, bound to a course or // not — course_count/courses[] + is_locked below tell the learner which is // which. This does not affect course-scoped consumption (which runs through // ClientCoursesContext/getCourse, a separate path) or direct-link access to // UnitDetails/LessonDetails, which still enforce access normally. exports.getUnits = async (req, res) => { try { const rows = await sequelize.query(` SELECT u.unit_id, u.uuid, u.title, u.subscription, u.description, u.duration_seconds, (SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul JOIN lessons l ON l.lesson_id = ul.lesson_id AND l."deletedAt" IS NULL WHERE ul.unit_id = u.unit_id) AS lesson_count, (SELECT CAST(COUNT(*) AS INTEGER) FROM course_units cu JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL AND c.status = 'published' WHERE cu.unit_id = u.unit_id) AS course_count, (SELECT quiz_id FROM unit_quizzes q WHERE q.unit_id = u.unit_id AND q."deletedAt" IS NULL LIMIT 1) AS quiz_id FROM units u WHERE u."deletedAt" IS NULL ORDER BY u.title ASC `, { type: sequelize.QueryTypes.SELECT }); // Batch-fetch attached courses for every returned unit in one query, so the // learner-facing upsell modal can say which course(s)/tier(s) unlock a unit // (a unit may sit under several courses at different tiers — no single "Buy"). const unitIds = rows.map((r) => r.unit_id); const courseLinkRows = unitIds.length ? await sequelize.query(` SELECT cu.unit_id, c.course_id, c.uuid, c.title, c.subscription FROM course_units cu JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL AND c.status = 'published' WHERE cu.unit_id IN (:unitIds) `, { replacements: { unitIds }, type: sequelize.QueryTypes.SELECT }) : []; const coursesByUnit = new Map(); for (const row of courseLinkRows) { const list = coursesByUnit.get(row.unit_id) ?? []; list.push({ course_id: row.course_id, uuid: row.uuid, title: row.title, subscription: row.subscription }); coursesByUnit.set(row.unit_id, list); } // is_locked mirrors canAccessUnit: a unit with its own subscription or at // least one attached course needs an access check; a fully open standalone // unit (no subscription, no course links) is never locked. const result = []; for (const row of rows) { const is_locked = (row.subscription || Number(row.course_count) > 0) ? !(await coursesCtrl.canAccessUnit(req.user.user_id, row.unit_id)) : false; result.push({ ...row, courses: coursesByUnit.get(row.unit_id) ?? [], is_locked }); } return R.success(res, "Units retrieved.", result); } catch (err) { console.error("[CLIENT][UNITS][GET ALL]", err); return R.error(res, "Could not retrieve units.", 500); } }; // ─── LESSON LIBRARY (learner view) ──────────────────────────────────────────── // Mirrors getUnits above — a Lesson may sit in several Units (each possibly in // different courses), so is_locked/courses are resolved across ALL attached // units rather than a single direct course link. exports.getLessons = async (req, res) => { try { const rows = await sequelize.query(` SELECT l.lesson_id, l.uuid, l.title, l.description, l.duration_seconds, (SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL WHERE ul.lesson_id = l.lesson_id) AS unit_count FROM lessons l WHERE l."deletedAt" IS NULL ORDER BY l.title ASC `, { type: sequelize.QueryTypes.SELECT }); // Batch-fetch every course reachable through any attached unit, for every // returned lesson, in one query — same batching style as getUnits. const lessonIds = rows.map((r) => r.lesson_id); const courseLinkRows = lessonIds.length ? await sequelize.query(` SELECT DISTINCT ul.lesson_id, c.course_id, c.uuid, c.title, c.subscription FROM unit_lessons ul JOIN course_units cu ON cu.unit_id = ul.unit_id JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL AND c.status = 'published' WHERE ul.lesson_id IN (:lessonIds) `, { replacements: { lessonIds }, type: sequelize.QueryTypes.SELECT }) : []; const coursesByLesson = new Map(); for (const row of courseLinkRows) { const list = coursesByLesson.get(row.lesson_id) ?? []; list.push({ course_id: row.course_id, uuid: row.uuid, title: row.title, subscription: row.subscription }); coursesByLesson.set(row.lesson_id, list); } // is_locked mirrors canAccessLesson: standalone/unattached lessons are // open, attached lessons need at least one accessible course through // any attached unit. const result = []; for (const row of rows) { const is_locked = Number(row.unit_count) > 0 ? !(await coursesCtrl.canAccessLesson(req.user.user_id, row.lesson_id)) : false; result.push({ ...row, courses: coursesByLesson.get(row.lesson_id) ?? [], is_locked }); } return R.success(res, "Lessons retrieved.", result); } catch (err) { console.error("[CLIENT][LESSONS][GET ALL]", err); return R.error(res, "Could not retrieve lessons.", 500); } }; // ─── STANDALONE UNIT QUIZ ───────────────────────────────────────────────────── exports.getUnitQuiz = async (req, res) => { try { const { uuid } = req.params; const unit = await Unit.findOne({ where: { uuid, ...notDeleted } }); if (!unit) return R.error(res, "Unit not found.", 404); if (!await coursesCtrl.canAccessUnit(req.user.user_id, unit.unit_id)) { return R.error(res, "You do not have access to this unit.", 403); } const quiz = await UnitQuiz.findOne({ where: { unit_id: unit.unit_id, ...notDeleted }, attributes: [ "quiz_id", "uuid", "title", "is_required", "passing_score", "max_questions", "shuffle_questions", ], include: [{ model: QuizQuestion, as: "questions", where: notDeleted, required: false, attributes: ["question_id", "uuid", "type", "question", "order_index", "points"], include: [{ model: QuizOption, as: "options", attributes: ["option_id", "text", "order_index", "is_correct"], }], }], order: [[{ model: QuizQuestion, as: "questions" }, "order_index", "ASC"]], }); if (!quiz) return R.error(res, "Quiz not found.", 404); const plain = quiz.toJSON(); let qs = sanitizeQuestions(plain.questions ?? []); if (plain.shuffle_questions) qs = shuffleQuestions(qs); plain.questions = shuffleOptions(qs); const attempts = await QuizAttempt.findAll({ where: { quiz_id: quiz.quiz_id, user_id: req.user.user_id }, attributes: ["attempt_id", "attempt_number", "score", "passing_score", "passed", "createdAt"], }); const status = getAttemptStatus(attempts, "quiz"); plain.attempt_count = status.attempt_count; plain.has_passed = status.has_passed; plain.best_attempt = status.best_attempt; plain.attempts_remaining = status.attempts_remaining; plain.cooldown_until = status.cooldown_until; plain.window_reset_at = status.window_reset_at; plain.can_attempt = status.can_attempt; const activeSession = await QuizSession.findOne({ where: { quiz_id: quiz.quiz_id, user_id: req.user.user_id, status: "in_progress" }, attributes: ["session_id", "draft_answers", "started_at", "last_saved_at"], }); plain.active_session = activeSession ?? null; return R.success(res, "Quiz retrieved.", plain); } catch (err) { console.error("[CLIENT][UNITS][QUIZ][GET]", err); return R.error(res, "Could not retrieve quiz.", 500); } }; exports.submitUnitQuiz = async (req, res) => { try { const { uuid, quizId } = req.params; const { answers = {} } = req.body; const user_id = req.user.user_id; const unit = await Unit.findOne({ where: { uuid, ...notDeleted } }); if (!unit) return R.error(res, "Unit not found.", 404); if (!await coursesCtrl.canAccessUnit(user_id, unit.unit_id)) { return R.error(res, "You do not have access to this unit.", 403); } const quiz = await UnitQuiz.findOne({ where: { quiz_id: quizId, unit_id: unit.unit_id, ...notDeleted }, include: [{ model: QuizQuestion, as: "questions", where: notDeleted, required: false, include: [{ model: QuizOption, as: "options" }], }], }); if (!quiz) return R.error(res, "Quiz not found.", 404); const priorAttempts = await QuizAttempt.findAll({ where: { quiz_id: quiz.quiz_id, user_id }, attributes: ["attempt_id", "score", "passed", "createdAt"], }); const { totalPoints, earnedPoints, score } = gradeSubmission(quiz.questions ?? [], answers); const passed = score >= (quiz.passing_score ?? 70); const attempt = await QuizAttempt.create({ user_id, quiz_id: quiz.quiz_id, course_id: null, // standalone — no course context attempt_number: priorAttempts.length + 1, answers, total_points: totalPoints, earned_points: earnedPoints, score, passing_score: quiz.passing_score ?? 70, passed, }); await QuizSession.update( { status: "submitted" }, { where: { quiz_id: quiz.quiz_id, user_id, status: "in_progress" } } ); return R.success(res, "Quiz submitted.", { attempt_id: attempt.attempt_id, attempt_number: attempt.attempt_number, score, passed, passing_score: attempt.passing_score, total_points: totalPoints, earned_points: earnedPoints, }); } catch (err) { console.error("[CLIENT][UNITS][QUIZ][SUBMIT]", err); return R.error(res, "Could not submit quiz.", 500); } }; // ─── STANDALONE UNIT QUIZ DRAFT ─────────────────────────────────────────────── // PATCH /client/units/:uuid/quiz/:quizId/draft — mirrors the course-scoped // saveQuizDraft in courses.controller.js; only quiz_id + user_id are needed to // locate the session, course_id/unit_id are just extra nullable columns on it. exports.saveUnitQuizDraft = async (req, res) => { try { const { uuid, quizId } = req.params; const { answers = {} } = req.body; const user_id = req.user.user_id; const unit = await Unit.findOne({ where: { uuid, ...notDeleted } }); if (!unit) return R.error(res, "Unit not found.", 404); const [updatedCount] = await QuizSession.update( { draft_answers: answers, last_saved_at: new Date() }, { where: { quiz_id: quizId, user_id, status: "in_progress" } } ); if (updatedCount === 0) { await QuizSession.create({ quiz_id: quizId, user_id, course_id: null, unit_id: unit.unit_id, draft_answers: answers, started_at: new Date(), }); } return res.status(204).end(); } catch (err) { console.error("[CLIENT][UNITS][QUIZ][DRAFT]", err); return R.error(res, "Could not save quiz draft.", 500); } }; // ─── STANDALONE LESSON PROGRESS ─────────────────────────────────────────────── // POST /client/lessons/:uuid/progress Body: { status, unit_uuid? } // Evaluated + written via completion_requirements.service#recomputeCascade with // courseId null, which persists to lesson_reading_progress/unit_reading_progress // (the tables that tolerate a null course_id) instead of course_reading_progress. // When unit_uuid is given (unit context, still no course) the parent unit is // re-evaluated + upserted too, against any configured CompletionRequirement rows. exports.upsertStandaloneLessonProgress = async (req, res) => { try { const { uuid } = req.params; const userId = req.user.user_id; const status = req.body.status === "completed" ? "completed" : "in_progress"; const unitUuid = req.body.unit_uuid ?? null; const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid"] }); if (!lesson) return R.error(res, "Lesson not found.", 404); if (!await coursesCtrl.canAccessLesson(userId, lesson.lesson_id)) { return R.error(res, "You do not have access to this lesson.", 403); } let unitId = null; if (unitUuid) { const unit = await Unit.findOne({ where: { uuid: unitUuid, ...notDeleted }, attributes: ["unit_id"] }); if (!unit) return R.error(res, "Unit not found.", 404); const link = await UnitLesson.findOne({ where: { unit_id: unit.unit_id, lesson_id: lesson.lesson_id } }); if (!link) return R.error(res, "Lesson is not attached to this unit.", 404); unitId = unit.unit_id; } const result = await recomputeCascade(userId, { courseId: null, unitId, unitUuid, lessonId: lesson.lesson_id, lessonUuid: lesson.uuid, lessonStatus: status, }); logActivity(userId, "lesson_read", { entityType: "lesson", entityId: lesson.lesson_id, details: { lesson_uuid: lesson.uuid, status, standalone: true }, }); return R.success(res, "Progress updated.", result, 200); } catch (err) { console.error("[CLIENT][LESSONS][STANDALONE PROGRESS]", err); return R.error(res, "Could not update progress.", 500); } }; // ─── STANDALONE WATCH PROGRESS ───────────────────────────────────────────────── // POST /client/lessons/:uuid/watch-progress Body: { percent, unit_uuid?, block_id?, block_type? } // block_id/block_type identify which block on the lesson's page sent this update — needed // to drive watch_video/listen_audio; omit them and only watch_percent (if configured) is touched. exports.upsertStandaloneWatchProgress = async (req, res) => { try { const { uuid } = 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 unitUuid = req.body.unit_uuid ?? null; const blockId = req.body.block_id ?? null; const blockType = req.body.block_type ?? null; const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid"] }); if (!lesson) return R.error(res, "Lesson not found.", 404); if (!await coursesCtrl.canAccessLesson(userId, lesson.lesson_id)) { return R.error(res, "You do not have access to this lesson.", 403); } let unitId = null; if (unitUuid) { const unit = await Unit.findOne({ where: { uuid: unitUuid, ...notDeleted }, attributes: ["unit_id"] }); if (!unit) return R.error(res, "Unit not found.", 404); unitId = unit.unit_id; } const result = await recordWatchProgress(userId, { lessonId: lesson.lesson_id, lessonUuid: lesson.uuid, unitId, unitUuid, courseId: null, courseUuid: null, percent, blockId, blockType, }); return R.success(res, "Watch progress updated.", result, 200); } catch (err) { console.error("[CLIENT][LESSONS][STANDALONE WATCH PROGRESS]", err); return R.error(res, "Could not update watch progress.", 500); } }; // ─── STANDALONE MARK COMPLETE ────────────────────────────────────────────────── // POST /client/lessons/:uuid/mark-complete Body: { unit_uuid? } exports.markStandaloneLessonComplete = async (req, res) => { try { const { uuid } = req.params; const userId = req.user.user_id; const unitUuid = req.body.unit_uuid ?? null; const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid"] }); if (!lesson) return R.error(res, "Lesson not found.", 404); if (!await coursesCtrl.canAccessLesson(userId, lesson.lesson_id)) { return R.error(res, "You do not have access to this lesson.", 403); } let unitId = null; if (unitUuid) { const unit = await Unit.findOne({ where: { uuid: unitUuid, ...notDeleted }, attributes: ["unit_id"] }); if (!unit) return R.error(res, "Unit not found.", 404); unitId = unit.unit_id; } const result = await recordManualComplete(userId, { entityType: "lesson", entityId: lesson.lesson_id, lessonUuid: lesson.uuid, unitId, unitUuid, courseId: null, courseUuid: null, }); return R.success(res, "Lesson marked complete.", result, 200); } catch (err) { console.error("[CLIENT][LESSONS][STANDALONE MARK COMPLETE]", err); return R.error(res, "Could not mark lesson complete.", 500); } }; // ─── Shared UUID handlers re-exported for the standalone routes ─────────────── exports.getUnitByUuid = coursesCtrl.getUnitByUuid; exports.getLessonsByUnitUuid = coursesCtrl.getLessonsByUnitUuid; exports.getLessonByUuid = coursesCtrl.getLessonByUuid;