perform test

test to courses

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-24 12:48:51 +08:00
parent 358fecb510
commit 9b8577b79b
27 changed files with 1122 additions and 221 deletions
+208 -2
View File
@@ -11,6 +11,8 @@ const { archiveOne, archiveMany } = require("../../utils/courses/archive.util");
const { restoreOne, restoreMany } = require("../../utils/courses/restore.util"); const { restoreOne, restoreMany } = require("../../utils/courses/restore.util");
const { getFieldValues } = require("../../utils/fieldValues.util"); const { getFieldValues } = require("../../utils/fieldValues.util");
const logActivity = require('../../utils/logActivity.util'); const logActivity = require('../../utils/logActivity.util');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
// ── Models ──────────────────────────────────────────────────────────────────── // ── Models ────────────────────────────────────────────────────────────────────
@@ -20,6 +22,7 @@ const {
CourseObjective, LessonObjective, CourseObjective, LessonObjective,
CoursePrerequisite, CourseAssessment, CoursePrerequisite, CourseAssessment,
UnitQuiz, QuizQuestion, QuizOption, UnitQuiz, QuizQuestion, QuizOption,
QuizAttempt, AssessmentSession,
CourseInstructor, CourseInstructor,
} = require("../../models/courses/courses.associations"); } = require("../../models/courses/courses.associations");
@@ -1241,7 +1244,7 @@ exports.getAssessment = async (req, res) => {
exports.createAssessment = async (req, res) => { exports.createAssessment = async (req, res) => {
try { try {
const { courseId } = req.params; 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 } }); const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted } });
if (!course) return R.error(res, "Course not found.", 404); if (!course) return R.error(res, "Course not found.", 404);
@@ -1256,6 +1259,8 @@ exports.createAssessment = async (req, res) => {
passing_score: passing_score ?? 70, passing_score: passing_score ?? 70,
time_limit_minutes: time_limit_minutes ?? null, time_limit_minutes: time_limit_minutes ?? null,
max_questions: max_questions ?? null, max_questions: max_questions ?? null,
max_attempts: max_attempts ?? 3,
cooldown_hours: cooldown_hours ?? 24,
createdBy: createdBy ?? null, createdBy: createdBy ?? null,
}); });
@@ -1270,7 +1275,7 @@ exports.createAssessment = async (req, res) => {
exports.updateAssessment = async (req, res) => { exports.updateAssessment = async (req, res) => {
try { try {
const { courseId, assessmentId } = req.params; 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({ const assessment = await CourseAssessment.findOne({
where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted }, where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted },
@@ -1282,11 +1287,63 @@ exports.updateAssessment = async (req, res) => {
if (passing_score !== undefined) assessment.passing_score = passing_score; if (passing_score !== undefined) assessment.passing_score = passing_score;
if (time_limit_minutes !== undefined) assessment.time_limit_minutes = time_limit_minutes; 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; assessment.updatedBy = updatedBy ?? null;
await assessment.save(); await assessment.save();
logActivity(req.user?.user_id, 'update_assessment', { entityType: 'assessment', entityId: Number(assessmentId) }); 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 }); return R.success(res, "Assessment updated.", { data: assessment });
} catch (err) { } catch (err) {
console.error("[ASSESSMENT][UPDATE]", err); console.error("[ASSESSMENT][UPDATE]", err);
@@ -1504,3 +1561,152 @@ exports.syncInstructors = async (req, res) => {
return R.error(res, "Could not update instructors.", 500); 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);
}
};
+9 -2
View File
@@ -22,6 +22,13 @@ const { archiveOne, archiveMany } = require("../../utils/courses/archive.util");
const { restoreOne, restoreMany } = require("../../utils/courses/restore.util"); const { restoreOne, restoreMany } = require("../../utils/courses/restore.util");
const logActivity = require('../../utils/logActivity.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 ─────────────────────────────────────────────── // ─── Allowed filter/sort fields ───────────────────────────────────────────────
const TASK_LIST_FIELDS = ['name', 'description', 'createdAt', 'updatedAt', 'deletedAt']; const TASK_LIST_FIELDS = ['name', 'description', 'createdAt', 'updatedAt', 'deletedAt'];
const TASK_FIELDS = ['name', 'description', 'deadline', 'status', '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, order: r.order ?? i,
reference_id: r.reference_id || null, // '' → null (UUID column) reference_id: r.reference_id || null, // '' → null (UUID column)
reference_label: r.reference_label || null, // '' → null 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, link_label: r.link_label || null,
createdBy: req.user.user_id, createdBy: req.user.user_id,
updatedBy: req.user.user_id, updatedBy: req.user.user_id,
@@ -593,7 +600,7 @@ exports.updateTask = async (req, res) => {
order: rest.order ?? i, order: rest.order ?? i,
reference_id: rest.reference_id || null, // '' → null (UUID column) reference_id: rest.reference_id || null, // '' → null (UUID column)
reference_label: rest.reference_label || null, // '' → null 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, link_label: rest.link_label || null,
createdBy: req.user.user_id, createdBy: req.user.user_id,
updatedBy: req.user.user_id, updatedBy: req.user.user_id,
+31 -4
View File
@@ -71,6 +71,10 @@ exports.register = async (req, res) => {
if (!group) return R.error(res, 'Invalid or inactive group code.', 400); 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 ─────────────────────────────────────────────────────────── // ── Create user ───────────────────────────────────────────────────────────
const hashed = await bcrypt.hash(password, 12); const hashed = await bcrypt.hash(password, 12);
const otp = generateOTP(); const otp = generateOTP();
@@ -85,13 +89,14 @@ exports.register = async (req, res) => {
reg_type: 'system', reg_type: 'system',
acc_type: 'user', acc_type: 'user',
personal_info: personal_info ?? null, personal_info: personal_info ?? null,
needs_intro: true,
createdBy: null, createdBy: null,
}, { transaction }); }, { transaction });
// ── Enroll into group ───────────────────────────────────────────────────── // ── Enroll into group ─────────────────────────────────────────────────────
if (group) { if (enrollGroup) {
await mdl_UserGroupMembers.create({ await mdl_UserGroupMembers.create({
group_id: group.group_id, group_id: enrollGroup.group_id,
user_id: user.user_id, user_id: user.user_id,
createdBy: null, createdBy: null,
}, { transaction }); }, { transaction });
@@ -101,7 +106,7 @@ exports.register = async (req, res) => {
await transaction.commit(); 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) { if (group) {
AdminNotification.create({ AdminNotification.create({
...NOTIFICATION_REGISTRY.user_registration.build({ ...NOTIFICATION_REGISTRY.user_registration.build({
@@ -318,12 +323,15 @@ exports.googleCallback = async (req, res) => {
// Find or auto-create the user. // Find or auto-create the user.
let user = await mdl_Users.findOne({ where: { email: payload.email } }); let user = await mdl_Users.findOne({ where: { email: payload.email } });
if (!user) { if (!user) {
const t = await sequelize.transaction();
try {
user = await mdl_Users.create({ user = await mdl_Users.create({
email: payload.email, email: payload.email,
reg_type: 'google', reg_type: 'google',
acc_type: 'user', acc_type: 'user',
is_active: true, is_active: true,
is_verified: true, is_verified: true,
needs_intro: true,
personal_info: { personal_info: {
name: { name: {
given_name: payload.given_name ?? '', given_name: payload.given_name ?? '',
@@ -332,7 +340,26 @@ exports.googleCallback = async (req, res) => {
}, },
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) { if (!user.is_active) {
+13 -55
View File
@@ -13,11 +13,8 @@
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const mdl_Users = require('../../models/users/users.mdl'); const mdl_Users = require('../../models/users/users.mdl');
const mdl_Achievements = require('../../models/users/achievements.mdl');
const { generateCertificate } = require('../../services/certificate.service'); const { generateCertificate } = require('../../services/certificate.service');
const { formatDuration } = require('../../utils/duration.util'); const { formatDuration } = require('../../utils/duration.util');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const { const {
Course, Course,
@@ -113,17 +110,20 @@ exports.getCertificate = async (req, res) => {
const fullName = user?.personal_info?.name?.full_name?.trim() || 'Participant'; const fullName = user?.personal_info?.name?.full_name?.trim() || 'Participant';
// ── 4. Resolve or create the certificate record ──────────────────────────── // ── 4. Resolve or create the certificate record ────────────────────────────
const [cert, created] = await Certificate.findOrCreate({ // CockroachDB does not support findOrCreate (uses temp PL/pgSQL functions).
where: { user_id, course_id: course.course_id }, let cert = await Certificate.findOne({ where: { user_id, course_id: course.course_id } });
defaults: { if (!cert) {
cert = await Certificate.create({
user_id,
course_id: course.course_id,
cert_no: await buildCertNo(user_id), cert_no: await buildCertNo(user_id),
ref_no: await buildRefNo(), ref_no: await buildRefNo(),
instructors: formatInstructors(course.instructors ?? []), instructors: formatInstructors(course.instructors ?? []),
score: passedAttempt.score ?? null, score: passedAttempt.score ?? null,
length_str: formatDuration(course.duration_seconds), length_str: formatDuration(course.duration_seconds),
issued_at: passedAttempt.createdAt, issued_at: passedAttempt.createdAt,
},
}); });
}
// Always use live instructors from course_instructors table for the PDF. // Always use live instructors from course_instructors table for the PDF.
// Keep the snapshot in sync so it reflects the current state. // 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 }); await cert.update({ instructors: liveInstructors });
} }
// ── 5. On first issue: fire notification + achievements ──────────────────── // ── 5. Format issued date as MM/DD/YY HH:MM AM/PM ───────────────────────────
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 ────────────────────────
const issuedDate = new Date(cert.issued_at); const issuedDate = new Date(cert.issued_at);
const dateStr = new Intl.DateTimeFormat('en-US', { const dateStr = new Intl.DateTimeFormat('en-US', {
month: '2-digit', month: 'long',
day: '2-digit', day: 'numeric',
year: '2-digit', year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true,
}).format(issuedDate); }).format(issuedDate);
// ── 7. Generate PDF ──────────────────────────────────────────────────────── // ── 6. Generate PDF ────────────────────────────────────────────────────────
const pdf = await generateCertificate({ const pdf = await generateCertificate({
name: fullName, name: fullName,
course: course.title, course: course.title,
@@ -193,7 +151,7 @@ exports.getCertificate = async (req, res) => {
length: cert.length_str ?? '', length: cert.length_str ?? '',
}); });
// ── 8. Stream response ───────────────────────────────────────────────────── // ── 7. Stream response ─────────────────────────────────────────────────────
const nameParts = fullName.trim().split(/\s+/); const nameParts = fullName.trim().split(/\s+/);
const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : nameParts[0]; const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : nameParts[0];
const firstName = nameParts.length > 1 ? nameParts.slice(0, -1).join(' ') : ''; const firstName = nameParts.length > 1 ? nameParts.slice(0, -1).join(' ') : '';
+413 -64
View File
@@ -27,24 +27,119 @@ const {
Unit, Lesson, LessonPage, Unit, Lesson, LessonPage,
CourseObjective, LessonObjective, CourseObjective, LessonObjective,
CoursePrerequisite, CourseAssessment, CoursePrerequisite, CourseAssessment,
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt UnitQuiz, QuizQuestion, QuizOption, QuizAttempt,
AssessmentSession,
} = require("../../models/courses/courses.associations"); } = require("../../models/courses/courses.associations");
const { gradeSubmission } = require("../../utils/courses/grading.util"); const { gradeSubmission } = require("../../utils/courses/grading.util");
const { shuffleOptions, getAttemptStatus, MAX_ATTEMPTS } = require("../../utils/courses/quiz_security.util"); const { shuffleOptions, getAttemptStatus, ASSESSMENT_FAILS_BEFORE_COOLDOWN, ASSESSMENT_COOLDOWN_HOURS } = require("../../utils/courses/quiz_security.util");
const { onCourseCompleted } = require('../../services/achievements.service') 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 }; 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 = [ const COURSE_LIST_ATTRS = [
"course_id", "uuid", "title", "description", "course_id", "uuid", "title", "description",
"course_code", "level", "subscription", "course_code", "level", "subscription",
"duration_seconds", "order_index", "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 = []) { function sanitizeQuestions(questions = []) {
return questions.map((q) => { return questions.map((q) => {
const plain = q.toJSON ? q.toJSON() : { ...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); plain.options = (plain.options ?? []).map(({ is_correct: _drop, ...o }) => o);
delete plain.explanation; delete plain.explanation;
return plain; return plain;
@@ -122,14 +217,15 @@ exports.getCourses = async (req, res) => {
const plan_tier = planCourse?.plan?.tier ?? null; const plan_tier = planCourse?.plan?.tier ?? null;
const has_purchased = purchasedCourseIds.has(String(plain.course_id)); const has_purchased = purchasedCourseIds.has(String(plain.course_id));
const effectiveTier = plan_tier || plain.subscription || 'free';
let is_locked = false; let is_locked = false;
if (plan_tier && plan_tier !== 'free') { if (effectiveTier && effectiveTier !== 'free') {
const reqRank = tierRank[plan_tier] ?? 0; const reqRank = tierRank[effectiveTier] ?? 0;
if (userRank < reqRank && !has_purchased) is_locked = true; if (userRank < reqRank && !has_purchased) is_locked = true;
} }
delete plain.planCourse; 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); return R.success(res, "Courses retrieved.", result);
@@ -145,29 +241,9 @@ exports.getCourse = async (req, res) => {
try { try {
const { courseId } = req.params; const { courseId } = req.params;
// Access check — tier OR individual purchase // Access check — plan association → subscription field → individual purchase
const activeTier = await getActiveTier(req.user.user_id); if (!await canAccessCourse(req.user.user_id, courseId)) {
const planCourse = await mdl_PlanCourses.findOne({ where: { course_id: courseId } }); return R.error(res, "You do not have access to this course.", 403);
let plan = null;
if (planCourse) {
plan = await mdl_TierPlans.findByPk(planCourse.plan_id, { attributes: ['tier'] });
const requiredTier = plan?.tier ?? 'free';
const tierRank = { free: 0, premium: 1, exclusive: 2 };
const userRank = tierRank[activeTier?.tier ?? 'free'] ?? 0;
const reqRank = tierRank[requiredTier] ?? 0;
if (userRank < reqRank) {
// Check individual purchase as fallback
const product = await mdl_Product.findOne({ where: { course_id: courseId } });
const hasPurchase = product && await mdl_CoursePurchase.findOne({
where: {
user_id: req.user.user_id, product_id: product.id, status: 'completed',
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
},
});
if (!hasPurchase) return R.error(res, "You do not have access to this course.", 403);
}
} }
const course = await Course.findOne({ const course = await Course.findOne({
@@ -258,7 +334,11 @@ exports.getCourse = async (req, res) => {
} }
plain.is_completed = is_completed; 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 // Attach product info and purchase status for the buy-course flow
const product = await mdl_Product.findOne({ 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.", { return R.success(res, "Course retrieved.", {
...plain, ...plain,
plan_tier, plan_tier,
product: product ?? null, product: product ?? null,
has_purchased: !!hasPurchase, has_purchased: !!hasPurchase,
pending_certificate: pendingCert ?? null,
certificate: certificate ?? null,
}); });
} catch (err) { } catch (err) {
console.error("[CLIENT][COURSES][GET ONE]", 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"], attributes: ["question_id", "uuid", "type", "question", "order_index", "points"],
include: [{ include: [{
model: QuizOption, as: "options", 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"]], 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"], 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.attempt_count = status.attempt_count;
plain.has_passed = status.has_passed; plain.has_passed = status.has_passed;
plain.best_attempt = status.best_attempt; plain.best_attempt = status.best_attempt;
@@ -430,6 +524,7 @@ exports.getCourseAssessment = async (req, res) => {
"assessment_id", "uuid", "title", "assessment_id", "uuid", "title",
"is_required", "passing_score", "is_required", "passing_score",
"time_limit_minutes", "max_questions", "time_limit_minutes", "max_questions",
"max_attempts", "cooldown_hours",
], ],
include: [{ include: [{
model: QuizQuestion, as: "questions", model: QuizQuestion, as: "questions",
@@ -437,7 +532,7 @@ exports.getCourseAssessment = async (req, res) => {
attributes: ["question_id", "uuid", "type", "question", "order_index", "points"], attributes: ["question_id", "uuid", "type", "question", "order_index", "points"],
include: [{ include: [{
model: QuizOption, as: "options", 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"]], order: [[{ model: QuizQuestion, as: "questions" }, "order_index", "ASC"]],
@@ -448,12 +543,40 @@ exports.getCourseAssessment = async (req, res) => {
const plain = assessment.toJSON(); const plain = assessment.toJSON();
plain.questions = shuffleOptions(sanitizeQuestions(plain.questions ?? [])); plain.questions = shuffleOptions(sanitizeQuestions(plain.questions ?? []));
// All graded attempts for cooldown/status calc
const attempts = await QuizAttempt.findAll({ const attempts = await QuizAttempt.findAll({
where: { assessment_id: assessment.assessment_id, user_id: req.user.user_id }, where: { assessment_id: assessment.assessment_id, user_id: req.user.user_id },
attributes: ["attempt_id", "attempt_number", "score", "passing_score", "passed", "createdAt"], 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.attempt_count = status.attempt_count;
plain.has_passed = status.has_passed; plain.has_passed = status.has_passed;
plain.best_attempt = status.best_attempt; 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 ────────────────────────────────────────────────────────────── // ─── QUIZ SUBMIT ──────────────────────────────────────────────────────────────
exports.submitUnitQuiz = async (req, res) => { exports.submitUnitQuiz = async (req, res) => {
@@ -496,14 +774,6 @@ exports.submitUnitQuiz = async (req, res) => {
where: { quiz_id: quiz.quiz_id, user_id }, where: { quiz_id: quiz.quiz_id, user_id },
attributes: ["attempt_id", "score", "passed", "createdAt"], 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 { totalPoints, earnedPoints, score } = gradeSubmission(quiz.questions ?? [], answers);
const passed = score >= (quiz.passing_score ?? 70); const passed = score >= (quiz.passing_score ?? 70);
@@ -529,7 +799,6 @@ exports.submitUnitQuiz = async (req, res) => {
passing_score: attempt.passing_score, passing_score: attempt.passing_score,
total_points: totalPoints, total_points: totalPoints,
earned_points: earnedPoints, earned_points: earnedPoints,
attempts_remaining: Math.max(0, status.attempts_remaining - 1),
}); });
} catch (err) { } catch (err) {
console.error("[CLIENT][QUIZ][SUBMIT]", err); console.error("[CLIENT][QUIZ][SUBMIT]", err);
@@ -540,7 +809,7 @@ exports.submitUnitQuiz = async (req, res) => {
exports.submitCourseAssessment = async (req, res) => { exports.submitCourseAssessment = async (req, res) => {
try { try {
const { courseId, assessmentId } = req.params; const { courseId, assessmentId } = req.params;
const { answers = {} } = req.body; const { answers = {}, session_id } = req.body;
const user_id = req.user.user_id; const user_id = req.user.user_id;
const assessment = await CourseAssessment.findOne({ const assessment = await CourseAssessment.findOne({
@@ -551,26 +820,41 @@ exports.submitCourseAssessment = async (req, res) => {
include: [{ model: QuizOption, as: "options" }], include: [{ model: QuizOption, as: "options" }],
}], }],
}); });
if (!assessment) return R.error(res, "Assessment not found.", 404); 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({ 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"], attributes: ["attempt_id", "score", "passed", "createdAt"],
}); });
const status = getAttemptStatus(priorAttempts); const cooldownStatus = getAttemptStatus(priorAttempts, 'assessment', {
maxFails: assessment.max_attempts,
if (!status.can_attempt) { cooldownHours: assessment.cooldown_hours,
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); 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);
return R.error(res, `You've used all ${MAX_ATTEMPTS} attempts for this ${ATTEMPT_WINDOW_HOURS}-hour window — check the quiz screen for when it resets.`, 429);
} }
const { totalPoints, earnedPoints, score } = gradeSubmission(assessment.questions ?? [], answers); const { totalPoints, earnedPoints, score } = gradeSubmission(assessment.questions ?? [], answers);
const passed = score >= (assessment.passing_score ?? 70); const passed = score >= (assessment.passing_score ?? 70);
const attempt = await QuizAttempt.create({ const finalAttempt = await QuizAttempt.create({
user_id, user_id,
assessment_id: assessment.assessment_id, assessment_id: assessment.assessment_id,
course_id: courseId, course_id: courseId,
@@ -583,27 +867,54 @@ exports.submitCourseAssessment = async (req, res) => {
passed, passed,
}); });
if (activeSession) {
await AssessmentSession.update(
{ status: 'completed', attempt_id: finalAttempt.attempt_id },
{ where: { session_id: activeSession.session_id } }
);
}
let course_completed = false; let course_completed = false;
if (passed) { if (passed) {
course_completed = true; 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({ const totalCompleted = await QuizAttempt.count({
where: { user_id, passed: true, assessment_id: { [Op.ne]: null } }, where: { user_id, passed: true, assessment_id: { [Op.ne]: null } },
distinct: true, 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); 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.", { return R.success(res, "Assessment submitted.", {
attempt_id: attempt.attempt_id, attempt_id: finalAttempt.attempt_id,
attempt_number: attempt.attempt_number, attempt_number: finalAttempt.attempt_number,
score, score,
passed, passed,
passing_score: attempt.passing_score, passing_score: finalAttempt.passing_score,
total_points: totalPoints, total_points: totalPoints,
earned_points: earnedPoints, earned_points: earnedPoints,
attempts_remaining: Math.max(0, status.attempts_remaining - 1),
course_completed, course_completed,
}); });
} catch (err) { } catch (err) {
@@ -622,6 +933,15 @@ exports.getCourseByUuid = async (req, res) => {
attributes: ["course_id", "uuid", "title", "description", "level", "subscription"], attributes: ["course_id", "uuid", "title", "description", "level", "subscription"],
}); });
if (!course) return R.error(res, "Course not found.", 404); 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); return R.success(res, "Course retrieved.", course);
} catch (err) { } catch (err) {
console.error("[CLIENT][COURSES][BY UUID]", err); console.error("[CLIENT][COURSES][BY UUID]", err);
@@ -635,9 +955,19 @@ exports.getUnitByUuid = async (req, res) => {
const unit = await Unit.findOne({ const unit = await Unit.findOne({
where: { uuid, ...notDeleted }, where: { uuid, ...notDeleted },
attributes: ["unit_id", "uuid", "title", "description"], 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) 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); return R.success(res, "Unit retrieved.", unit);
} catch (err) { } catch (err) {
console.error("[CLIENT][UNITS][BY UUID]", err); console.error("[CLIENT][UNITS][BY UUID]", err);
@@ -652,7 +982,7 @@ exports.getLessonsByUnitUuid = async (req, res) => {
where: { uuid, ...notDeleted }, where: { uuid, ...notDeleted },
attributes: ["unit_id", "uuid", "title", "description", "order_index"], attributes: ["unit_id", "uuid", "title", "description", "order_index"],
include: [ include: [
{ model: Course, as: "course", attributes: ["course_id", "title"] }, { model: Course, as: "course", attributes: ["course_id", "title", "subscription"] },
{ {
model: Lesson, model: Lesson,
as: "lessons", as: "lessons",
@@ -665,6 +995,15 @@ exports.getLessonsByUnitUuid = async (req, res) => {
], ],
}); });
if (!unit) return R.error(res, "Unit not found.", 404); 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 ?? []) const lessons = (unit.lessons ?? [])
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
.map((l) => ({ .map((l) => ({
@@ -706,11 +1045,21 @@ exports.getLessonByUuid = async (req, res) => {
model: Unit, model: Unit,
as: "unit", as: "unit",
attributes: ["unit_id", "title", "order_index"], 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) 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 = { const data = {
lesson_id: lesson.lesson_id, lesson_id: lesson.lesson_id,
uuid: lesson.uuid, uuid: lesson.uuid,
+1 -1
View File
@@ -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, { const updated = await mdl_Users.findByPk(req.user.user_id, {
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
+5
View File
@@ -23,6 +23,9 @@ const { clientExclude } = require('../../models/task/task_completion.attributes'
const logActivity = require('../../utils/logActivity.util'); const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.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 ──────────────────────────────────────────────────────────────────── // ── GROUPS ────────────────────────────────────────────────────────────────────
// ============================================================================= // =============================================================================
@@ -420,6 +423,8 @@ exports.getTask = async (req, res) => {
try { try {
const { groupId, taskListId, taskId } = req.params; 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); 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); if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
@@ -27,6 +27,9 @@ const { mdl_UserGroupMembers } = require('../../models/users/use
const logActivity = require('../../utils/logActivity.util'); const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.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 ─────────────────────────────────── // ─── Helper: verify user is member of group ───────────────────────────────────
const isMember = async (userId, groupId) => { const isMember = async (userId, groupId) => {
const membership = await mdl_UserGroupMembers.findOne({ const membership = await mdl_UserGroupMembers.findOne({
@@ -86,6 +89,8 @@ exports.getTaskProgress = async (req, res) => {
try { try {
const { groupId, taskListId, taskId } = req.params; 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); 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); if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
+3
View File
@@ -7,16 +7,19 @@
* *
* Currently registered: * 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) * Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 17, 2026 * Date Created: Jun. 17, 2026
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const cron = require('node-cron'); 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 ──────────────────────── // ─── Registry — add future client-side cron jobs here ────────────────────────
const jobs = [ const jobs = [
userNotifications, userNotifications,
issueCertificates,
]; ];
// ─── Boot all registered client-side jobs ───────────────────────────────────── // ─── Boot all registered client-side jobs ─────────────────────────────────────
+109
View File
@@ -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_<uuid> 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_<uuid> 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,
};
+31 -2
View File
@@ -21,7 +21,7 @@
* Current types: * Current types:
* Admin : task_overdue * Admin : task_overdue
* User : user_task_overdue, achievement, course_unlocked, * User : user_task_overdue, achievement, course_unlocked,
* certificate_issued, task_reminder, announcement * course_completed, certificate_issued, task_reminder, announcement
* *
* Author: Kenneth Obsequio (@lash0000) * Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 19, 2026 * Date Created: Jun. 19, 2026
@@ -128,10 +128,24 @@ const NOTIFICATION_REGISTRY = {
}, },
}, },
certificate_issued: { course_completed: {
type: 'course', type: 'course',
scope: 'user', scope: 'user',
trigger: 'event', 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 }) { build({ courseTitle, courseUuid }) {
return { return {
type: 'course', 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 ────────────────────────────────────────────────────────────── // ── Platform ──────────────────────────────────────────────────────────────
announcement: { announcement: {
type: 'announcement', type: 'announcement',
@@ -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');
},
};
@@ -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');
},
};
@@ -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 });
},
};
@@ -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');
},
};
@@ -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');
},
};
+21
View File
@@ -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;
+2
View File
@@ -10,6 +10,8 @@ const CourseAssessment = sequelize.define("CourseAssessment", {
passing_score: { type: DataTypes.INTEGER, defaultValue: 70 }, passing_score: { type: DataTypes.INTEGER, defaultValue: 70 },
time_limit_minutes: { type: DataTypes.INTEGER, allowNull: true }, // null = no limit 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 }, createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true }, updatedBy: { type: DataTypes.BIGINT, allowNull: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true }, deletedBy: { type: DataTypes.BIGINT, allowNull: true },
+7
View File
@@ -13,6 +13,7 @@ const QuizQuestion = require("./quiz_question.mdl");
const QuizOption = require("./quiz_option.mdl"); const QuizOption = require("./quiz_option.mdl");
const mdl_Users = require("../users/users.mdl"); const mdl_Users = require("../users/users.mdl");
const QuizAttempt = require("./quiz_attempt.mdl"); const QuizAttempt = require("./quiz_attempt.mdl");
const AssessmentSession = require("./assessment_session.mdl");
const mdl_Category = require("./categories.mdl"); const mdl_Category = require("./categories.mdl");
const Certificate = require("./certificate.mdl"); const Certificate = require("./certificate.mdl");
const CourseInstructor = require("./course_instructor.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" }); UnitQuiz.hasMany(QuizAttempt, { as: "attempts", foreignKey: "quiz_id" });
CourseAssessment.hasMany(QuizAttempt, { as: "attempts", foreignKey: "assessment_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 = { module.exports = {
Course, CourseProductCategory, Course, CourseProductCategory,
Unit, Lesson, LessonPage, Unit, Lesson, LessonPage,
CourseObjective, LessonObjective, CourseObjective, LessonObjective,
CoursePrerequisite, CourseAssessment, CoursePrerequisite, CourseAssessment,
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt, UnitQuiz, QuizQuestion, QuizOption, QuizAttempt,
AssessmentSession,
mdl_Category, Certificate, CourseInstructor, mdl_Category, Certificate, CourseInstructor,
CourseReadingProgress, CourseReadingProgress,
}; };
+34
View File
@@ -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;
+1
View File
@@ -28,6 +28,7 @@ const mdl_Users = sequelize.define('User', {
* - "admin" → All endpoints * - "admin" → All endpoints
*/ */
acc_type: { type: DataTypes.ENUM('admin', 'staff', 'user'), defaultValue: 'user', label: "Account Type", order: 9 }, 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: * personal_info JSONB structure:
* { * {
+7
View File
@@ -73,6 +73,10 @@ router.patch("/:courseId/assessment/:assessmentId/questions/:questionId", ctrl.u
router.delete("/:courseId/assessment/:assessmentId/questions/:questionId", ctrl.deleteQuestion); router.delete("/:courseId/assessment/:assessmentId/questions/:questionId", ctrl.deleteQuestion);
router.patch("/:courseId/assessment/:assessmentId/questions/:questionId/restore", ctrl.restoreQuestion); 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 // 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.delete("/:courseId/units/:unitId/quiz/:quizId/questions/:questionId", ctrl.deleteQuestion);
router.patch("/:courseId/units/:unitId/quiz/:quizId/questions/:questionId/restore", ctrl.restoreQuestion); 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 // LESSONS
// ══════════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════════
+4 -1
View File
@@ -25,8 +25,11 @@ router.get('/:courseId/units/:unitId/lessons/:lessonId', ctrl.getLesson);
// Quiz (no answers) // Quiz (no answers)
router.get('/:courseId/units/:unitId/quiz', ctrl.getUnitQuiz); router.get('/:courseId/units/:unitId/quiz', ctrl.getUnitQuiz);
// Assessment (no answers) // Assessment
router.get( '/:courseId/assessment', ctrl.getCourseAssessment); 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/units/:unitId/quiz/:quizId/submit', ctrl.submitUnitQuiz);
router.post('/:courseId/assessment/:assessmentId/submit', ctrl.submitCourseAssessment); router.post('/:courseId/assessment/:assessmentId/submit', ctrl.submitCourseAssessment);
+9 -3
View File
@@ -44,16 +44,22 @@
#v(12pt) #v(12pt)
// ─── Instructors ────────────────────────────────────────────────────────────── // ─── Instructors (only when provided) ────────────────────────────────────────
#if instructors != "" [
#text(size: 13pt, fill: rgb("#555555"))[Instructors] #text(size: 13pt, fill: rgb("#555555"))[Instructors]
#linebreak() #linebreak()
#text(size: 15pt, weight: "bold")[#instructors] #text(size: 15pt, weight: "bold")[#instructors]
]
#v(1fr) #v(1fr)
// ─── Bottom: recipient, date, length ────────────────────────────────────────── // ─── Bottom: recipient, date, duration, issuer ────────────────────────────────
#text(size: 15pt, weight: "bold")[#recipient] #text(size: 15pt, weight: "bold")[#recipient]
#linebreak() #linebreak()
#text(size: 12pt)[*Date Issued:* #date_str] #text(size: 12pt)[*Date Issued:* #date_str]
#linebreak() #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]
+51 -36
View File
@@ -1,7 +1,7 @@
// This will do mandatory call // Assessment cooldown policy is now stored per-assessment in the DB (max_attempts / cooldown_hours).
const MAX_ATTEMPTS = 10; // These fallbacks are used only if values are missing (e.g. legacy rows before the migration).
const ATTEMPT_WINDOW_HOURS = 24; const ASSESSMENT_FAILS_BEFORE_COOLDOWN = 3;
const COOLDOWN_MINUTES = 60; const ASSESSMENT_COOLDOWN_HOURS = 24;
// Fisher-Yates shuffle of each question's options. Pure — returns new // Fisher-Yates shuffle of each question's options. Pure — returns new
// arrays/objects, never mutates input. Grading is unaffected since // 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 // Single source of truth for both the GET-time info fields and the
// submit-time enforcement check. Doesn't care about input order — // submit-time enforcement check.
// derives best/most-recent itself, so callers can just fetch attempts //
// with no ORDER BY. // type = 'quiz' → unit quizzes: no cooldown, no attempt cap, always open
function getAttemptStatus(attempts) { // type = 'assessment' → course assessments: maxFails failed attempts → cooldownHours cooldown (rolling cycles)
const now = new Date(); // maxFails / cooldownHours come from the assessment row; fallback to the constants above.
const windowStart = new Date(now.getTime() - ATTEMPT_WINDOW_HOURS * 60 * 60 * 1000); function getAttemptStatus(attempts, type = 'quiz', { maxFails, cooldownHours } = {}) {
const attemptsInWindow = attempts.filter((a) => new Date(a.createdAt) >= windowStart); const attempt_count = attempts.length;
const attempt_count = attempts.length; // lifetime — still used for has_passed/best_attempt
const has_passed = attempts.some((a) => a.passed); const has_passed = attempts.some((a) => a.passed);
const best_attempt = attempts.reduce( const best_attempt = attempts.reduce(
(best, a) => (!best || a.score > best.score ? a : best), (best, a) => (!best || a.score > best.score ? a : best),
null null
); );
const most_recent = attempts.reduce( if (type === 'quiz') {
(latest, a) => (!latest || new Date(a.createdAt) > new Date(latest.createdAt) ? a : latest), return {
null attempt_count,
); has_passed,
best_attempt,
let cooldown_until = null; attempts_remaining: null,
if (most_recent) { cooldown_until: null,
const unlockAt = new Date(new Date(most_recent.createdAt).getTime() + COOLDOWN_MINUTES * 60000); window_reset_at: null,
if (unlockAt > now) cooldown_until = unlockAt.toISOString(); 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; const sorted = [...attempts].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
if (attempts_remaining === 0 && attemptsInWindow.length > 0) {
const oldestInWindow = attemptsInWindow.reduce( let cycle_end = null; // when the current cooldown expires (null = no active or past cooldown)
(oldest, a) => (!oldest || new Date(a.createdAt) < new Date(oldest.createdAt) ? a : oldest), let failed_in_cycle = 0;
null
); for (const a of sorted) {
window_reset_at = new Date( // Skip attempts that fall inside a previous cooldown window (they shouldn't exist, but guard anyway)
new Date(oldestInWindow.createdAt).getTime() + ATTEMPT_WINDOW_HOURS * 60 * 60 * 1000 if (cycle_end && new Date(a.createdAt) < cycle_end) continue;
).toISOString(); 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 }; module.exports = { ASSESSMENT_FAILS_BEFORE_COOLDOWN, ASSESSMENT_COOLDOWN_HOURS, shuffleOptions, getAttemptStatus };
+3 -3
View File
@@ -103,9 +103,9 @@ function formatDuration(seconds) {
const s = seconds % 60; const s = seconds % 60;
const parts = []; const parts = [];
if (h) parts.push(`${h} hr`); if (h) parts.push(`${h} hour${h !== 1 ? "s" : ""}`);
if (m) parts.push(`${m} min${m !== 1 ? "s" : ""}`); if (m) parts.push(`${m} minute${m !== 1 ? "s" : ""}`);
if (!h && !m && s) parts.push(`${s} sec${s !== 1 ? "s" : ""}`); if (!h && !m && s) parts.push(`${s} second${s !== 1 ? "s" : ""}`);
return parts.join(" "); return parts.join(" ");
} }
+2 -1
View File
@@ -12,11 +12,12 @@ const mdl_UserActivity = require('../models/users/user_activity.mdl');
* @param {Object} [opts.details] — free-form JSONB context * @param {Object} [opts.details] — free-form JSONB context
*/ */
function logActivity(userId, action, { entityType = null, entityId = null, sessionId = null, details = null } = {}) { function logActivity(userId, action, { entityType = null, entityId = null, sessionId = null, details = null } = {}) {
const numericId = entityId !== null ? Number(entityId) : null;
mdl_UserActivity.create({ mdl_UserActivity.create({
user_id: userId, user_id: userId,
action, action,
entity_type: entityType, entity_type: entityType,
entity_id: entityId, entity_id: Number.isFinite(numericId) ? numericId : null,
session_id: sessionId, session_id: sessionId,
details, details,
}).catch((err) => console.error('[USER_ACTIVITY] Failed to log:', action, err.message)); }).catch((err) => console.error('[USER_ACTIVITY] Failed to log:', action, err.message));