/*********************************************************************************************************************************************************************** * File Name: courses.controller.js (client) * Type of Program: Controller * Description: User-facing course endpoints (read-only). * Access rules: * - All courses are returned in the list (for upsell visibility) * - Each course has is_locked: boolean based on the user's active tier * - free / no active tier → unassigned courses are open; plan courses are locked * - premium (active tier) → unassigned + courses under their plan are open * - getCourse still enforces hard 403 on locked access * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 7, 2026 ***********************************************************************************************************************************************************************/ "use strict"; const { Op } = require("sequelize"); const R = require("../../utils/response.util"); const mdl_UserTiers = require("../../models/tiers/user_tiers.mdl"); const mdl_PlanCourses = require("../../models/tiers/plan_courses.mdl"); const mdl_TierPlans = require("../../models/tiers/tier_plans.mdl"); const mdl_CoursePurchase = require("../../models/courses/course_purchases.mdl"); const mdl_Product = require("../../models/courses/products.mdl"); const mdl_Category = require("../../models/courses/categories.mdl"); const { Course, Unit, Lesson, LessonPage, CourseObjective, LessonObjective, CoursePrerequisite, CourseAssessment, UnitQuiz, QuizQuestion, QuizOption, QuizAttempt } = require("../../models/courses/courses.associations"); const { gradeSubmission } = require("../../utils/courses/grading.util"); const { shuffleOptions, getAttemptStatus, MAX_ATTEMPTS } = require("../../utils/courses/quiz_security.util"); const { onCourseCompleted } = require('../../services/achievements.service') const notDeleted = { deletedAt: null }; const COURSE_LIST_ATTRS = [ "course_id", "uuid", "title", "description", "course_code", "level", "subscription", "duration_seconds", "order_index", ]; // Strip correct-answer data before sending quiz questions to the client function sanitizeQuestions(questions = []) { return questions.map((q) => { const plain = q.toJSON ? q.toJSON() : { ...q }; plain.options = (plain.options ?? []).map(({ is_correct: _drop, ...o }) => o); delete plain.explanation; return plain; }); } // Resolve the caller's active tier (returns null if free/expired) async function getActiveTier(user_id) { return mdl_UserTiers.findOne({ where: { user_id, status: "active" }, order: [["createdAt", "DESC"]], }); } // ─── COURSES (all visible, is_locked per user tier) ─────────────────────────── exports.getCourses = async (req, res) => { try { const { category } = req.query; // optional slug filter const activeTier = await getActiveTier(req.user.user_id); const userTier = activeTier?.tier ?? 'free'; const tierRank = { free: 0, premium: 1, exclusive: 2 }; const userRank = tierRank[userTier] ?? 0; // Fetch all completed purchases for this user (for has_purchased check) const myPurchases = await mdl_CoursePurchase.findAll({ where: { user_id: req.user.user_id, status: 'completed' }, include: [{ model: mdl_Product, as: 'product', attributes: ['course_id', 'access_days'] }], attributes: ['id', 'expires_at', 'product_id'], }); const purchasedCourseIds = new Set( myPurchases .filter((p) => !p.expires_at || new Date(p.expires_at) > new Date()) .map((p) => String(p.product?.course_id)) ); // Build category filter const categoryInclude = { model: mdl_Category, as: 'categories', through: { attributes: [] }, attributes: ['id', 'name', 'slug'], required: !!category, ...(category ? { where: { slug: category } } : {}), }; const courses = await Course.findAll({ where: { ...notDeleted }, attributes: COURSE_LIST_ATTRS, include: [ { model: mdl_PlanCourses, as: 'planCourse', required: false, attributes: ['id', 'plan_id'], include: [{ model: mdl_TierPlans, as: 'plan', attributes: ['tier'] }], }, { model: mdl_Product, as: 'product', required: false, attributes: ['id', 'name', 'price', 'currency', 'access_days', 'is_active'], paranoid: false, }, categoryInclude, ], order: [['order_index', 'ASC'], ['title', 'ASC']], }); const result = courses.map((c) => { const plain = c.toJSON(); const planCourse = plain.planCourse; const plan_tier = planCourse?.plan?.tier ?? null; const has_purchased = purchasedCourseIds.has(String(plain.course_id)); let is_locked = false; if (plan_tier && plan_tier !== 'free') { const reqRank = tierRank[plan_tier] ?? 0; if (userRank < reqRank && !has_purchased) is_locked = true; } delete plain.planCourse; return { ...plain, is_locked, plan_tier, has_purchased }; }); return R.success(res, "Courses retrieved.", result); } catch (err) { console.error("[CLIENT][COURSES][GET ALL]", err); return R.error(res, "Could not retrieve courses.", 500); } }; // ─── COURSE DETAIL (hard access check) ─────────────────────────────────────── exports.getCourse = async (req, res) => { try { const { courseId } = req.params; // Access check — tier OR individual purchase const activeTier = await getActiveTier(req.user.user_id); const planCourse = await mdl_PlanCourses.findOne({ where: { course_id: courseId } }); let plan = null; if (planCourse) { plan = await mdl_TierPlans.findByPk(planCourse.plan_id, { attributes: ['tier'] }); const requiredTier = plan?.tier ?? 'free'; const tierRank = { free: 0, premium: 1, exclusive: 2 }; const userRank = tierRank[activeTier?.tier ?? 'free'] ?? 0; const reqRank = tierRank[requiredTier] ?? 0; if (userRank < reqRank) { // Check individual purchase as fallback const product = await mdl_Product.findOne({ where: { course_id: courseId } }); const hasPurchase = product && await mdl_CoursePurchase.findOne({ where: { user_id: req.user.user_id, product_id: product.id, status: 'completed', [Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }], }, }); if (!hasPurchase) return R.error(res, "You do not have access to this course.", 403); } } const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: COURSE_LIST_ATTRS, include: [ { model: Unit, as: "units", where: notDeleted, required: false, attributes: [ "unit_id", "uuid", "title", "description", "order_index", "duration_seconds", ], include: [ { model: Lesson, as: "lessons", where: notDeleted, required: false, attributes: [ "lesson_id", "uuid", "title", "description", "order_index", "duration_seconds", ], }, { model: UnitQuiz, as: "quiz", required: false, attributes: [ "quiz_id", "uuid", "title", "is_required", "passing_score", "max_questions", ], }, ], }, { model: CourseObjective, as: "objectives", required: false, attributes: ["objective_id", "text", "order_index"], }, { model: CoursePrerequisite, as: "prerequisites", required: false, attributes: ["prereq_id", "ref_type", "ref_id", "order_index"], }, { model: CourseAssessment, as: "assessment", required: false, attributes: [ "assessment_id", "uuid", "title", "is_required", "passing_score", "time_limit_minutes", "max_questions", ], }, ], order: [ [{ model: Unit, as: "units" }, "order_index", "ASC"], [{ model: Unit, as: "units" }, { model: Lesson, as: "lessons" }, "order_index", "ASC"], [{ model: CourseObjective, as: "objectives" }, "order_index", "ASC"], [{ model: CoursePrerequisite, as: "prerequisites" }, "order_index", "ASC"], ], }); if (!course) return R.error(res, "Course not found.", 404); const plain = course.toJSON(); // Attach has_passed to each unit's quiz in one query const quizIds = plain.units ?.map((u) => u.quiz?.quiz_id) .filter(Boolean) ?? []; if (quizIds.length) { const passedQuizAttempts = await QuizAttempt.findAll({ where: { quiz_id: quizIds, user_id: req.user.user_id, passed: true }, attributes: ["quiz_id"], }); const passedSet = new Set(passedQuizAttempts.map((a) => String(a.quiz_id))); plain.units = plain.units.map((u) => ({ ...u, quiz: u.quiz ? { ...u.quiz, has_passed: passedSet.has(String(u.quiz.quiz_id)) } : null, })); } let is_completed = false; if (plain.assessment) { const passedAttempt = await QuizAttempt.findOne({ where: { assessment_id: plain.assessment.assessment_id, user_id: req.user.user_id, passed: true }, }); is_completed = !!passedAttempt; } plain.is_completed = is_completed; const plan_tier = plan?.tier ?? null; // Attach product info and purchase status for the buy-course flow const product = await mdl_Product.findOne({ where: { course_id: courseId, is_active: true }, attributes: ['id', 'name', 'price', 'currency', 'access_days'], }); const hasPurchase = product && await mdl_CoursePurchase.findOne({ where: { user_id: req.user.user_id, product_id: product.id, status: 'completed', [Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }], }, }); return R.success(res, "Course retrieved.", { ...plain, plan_tier, product: product ?? null, has_purchased: !!hasPurchase, }); } catch (err) { console.error("[CLIENT][COURSES][GET ONE]", err); return R.error(res, "Could not retrieve course.", 500); } }; // ─── UNIT ───────────────────────────────────────────────────────────────────── exports.getUnit = async (req, res) => { try { const { courseId, unitId } = req.params; const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted }, attributes: [ "unit_id", "uuid", "title", "description", "order_index", "duration_seconds", ], include: [ { model: Lesson, as: "lessons", where: notDeleted, required: false, attributes: [ "lesson_id", "uuid", "title", "description", "order_index", "duration_seconds", ], }, { model: UnitQuiz, as: "quiz", required: false, attributes: [ "quiz_id", "uuid", "title", "is_required", "passing_score", "max_questions", ], }, ], order: [[{ model: Lesson, as: "lessons" }, "order_index", "ASC"]], }); if (!unit) return R.error(res, "Unit not found.", 404); return R.success(res, "Unit retrieved.", unit); } catch (err) { console.error("[CLIENT][UNIT][GET ONE]", err); return R.error(res, "Could not retrieve unit.", 500); } }; // ─── LESSON ─────────────────────────────────────────────────────────────────── exports.getLesson = async (req, res) => { try { const { courseId, unitId, lessonId } = req.params; const lesson = await Lesson.findOne({ where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted }, attributes: [ "lesson_id", "uuid", "unit_id", "title", "description", "order_index", "duration_seconds", ], include: [ { model: Unit, as: "unit", where: { course_id: courseId, ...notDeleted }, attributes: [], }, { model: LessonPage, as: "page", required: false, attributes: ["page_id", "blocks"], }, { model: LessonObjective, as: "objectives", required: false, attributes: ["objective_id", "text", "order_index"], }, ], order: [[{ model: LessonObjective, as: "objectives" }, "order_index", "ASC"]], }); if (!lesson) return R.error(res, "Lesson not found.", 404); return R.success(res, "Lesson retrieved.", lesson); } catch (err) { console.error("[CLIENT][LESSON][GET ONE]", err); return R.error(res, "Could not retrieve lesson.", 500); } }; // ─── QUIZ (no answers) ──────────────────────────────────────────────────────── exports.getUnitQuiz = async (req, res) => { try { const { courseId, unitId } = req.params; const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } }); if (!unit) return R.error(res, "Unit not found.", 404); const quiz = await UnitQuiz.findOne({ where: { unit_id: unitId, ...notDeleted }, attributes: [ "quiz_id", "uuid", "title", "is_required", "passing_score", "max_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"], }], }], order: [[{ model: QuizQuestion, as: "questions" }, "order_index", "ASC"]], }); if (!quiz) return R.error(res, "Quiz not found.", 404); const plain = quiz.toJSON(); plain.questions = shuffleOptions(sanitizeQuestions(plain.questions ?? [])); 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); 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; return R.success(res, "Quiz retrieved.", plain); } catch (err) { console.error("[CLIENT][QUIZ][GET]", err); return R.error(res, "Could not retrieve quiz.", 500); } }; // ─── ASSESSMENT (no answers) ────────────────────────────────────────────────── exports.getCourseAssessment = async (req, res) => { try { const { courseId } = req.params; const assessment = await CourseAssessment.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: [ "assessment_id", "uuid", "title", "is_required", "passing_score", "time_limit_minutes", "max_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"], }], }], order: [[{ model: QuizQuestion, as: "questions" }, "order_index", "ASC"]], }); if (!assessment) return R.error(res, "Assessment not found.", 404); const plain = assessment.toJSON(); plain.questions = shuffleOptions(sanitizeQuestions(plain.questions ?? [])); const attempts = await QuizAttempt.findAll({ where: { assessment_id: assessment.assessment_id, user_id: req.user.user_id }, attributes: ["attempt_id", "attempt_number", "score", "passing_score", "passed", "createdAt"], }); const status = getAttemptStatus(attempts); 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; return R.success(res, "Assessment retrieved.", plain); } catch (err) { console.error("[CLIENT][ASSESSMENT][GET]", err); return R.error(res, "Could not retrieve assessment.", 500); } }; // ─── QUIZ SUBMIT ────────────────────────────────────────────────────────────── exports.submitUnitQuiz = async (req, res) => { try { const { courseId, unitId, quizId } = req.params; const { answers = {} } = req.body; const user_id = req.user.user_id; const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } }); if (!unit) return R.error(res, "Unit not found.", 404); const quiz = await UnitQuiz.findOne({ where: { quiz_id: quizId, unit_id: unitId, ...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 status = getAttemptStatus(priorAttempts); if (!status.can_attempt) { if (status.cooldown_until) { return R.error(res, "Please wait a bit before retaking this quiz — check the quiz screen for when you can try again.", 429); } return R.error(res, `You've used all ${MAX_ATTEMPTS} attempts for this ${ATTEMPT_WINDOW_HOURS}-hour window — check the quiz screen for when it resets.`, 429); } 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: courseId, attempt_number: priorAttempts.length + 1, answers, total_points: totalPoints, earned_points: earnedPoints, score, passing_score: quiz.passing_score ?? 70, passed, }); 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, attempts_remaining: Math.max(0, status.attempts_remaining - 1), }); } catch (err) { console.error("[CLIENT][QUIZ][SUBMIT]", err); return R.error(res, "Could not submit quiz.", 500); } }; exports.submitCourseAssessment = async (req, res) => { try { const { courseId, assessmentId } = req.params; const { answers = {} } = req.body; const user_id = req.user.user_id; const assessment = await CourseAssessment.findOne({ where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted }, include: [{ model: QuizQuestion, as: "questions", where: notDeleted, required: false, include: [{ model: QuizOption, as: "options" }], }], }); if (!assessment) return R.error(res, "Assessment not found.", 404); const priorAttempts = await QuizAttempt.findAll({ where: { assessment_id: assessment.assessment_id, user_id }, attributes: ["attempt_id", "score", "passed", "createdAt"], }); const status = getAttemptStatus(priorAttempts); if (!status.can_attempt) { if (status.cooldown_until) { return R.error(res, "Please wait a bit before retaking this quiz — check the quiz screen for when you can try again.", 429); } return R.error(res, `You've used all ${MAX_ATTEMPTS} attempts for this ${ATTEMPT_WINDOW_HOURS}-hour window — check the quiz screen for when it resets.`, 429); } const { totalPoints, earnedPoints, score } = gradeSubmission(assessment.questions ?? [], answers); const passed = score >= (assessment.passing_score ?? 70); const attempt = await QuizAttempt.create({ user_id, assessment_id: assessment.assessment_id, course_id: courseId, attempt_number: priorAttempts.length + 1, answers, total_points: totalPoints, earned_points: earnedPoints, score, passing_score: assessment.passing_score ?? 70, passed, }); let course_completed = false; if (passed) { course_completed = true; const course = await Course.findOne({ where: { course_id: courseId }, attributes: ["course_id", "title"] }); const totalCompleted = await QuizAttempt.count({ where: { user_id, passed: true, assessment_id: { [Op.ne]: null } }, distinct: true, col: "assessment_id", }); await onCourseCompleted(user_id, courseId, totalCompleted, course?.title ?? null); } return R.success(res, "Assessment submitted.", { attempt_id: attempt.attempt_id, attempt_number: attempt.attempt_number, score, passed, passing_score: attempt.passing_score, total_points: totalPoints, earned_points: earnedPoints, attempts_remaining: Math.max(0, status.attempts_remaining - 1), course_completed, }); } catch (err) { console.error("[CLIENT][ASSESSMENT][SUBMIT]", err); return R.error(res, "Could not submit assessment.", 500); } }; // ─── UUID LOOKUPS (task requirement detail blocks) ──────────────────────────── exports.getCourseByUuid = async (req, res) => { try { const { uuid } = req.params; const course = await Course.findOne({ where: { uuid, ...notDeleted }, attributes: ["course_id", "uuid", "title", "description", "level", "subscription"], }); if (!course) return R.error(res, "Course not found.", 404); return R.success(res, "Course retrieved.", course); } catch (err) { console.error("[CLIENT][COURSES][BY UUID]", err); return R.error(res, "Could not retrieve course.", 500); } }; exports.getUnitByUuid = async (req, res) => { try { const { uuid } = req.params; const unit = await Unit.findOne({ where: { uuid, ...notDeleted }, attributes: ["unit_id", "uuid", "title", "description"], include: [{ model: Course, as: "course", attributes: ["course_id", "title"] }], }); if (!unit) return R.error(res, "Unit not found.", 404); return R.success(res, "Unit retrieved.", unit); } catch (err) { console.error("[CLIENT][UNITS][BY UUID]", err); return R.error(res, "Could not retrieve unit.", 500); } }; exports.getLessonsByUnitUuid = async (req, res) => { try { const { uuid } = req.params; const unit = await Unit.findOne({ where: { uuid, ...notDeleted }, attributes: ["unit_id", "uuid", "title", "description", "order_index"], include: [ { model: Course, as: "course", attributes: ["course_id", "title"] }, { model: Lesson, as: "lessons", where: notDeleted, required: false, attributes: ["lesson_id", "uuid", "title", "description", "order_index"], include: [{ model: LessonPage, as: "page", attributes: ["blocks"], required: false }], order: [["order_index", "ASC"]], }, ], }); if (!unit) return R.error(res, "Unit not found.", 404); const lessons = (unit.lessons ?? []) .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) .map((l) => ({ lesson_id: l.lesson_id, uuid: l.uuid, title: l.title, description: l.description, order_index: l.order_index ?? 0, blocks: l.page?.blocks ?? [], })); return R.success(res, "Unit lessons retrieved.", { unit_id: unit.unit_id, uuid: unit.uuid, title: unit.title, description: unit.description, course: unit.course ?? null, lessons, }); } catch (err) { console.error("[CLIENT][UNITS][LESSONS BY UUID]", err); return R.error(res, "Could not retrieve unit lessons.", 500); } }; exports.getLessonByUuid = async (req, res) => { try { const { uuid } = req.params; const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid", "title", "description"], include: [ { model: LessonPage, as: "page", attributes: ["blocks"], required: false, }, { model: Unit, as: "unit", attributes: ["unit_id", "title", "order_index"], include: [{ model: Course, as: "course", attributes: ["course_id", "title"] }], }, ], }); if (!lesson) return R.error(res, "Lesson not found.", 404); const data = { lesson_id: lesson.lesson_id, uuid: lesson.uuid, title: lesson.title, description: lesson.description, blocks: lesson.page?.blocks ?? [], unit: lesson.unit ?? null, }; return R.success(res, "Lesson retrieved.", data); } catch (err) { console.error("[CLIENT][LESSONS][BY UUID]", err); return R.error(res, "Could not retrieve lesson.", 500); } };