|
|
|
@@ -27,24 +27,119 @@ const {
|
|
|
|
|
Unit, Lesson, LessonPage,
|
|
|
|
|
CourseObjective, LessonObjective,
|
|
|
|
|
CoursePrerequisite, CourseAssessment,
|
|
|
|
|
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt
|
|
|
|
|
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt,
|
|
|
|
|
AssessmentSession,
|
|
|
|
|
} = 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 { 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
|
|
|
|
|
// 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;
|
|
|
|
@@ -122,14 +217,15 @@ exports.getCourses = async (req, res) => {
|
|
|
|
|
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 (plan_tier && plan_tier !== 'free') {
|
|
|
|
|
const reqRank = tierRank[plan_tier] ?? 0;
|
|
|
|
|
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, has_purchased };
|
|
|
|
|
return { ...plain, is_locked, plan_tier: effectiveTier, has_purchased };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return R.success(res, "Courses retrieved.", result);
|
|
|
|
@@ -145,29 +241,9 @@ 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);
|
|
|
|
|
}
|
|
|
|
|
// 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({
|
|
|
|
@@ -258,7 +334,11 @@ exports.getCourse = async (req, res) => {
|
|
|
|
|
}
|
|
|
|
|
plain.is_completed = is_completed;
|
|
|
|
|
|
|
|
|
|
const plan_tier = plan?.tier ?? null;
|
|
|
|
|
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({
|
|
|
|
@@ -272,11 +352,25 @@ exports.getCourse = async (req, res) => {
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
product: product ?? null,
|
|
|
|
|
has_purchased: !!hasPurchase,
|
|
|
|
|
pending_certificate: pendingCert ?? null,
|
|
|
|
|
certificate: certificate ?? null,
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("[CLIENT][COURSES][GET ONE]", err);
|
|
|
|
@@ -386,7 +480,7 @@ exports.getUnitQuiz = async (req, res) => {
|
|
|
|
|
attributes: ["question_id", "uuid", "type", "question", "order_index", "points"],
|
|
|
|
|
include: [{
|
|
|
|
|
model: QuizOption, as: "options",
|
|
|
|
|
attributes: ["option_id", "text", "order_index"],
|
|
|
|
|
attributes: ["option_id", "text", "order_index", "is_correct"],
|
|
|
|
|
}],
|
|
|
|
|
}],
|
|
|
|
|
order: [[{ model: QuizQuestion, as: "questions" }, "order_index", "ASC"]],
|
|
|
|
@@ -402,7 +496,7 @@ exports.getUnitQuiz = async (req, res) => {
|
|
|
|
|
attributes: ["attempt_id", "attempt_number", "score", "passing_score", "passed", "createdAt"],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const status = getAttemptStatus(attempts);
|
|
|
|
|
const status = getAttemptStatus(attempts, 'quiz');
|
|
|
|
|
plain.attempt_count = status.attempt_count;
|
|
|
|
|
plain.has_passed = status.has_passed;
|
|
|
|
|
plain.best_attempt = status.best_attempt;
|
|
|
|
@@ -430,6 +524,7 @@ exports.getCourseAssessment = async (req, res) => {
|
|
|
|
|
"assessment_id", "uuid", "title",
|
|
|
|
|
"is_required", "passing_score",
|
|
|
|
|
"time_limit_minutes", "max_questions",
|
|
|
|
|
"max_attempts", "cooldown_hours",
|
|
|
|
|
],
|
|
|
|
|
include: [{
|
|
|
|
|
model: QuizQuestion, as: "questions",
|
|
|
|
@@ -437,7 +532,7 @@ exports.getCourseAssessment = async (req, res) => {
|
|
|
|
|
attributes: ["question_id", "uuid", "type", "question", "order_index", "points"],
|
|
|
|
|
include: [{
|
|
|
|
|
model: QuizOption, as: "options",
|
|
|
|
|
attributes: ["option_id", "text", "order_index"],
|
|
|
|
|
attributes: ["option_id", "text", "order_index", "is_correct"],
|
|
|
|
|
}],
|
|
|
|
|
}],
|
|
|
|
|
order: [[{ model: QuizQuestion, as: "questions" }, "order_index", "ASC"]],
|
|
|
|
@@ -448,12 +543,40 @@ exports.getCourseAssessment = async (req, res) => {
|
|
|
|
|
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"],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const status = getAttemptStatus(attempts);
|
|
|
|
|
// 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;
|
|
|
|
@@ -469,6 +592,161 @@ exports.getCourseAssessment = async (req, res) => {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ─── 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) => {
|
|
|
|
@@ -496,14 +774,6 @@ exports.submitUnitQuiz = async (req, res) => {
|
|
|
|
|
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);
|
|
|
|
@@ -522,14 +792,13 @@ exports.submitUnitQuiz = async (req, res) => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return R.success(res, "Quiz submitted.", {
|
|
|
|
|
attempt_id: attempt.attempt_id,
|
|
|
|
|
attempt_number: attempt.attempt_number,
|
|
|
|
|
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),
|
|
|
|
|
passing_score: attempt.passing_score,
|
|
|
|
|
total_points: totalPoints,
|
|
|
|
|
earned_points: earnedPoints,
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("[CLIENT][QUIZ][SUBMIT]", err);
|
|
|
|
@@ -540,7 +809,7 @@ exports.submitUnitQuiz = async (req, res) => {
|
|
|
|
|
exports.submitCourseAssessment = async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { courseId, assessmentId } = req.params;
|
|
|
|
|
const { answers = {} } = req.body;
|
|
|
|
|
const { answers = {}, session_id } = req.body;
|
|
|
|
|
const user_id = req.user.user_id;
|
|
|
|
|
|
|
|
|
|
const assessment = await CourseAssessment.findOne({
|
|
|
|
@@ -551,26 +820,41 @@ exports.submitCourseAssessment = async (req, res) => {
|
|
|
|
|
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: assessment.assessment_id, user_id },
|
|
|
|
|
where: { assessment_id: assessmentId, 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 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 attempt = await QuizAttempt.create({
|
|
|
|
|
const finalAttempt = await QuizAttempt.create({
|
|
|
|
|
user_id,
|
|
|
|
|
assessment_id: assessment.assessment_id,
|
|
|
|
|
course_id: courseId,
|
|
|
|
@@ -583,27 +867,54 @@ exports.submitCourseAssessment = async (req, res) => {
|
|
|
|
|
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", "title"] });
|
|
|
|
|
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",
|
|
|
|
|
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: attempt.attempt_id,
|
|
|
|
|
attempt_number: attempt.attempt_number,
|
|
|
|
|
attempt_id: finalAttempt.attempt_id,
|
|
|
|
|
attempt_number: finalAttempt.attempt_number,
|
|
|
|
|
score,
|
|
|
|
|
passed,
|
|
|
|
|
passing_score: attempt.passing_score,
|
|
|
|
|
total_points: totalPoints,
|
|
|
|
|
earned_points: earnedPoints,
|
|
|
|
|
attempts_remaining: Math.max(0, status.attempts_remaining - 1),
|
|
|
|
|
passing_score: finalAttempt.passing_score,
|
|
|
|
|
total_points: totalPoints,
|
|
|
|
|
earned_points: earnedPoints,
|
|
|
|
|
course_completed,
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
@@ -622,6 +933,15 @@ exports.getCourseByUuid = async (req, res) => {
|
|
|
|
|
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);
|
|
|
|
@@ -635,9 +955,19 @@ exports.getUnitByUuid = async (req, res) => {
|
|
|
|
|
const unit = await Unit.findOne({
|
|
|
|
|
where: { uuid, ...notDeleted },
|
|
|
|
|
attributes: ["unit_id", "uuid", "title", "description"],
|
|
|
|
|
include: [{ model: Course, as: "course", attributes: ["course_id", "title"] }],
|
|
|
|
|
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);
|
|
|
|
@@ -652,7 +982,7 @@ exports.getLessonsByUnitUuid = async (req, res) => {
|
|
|
|
|
where: { uuid, ...notDeleted },
|
|
|
|
|
attributes: ["unit_id", "uuid", "title", "description", "order_index"],
|
|
|
|
|
include: [
|
|
|
|
|
{ model: Course, as: "course", attributes: ["course_id", "title"] },
|
|
|
|
|
{ model: Course, as: "course", attributes: ["course_id", "title", "subscription"] },
|
|
|
|
|
{
|
|
|
|
|
model: Lesson,
|
|
|
|
|
as: "lessons",
|
|
|
|
@@ -665,6 +995,15 @@ exports.getLessonsByUnitUuid = async (req, res) => {
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
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) => ({
|
|
|
|
@@ -706,11 +1045,21 @@ exports.getLessonByUuid = async (req, res) => {
|
|
|
|
|
model: Unit,
|
|
|
|
|
as: "unit",
|
|
|
|
|
attributes: ["unit_id", "title", "order_index"],
|
|
|
|
|
include: [{ model: Course, as: "course", attributes: ["course_id", "title"] }],
|
|
|
|
|
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,
|
|
|
|
|