mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
727 lines
26 KiB
JavaScript
727 lines
26 KiB
JavaScript
/***********************************************************************************************************************************************************************
|
|
* 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);
|
|
}
|
|
}; |