mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
perform test
test to courses Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -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,23 +1275,75 @@ 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 },
|
||||||
});
|
});
|
||||||
if (!assessment) return R.error(res, "Assessment not found.", 404);
|
if (!assessment) return R.error(res, "Assessment not found.", 404);
|
||||||
|
|
||||||
if (title !== undefined) assessment.title = title;
|
if (title !== undefined) assessment.title = title;
|
||||||
if (is_required !== undefined) assessment.is_required = is_required;
|
if (is_required !== undefined) assessment.is_required = is_required;
|
||||||
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,21 +323,43 @@ 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) {
|
||||||
user = await mdl_Users.create({
|
const t = await sequelize.transaction();
|
||||||
email: payload.email,
|
try {
|
||||||
reg_type: 'google',
|
user = await mdl_Users.create({
|
||||||
acc_type: 'user',
|
email: payload.email,
|
||||||
is_active: true,
|
reg_type: 'google',
|
||||||
is_verified: true,
|
acc_type: 'user',
|
||||||
personal_info: {
|
is_active: true,
|
||||||
name: {
|
is_verified: true,
|
||||||
given_name: payload.given_name ?? '',
|
needs_intro: true,
|
||||||
last_name: payload.family_name ?? '',
|
personal_info: {
|
||||||
full_name: payload.name ?? '',
|
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) {
|
if (!user.is_active) {
|
||||||
|
|||||||
@@ -11,13 +11,10 @@
|
|||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
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(' ') : '';
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -522,14 +792,13 @@ exports.submitUnitQuiz = async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return R.success(res, "Quiz submitted.", {
|
return R.success(res, "Quiz submitted.", {
|
||||||
attempt_id: attempt.attempt_id,
|
attempt_id: attempt.attempt_id,
|
||||||
attempt_number: attempt.attempt_number,
|
attempt_number: attempt.attempt_number,
|
||||||
score,
|
score,
|
||||||
passed,
|
passed,
|
||||||
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,
|
||||||
|
|||||||
@@ -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'] },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -6,17 +6,20 @@
|
|||||||
* { name, schedule, run }, listed in the `jobs` array below.
|
* { name, schedule, run }, listed in the `jobs` array below.
|
||||||
*
|
*
|
||||||
* 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 ─────────────────────────────────────
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -9,7 +9,9 @@ const CourseAssessment = sequelize.define("CourseAssessment", {
|
|||||||
is_required: { type: DataTypes.BOOLEAN, defaultValue: false },
|
is_required: { type: DataTypes.BOOLEAN, defaultValue: false },
|
||||||
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 },
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
@@ -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;
|
||||||
@@ -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:
|
||||||
* {
|
* {
|
||||||
|
|||||||
@@ -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
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -25,11 +25,14 @@ 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);
|
||||||
|
|
||||||
// Reading progress
|
// Reading progress
|
||||||
router.get( '/:courseId/progress/summary', progressCtrl.getCourseProgressSummary);
|
router.get( '/:courseId/progress/summary', progressCtrl.getCourseProgressSummary);
|
||||||
|
|||||||
@@ -44,16 +44,22 @@
|
|||||||
|
|
||||||
#v(12pt)
|
#v(12pt)
|
||||||
|
|
||||||
// ─── Instructors ──────────────────────────────────────────────────────────────
|
// ─── Instructors (only when provided) ────────────────────────────────────────
|
||||||
#text(size: 13pt, fill: rgb("#555555"))[Instructors]
|
#if instructors != "" [
|
||||||
#linebreak()
|
#text(size: 13pt, fill: rgb("#555555"))[Instructors]
|
||||||
#text(size: 15pt, weight: "bold")[#instructors]
|
#linebreak()
|
||||||
|
#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]
|
||||||
|
|||||||
@@ -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 has_passed = attempts.some((a) => a.passed);
|
||||||
const attempt_count = attempts.length; // lifetime — still used for has_passed/best_attempt
|
const best_attempt = attempts.reduce(
|
||||||
const has_passed = attempts.some((a) => a.passed);
|
|
||||||
|
|
||||||
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 };
|
||||||
@@ -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(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user