pushy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-28 11:29:07 +08:00
parent 463a8d3978
commit 89acdfc239
67 changed files with 2736 additions and 306 deletions
+87 -60
View File
@@ -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;