From 9b8577b79bbd2378fbb251884afc6e7a0c6de6f8 Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Wed, 24 Jun 2026 12:48:51 +0800 Subject: [PATCH] perform test test to courses Signed-off-by: Kenneth Obsequio --- controllers/admin/courses.controller.js | 218 +++++++- controllers/admin/task.controller.js | 11 +- controllers/auth.controller.js | 71 ++- controllers/client/certificate.controller.js | 78 +-- controllers/client/courses.controller.js | 495 +++++++++++++++--- controllers/client/profile.controller.js | 2 +- controllers/client/task.controller.js | 5 + .../client/task_progress.controller.js | 5 + cron/client.cron.js | 7 +- cron/jobs/issue_certificates.cron.js | 109 ++++ data/notifications.data.js | 33 +- ...20260101000051-add-needs-intro-to-users.js | 15 + ...260101000052-create-assessment-sessions.js | 27 + ...53-drop-session-cols-from-quiz-attempts.js | 17 + ...000054-add-draft-to-assessment-sessions.js | 20 + ...60101000055-create-pending-certificates.js | 27 + models/courses/assessment_session.mdl.js | 21 + models/courses/course_assessment.mdl.js | 4 +- models/courses/courses.associations.js | 7 + models/courses/pending_certificate.mdl.js | 34 ++ models/users/users.mdl.js | 1 + routes/admin/courses.routes.js | 7 + routes/client/courses.routes.js | 11 +- templates/certificate.typ | 18 +- utils/courses/quiz_security.util.js | 91 ++-- utils/duration.util.js | 6 +- utils/logActivity.util.js | 3 +- 27 files changed, 1122 insertions(+), 221 deletions(-) create mode 100644 cron/jobs/issue_certificates.cron.js create mode 100644 database/migrations/20260101000051-add-needs-intro-to-users.js create mode 100644 database/migrations/20260101000052-create-assessment-sessions.js create mode 100644 database/migrations/20260101000053-drop-session-cols-from-quiz-attempts.js create mode 100644 database/migrations/20260101000054-add-draft-to-assessment-sessions.js create mode 100644 database/migrations/20260101000055-create-pending-certificates.js create mode 100644 models/courses/assessment_session.mdl.js create mode 100644 models/courses/pending_certificate.mdl.js diff --git a/controllers/admin/courses.controller.js b/controllers/admin/courses.controller.js index 4296d91..c401f8b 100644 --- a/controllers/admin/courses.controller.js +++ b/controllers/admin/courses.controller.js @@ -11,6 +11,8 @@ const { archiveOne, archiveMany } = require("../../utils/courses/archive.util"); const { restoreOne, restoreMany } = require("../../utils/courses/restore.util"); const { getFieldValues } = require("../../utils/fieldValues.util"); const logActivity = require('../../utils/logActivity.util'); +const UserNotification = require('../../models/notifications/user_notification.mdl'); +const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); // ── Models ──────────────────────────────────────────────────────────────────── @@ -20,6 +22,7 @@ const { CourseObjective, LessonObjective, CoursePrerequisite, CourseAssessment, UnitQuiz, QuizQuestion, QuizOption, + QuizAttempt, AssessmentSession, CourseInstructor, } = require("../../models/courses/courses.associations"); @@ -1241,7 +1244,7 @@ exports.getAssessment = async (req, res) => { exports.createAssessment = async (req, res) => { try { const { courseId } = req.params; - const { title, is_required, passing_score, time_limit_minutes, max_questions, createdBy } = req.body; + const { title, is_required, passing_score, time_limit_minutes, max_questions, max_attempts, cooldown_hours, createdBy } = req.body; const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted } }); if (!course) return R.error(res, "Course not found.", 404); @@ -1256,6 +1259,8 @@ exports.createAssessment = async (req, res) => { passing_score: passing_score ?? 70, time_limit_minutes: time_limit_minutes ?? null, max_questions: max_questions ?? null, + max_attempts: max_attempts ?? 3, + cooldown_hours: cooldown_hours ?? 24, createdBy: createdBy ?? null, }); @@ -1270,23 +1275,75 @@ exports.createAssessment = async (req, res) => { exports.updateAssessment = async (req, res) => { try { const { courseId, assessmentId } = req.params; - const { title, is_required, passing_score, time_limit_minutes, max_questions, updatedBy } = req.body; + const { title, is_required, passing_score, time_limit_minutes, max_questions, max_attempts, cooldown_hours, updatedBy } = req.body; const assessment = await CourseAssessment.findOne({ where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted }, }); if (!assessment) return R.error(res, "Assessment not found.", 404); - if (title !== undefined) assessment.title = title; - if (is_required !== undefined) assessment.is_required = is_required; - if (passing_score !== undefined) assessment.passing_score = passing_score; + if (title !== undefined) assessment.title = title; + if (is_required !== undefined) assessment.is_required = is_required; + if (passing_score !== undefined) assessment.passing_score = passing_score; if (time_limit_minutes !== undefined) assessment.time_limit_minutes = time_limit_minutes; - if (max_questions !== undefined) assessment.max_questions = max_questions; + if (max_questions !== undefined) assessment.max_questions = max_questions; + if (max_attempts !== undefined) assessment.max_attempts = max_attempts; + if (cooldown_hours !== undefined) assessment.cooldown_hours = cooldown_hours; assessment.updatedBy = updatedBy ?? null; await assessment.save(); logActivity(req.user?.user_id, 'update_assessment', { entityType: 'assessment', entityId: Number(assessmentId) }); + + // Update in-progress sessions + notify affected students + try { + const inProgressSessions = await AssessmentSession.findAll({ + where: { assessment_id: assessmentId, status: 'in_progress' }, + attributes: ['session_id', 'user_id', 'started_at'], + }); + if (inProgressSessions.length > 0) { + // Update expires_at based on new time limit — but never shorten a student's + // remaining time. If the new limit would expire sooner than the current one, + // leave that session untouched. + const newTimeLimitMs = (assessment.time_limit_minutes ?? 0) * 60_000; + await Promise.all( + inProgressSessions.map(s => { + if (newTimeLimitMs === 0) { + // Removing the time limit entirely → always an improvement + return s.update({ expires_at: null }); + } + const candidate = new Date(new Date(s.started_at).getTime() + newTimeLimitMs); + // Only update if the new expiry is later than what they already have + if (s.expires_at && candidate <= new Date(s.expires_at)) return Promise.resolve(); + return s.update({ expires_at: candidate }); + }) + ); + + const course = await Course.findOne({ + where: { course_id: courseId }, + attributes: ['title'], + }); + const notify = NOTIFICATION_REGISTRY.assessment_updated.build({ + assessmentTitle: assessment.title, + courseTitle: course?.title ?? null, + }); + const now = new Date(); + await UserNotification.bulkCreate( + inProgressSessions.map(({ user_id }) => ({ + user_id, + ...notify, + seen: false, + createdAt: now, + updatedAt: now, + })), + { validate: false } + ); + } + } catch (notifyErr) { + // Non-fatal — log but don't fail the update response + console.error('[ASSESSMENT][UPDATE][NOTIFY]', notifyErr); + } + return R.success(res, "Assessment updated.", { data: assessment }); } catch (err) { console.error("[ASSESSMENT][UPDATE]", err); @@ -1504,3 +1561,152 @@ exports.syncInstructors = async (req, res) => { return R.error(res, "Could not update instructors.", 500); } }; + +// ─── COMPLETIONS HELPERS ────────────────────────────────────────────────────── + +function extractUserInfo(user) { + if (!user) return { full_name: null, email: null, avatar_url: null }; + return { + full_name: user.personal_info?.name?.full_name ?? null, + email: user.email ?? null, + avatar_url: user.personal_info?.avatar?.url ?? null, + }; +} + +function groupByUser(attempts) { + const map = new Map(); + for (const a of attempts) { + const uid = String(a.user_id); + if (!map.has(uid)) { + const { full_name, email, avatar_url } = extractUserInfo(a.user); + map.set(uid, { + user_id: a.user_id, + full_name, + email, + avatar_url, + attempt_count: 0, + best_score: 0, + passed: false, + latest_at: null, + attempts: [], + }); + } + const row = map.get(uid); + row.attempt_count += 1; + if (a.score > row.best_score) row.best_score = a.score; + if (a.passed) row.passed = true; + if (!row.latest_at || new Date(a.createdAt) > new Date(row.latest_at)) row.latest_at = a.createdAt; + row.attempts.push({ attempt_id: a.attempt_id, attempt_number: a.attempt_number, score: a.score, earned_points: a.earned_points, total_points: a.total_points, passed: a.passed, createdAt: a.createdAt }); + } + return [...map.values()].sort((a, b) => new Date(b.latest_at) - new Date(a.latest_at)); +} + +function buildSummary(attempts) { + const takers = new Set(attempts.map((a) => String(a.user_id))).size; + const passed = attempts.filter((a) => a.passed).length; + const failed = attempts.length - passed; + const avg = attempts.length ? Math.round(attempts.reduce((s, a) => s + a.score, 0) / attempts.length) : 0; + return { + total_takers: takers, + passed_count: passed, + failed_count: failed, + pass_rate: takers ? Math.round((new Set(attempts.filter((a) => a.passed).map((a) => String(a.user_id))).size / takers) * 100) : 0, + avg_score: avg, + total_attempts: attempts.length, + }; +} + +// ─── QUIZ COMPLETIONS ───────────────────────────────────────────────────────── + +exports.getQuizCompletions = async (req, res) => { + try { + const { quizId } = req.params; + + const attempts = await QuizAttempt.findAll({ + where: { quiz_id: quizId }, + attributes: ["attempt_id", "user_id", "attempt_number", "score", "earned_points", "total_points", "passed", "createdAt"], + include: [{ model: mdl_Users, as: "user", attributes: ["user_id", "email", "personal_info"] }], + order: [["createdAt", "DESC"]], + }); + + const plain = attempts.map((a) => a.toJSON()); + return R.success(res, "Quiz completions retrieved.", { + summary: buildSummary(plain), + completions: groupByUser(plain), + }); + } catch (err) { + console.error("[ADMIN][QUIZ][COMPLETIONS]", err); + return R.error(res, "Could not retrieve quiz completions.", 500); + } +}; + +// ─── ASSESSMENT COMPLETIONS ─────────────────────────────────────────────────── + +exports.getAssessmentCompletions = async (req, res) => { + try { + const { assessmentId } = req.params; + + const attempts = await QuizAttempt.findAll({ + where: { assessment_id: assessmentId }, + attributes: ["attempt_id", "user_id", "attempt_number", "score", "earned_points", "total_points", "passed", "createdAt"], + include: [{ model: mdl_Users, as: "user", attributes: ["user_id", "email", "personal_info"] }], + order: [["createdAt", "DESC"]], + }); + + const plain = attempts.map((a) => a.toJSON()); + return R.success(res, "Assessment completions retrieved.", { + summary: buildSummary(plain), + completions: groupByUser(plain), + }); + } catch (err) { + console.error("[ADMIN][ASSESSMENT][COMPLETIONS]", err); + return R.error(res, "Could not retrieve assessment completions.", 500); + } +}; + +// ─── ASSESSMENT SESSIONS ────────────────────────────────────────────────────── + +exports.getAssessmentSessions = async (req, res) => { + try { + const { assessmentId } = req.params; + + const sessions = await AssessmentSession.findAll({ + where: { assessment_id: assessmentId }, + attributes: ["session_id", "user_id", "status", "started_at", "expires_at", "attempt_id", "createdAt", "updatedAt"], + include: [{ model: mdl_Users, as: "user", attributes: ["user_id", "email", "personal_info"] }], + order: [["createdAt", "DESC"]], + }); + + const rows = sessions.map((s) => { + const j = s.toJSON(); + const { full_name, email, avatar_url } = extractUserInfo(j.user); + const time_spent_seconds = j.status !== 'in_progress' && j.started_at + ? Math.round((new Date(j.updatedAt) - new Date(j.started_at)) / 1000) + : null; + return { + session_id: j.session_id, + user_id: j.user_id, + full_name, + email, + avatar_url, + status: j.status, + started_at: j.started_at, + expires_at: j.expires_at, + time_spent_seconds, + attempt_id: j.attempt_id, + }; + }); + + const summary = { + total_sessions: rows.length, + in_progress_count: rows.filter((r) => r.status === 'in_progress').length, + completed_count: rows.filter((r) => r.status === 'completed').length, + expired_count: rows.filter((r) => r.status === 'expired').length, + }; + + return R.success(res, "Assessment sessions retrieved.", { summary, sessions: rows }); + } catch (err) { + console.error("[ADMIN][ASSESSMENT][SESSIONS]", err); + return R.error(res, "Could not retrieve assessment sessions.", 500); + } +}; diff --git a/controllers/admin/task.controller.js b/controllers/admin/task.controller.js index b9bbdc2..b9a9238 100644 --- a/controllers/admin/task.controller.js +++ b/controllers/admin/task.controller.js @@ -22,6 +22,13 @@ const { archiveOne, archiveMany } = require("../../utils/courses/archive.util"); const { restoreOne, restoreMany } = require("../../utils/courses/restore.util"); const logActivity = require('../../utils/logActivity.util'); +// ─── URL normalizer — ensures protocol prefix so link_url is never a relative path ── +const normalizeUrl = (url) => { + if (!url) return null; + if (/^https?:\/\//i.test(url)) return url; + return `https://${url}`; +}; + // ─── Allowed filter/sort fields ─────────────────────────────────────────────── const TASK_LIST_FIELDS = ['name', 'description', 'createdAt', 'updatedAt', 'deletedAt']; const TASK_FIELDS = ['name', 'description', 'deadline', 'status', 'createdAt', 'updatedAt', 'deletedAt']; @@ -503,7 +510,7 @@ exports.createTask = async (req, res) => { order: r.order ?? i, reference_id: r.reference_id || null, // '' → null (UUID column) reference_label: r.reference_label || null, // '' → null - link_url: r.link_url || null, + link_url: normalizeUrl(r.link_url), link_label: r.link_label || null, createdBy: req.user.user_id, updatedBy: req.user.user_id, @@ -593,7 +600,7 @@ exports.updateTask = async (req, res) => { order: rest.order ?? i, reference_id: rest.reference_id || null, // '' → null (UUID column) reference_label: rest.reference_label || null, // '' → null - link_url: rest.link_url || null, + link_url: normalizeUrl(rest.link_url), link_label: rest.link_label || null, createdBy: req.user.user_id, updatedBy: req.user.user_id, diff --git a/controllers/auth.controller.js b/controllers/auth.controller.js index cd92ef2..8c26bcc 100644 --- a/controllers/auth.controller.js +++ b/controllers/auth.controller.js @@ -70,11 +70,15 @@ exports.register = async (req, res) => { }); if (!group) return R.error(res, 'Invalid or inactive group code.', 400); } - + + // ── Resolve enroll target (explicit group or NOGRP fallback) ────────────── + const enrollGroup = group + ?? await mdl_UserGroups.findOne({ where: { group_code: 'NOGRP', is_active: true } }); + // ── Create user ─────────────────────────────────────────────────────────── const hashed = await bcrypt.hash(password, 12); const otp = generateOTP(); - + const user = await mdl_Users.create({ email, password: hashed, @@ -85,23 +89,24 @@ exports.register = async (req, res) => { reg_type: 'system', acc_type: 'user', personal_info: personal_info ?? null, + needs_intro: true, createdBy: null, }, { transaction }); - + // ── Enroll into group ───────────────────────────────────────────────────── - if (group) { + if (enrollGroup) { await mdl_UserGroupMembers.create({ - group_id: group.group_id, + group_id: enrollGroup.group_id, user_id: user.user_id, createdBy: null, }, { transaction }); } - + await sendEmail({ to: email, type: 'OTP', data: { otp } }); - + await transaction.commit(); - // Fire-and-forget: notify admins about the new group registration + // Fire-and-forget: notify admins only for explicit group code registrations if (group) { AdminNotification.create({ ...NOTIFICATION_REGISTRY.user_registration.build({ @@ -318,21 +323,43 @@ exports.googleCallback = async (req, res) => { // Find or auto-create the user. let user = await mdl_Users.findOne({ where: { email: payload.email } }); if (!user) { - user = await mdl_Users.create({ - email: payload.email, - reg_type: 'google', - acc_type: 'user', - is_active: true, - is_verified: true, - personal_info: { - name: { - given_name: payload.given_name ?? '', - last_name: payload.family_name ?? '', - full_name: payload.name ?? '', + const t = await sequelize.transaction(); + try { + user = await mdl_Users.create({ + email: payload.email, + reg_type: 'google', + acc_type: 'user', + is_active: true, + is_verified: true, + needs_intro: true, + personal_info: { + name: { + given_name: payload.given_name ?? '', + last_name: payload.family_name ?? '', + full_name: payload.name ?? '', + }, + avatar: { url: payload.picture ?? null }, }, - avatar: { url: payload.picture ?? null }, - }, - }); + }, { transaction: t }); + + const noGrp = await mdl_UserGroups.findOne({ where: { group_code: 'NOGRP', is_active: true }, transaction: t }); + if (noGrp) { + await mdl_UserGroupMembers.create({ + group_id: noGrp.group_id, + user_id: user.user_id, + createdBy: null, + }, { transaction: t }); + } + + await t.commit(); + + // Fire-and-forget: achievements + welcome notification for new Google user + onUserRegistered(user.user_id) + .catch(err => console.error('[AUTH] googleCallback: Failed to grant achievements:', err)); + } catch (err) { + await t.rollback(); + throw err; + } } if (!user.is_active) { diff --git a/controllers/client/certificate.controller.js b/controllers/client/certificate.controller.js index ac9dbe4..a3e4327 100644 --- a/controllers/client/certificate.controller.js +++ b/controllers/client/certificate.controller.js @@ -11,13 +11,10 @@ ***********************************************************************************************************************************************************************/ 'use strict'; -const R = require('../../utils/response.util'); -const mdl_Users = require('../../models/users/users.mdl'); -const mdl_Achievements = require('../../models/users/achievements.mdl'); -const { generateCertificate } = require('../../services/certificate.service'); -const { formatDuration } = require('../../utils/duration.util'); -const UserNotification = require('../../models/notifications/user_notification.mdl'); -const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); +const R = require('../../utils/response.util'); +const mdl_Users = require('../../models/users/users.mdl'); +const { generateCertificate } = require('../../services/certificate.service'); +const { formatDuration } = require('../../utils/duration.util'); const { Course, @@ -113,17 +110,20 @@ exports.getCertificate = async (req, res) => { const fullName = user?.personal_info?.name?.full_name?.trim() || 'Participant'; // ── 4. Resolve or create the certificate record ──────────────────────────── - const [cert, created] = await Certificate.findOrCreate({ - where: { user_id, course_id: course.course_id }, - defaults: { + // CockroachDB does not support findOrCreate (uses temp PL/pgSQL functions). + let cert = await Certificate.findOne({ where: { user_id, course_id: course.course_id } }); + if (!cert) { + cert = await Certificate.create({ + user_id, + course_id: course.course_id, cert_no: await buildCertNo(user_id), ref_no: await buildRefNo(), instructors: formatInstructors(course.instructors ?? []), score: passedAttempt.score ?? null, length_str: formatDuration(course.duration_seconds), issued_at: passedAttempt.createdAt, - }, - }); + }); + } // Always use live instructors from course_instructors table for the PDF. // Keep the snapshot in sync so it reflects the current state. @@ -132,57 +132,15 @@ exports.getCertificate = async (req, res) => { await cert.update({ instructors: liveInstructors }); } - // ── 5. On first issue: fire notification + achievements ──────────────────── - if (created) { - // Certificate issued notification - UserNotification.create({ - user_id, - ...NOTIFICATION_REGISTRY.certificate_issued.build({ - courseTitle: course.title, - courseUuid, - }), - }).catch(err => console.error('[CERTIFICATE] Failed to emit notification:', err)); - - // Per-course completion achievement - mdl_Achievements.findOrCreate({ - where: { user_id, key: `course_completed_${courseUuid}` }, - defaults: { - type: 'milestone', - label: 'Certificate of Completion', - description: course.title, - granted_at: passedAttempt.createdAt, - metadata: { courseTitle: course.title, courseUuid }, - }, - }).catch(err => console.error('[CERTIFICATE] Failed to grant course achievement:', err)); - - // First-course achievement (only if this is their very first certificate) - const totalCerts = await Certificate.count({ where: { user_id } }); - if (totalCerts === 1) { - mdl_Achievements.findOrCreate({ - where: { user_id, key: 'first_course_completed' }, - defaults: { - type: 'milestone', - label: 'First Course Completed', - description: 'Completed your very first course on Philproperties.', - granted_at: passedAttempt.createdAt, - metadata: { courseTitle: course.title, courseUuid }, - }, - }).catch(err => console.error('[CERTIFICATE] Failed to grant first-course achievement:', err)); - } - } - - // ── 6. Format issued date as MM/DD/YY HH:MM AM/PM ──────────────────────── + // ── 5. Format issued date as MM/DD/YY HH:MM AM/PM ─────────────────────────── const issuedDate = new Date(cert.issued_at); const dateStr = new Intl.DateTimeFormat('en-US', { - month: '2-digit', - day: '2-digit', - year: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12: true, + month: 'long', + day: 'numeric', + year: 'numeric', }).format(issuedDate); - // ── 7. Generate PDF ──────────────────────────────────────────────────────── + // ── 6. Generate PDF ──────────────────────────────────────────────────────── const pdf = await generateCertificate({ name: fullName, course: course.title, @@ -193,7 +151,7 @@ exports.getCertificate = async (req, res) => { length: cert.length_str ?? '', }); - // ── 8. Stream response ───────────────────────────────────────────────────── + // ── 7. Stream response ───────────────────────────────────────────────────── const nameParts = fullName.trim().split(/\s+/); const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : nameParts[0]; const firstName = nameParts.length > 1 ? nameParts.slice(0, -1).join(' ') : ''; diff --git a/controllers/client/courses.controller.js b/controllers/client/courses.controller.js index 916f12d..78da4d5 100644 --- a/controllers/client/courses.controller.js +++ b/controllers/client/courses.controller.js @@ -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, diff --git a/controllers/client/profile.controller.js b/controllers/client/profile.controller.js index 991b2ae..d8c4f7a 100644 --- a/controllers/client/profile.controller.js +++ b/controllers/client/profile.controller.js @@ -51,7 +51,7 @@ exports.updateProfile = async (req, res) => { }, }; - await user.update({ personal_info: merged }); + await user.update({ personal_info: merged, needs_intro: false }); const updated = await mdl_Users.findByPk(req.user.user_id, { attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, diff --git a/controllers/client/task.controller.js b/controllers/client/task.controller.js index 2437759..1c4e4fc 100644 --- a/controllers/client/task.controller.js +++ b/controllers/client/task.controller.js @@ -23,6 +23,9 @@ const { clientExclude } = require('../../models/task/task_completion.attributes' const logActivity = require('../../utils/logActivity.util'); const R = require('../../utils/response.util'); +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const isUUID = (v) => UUID_RE.test(v); + // ============================================================================= // ── GROUPS ──────────────────────────────────────────────────────────────────── // ============================================================================= @@ -420,6 +423,8 @@ exports.getTask = async (req, res) => { try { const { groupId, taskListId, taskId } = req.params; + if (!isUUID(taskId) || !isUUID(taskListId)) return R.error(res, 'Task not found.', 404); + const member = await isMember(req.user.user_id, groupId); if (!member) return R.error(res, 'Group not found or you are not a member.', 403); diff --git a/controllers/client/task_progress.controller.js b/controllers/client/task_progress.controller.js index 83e4a37..6db721d 100644 --- a/controllers/client/task_progress.controller.js +++ b/controllers/client/task_progress.controller.js @@ -27,6 +27,9 @@ const { mdl_UserGroupMembers } = require('../../models/users/use const logActivity = require('../../utils/logActivity.util'); const R = require('../../utils/response.util'); +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const isUUID = (v) => UUID_RE.test(v); + // ─── Helper: verify user is member of group ─────────────────────────────────── const isMember = async (userId, groupId) => { const membership = await mdl_UserGroupMembers.findOne({ @@ -86,6 +89,8 @@ exports.getTaskProgress = async (req, res) => { try { const { groupId, taskListId, taskId } = req.params; + if (!isUUID(taskId) || !isUUID(taskListId)) return R.error(res, 'Task not found.', 404); + const member = await isMember(req.user.user_id, groupId); if (!member) return R.error(res, 'Group not found or you are not a member.', 403); diff --git a/cron/client.cron.js b/cron/client.cron.js index d3ce269..364319a 100644 --- a/cron/client.cron.js +++ b/cron/client.cron.js @@ -6,17 +6,20 @@ * { name, schedule, run }, listed in the `jobs` array below. * * Currently registered: - * - userNotifications (cron/jobs/user_notifications.cron.js) + * - userNotifications (cron/jobs/user_notifications.cron.js) + * - issueCertificates (cron/jobs/issue_certificates.cron.js) * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 17, 2026 ***********************************************************************************************************************************************************************/ const cron = require('node-cron'); -const userNotifications = require('./jobs/user_notifications.cron'); +const userNotifications = require('./jobs/user_notifications.cron'); +const issueCertificates = require('./jobs/issue_certificates.cron'); // ─── Registry — add future client-side cron jobs here ──────────────────────── const jobs = [ userNotifications, + issueCertificates, ]; // ─── Boot all registered client-side jobs ───────────────────────────────────── diff --git a/cron/jobs/issue_certificates.cron.js b/cron/jobs/issue_certificates.cron.js new file mode 100644 index 0000000..e1b249a --- /dev/null +++ b/cron/jobs/issue_certificates.cron.js @@ -0,0 +1,109 @@ +/*********************************************************************************************************************************************************************** + * File Name : issue_certificates.cron.js + * Type : Cron Job + * Description : Issues certificates for users who passed a course assessment + * 45 minutes ago. Runs every 5 minutes and processes any + * pending_certificates row where issue_at <= NOW() and + * processed_at IS NULL. + * + * For each ready row it: + * 1. Grants the course_completed_ achievement (the key + * MyCertificates / Profile use to display certificate cards). + * 2. Sends a 'certificate_issued' UserNotification. + * 3. Marks the row processed_at = NOW() so it never fires again. + * + * Safety pattern: processed_at is set only after both step 1 and + * step 2 succeed. If the process restarts mid-run the row will be + * picked up again on the next tick — both DB writes are idempotent. + * + * Schedule : Every hour at minute 5 ("5 * * * *"). Registered by + * cron/client.cron.js. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jun. 24, 2026 + ***********************************************************************************************************************************************************************/ +'use strict'; + +const { Op } = require('sequelize'); +const PendingCertificate = require('../../models/courses/pending_certificate.mdl'); +const mdl_Achievements = require('../../models/users/achievements.mdl'); +const UserNotification = require('../../models/notifications/user_notification.mdl'); +const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); + +const DELAY_MS = 5 * 60 * 1000; // 5 minutes + +async function run() { + // ── 1. Fetch all rows ready to process ──────────────────────────────────── + let rows; + try { + rows = await PendingCertificate.findAll({ + where: { + issue_at: { [Op.lte]: new Date() }, + processed_at: null, + }, + raw: true, + }); + } catch (err) { + console.error('[CRON][ISSUE CERTS] Failed to query pending_certificates:', err); + return; + } + + if (rows.length === 0) return; + + console.log(`[CRON][ISSUE CERTS] Processing ${rows.length} pending certificate(s).`); + + for (const row of rows) { + const { pending_id, user_id, course_uuid, course_title } = row; + const achKey = `course_completed_${course_uuid}`; + + try { + // ── 2. Grant course_completed_ achievement (idempotent) ────── + const existing = await mdl_Achievements.findOne({ where: { user_id, key: achKey } }); + if (!existing) { + await mdl_Achievements.create({ + user_id, + type: 'milestone', + key: achKey, + label: 'Certificate of Completion', + description: course_title ?? '', + granted_at: new Date(), + metadata: { courseTitle: course_title, courseUuid: course_uuid }, + }); + } + + // ── 3. Send certificate_issued notification ──────────────────────── + await UserNotification.create({ + user_id, + ...NOTIFICATION_REGISTRY.certificate_issued.build({ + courseTitle: course_title ?? '', + courseUuid: course_uuid, + }), + }); + + // ── 4. Mark row processed ───────────────────────────────────────── + await PendingCertificate.update( + { processed_at: new Date() }, + { where: { pending_id } } + ); + + console.log(`[CRON][ISSUE CERTS] Issued certificate for user ${user_id} / course ${course_uuid}.`); + } catch (err) { + // Log and continue — next tick will retry this row + if (err?.parent?.code !== '23505') { + console.error(`[CRON][ISSUE CERTS] Failed for pending_id ${pending_id}:`, err); + } else { + // Unique constraint: achievement already exists — still mark processed + await PendingCertificate.update( + { processed_at: new Date() }, + { where: { pending_id } } + ).catch(() => {}); + } + } + } +} + +module.exports = { + name: 'issueCertificates', + schedule: '5 * * * *', + run, +}; diff --git a/data/notifications.data.js b/data/notifications.data.js index 434760b..6e078d8 100644 --- a/data/notifications.data.js +++ b/data/notifications.data.js @@ -21,7 +21,7 @@ * Current types: * Admin : task_overdue * User : user_task_overdue, achievement, course_unlocked, - * certificate_issued, task_reminder, announcement + * course_completed, certificate_issued, task_reminder, announcement * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 19, 2026 @@ -128,10 +128,24 @@ const NOTIFICATION_REGISTRY = { }, }, - certificate_issued: { + course_completed: { type: 'course', scope: 'user', trigger: 'event', + build({ courseTitle }) { + return { + type: 'course', + title: 'Course Completed', + message: `Great job! You've completed "${courseTitle}". Your certificate is being prepared and will be ready in about 5 minutes.`, + data: { courseTitle }, + }; + }, + }, + + certificate_issued: { + type: 'course', + scope: 'user', + trigger: 'cron', build({ courseTitle, courseUuid }) { return { type: 'course', @@ -162,6 +176,21 @@ const NOTIFICATION_REGISTRY = { }, }, + // ── Assessment ──────────────────────────────────────────────────────────── + assessment_updated: { + type: 'assessment', + scope: 'user', + trigger: 'event', + build({ assessmentTitle, courseTitle }) { + return { + type: 'assessment', + title: 'Assessment Updated', + message: `The administrator has updated the "${assessmentTitle || 'Course Assessment'}" in "${courseTitle || 'your course'}". Your current session is still valid — continue where you left off.`, + data: { assessmentTitle, courseTitle }, + }; + }, + }, + // ── Platform ────────────────────────────────────────────────────────────── announcement: { type: 'announcement', diff --git a/database/migrations/20260101000051-add-needs-intro-to-users.js b/database/migrations/20260101000051-add-needs-intro-to-users.js new file mode 100644 index 0000000..d0c8e8d --- /dev/null +++ b/database/migrations/20260101000051-add-needs-intro-to-users.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn('users', 'needs_intro', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, // existing users skip intro + after: 'acc_type', + }); + }, + down: async (queryInterface) => { + await queryInterface.removeColumn('users', 'needs_intro'); + }, +}; diff --git a/database/migrations/20260101000052-create-assessment-sessions.js b/database/migrations/20260101000052-create-assessment-sessions.js new file mode 100644 index 0000000..02b8da3 --- /dev/null +++ b/database/migrations/20260101000052-create-assessment-sessions.js @@ -0,0 +1,27 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('assessment_sessions', { + session_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, unique: true, allowNull: false }, + user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' }, + assessment_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'course_assessments', key: 'assessment_id' }, onDelete: 'CASCADE' }, + course_id: { type: Sequelize.BIGINT, allowNull: true }, + started_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }, + expires_at: { type: Sequelize.DATE, allowNull: true }, + status: { type: Sequelize.STRING(20), allowNull: false, defaultValue: 'in_progress' }, + attempt_id: { type: Sequelize.BIGINT, allowNull: true }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + + await queryInterface.addIndex('assessment_sessions', ['user_id'], { name: 'idx_as_user_id' }); + await queryInterface.addIndex('assessment_sessions', ['assessment_id'], { name: 'idx_as_assessment_id' }); + await queryInterface.addIndex('assessment_sessions', ['status'], { name: 'idx_as_status' }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('assessment_sessions'); + }, +}; diff --git a/database/migrations/20260101000053-drop-session-cols-from-quiz-attempts.js b/database/migrations/20260101000053-drop-session-cols-from-quiz-attempts.js new file mode 100644 index 0000000..7f8df48 --- /dev/null +++ b/database/migrations/20260101000053-drop-session-cols-from-quiz-attempts.js @@ -0,0 +1,17 @@ +'use strict'; + +// quiz_attempts.started_at and quiz_attempts.status were added via raw SQL +// during an earlier development session. The assessment_sessions table now owns +// session lifecycle; quiz_attempts are always-graded completed records only. +module.exports = { + async up(queryInterface, Sequelize) { + const tableDesc = await queryInterface.describeTable('quiz_attempts'); + if (tableDesc.started_at) await queryInterface.removeColumn('quiz_attempts', 'started_at'); + if (tableDesc.status) await queryInterface.removeColumn('quiz_attempts', 'status'); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.addColumn('quiz_attempts', 'started_at', { type: Sequelize.DATE, allowNull: true }); + await queryInterface.addColumn('quiz_attempts', 'status', { type: Sequelize.STRING(20), allowNull: true }); + }, +}; diff --git a/database/migrations/20260101000054-add-draft-to-assessment-sessions.js b/database/migrations/20260101000054-add-draft-to-assessment-sessions.js new file mode 100644 index 0000000..52f52df --- /dev/null +++ b/database/migrations/20260101000054-add-draft-to-assessment-sessions.js @@ -0,0 +1,20 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + const table = await queryInterface.describeTable('assessment_sessions'); + if (!table.draft_answers) + await queryInterface.addColumn('assessment_sessions', 'draft_answers', { + type: Sequelize.JSONB, allowNull: true, defaultValue: null, + }); + if (!table.last_heartbeat_at) + await queryInterface.addColumn('assessment_sessions', 'last_heartbeat_at', { + type: Sequelize.DATE, allowNull: true, defaultValue: null, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('assessment_sessions', 'last_heartbeat_at'); + await queryInterface.removeColumn('assessment_sessions', 'draft_answers'); + }, +}; diff --git a/database/migrations/20260101000055-create-pending-certificates.js b/database/migrations/20260101000055-create-pending-certificates.js new file mode 100644 index 0000000..0cd5305 --- /dev/null +++ b/database/migrations/20260101000055-create-pending-certificates.js @@ -0,0 +1,27 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('pending_certificates', { + pending_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' }, + course_id: { type: Sequelize.BIGINT, allowNull: false }, + course_uuid: { type: Sequelize.STRING(36), allowNull: false }, + course_title: { type: Sequelize.TEXT, allowNull: true }, + passed_at: { type: Sequelize.DATE, allowNull: false }, + issue_at: { type: Sequelize.DATE, allowNull: false }, + processed_at: { type: Sequelize.DATE, allowNull: true }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + + await queryInterface.addIndex('pending_certificates', ['user_id'], { name: 'idx_pc_user_id' }); + await queryInterface.addIndex('pending_certificates', ['issue_at'], { name: 'idx_pc_issue_at' }); + await queryInterface.addIndex('pending_certificates', ['processed_at'], { name: 'idx_pc_processed_at' }); + await queryInterface.addIndex('pending_certificates', ['user_id', 'course_id'], { name: 'idx_pc_user_course', unique: true }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('pending_certificates'); + }, +}; diff --git a/models/courses/assessment_session.mdl.js b/models/courses/assessment_session.mdl.js new file mode 100644 index 0000000..b52933a --- /dev/null +++ b/models/courses/assessment_session.mdl.js @@ -0,0 +1,21 @@ +const { DataTypes } = require("sequelize"); +const sequelize = require("../../config/db.config"); + +const AssessmentSession = sequelize.define("AssessmentSession", { + session_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, unique: true }, + user_id: { type: DataTypes.BIGINT, allowNull: false }, + assessment_id: { type: DataTypes.BIGINT, allowNull: false }, + course_id: { type: DataTypes.BIGINT, allowNull: true }, + started_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + expires_at: { type: DataTypes.DATE, allowNull: true }, // null = no time limit + status: { type: DataTypes.STRING(20), allowNull: false, defaultValue: 'in_progress' }, // 'in_progress' | 'completed' | 'expired' + attempt_id: { type: DataTypes.BIGINT, allowNull: true }, // FK → quiz_attempts once graded/expired + draft_answers: { type: DataTypes.JSONB, allowNull: true, defaultValue: null }, + last_heartbeat_at: { type: DataTypes.DATE, allowNull: true, defaultValue: null }, +}, { + tableName: "assessment_sessions", + timestamps: true, +}); + +module.exports = AssessmentSession; diff --git a/models/courses/course_assessment.mdl.js b/models/courses/course_assessment.mdl.js index 5dca457..5f73035 100644 --- a/models/courses/course_assessment.mdl.js +++ b/models/courses/course_assessment.mdl.js @@ -9,7 +9,9 @@ const CourseAssessment = sequelize.define("CourseAssessment", { is_required: { type: DataTypes.BOOLEAN, defaultValue: false }, passing_score: { type: DataTypes.INTEGER, defaultValue: 70 }, time_limit_minutes: { type: DataTypes.INTEGER, allowNull: true }, // null = no limit - max_questions: { type: DataTypes.INTEGER, allowNull: true }, // null = show all + max_questions: { type: DataTypes.INTEGER, allowNull: true }, // null = show all + max_attempts: { type: DataTypes.INTEGER, defaultValue: 3 }, // failed attempts before cooldown + cooldown_hours: { type: DataTypes.INTEGER, defaultValue: 24 }, // hours locked after hitting max_attempts createdBy: { type: DataTypes.BIGINT, allowNull: true }, updatedBy: { type: DataTypes.BIGINT, allowNull: true }, deletedBy: { type: DataTypes.BIGINT, allowNull: true }, diff --git a/models/courses/courses.associations.js b/models/courses/courses.associations.js index e6d3229..bffc2b1 100644 --- a/models/courses/courses.associations.js +++ b/models/courses/courses.associations.js @@ -13,6 +13,7 @@ const QuizQuestion = require("./quiz_question.mdl"); const QuizOption = require("./quiz_option.mdl"); const mdl_Users = require("../users/users.mdl"); const QuizAttempt = require("./quiz_attempt.mdl"); +const AssessmentSession = require("./assessment_session.mdl"); const mdl_Category = require("./categories.mdl"); const Certificate = require("./certificate.mdl"); const CourseInstructor = require("./course_instructor.mdl"); @@ -79,12 +80,18 @@ QuizAttempt.belongsTo(mdl_Users, { as: "user", foreignKey: "user_id" }); UnitQuiz.hasMany(QuizAttempt, { as: "attempts", foreignKey: "quiz_id" }); CourseAssessment.hasMany(QuizAttempt, { as: "attempts", foreignKey: "assessment_id" }); +// ── AssessmentSession ───────────────────────────────────────────────────────── +AssessmentSession.belongsTo(CourseAssessment, { as: "assessment", foreignKey: "assessment_id" }); +AssessmentSession.belongsTo(mdl_Users, { as: "user", foreignKey: "user_id" }); +CourseAssessment.hasMany(AssessmentSession, { as: "sessions", foreignKey: "assessment_id" }); + module.exports = { Course, CourseProductCategory, Unit, Lesson, LessonPage, CourseObjective, LessonObjective, CoursePrerequisite, CourseAssessment, UnitQuiz, QuizQuestion, QuizOption, QuizAttempt, + AssessmentSession, mdl_Category, Certificate, CourseInstructor, CourseReadingProgress, }; \ No newline at end of file diff --git a/models/courses/pending_certificate.mdl.js b/models/courses/pending_certificate.mdl.js new file mode 100644 index 0000000..bb1a9db --- /dev/null +++ b/models/courses/pending_certificate.mdl.js @@ -0,0 +1,34 @@ +/*********************************************************************************************************************************************************************** + * File Name: pending_certificate.mdl.js + * Type of Program: Model + * Description: Holds certificates queued for issuance after a 45-minute delay + * following a passed course assessment. The cron job + * (cron/jobs/issue_certificates.cron.js) polls this table every + * 5 minutes and processes rows where issue_at <= NOW(). + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jun. 24, 2026 + ***********************************************************************************************************************************************************************/ +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); + +const PendingCertificate = sequelize.define('PendingCertificate', { + pending_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + user_id: { type: DataTypes.BIGINT, allowNull: false }, + course_id: { type: DataTypes.BIGINT, allowNull: false }, + course_uuid: { type: DataTypes.STRING(36), allowNull: false }, + course_title: { type: DataTypes.TEXT, allowNull: true }, + passed_at: { type: DataTypes.DATE, allowNull: false }, + issue_at: { type: DataTypes.DATE, allowNull: false }, + processed_at: { type: DataTypes.DATE, allowNull: true }, +}, { + tableName: 'pending_certificates', + timestamps: true, + indexes: [ + { fields: ['user_id'] }, + { fields: ['issue_at'] }, + { unique: true, fields: ['user_id', 'course_id'] }, + ], +}); + +module.exports = PendingCertificate; diff --git a/models/users/users.mdl.js b/models/users/users.mdl.js index 80dc1fd..7998b3c 100644 --- a/models/users/users.mdl.js +++ b/models/users/users.mdl.js @@ -28,6 +28,7 @@ const mdl_Users = sequelize.define('User', { * - "admin" → All endpoints */ acc_type: { type: DataTypes.ENUM('admin', 'staff', 'user'), defaultValue: 'user', label: "Account Type", order: 9 }, + needs_intro: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Needs Intro" }, /** * personal_info JSONB structure: * { diff --git a/routes/admin/courses.routes.js b/routes/admin/courses.routes.js index b84a3e5..8d0d5c1 100644 --- a/routes/admin/courses.routes.js +++ b/routes/admin/courses.routes.js @@ -73,6 +73,10 @@ router.patch("/:courseId/assessment/:assessmentId/questions/:questionId", ctrl.u router.delete("/:courseId/assessment/:assessmentId/questions/:questionId", ctrl.deleteQuestion); router.patch("/:courseId/assessment/:assessmentId/questions/:questionId/restore", ctrl.restoreQuestion); +// ── Assessment Completions & Sessions ───────────────────────────────────────── +router.get("/:courseId/assessment/:assessmentId/completions", ctrl.getAssessmentCompletions); +router.get("/:courseId/assessment/:assessmentId/sessions", ctrl.getAssessmentSessions); + // ══════════════════════════════════════════════════════════════════════════════ // UNITS // ══════════════════════════════════════════════════════════════════════════════ @@ -119,6 +123,9 @@ router.patch("/:courseId/units/:unitId/quiz/:quizId/questions/:questionId", ctrl router.delete("/:courseId/units/:unitId/quiz/:quizId/questions/:questionId", ctrl.deleteQuestion); router.patch("/:courseId/units/:unitId/quiz/:quizId/questions/:questionId/restore", ctrl.restoreQuestion); +// ── Quiz Completions ────────────────────────────────────────────────────────── +router.get("/:courseId/units/:unitId/quiz/:quizId/completions", ctrl.getQuizCompletions); + // ══════════════════════════════════════════════════════════════════════════════ // LESSONS // ══════════════════════════════════════════════════════════════════════════════ diff --git a/routes/client/courses.routes.js b/routes/client/courses.routes.js index 96d6f41..7b22e41 100644 --- a/routes/client/courses.routes.js +++ b/routes/client/courses.routes.js @@ -25,11 +25,14 @@ router.get('/:courseId/units/:unitId/lessons/:lessonId', ctrl.getLesson); // Quiz (no answers) router.get('/:courseId/units/:unitId/quiz', ctrl.getUnitQuiz); -// Assessment (no answers) -router.get('/:courseId/assessment', ctrl.getCourseAssessment); +// Assessment +router.get( '/:courseId/assessment', ctrl.getCourseAssessment); +router.post('/:courseId/assessment/:assessmentId/start', ctrl.startCourseAssessment); +router.get( '/:courseId/assessment/:assessmentId/session', ctrl.getAssessmentSession); +router.patch('/:courseId/assessment/:assessmentId/draft', ctrl.saveDraft); -router.post('/:courseId/units/:unitId/quiz/:quizId/submit', ctrl.submitUnitQuiz); -router.post('/:courseId/assessment/:assessmentId/submit', ctrl.submitCourseAssessment); +router.post('/:courseId/units/:unitId/quiz/:quizId/submit', ctrl.submitUnitQuiz); +router.post('/:courseId/assessment/:assessmentId/submit', ctrl.submitCourseAssessment); // Reading progress router.get( '/:courseId/progress/summary', progressCtrl.getCourseProgressSummary); diff --git a/templates/certificate.typ b/templates/certificate.typ index 4c7cae0..757cb16 100644 --- a/templates/certificate.typ +++ b/templates/certificate.typ @@ -44,16 +44,22 @@ #v(12pt) -// ─── Instructors ────────────────────────────────────────────────────────────── -#text(size: 13pt, fill: rgb("#555555"))[Instructors] -#linebreak() -#text(size: 15pt, weight: "bold")[#instructors] +// ─── Instructors (only when provided) ──────────────────────────────────────── +#if instructors != "" [ + #text(size: 13pt, fill: rgb("#555555"))[Instructors] + #linebreak() + #text(size: 15pt, weight: "bold")[#instructors] +] #v(1fr) -// ─── Bottom: recipient, date, length ────────────────────────────────────────── +// ─── Bottom: recipient, date, duration, issuer ──────────────────────────────── #text(size: 15pt, weight: "bold")[#recipient] #linebreak() #text(size: 12pt)[*Date Issued:* #date_str] #linebreak() -#text(size: 12pt)[*Length:* #length_str] +#if length_str != "" and length_str != "0 mins" [ + #text(size: 12pt)[*Duration:* #length_str] + #linebreak() +] +#text(size: 12pt)[*Issued by:* Philproperties] diff --git a/utils/courses/quiz_security.util.js b/utils/courses/quiz_security.util.js index e5ecd44..4cc9ad2 100644 --- a/utils/courses/quiz_security.util.js +++ b/utils/courses/quiz_security.util.js @@ -1,7 +1,7 @@ -// This will do mandatory call -const MAX_ATTEMPTS = 10; -const ATTEMPT_WINDOW_HOURS = 24; -const COOLDOWN_MINUTES = 60; +// Assessment cooldown policy is now stored per-assessment in the DB (max_attempts / cooldown_hours). +// These fallbacks are used only if values are missing (e.g. legacy rows before the migration). +const ASSESSMENT_FAILS_BEFORE_COOLDOWN = 3; +const ASSESSMENT_COOLDOWN_HOURS = 24; // Fisher-Yates shuffle of each question's options. Pure — returns new // arrays/objects, never mutates input. Grading is unaffected since @@ -19,49 +19,64 @@ function shuffleOptions(questions) { } // Single source of truth for both the GET-time info fields and the -// submit-time enforcement check. Doesn't care about input order — -// derives best/most-recent itself, so callers can just fetch attempts -// with no ORDER BY. -function getAttemptStatus(attempts) { - const now = new Date(); - const windowStart = new Date(now.getTime() - ATTEMPT_WINDOW_HOURS * 60 * 60 * 1000); - const attemptsInWindow = attempts.filter((a) => new Date(a.createdAt) >= windowStart); - - const attempt_count = attempts.length; // lifetime — still used for has_passed/best_attempt - const has_passed = attempts.some((a) => a.passed); - - const best_attempt = attempts.reduce( +// submit-time enforcement check. +// +// type = 'quiz' → unit quizzes: no cooldown, no attempt cap, always open +// type = 'assessment' → course assessments: maxFails failed attempts → cooldownHours cooldown (rolling cycles) +// maxFails / cooldownHours come from the assessment row; fallback to the constants above. +function getAttemptStatus(attempts, type = 'quiz', { maxFails, cooldownHours } = {}) { + const attempt_count = attempts.length; + const has_passed = attempts.some((a) => a.passed); + const best_attempt = attempts.reduce( (best, a) => (!best || a.score > best.score ? a : best), null ); - const most_recent = attempts.reduce( - (latest, a) => (!latest || new Date(a.createdAt) > new Date(latest.createdAt) ? a : latest), - null - ); - - let cooldown_until = null; - if (most_recent) { - const unlockAt = new Date(new Date(most_recent.createdAt).getTime() + COOLDOWN_MINUTES * 60000); - if (unlockAt > now) cooldown_until = unlockAt.toISOString(); + if (type === 'quiz') { + return { + attempt_count, + has_passed, + best_attempt, + attempts_remaining: null, + cooldown_until: null, + window_reset_at: null, + can_attempt: true, + }; } - const attempts_remaining = Math.max(0, MAX_ATTEMPTS - attemptsInWindow.length); + // Assessment: simulate rolling cycles — N failed attempts → cooldown (from the assessment's own config) + const failLimit = maxFails ?? ASSESSMENT_FAILS_BEFORE_COOLDOWN; + const lockHours = cooldownHours ?? ASSESSMENT_COOLDOWN_HOURS; - let window_reset_at = null; - if (attempts_remaining === 0 && attemptsInWindow.length > 0) { - const oldestInWindow = attemptsInWindow.reduce( - (oldest, a) => (!oldest || new Date(a.createdAt) < new Date(oldest.createdAt) ? a : oldest), - null - ); - window_reset_at = new Date( - new Date(oldestInWindow.createdAt).getTime() + ATTEMPT_WINDOW_HOURS * 60 * 60 * 1000 - ).toISOString(); + const sorted = [...attempts].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)); + + let cycle_end = null; // when the current cooldown expires (null = no active or past cooldown) + let failed_in_cycle = 0; + + for (const a of sorted) { + // Skip attempts that fall inside a previous cooldown window (they shouldn't exist, but guard anyway) + if (cycle_end && new Date(a.createdAt) < cycle_end) continue; + if (!a.passed) { + failed_in_cycle++; + if (failed_in_cycle >= failLimit) { + cycle_end = new Date(new Date(a.createdAt).getTime() + lockHours * 3600000); + failed_in_cycle = 0; + } + } } - const can_attempt = attempts_remaining > 0 && !cooldown_until; + const now = new Date(); + const cooldown_until = (cycle_end && cycle_end > now) ? cycle_end.toISOString() : null; - return { attempt_count, has_passed, best_attempt, attempts_remaining, cooldown_until, window_reset_at, can_attempt }; + return { + attempt_count, + has_passed, + best_attempt, + attempts_remaining: null, + cooldown_until, + window_reset_at: null, + can_attempt: !cooldown_until, + }; } -module.exports = { MAX_ATTEMPTS, ATTEMPT_WINDOW_HOURS, COOLDOWN_MINUTES, shuffleOptions, getAttemptStatus }; \ No newline at end of file +module.exports = { ASSESSMENT_FAILS_BEFORE_COOLDOWN, ASSESSMENT_COOLDOWN_HOURS, shuffleOptions, getAttemptStatus }; \ No newline at end of file diff --git a/utils/duration.util.js b/utils/duration.util.js index 67bed58..42a07be 100644 --- a/utils/duration.util.js +++ b/utils/duration.util.js @@ -103,9 +103,9 @@ function formatDuration(seconds) { const s = seconds % 60; const parts = []; - if (h) parts.push(`${h} hr`); - if (m) parts.push(`${m} min${m !== 1 ? "s" : ""}`); - if (!h && !m && s) parts.push(`${s} sec${s !== 1 ? "s" : ""}`); + if (h) parts.push(`${h} hour${h !== 1 ? "s" : ""}`); + if (m) parts.push(`${m} minute${m !== 1 ? "s" : ""}`); + if (!h && !m && s) parts.push(`${s} second${s !== 1 ? "s" : ""}`); return parts.join(" "); } diff --git a/utils/logActivity.util.js b/utils/logActivity.util.js index 2078afe..88fa184 100644 --- a/utils/logActivity.util.js +++ b/utils/logActivity.util.js @@ -12,11 +12,12 @@ const mdl_UserActivity = require('../models/users/user_activity.mdl'); * @param {Object} [opts.details] — free-form JSONB context */ function logActivity(userId, action, { entityType = null, entityId = null, sessionId = null, details = null } = {}) { + const numericId = entityId !== null ? Number(entityId) : null; mdl_UserActivity.create({ user_id: userId, action, entity_type: entityType, - entity_id: entityId, + entity_id: Number.isFinite(numericId) ? numericId : null, session_id: sessionId, details, }).catch((err) => console.error('[USER_ACTIVITY] Failed to log:', action, err.message));