mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -16,8 +16,6 @@
|
||||
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");
|
||||
@@ -28,10 +26,11 @@ const {
|
||||
CourseObjective, LessonObjective,
|
||||
CoursePrerequisite, CourseAssessment,
|
||||
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt,
|
||||
AssessmentSession,
|
||||
AssessmentSession, QuizSession,
|
||||
} = 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 mdl_TierCategories = require("../../models/tiers/tier_categories.mdl");
|
||||
const { gradeSubmission } = require("../../utils/courses/grading.util");
|
||||
const { shuffleOptions, shuffleQuestions, getAttemptStatus } = 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');
|
||||
@@ -78,37 +77,33 @@ async function expireSession(session, passingScore) {
|
||||
return expiredAttempt;
|
||||
}
|
||||
|
||||
// Builds minimal user context: active tier slug + live tier rank map
|
||||
async function buildUserContext(user_id) {
|
||||
const activeTier = await getActiveTier(user_id);
|
||||
const tier = activeTier?.tier ?? 'free';
|
||||
|
||||
const categories = await mdl_TierCategories.findAll({ attributes: ['slug', 'rank'] });
|
||||
const tierRankMap = {};
|
||||
for (const c of categories) tierRankMap[c.slug] = c.rank;
|
||||
|
||||
return { tier, tierRankMap };
|
||||
}
|
||||
|
||||
// ─── 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';
|
||||
const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription'] });
|
||||
const requiredTier = course?.subscription ?? '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';
|
||||
}
|
||||
const userCtx = await buildUserContext(user_id);
|
||||
|
||||
if (requiredTier === 'free') return true;
|
||||
// Rank-0 slugs (default/free tier) are always accessible — resolved dynamically
|
||||
const courseRank = userCtx.tierRankMap[requiredTier] ?? Infinity;
|
||||
if (courseRank === 0) 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;
|
||||
const userRank = userCtx.tierRankMap[userCtx.tier] ?? 0;
|
||||
if (userRank >= courseRank) return true;
|
||||
|
||||
// Individual purchase as fallback
|
||||
const product = await mdl_Product.findOne({ where: { course_id } });
|
||||
@@ -160,10 +155,8 @@ 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;
|
||||
const userCtx = await buildUserContext(req.user.user_id);
|
||||
const userTier = userCtx.tier;
|
||||
|
||||
// Fetch all completed purchases for this user (for has_purchased check)
|
||||
const myPurchases = await mdl_CoursePurchase.findAll({
|
||||
@@ -192,13 +185,6 @@ exports.getCourses = async (req, res) => {
|
||||
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',
|
||||
@@ -211,21 +197,17 @@ exports.getCourses = async (req, res) => {
|
||||
order: [['order_index', 'ASC'], ['title', 'ASC']],
|
||||
});
|
||||
|
||||
const userRank = userCtx.tierRankMap[userCtx.tier] ?? 0;
|
||||
|
||||
const result = courses.map((c) => {
|
||||
const plain = c.toJSON();
|
||||
const planCourse = plain.planCourse;
|
||||
const plan_tier = planCourse?.plan?.tier ?? null;
|
||||
const plain = c.toJSON();
|
||||
const has_purchased = purchasedCourseIds.has(String(plain.course_id));
|
||||
const subscription = plain.subscription ?? 'free';
|
||||
const courseRank = userCtx.tierRankMap[subscription] ?? Infinity;
|
||||
|
||||
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;
|
||||
}
|
||||
const is_locked = courseRank > 0 && !has_purchased && userRank < courseRank;
|
||||
|
||||
delete plain.planCourse;
|
||||
return { ...plain, is_locked, plan_tier: effectiveTier, has_purchased };
|
||||
return { ...plain, is_locked, has_purchased };
|
||||
});
|
||||
|
||||
return R.success(res, "Courses retrieved.", result);
|
||||
@@ -331,14 +313,11 @@ exports.getCourse = async (req, res) => {
|
||||
where: { assessment_id: plain.assessment.assessment_id, user_id: req.user.user_id, passed: true },
|
||||
});
|
||||
is_completed = !!passedAttempt;
|
||||
plain.assessment = { ...plain.assessment, has_passed: is_completed };
|
||||
}
|
||||
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;
|
||||
const plan_tier = plain.subscription ?? null;
|
||||
|
||||
// Attach product info and purchase status for the buy-course flow
|
||||
const product = await mdl_Product.findOne({
|
||||
@@ -472,7 +451,7 @@ exports.getUnitQuiz = async (req, res) => {
|
||||
where: { unit_id: unitId, ...notDeleted },
|
||||
attributes: [
|
||||
"quiz_id", "uuid", "title",
|
||||
"is_required", "passing_score", "max_questions",
|
||||
"is_required", "passing_score", "max_questions", "shuffle_questions",
|
||||
],
|
||||
include: [{
|
||||
model: QuizQuestion, as: "questions",
|
||||
@@ -489,7 +468,9 @@ exports.getUnitQuiz = async (req, res) => {
|
||||
if (!quiz) return R.error(res, "Quiz not found.", 404);
|
||||
|
||||
const plain = quiz.toJSON();
|
||||
plain.questions = shuffleOptions(sanitizeQuestions(plain.questions ?? []));
|
||||
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 },
|
||||
@@ -505,6 +486,12 @@ exports.getUnitQuiz = async (req, res) => {
|
||||
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][QUIZ][GET]", err);
|
||||
@@ -524,7 +511,7 @@ exports.getCourseAssessment = async (req, res) => {
|
||||
"assessment_id", "uuid", "title",
|
||||
"is_required", "passing_score",
|
||||
"time_limit_minutes", "max_questions",
|
||||
"max_attempts", "cooldown_hours",
|
||||
"max_attempts", "cooldown_hours", "shuffle_questions",
|
||||
],
|
||||
include: [{
|
||||
model: QuizQuestion, as: "questions",
|
||||
@@ -541,7 +528,9 @@ exports.getCourseAssessment = async (req, res) => {
|
||||
if (!assessment) return R.error(res, "Assessment not found.", 404);
|
||||
|
||||
const plain = assessment.toJSON();
|
||||
plain.questions = shuffleOptions(sanitizeQuestions(plain.questions ?? []));
|
||||
let qs = sanitizeQuestions(plain.questions ?? []);
|
||||
if (plain.shuffle_questions) qs = shuffleQuestions(qs);
|
||||
plain.questions = shuffleOptions(qs);
|
||||
|
||||
// All graded attempts for cooldown/status calc
|
||||
const attempts = await QuizAttempt.findAll({
|
||||
@@ -791,6 +780,12 @@ exports.submitUnitQuiz = async (req, res) => {
|
||||
passed,
|
||||
});
|
||||
|
||||
// Close any open draft session for this quiz
|
||||
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,
|
||||
@@ -806,6 +801,38 @@ exports.submitUnitQuiz = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── QUIZ DRAFT UPSERT ────────────────────────────────────────────────────────
|
||||
|
||||
exports.saveQuizDraft = async (req, res) => {
|
||||
try {
|
||||
const { courseId, unitId, quizId } = req.params;
|
||||
const { answers = {} } = req.body;
|
||||
const user_id = req.user.user_id;
|
||||
|
||||
// Update all in_progress sessions for this user+quiz (handles any duplicates gracefully)
|
||||
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: courseId,
|
||||
unit_id: unitId,
|
||||
draft_answers: answers,
|
||||
started_at: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(204).end();
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][QUIZ][DRAFT]", err);
|
||||
return R.error(res, "Could not save quiz draft.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.submitCourseAssessment = async (req, res) => {
|
||||
try {
|
||||
const { courseId, assessmentId } = req.params;
|
||||
|
||||
Reference in New Issue
Block a user