/*********************************************************************************************************************************************************************** * 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, AssessmentSession, } = require("../../models/courses/courses.associations"); const { gradeSubmission } = require("../../utils/courses/grading.util"); const { shuffleOptions, getAttemptStatus, ASSESSMENT_FAILS_BEFORE_COOLDOWN, ASSESSMENT_COOLDOWN_HOURS } = require("../../utils/courses/quiz_security.util"); const { onCourseCompleted } = require('../../services/achievements.service'); const PendingCertificate = require('../../models/courses/pending_certificate.mdl'); const Certificate = require('../../models/courses/certificate.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const notDeleted = { deletedAt: null }; // Returns expiry info for a timed assessment session. // Pass storedExpiresAt when the session already has a DB-persisted expires_at // (post-resume sessions get their expires_at extended to account for offline gaps). function computeExpiryInfo(startedAt, timeLimitMinutes, storedExpiresAt = null) { const expires_at = storedExpiresAt ? new Date(storedExpiresAt) : (timeLimitMinutes && startedAt ? new Date(new Date(startedAt).getTime() + timeLimitMinutes * 60000) : null); if (!expires_at) return { expires_at: null, expired: false, remaining_seconds: null }; const now = new Date(); const expired = now >= expires_at; const remaining_seconds = expired ? 0 : Math.ceil((expires_at - now) / 1000); return { expires_at, expired, remaining_seconds }; } // Creates a zero-score quiz_attempt for an expired session and marks the session 'expired'. async function expireSession(session, passingScore) { const priorCount = await QuizAttempt.count({ where: { assessment_id: session.assessment_id, user_id: session.user_id }, }); const expiredAttempt = await QuizAttempt.create({ user_id: session.user_id, assessment_id: session.assessment_id, course_id: session.course_id, attempt_number: priorCount + 1, answers: {}, total_points: 0, earned_points: 0, score: 0, passing_score: passingScore ?? 70, passed: false, }); await AssessmentSession.update( { status: 'expired', attempt_id: expiredAttempt.attempt_id }, { where: { session_id: session.session_id } } ); return expiredAttempt; } // ─── Shared tier + purchase access check ───────────────────────────────────── // Returns true → user may access the course. // Returns false → user's tier is too low AND no valid individual purchase. async function canAccessCourse(user_id, course_id) { let requiredTier = 'free'; // Primary: explicit plan association const planCourse = await mdl_PlanCourses.findOne({ where: { course_id } }); if (planCourse) { const plan = await mdl_TierPlans.findByPk(planCourse.plan_id, { attributes: ['tier'] }); if (plan?.tier) { requiredTier = plan.tier; } else { // Plan was soft-deleted or missing — fall back to course.subscription const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription'] }); requiredTier = course?.subscription ?? 'free'; } } else { // Fallback: use the course's own subscription field (premium / exclusive / free) const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription'] }); requiredTier = course?.subscription ?? 'free'; } if (requiredTier === 'free') return true; const tierRank = { free: 0, premium: 1, exclusive: 2 }; const activeTier = await getActiveTier(user_id); const userRank = tierRank[activeTier?.tier ?? 'free'] ?? 0; const reqRank = tierRank[requiredTier] ?? 0; if (userRank >= reqRank) return true; // Individual purchase as fallback const product = await mdl_Product.findOne({ where: { course_id } }); if (!product) return false; const hasPurchase = await mdl_CoursePurchase.findOne({ where: { user_id, product_id: product.id, status: 'completed', [Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }], }, }); return !!hasPurchase; } 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. // For multi_select, preserve correct_count so the client can show "Select X answers" // without revealing which options are correct. 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; }); } // 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)); const effectiveTier = plan_tier || plain.subscription || 'free'; let is_locked = false; if (effectiveTier && effectiveTier !== 'free') { const reqRank = tierRank[effectiveTier] ?? 0; if (userRank < reqRank && !has_purchased) is_locked = true; } delete plain.planCourse; return { ...plain, is_locked, plan_tier: effectiveTier, 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 — plan association → subscription field → individual purchase if (!await canAccessCourse(req.user.user_id, courseId)) { 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 planCourse = await mdl_PlanCourses.findOne({ where: { course_id: courseId }, include: [{ model: mdl_TierPlans, as: 'plan', attributes: ['tier'] }], }); const plan_tier = planCourse?.plan?.tier ?? plain.subscription ?? 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() } }], }, }); // Certificate status for the course details card const [pendingCert, certificate] = await Promise.all([ PendingCertificate.findOne({ where: { user_id: req.user.user_id, course_id: courseId, processed_at: null }, attributes: ['pending_id', 'passed_at', 'issue_at'], }), Certificate.findOne({ where: { user_id: req.user.user_id, course_id: courseId }, attributes: ['uuid', 'cert_no', 'issued_at'], }), ]); return R.success(res, "Course retrieved.", { ...plain, plan_tier, product: product ?? null, has_purchased: !!hasPurchase, pending_certificate: pendingCert ?? null, certificate: certificate ?? null, }); } 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", "is_correct"], }], }], 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, '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; 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", "max_attempts", "cooldown_hours", ], 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 (!assessment) return R.error(res, "Assessment not found.", 404); const plain = assessment.toJSON(); plain.questions = shuffleOptions(sanitizeQuestions(plain.questions ?? [])); // All graded attempts for cooldown/status calc 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"], }); // Find active session from the dedicated sessions table const activeSession = await AssessmentSession.findOne({ where: { assessment_id: assessment.assessment_id, user_id: req.user.user_id, status: 'in_progress' }, attributes: ["session_id", "started_at", "expires_at", "status", "assessment_id", "user_id", "course_id", "draft_answers", "last_heartbeat_at"], }); if (activeSession) { const { expired, expires_at, remaining_seconds } = computeExpiryInfo(activeSession.started_at, assessment.time_limit_minutes, activeSession.expires_at); if (expired) { await expireSession(activeSession, assessment.passing_score); plain.active_session = null; } else { plain.active_session = { session_id: activeSession.session_id, started_at: activeSession.started_at, expires_at: expires_at?.toISOString() ?? null, remaining_seconds, draft_answers: activeSession.draft_answers ?? {}, }; } } else { plain.active_session = null; } const status = getAttemptStatus(attempts, 'assessment', { maxFails: plain.max_attempts, cooldownHours: plain.cooldown_hours, }); 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); } }; // ─── ASSESSMENT START (timed sessions) ─────────────────────────────────────── exports.startCourseAssessment = async (req, res) => { try { const { courseId, assessmentId } = req.params; const user_id = req.user.user_id; const assessment = await CourseAssessment.findOne({ where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted }, attributes: ["assessment_id", "time_limit_minutes", "passing_score", "max_attempts", "cooldown_hours"], }); if (!assessment) return R.error(res, "Assessment not found.", 404); // Return or expire any existing in_progress session const existing = await AssessmentSession.findOne({ where: { assessment_id: assessmentId, user_id, status: 'in_progress' }, attributes: ["session_id", "started_at", "expires_at", "assessment_id", "user_id", "course_id", "draft_answers", "last_heartbeat_at"], }); if (existing) { const now = new Date(); // Extend expires_at by the offline gap so the clock was effectively frozen // while the browser was closed. // Reference point: last_heartbeat_at if set (draft saved at least once), // otherwise fall back to updatedAt (session row last touched — typically creation). if (existing.expires_at) { const ref = existing.last_heartbeat_at ?? existing.updatedAt; const offlineMs = ref ? now - new Date(ref) : 0; const GRACE_MS = 30_000; // ignore gaps under 30s (normal between drafts) if (offlineMs > GRACE_MS) { const extended = new Date(new Date(existing.expires_at).getTime() + offlineMs); await existing.update({ expires_at: extended, last_heartbeat_at: now }); existing.expires_at = extended; } } const { expired, expires_at, remaining_seconds } = computeExpiryInfo(existing.started_at, assessment.time_limit_minutes, existing.expires_at); if (!expired) { return R.success(res, "Session resumed.", { session_id: existing.session_id, started_at: existing.started_at, expires_at: expires_at?.toISOString() ?? null, remaining_seconds, draft_answers: existing.draft_answers ?? {}, }); } await expireSession(existing, assessment.passing_score); } // Cooldown check against all graded attempts const priorAttempts = await QuizAttempt.findAll({ where: { assessment_id: assessmentId, user_id }, attributes: ["attempt_id", "score", "passed", "createdAt"], }); const cooldownStatus = getAttemptStatus(priorAttempts, 'assessment', { maxFails: assessment.max_attempts, cooldownHours: assessment.cooldown_hours, }); if (!cooldownStatus.can_attempt) { return R.error(res, `You're on a ${assessment.cooldown_hours}-hour cooldown. Try again after the cooldown expires.`, 429); } const now = new Date(); const { expires_at, remaining_seconds } = computeExpiryInfo(now, assessment.time_limit_minutes); const newSession = await AssessmentSession.create({ user_id, assessment_id: assessmentId, course_id: courseId, started_at: now, expires_at: expires_at ?? null, status: 'in_progress', }); return R.success(res, "Assessment started.", { session_id: newSession.session_id, started_at: now, expires_at: expires_at?.toISOString() ?? null, remaining_seconds, }); } catch (err) { console.error("[CLIENT][ASSESSMENT][START]", err); return R.error(res, "Could not start assessment.", 500); } }; // ─── ASSESSMENT DRAFT UPSERT ───────────────────────────────────────────────── // Called every ~25s from the client with current answers. // Saves draft_answers + last_heartbeat_at so a crash-resume can restore answers // and extend expires_at by the offline gap. exports.getAssessmentSession = async (req, res) => { try { const { courseId, assessmentId } = req.params; const user_id = req.user.user_id; const [session, assessment] = await Promise.all([ AssessmentSession.findOne({ where: { assessment_id: assessmentId, user_id, course_id: courseId, status: 'in_progress' }, attributes: ['session_id', 'started_at', 'expires_at'], }), CourseAssessment.findOne({ where: { assessment_id: assessmentId, course_id: courseId }, attributes: ['time_limit_minutes'], }), ]); if (!session) return R.error(res, "No active session.", 404); const { expired, expires_at, remaining_seconds } = computeExpiryInfo( session.started_at, assessment?.time_limit_minutes ?? 0, session.expires_at ); if (expired) return R.error(res, "Session expired.", 410); return R.success(res, "Session retrieved.", { session_id: session.session_id, expires_at: expires_at?.toISOString() ?? null, remaining_seconds, }); } catch (err) { console.error("[CLIENT][ASSESSMENT][SESSION]", err); return R.error(res, "Could not get session.", 500); } }; exports.saveDraft = async (req, res) => { try { const { assessmentId } = req.params; const { answers = {} } = req.body; const user_id = req.user.user_id; const session = await AssessmentSession.findOne({ where: { assessment_id: assessmentId, user_id, status: 'in_progress' }, attributes: ["session_id", "expires_at"], }); if (!session) return R.error(res, "No active session.", 404); await session.update({ draft_answers: answers, last_heartbeat_at: new Date(), }); return R.success(res, "Draft saved."); } catch (err) { console.error("[CLIENT][ASSESSMENT][DRAFT]", err); return R.error(res, "Could not save draft.", 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 { 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, }); } 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 = {}, session_id } = 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); let activeSession = null; if (session_id) { activeSession = await AssessmentSession.findOne({ where: { session_id, user_id, assessment_id: assessmentId, status: 'in_progress' }, attributes: ["session_id", "started_at", "assessment_id", "user_id", "course_id"], }); if (!activeSession) return R.error(res, "Session not found or already submitted.", 409); const { expired } = computeExpiryInfo(activeSession.started_at, assessment.time_limit_minutes); if (expired) { await expireSession(activeSession, assessment.passing_score); return R.error(res, "Time limit exceeded — your session has expired.", 410); } } // All graded attempts for cooldown guard + attempt_number const priorAttempts = await QuizAttempt.findAll({ where: { assessment_id: assessmentId, user_id }, attributes: ["attempt_id", "score", "passed", "createdAt"], }); const cooldownStatus = getAttemptStatus(priorAttempts, 'assessment', { maxFails: assessment.max_attempts, cooldownHours: assessment.cooldown_hours, }); if (!cooldownStatus.can_attempt) { return R.error(res, `You've failed ${assessment.max_attempts} times — you're on a ${assessment.cooldown_hours}-hour cooldown. Check the assessment screen for when you can try again.`, 429); } const { totalPoints, earnedPoints, score } = gradeSubmission(assessment.questions ?? [], answers); const passed = score >= (assessment.passing_score ?? 70); const finalAttempt = 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, }); if (activeSession) { await AssessmentSession.update( { status: 'completed', attempt_id: finalAttempt.attempt_id }, { where: { session_id: activeSession.session_id } } ); } let course_completed = false; if (passed) { course_completed = true; const course = await Course.findOne({ where: { course_id: courseId }, attributes: ['course_id', 'uuid', 'title'] }); const totalCompleted = await QuizAttempt.count({ where: { user_id, passed: true, assessment_id: { [Op.ne]: null } }, distinct: true, col: 'assessment_id', }); // Milestone achievements fire immediately (first_course_completed, etc.) await onCourseCompleted(user_id, courseId, totalCompleted, course?.title ?? null); // Queue the certificate for issuance 45 minutes from now const existing = await PendingCertificate.findOne({ where: { user_id, course_id: courseId } }); if (!existing) { await PendingCertificate.create({ user_id, course_id: courseId, course_uuid: course?.uuid ?? '', course_title: course?.title ?? '', passed_at: new Date(), issue_at: new Date(Date.now() + 5 * 60 * 1000), }).catch(err => console.error('[ASSESSMENT] Failed to queue pending certificate:', err)); } // Immediate notification: course completed, certificate incoming UserNotification.create({ user_id, ...NOTIFICATION_REGISTRY.course_completed.build({ courseTitle: course?.title ?? '' }), }).catch(err => console.error('[ASSESSMENT] Failed to emit course_completed notification:', err)); } return R.success(res, "Assessment submitted.", { attempt_id: finalAttempt.attempt_id, attempt_number: finalAttempt.attempt_number, score, passed, passing_score: finalAttempt.passing_score, total_points: totalPoints, earned_points: earnedPoints, 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); if (!await canAccessCourse(req.user.user_id, course.course_id)) { return res.status(403).json({ status: "error", message: "You do not have access to this course.", course: { title: course.title, subscription: course.subscription }, }); } 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", "subscription"] }], }); if (!unit) return R.error(res, "Unit not found.", 404); if (!unit.course) return R.error(res, "Unit has no associated course.", 404); if (!await canAccessCourse(req.user.user_id, unit.course.course_id)) { return res.status(403).json({ status: "error", message: "You do not have access to this course.", course: { title: unit.course.title, subscription: unit.course.subscription }, }); } 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", "subscription"] }, { 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); if (!unit.course) return R.error(res, "Unit has no associated course.", 404); if (!await canAccessCourse(req.user.user_id, unit.course.course_id)) { return res.status(403).json({ status: "error", message: "You do not have access to this course.", course: { title: unit.course.title, subscription: unit.course.subscription }, }); } 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", "subscription"] }], }, ], }); if (!lesson) return R.error(res, "Lesson not found.", 404); if (!lesson.unit) return R.error(res, "Lesson has no associated unit.", 404); if (!lesson.unit.course) return R.error(res, "Unit has no associated course.", 404); if (!await canAccessCourse(req.user.user_id, lesson.unit.course.course_id)) { return res.status(403).json({ status: "error", message: "You do not have access to this course.", course: { title: lesson.unit.course.title, subscription: lesson.unit.course.subscription }, }); } 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); } };