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 { getFieldValues } = require("../../utils/fieldValues.util");
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||
|
||||
// ── Models ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -20,6 +22,7 @@ const {
|
||||
CourseObjective, LessonObjective,
|
||||
CoursePrerequisite, CourseAssessment,
|
||||
UnitQuiz, QuizQuestion, QuizOption,
|
||||
QuizAttempt, AssessmentSession,
|
||||
CourseInstructor,
|
||||
} = require("../../models/courses/courses.associations");
|
||||
|
||||
@@ -1241,7 +1244,7 @@ exports.getAssessment = async (req, res) => {
|
||||
exports.createAssessment = async (req, res) => {
|
||||
try {
|
||||
const { courseId } = req.params;
|
||||
const { title, is_required, passing_score, time_limit_minutes, max_questions, createdBy } = req.body;
|
||||
const { title, is_required, passing_score, time_limit_minutes, max_questions, max_attempts, cooldown_hours, createdBy } = req.body;
|
||||
|
||||
const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted } });
|
||||
if (!course) return R.error(res, "Course not found.", 404);
|
||||
@@ -1256,6 +1259,8 @@ exports.createAssessment = async (req, res) => {
|
||||
passing_score: passing_score ?? 70,
|
||||
time_limit_minutes: time_limit_minutes ?? null,
|
||||
max_questions: max_questions ?? null,
|
||||
max_attempts: max_attempts ?? 3,
|
||||
cooldown_hours: cooldown_hours ?? 24,
|
||||
createdBy: createdBy ?? null,
|
||||
});
|
||||
|
||||
@@ -1270,23 +1275,75 @@ exports.createAssessment = async (req, res) => {
|
||||
exports.updateAssessment = async (req, res) => {
|
||||
try {
|
||||
const { courseId, assessmentId } = req.params;
|
||||
const { title, is_required, passing_score, time_limit_minutes, max_questions, updatedBy } = req.body;
|
||||
const { title, is_required, passing_score, time_limit_minutes, max_questions, max_attempts, cooldown_hours, updatedBy } = req.body;
|
||||
|
||||
const assessment = await CourseAssessment.findOne({
|
||||
where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted },
|
||||
});
|
||||
if (!assessment) return R.error(res, "Assessment not found.", 404);
|
||||
|
||||
if (title !== undefined) assessment.title = title;
|
||||
if (is_required !== undefined) assessment.is_required = is_required;
|
||||
if (passing_score !== undefined) assessment.passing_score = passing_score;
|
||||
if (title !== undefined) assessment.title = title;
|
||||
if (is_required !== undefined) assessment.is_required = is_required;
|
||||
if (passing_score !== undefined) assessment.passing_score = passing_score;
|
||||
if (time_limit_minutes !== undefined) assessment.time_limit_minutes = time_limit_minutes;
|
||||
if (max_questions !== undefined) assessment.max_questions = max_questions;
|
||||
if (max_questions !== undefined) assessment.max_questions = max_questions;
|
||||
if (max_attempts !== undefined) assessment.max_attempts = max_attempts;
|
||||
if (cooldown_hours !== undefined) assessment.cooldown_hours = cooldown_hours;
|
||||
|
||||
assessment.updatedBy = updatedBy ?? null;
|
||||
|
||||
await assessment.save();
|
||||
logActivity(req.user?.user_id, 'update_assessment', { entityType: 'assessment', entityId: Number(assessmentId) });
|
||||
|
||||
// Update in-progress sessions + notify affected students
|
||||
try {
|
||||
const inProgressSessions = await AssessmentSession.findAll({
|
||||
where: { assessment_id: assessmentId, status: 'in_progress' },
|
||||
attributes: ['session_id', 'user_id', 'started_at'],
|
||||
});
|
||||
if (inProgressSessions.length > 0) {
|
||||
// Update expires_at based on new time limit — but never shorten a student's
|
||||
// remaining time. If the new limit would expire sooner than the current one,
|
||||
// leave that session untouched.
|
||||
const newTimeLimitMs = (assessment.time_limit_minutes ?? 0) * 60_000;
|
||||
await Promise.all(
|
||||
inProgressSessions.map(s => {
|
||||
if (newTimeLimitMs === 0) {
|
||||
// Removing the time limit entirely → always an improvement
|
||||
return s.update({ expires_at: null });
|
||||
}
|
||||
const candidate = new Date(new Date(s.started_at).getTime() + newTimeLimitMs);
|
||||
// Only update if the new expiry is later than what they already have
|
||||
if (s.expires_at && candidate <= new Date(s.expires_at)) return Promise.resolve();
|
||||
return s.update({ expires_at: candidate });
|
||||
})
|
||||
);
|
||||
|
||||
const course = await Course.findOne({
|
||||
where: { course_id: courseId },
|
||||
attributes: ['title'],
|
||||
});
|
||||
const notify = NOTIFICATION_REGISTRY.assessment_updated.build({
|
||||
assessmentTitle: assessment.title,
|
||||
courseTitle: course?.title ?? null,
|
||||
});
|
||||
const now = new Date();
|
||||
await UserNotification.bulkCreate(
|
||||
inProgressSessions.map(({ user_id }) => ({
|
||||
user_id,
|
||||
...notify,
|
||||
seen: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
{ validate: false }
|
||||
);
|
||||
}
|
||||
} catch (notifyErr) {
|
||||
// Non-fatal — log but don't fail the update response
|
||||
console.error('[ASSESSMENT][UPDATE][NOTIFY]', notifyErr);
|
||||
}
|
||||
|
||||
return R.success(res, "Assessment updated.", { data: assessment });
|
||||
} catch (err) {
|
||||
console.error("[ASSESSMENT][UPDATE]", err);
|
||||
@@ -1504,3 +1561,152 @@ exports.syncInstructors = async (req, res) => {
|
||||
return R.error(res, "Could not update instructors.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── COMPLETIONS HELPERS ──────────────────────────────────────────────────────
|
||||
|
||||
function extractUserInfo(user) {
|
||||
if (!user) return { full_name: null, email: null, avatar_url: null };
|
||||
return {
|
||||
full_name: user.personal_info?.name?.full_name ?? null,
|
||||
email: user.email ?? null,
|
||||
avatar_url: user.personal_info?.avatar?.url ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function groupByUser(attempts) {
|
||||
const map = new Map();
|
||||
for (const a of attempts) {
|
||||
const uid = String(a.user_id);
|
||||
if (!map.has(uid)) {
|
||||
const { full_name, email, avatar_url } = extractUserInfo(a.user);
|
||||
map.set(uid, {
|
||||
user_id: a.user_id,
|
||||
full_name,
|
||||
email,
|
||||
avatar_url,
|
||||
attempt_count: 0,
|
||||
best_score: 0,
|
||||
passed: false,
|
||||
latest_at: null,
|
||||
attempts: [],
|
||||
});
|
||||
}
|
||||
const row = map.get(uid);
|
||||
row.attempt_count += 1;
|
||||
if (a.score > row.best_score) row.best_score = a.score;
|
||||
if (a.passed) row.passed = true;
|
||||
if (!row.latest_at || new Date(a.createdAt) > new Date(row.latest_at)) row.latest_at = a.createdAt;
|
||||
row.attempts.push({ attempt_id: a.attempt_id, attempt_number: a.attempt_number, score: a.score, earned_points: a.earned_points, total_points: a.total_points, passed: a.passed, createdAt: a.createdAt });
|
||||
}
|
||||
return [...map.values()].sort((a, b) => new Date(b.latest_at) - new Date(a.latest_at));
|
||||
}
|
||||
|
||||
function buildSummary(attempts) {
|
||||
const takers = new Set(attempts.map((a) => String(a.user_id))).size;
|
||||
const passed = attempts.filter((a) => a.passed).length;
|
||||
const failed = attempts.length - passed;
|
||||
const avg = attempts.length ? Math.round(attempts.reduce((s, a) => s + a.score, 0) / attempts.length) : 0;
|
||||
return {
|
||||
total_takers: takers,
|
||||
passed_count: passed,
|
||||
failed_count: failed,
|
||||
pass_rate: takers ? Math.round((new Set(attempts.filter((a) => a.passed).map((a) => String(a.user_id))).size / takers) * 100) : 0,
|
||||
avg_score: avg,
|
||||
total_attempts: attempts.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── QUIZ COMPLETIONS ─────────────────────────────────────────────────────────
|
||||
|
||||
exports.getQuizCompletions = async (req, res) => {
|
||||
try {
|
||||
const { quizId } = req.params;
|
||||
|
||||
const attempts = await QuizAttempt.findAll({
|
||||
where: { quiz_id: quizId },
|
||||
attributes: ["attempt_id", "user_id", "attempt_number", "score", "earned_points", "total_points", "passed", "createdAt"],
|
||||
include: [{ model: mdl_Users, as: "user", attributes: ["user_id", "email", "personal_info"] }],
|
||||
order: [["createdAt", "DESC"]],
|
||||
});
|
||||
|
||||
const plain = attempts.map((a) => a.toJSON());
|
||||
return R.success(res, "Quiz completions retrieved.", {
|
||||
summary: buildSummary(plain),
|
||||
completions: groupByUser(plain),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ADMIN][QUIZ][COMPLETIONS]", err);
|
||||
return R.error(res, "Could not retrieve quiz completions.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ASSESSMENT COMPLETIONS ───────────────────────────────────────────────────
|
||||
|
||||
exports.getAssessmentCompletions = async (req, res) => {
|
||||
try {
|
||||
const { assessmentId } = req.params;
|
||||
|
||||
const attempts = await QuizAttempt.findAll({
|
||||
where: { assessment_id: assessmentId },
|
||||
attributes: ["attempt_id", "user_id", "attempt_number", "score", "earned_points", "total_points", "passed", "createdAt"],
|
||||
include: [{ model: mdl_Users, as: "user", attributes: ["user_id", "email", "personal_info"] }],
|
||||
order: [["createdAt", "DESC"]],
|
||||
});
|
||||
|
||||
const plain = attempts.map((a) => a.toJSON());
|
||||
return R.success(res, "Assessment completions retrieved.", {
|
||||
summary: buildSummary(plain),
|
||||
completions: groupByUser(plain),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ADMIN][ASSESSMENT][COMPLETIONS]", err);
|
||||
return R.error(res, "Could not retrieve assessment completions.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ASSESSMENT SESSIONS ──────────────────────────────────────────────────────
|
||||
|
||||
exports.getAssessmentSessions = async (req, res) => {
|
||||
try {
|
||||
const { assessmentId } = req.params;
|
||||
|
||||
const sessions = await AssessmentSession.findAll({
|
||||
where: { assessment_id: assessmentId },
|
||||
attributes: ["session_id", "user_id", "status", "started_at", "expires_at", "attempt_id", "createdAt", "updatedAt"],
|
||||
include: [{ model: mdl_Users, as: "user", attributes: ["user_id", "email", "personal_info"] }],
|
||||
order: [["createdAt", "DESC"]],
|
||||
});
|
||||
|
||||
const rows = sessions.map((s) => {
|
||||
const j = s.toJSON();
|
||||
const { full_name, email, avatar_url } = extractUserInfo(j.user);
|
||||
const time_spent_seconds = j.status !== 'in_progress' && j.started_at
|
||||
? Math.round((new Date(j.updatedAt) - new Date(j.started_at)) / 1000)
|
||||
: null;
|
||||
return {
|
||||
session_id: j.session_id,
|
||||
user_id: j.user_id,
|
||||
full_name,
|
||||
email,
|
||||
avatar_url,
|
||||
status: j.status,
|
||||
started_at: j.started_at,
|
||||
expires_at: j.expires_at,
|
||||
time_spent_seconds,
|
||||
attempt_id: j.attempt_id,
|
||||
};
|
||||
});
|
||||
|
||||
const summary = {
|
||||
total_sessions: rows.length,
|
||||
in_progress_count: rows.filter((r) => r.status === 'in_progress').length,
|
||||
completed_count: rows.filter((r) => r.status === 'completed').length,
|
||||
expired_count: rows.filter((r) => r.status === 'expired').length,
|
||||
};
|
||||
|
||||
return R.success(res, "Assessment sessions retrieved.", { summary, sessions: rows });
|
||||
} catch (err) {
|
||||
console.error("[ADMIN][ASSESSMENT][SESSIONS]", err);
|
||||
return R.error(res, "Could not retrieve assessment sessions.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -22,6 +22,13 @@ const { archiveOne, archiveMany } = require("../../utils/courses/archive.util");
|
||||
const { restoreOne, restoreMany } = require("../../utils/courses/restore.util");
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
|
||||
// ─── URL normalizer — ensures protocol prefix so link_url is never a relative path ──
|
||||
const normalizeUrl = (url) => {
|
||||
if (!url) return null;
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
return `https://${url}`;
|
||||
};
|
||||
|
||||
// ─── Allowed filter/sort fields ───────────────────────────────────────────────
|
||||
const TASK_LIST_FIELDS = ['name', 'description', 'createdAt', 'updatedAt', 'deletedAt'];
|
||||
const TASK_FIELDS = ['name', 'description', 'deadline', 'status', 'createdAt', 'updatedAt', 'deletedAt'];
|
||||
@@ -503,7 +510,7 @@ exports.createTask = async (req, res) => {
|
||||
order: r.order ?? i,
|
||||
reference_id: r.reference_id || null, // '' → null (UUID column)
|
||||
reference_label: r.reference_label || null, // '' → null
|
||||
link_url: r.link_url || null,
|
||||
link_url: normalizeUrl(r.link_url),
|
||||
link_label: r.link_label || null,
|
||||
createdBy: req.user.user_id,
|
||||
updatedBy: req.user.user_id,
|
||||
@@ -593,7 +600,7 @@ exports.updateTask = async (req, res) => {
|
||||
order: rest.order ?? i,
|
||||
reference_id: rest.reference_id || null, // '' → null (UUID column)
|
||||
reference_label: rest.reference_label || null, // '' → null
|
||||
link_url: rest.link_url || null,
|
||||
link_url: normalizeUrl(rest.link_url),
|
||||
link_label: rest.link_label || null,
|
||||
createdBy: req.user.user_id,
|
||||
updatedBy: req.user.user_id,
|
||||
|
||||
Reference in New Issue
Block a user