Files
starr-philproperties/controllers/admin/courses.controller.js
T
kennethobsequio 1372f4e975 testing 101
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-06-30 19:31:45 +08:00

1828 lines
72 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
const { Op, Sequelize } = require("sequelize");
const sequelize = require("../../config/db.config");
const R = require("../../utils/response.util");
const { paginate } = require("../../utils/paginate.util");
const { recomputeDurations, formatDuration } = require("../../utils/duration.util");
const { syncObjectivesCreate, syncObjectivesUpdate } = require("../../utils/courses/objectives.util");
const { syncJunction } = require("../../utils/courses/junction.util");
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 ────────────────────────────────────────────────────────────────────
const {
Course, CourseProductCategory: CourseProductCat,
Unit, Lesson, LessonPage,
CourseObjective, LessonObjective,
CoursePrerequisite, CourseAssessment,
UnitQuiz, QuizQuestion, QuizOption,
QuizAttempt, AssessmentSession,
CourseInstructor, CourseAchievement,
} = require("../../models/courses/courses.associations");
const mdl_Users = require("../../models/users/users.mdl");
const {
excludeAttributes: courseExclude,
computedAttributes: courseComputed,
} = require("../../models/courses/courses.attributes");
const notDeleted = { deletedAt: null };
const onlyDeleted = { deletedAt: { [Op.not]: null } };
const auditByFields = ["createdBy", "updatedBy", "deletedBy"];
const adminExclude = [];
// ══════════════════════════════════════════════════════════════════════════════
// COURSE
// ══════════════════════════════════════════════════════════════════════════════
exports.getCourses = async (req, res) => {
try {
const result = await paginate(Course, req, {
excludeAttributes: courseExclude,
computedAttributes: courseComputed,
auditOptions: { mdl_Users, parentAlias: "Course" },
context: "list",
findOptions: { where: { ...notDeleted } },
});
return R.success(res, "Courses retrieved.", result);
} catch (err) {
console.error("[COURSE][GET ALL]", err);
return R.error(res, "Could not retrieve courses.", 500);
}
};
exports.getCourse = async (req, res) => {
try {
const { courseId } = req.params;
const course = await Course.findOne({
where: { course_id: courseId, ...notDeleted },
include: [
{
model: Unit, as: "units",
where: notDeleted, required: false,
include: [
{ model: Lesson, as: "lessons", where: notDeleted, required: false },
{ model: UnitQuiz, as: "quiz", required: false },
],
},
{ model: CourseObjective, as: "objectives", required: false },
{ model: CoursePrerequisite, as: "prerequisites", required: false },
{ model: CourseAssessment, as: "assessment", required: false },
],
order: [
[{ model: Unit, as: "units" }, "order_index", "ASC"],
[{ model: Unit, as: "units" }, { model: Lesson, as: "lessons" }, "order_index", "ASC"],
[{ model: CourseObjective, as: "objectives" }, "order_index", "ASC"],
],
});
if (!course) return R.error(res, "Course not found.", 404);
return R.success(res, "Course retrieved.", { data: course.toJSON() });
} catch (err) {
console.error("[COURSE][GET ONE]", err);
return R.error(res, "Could not retrieve course.", 500);
}
};
exports.createCourse = async (req, res) => {
const t = await sequelize.transaction();
try {
const {
title, description, order_index,
course_code, level, subscription,
objectives = [],
category_ids = [],
achievement_keys = [],
badge_color, badge_asset_id, badge_image_url,
createdBy,
} = req.body;
if (!title) return R.error(res, "Title is required.", 400);
const course = await Course.create({
title,
description: description ?? null,
order_index: order_index ?? 0,
course_code: course_code ?? null,
level: level ?? null,
subscription: subscription ?? "free",
duration_seconds: 0,
badge_color: badge_color ?? "purple",
badge_asset_id: badge_asset_id ?? null,
badge_image_url: badge_image_url ?? null,
createdBy: createdBy ?? null,
}, { transaction: t });
await syncObjectivesCreate(CourseObjective, "course_id", course.course_id, objectives, t);
await syncJunction(CourseProductCat, course.course_id, category_ids, "category_id", t);
if (achievement_keys.length) {
await CourseAchievement.bulkCreate(
achievement_keys.slice(0, 3).map((key, i) => ({ course_id: course.course_id, achievement_key: key, order_index: i })),
{ transaction: t },
);
}
await t.commit();
logActivity(req.user?.user_id, 'create_course', { entityType: 'course', entityId: course.course_id, details: { title: course.title } });
return R.success(res, "Course created.", { data: course }, 201);
} catch (err) {
await t.rollback();
console.error("[COURSE][CREATE]", err);
return R.error(res, "Could not create course.", 500);
}
};
exports.updateCourse = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId } = req.params;
const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted } });
if (!course) return R.error(res, "Course not found.", 404);
const {
title, description, order_index,
course_code, level, subscription,
objectives, category_ids,
badge_color, badge_asset_id, badge_image_url,
updatedBy,
} = req.body;
if (title !== undefined) course.title = title;
if (description !== undefined) course.description = description;
if (order_index !== undefined) course.order_index = order_index;
if (course_code !== undefined) course.course_code = course_code;
if (level !== undefined) course.level = level;
if (subscription !== undefined) course.subscription = subscription;
if (badge_color !== undefined) course.badge_color = badge_color;
if (badge_asset_id !== undefined) course.badge_asset_id = badge_asset_id;
if (badge_image_url !== undefined) course.badge_image_url = badge_image_url;
course.updatedBy = updatedBy ?? null;
await course.save({ transaction: t });
if (objectives !== undefined) await syncObjectivesUpdate(CourseObjective, "course_id", courseId, objectives, t);
if (category_ids !== undefined) await syncJunction(CourseProductCat, courseId, category_ids, "category_id", t);
await t.commit();
logActivity(req.user?.user_id, 'update_course', { entityType: 'course', entityId: Number(courseId), details: { title: course.title } });
return R.success(res, "Course updated.", { data: course });
} catch (err) {
await t.rollback();
console.error("[COURSE][UPDATE]", err);
return R.error(res, "Could not update course.", 500);
}
};
exports.archiveCourse = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId } = req.params;
const record = await archiveOne(Course, { course_id: courseId, ...notDeleted }, req.user.user_id, t);
if (!record) return R.error(res, "Course not found.", 404);
await t.commit();
logActivity(req.user.user_id, 'archive_course', { entityType: 'course', entityId: Number(courseId) });
return R.success(res, "Course archived.");
} catch (err) {
await t.rollback();
console.error("[COURSE][ARCHIVE]", err);
return R.error(res, "Could not archive course.", 500);
}
};
exports.bulkArchiveCourses = async (req, res) => {
const t = await sequelize.transaction();
try {
const { ids = [] } = req.body;
if (!ids.length) return R.error(res, "No IDs provided.", 400);
const count = await archiveMany(Course, "course_id", ids, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_archive_courses', { entityType: 'course', details: { ids, count } });
return R.success(res, `${count} course${count !== 1 ? "s" : ""} archived.`);
} catch (err) {
await t.rollback();
console.error("[COURSE][BULK ARCHIVE]", err);
return R.error(res, "Could not archive courses.", 500);
}
};
exports.getArchivedCourses = async (req, res) => {
try {
const result = await paginate(Course, req, {
excludeAttributes: courseExclude,
computedAttributes: courseComputed,
auditOptions: { mdl_Users, parentAlias: "Course" },
context: "archived",
findOptions: { where: { ...onlyDeleted }, paranoid: false },
});
return R.success(res, "Archived courses retrieved.", result);
} catch (err) {
console.error("[COURSE][GET ARCHIVES]", err);
return R.error(res, "Could not retrieve archived courses.", 500);
}
};
exports.getArchivedCourse = async (req, res) => {
try {
const { courseId } = req.params;
const course = await Course.findOne({
where: { course_id: courseId, ...onlyDeleted },
paranoid: false,
});
if (!course) return R.error(res, "Archived course not found.", 404);
return R.success(res, "Archived course retrieved.", { data: course.toJSON() });
} catch (err) {
console.error("[COURSE][GET ARCHIVE ONE]", err);
return R.error(res, "Could not retrieve archived course.", 500);
}
};
exports.restoreCourse = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId } = req.params;
const record = await restoreOne(Course, { course_id: courseId }, req.user.user_id, t);
if (!record) return R.error(res, "Archived course not found.", 404);
await t.commit();
logActivity(req.user.user_id, 'restore_course', { entityType: 'course', entityId: Number(courseId) });
return R.success(res, "Course restored.", { data: record });
} catch (err) {
await t.rollback();
console.error("[COURSE][RESTORE]", err);
return R.error(res, "Could not restore course.", 500);
}
};
exports.bulkRestoreCourses = async (req, res) => {
const t = await sequelize.transaction();
try {
const { ids = [] } = req.body;
if (!ids.length) return R.error(res, "No IDs provided.", 400);
const count = await restoreMany(Course, "course_id", ids, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_restore_courses', { entityType: 'course', details: { ids, count } });
return R.success(res, `${count} course${count !== 1 ? "s" : ""} restored.`);
} catch (err) {
await t.rollback();
console.error("[COURSE][BULK RESTORE]", err);
return R.error(res, "Could not restore courses.", 500);
}
};
// ══════════════════════════════════════════════════════════════════════════════
// COURSE PREREQUISITES
// ══════════════════════════════════════════════════════════════════════════════
exports.getPrerequisites = async (req, res) => {
try {
const { courseId } = req.params;
const prereqs = await CoursePrerequisite.findAll({
where: { course_id: courseId },
order: [["order_index", "ASC"]],
});
return R.success(res, "Prerequisites retrieved.", { data: prereqs });
} catch (err) {
console.error("[PREREQ][GET ALL]", err);
return R.error(res, "Could not retrieve prerequisites.", 500);
}
};
exports.syncPrerequisites = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId } = req.params;
const { prerequisites = [] } = req.body;
const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted } });
if (!course) return R.error(res, "Course not found.", 404);
await CoursePrerequisite.destroy({ where: { course_id: courseId }, transaction: t });
if (prerequisites.length) {
const validTypes = ["course", "unit", "lesson"];
for (const p of prerequisites) {
if (!validTypes.includes(p.ref_type)) {
await t.rollback();
return R.error(res, `Invalid ref_type: ${p.ref_type}`, 400);
}
}
await CoursePrerequisite.bulkCreate(
prerequisites.map((p, i) => ({
course_id: courseId,
ref_type: p.ref_type,
ref_id: p.ref_id,
order_index: i,
})),
{ transaction: t }
);
}
await t.commit();
logActivity(req.user?.user_id, 'sync_prerequisites', { entityType: 'course', entityId: Number(courseId), details: { count: prerequisites.length } });
return R.success(res, "Prerequisites updated.");
} catch (err) {
await t.rollback();
console.error("[PREREQ][SYNC]", err);
return R.error(res, "Could not update prerequisites.", 500);
}
};
// ══════════════════════════════════════════════════════════════════════════════
// UNIT
// ══════════════════════════════════════════════════════════════════════════════
exports.getUnits = async (req, res) => {
try {
const { courseId } = req.params;
const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted } });
if (!course) return R.error(res, "Course not found.", 404);
const result = await paginate(Unit, req, {
excludeAttributes: adminExclude,
auditOptions: { mdl_Users, parentAlias: "Unit" },
context: "list",
findOptions: {
where: { course_id: courseId, ...notDeleted },
order: [["order_index", "ASC"]],
},
});
return R.success(res, "Units retrieved.", result);
} catch (err) {
console.error("[UNIT][GET ALL]", err);
return R.error(res, "Could not retrieve units.", 500);
}
};
exports.getUnit = async (req, res) => {
try {
const { courseId, unitId } = req.params;
const unit = await Unit.findOne({
where: { unit_id: unitId, course_id: courseId, ...notDeleted },
include: [
{ model: Lesson, as: "lessons", where: notDeleted, required: false, order: [["order_index", "ASC"]] },
{ model: UnitQuiz, as: "quiz", required: false },
],
order: [[{ model: Lesson, as: "lessons" }, "order_index", "ASC"]],
});
if (!unit) return R.error(res, "Unit not found.", 404);
return R.success(res, "Unit retrieved.", { data: unit.toJSON() });
} catch (err) {
console.error("[UNIT][GET ONE]", err);
return R.error(res, "Could not retrieve unit.", 500);
}
};
exports.createUnit = async (req, res) => {
try {
const { courseId } = req.params;
const { title, description, order, createdBy } = req.body;
if (!title) return R.error(res, "Title is required.", 400);
const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted } });
if (!course) return R.error(res, "Course not found.", 404);
const unit = await Unit.create({
course_id: courseId,
title,
description: description ?? null,
order_index: order ?? 0,
duration_seconds: 0,
createdBy: createdBy ?? null,
});
logActivity(req.user?.user_id, 'create_unit', { entityType: 'unit', entityId: unit.unit_id, details: { title: unit.title } });
return R.success(res, "Unit created.", { data: unit }, 201);
} catch (err) {
console.error("[UNIT][CREATE]", err);
return R.error(res, "Could not create unit.", 500);
}
};
exports.updateUnit = async (req, res) => {
try {
const { courseId, unitId } = req.params;
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
const { title, description, order, updatedBy } = req.body;
if (title !== undefined) unit.title = title;
if (description !== undefined) unit.description = description;
if (order !== undefined) unit.order_index = order;
unit.updatedBy = updatedBy ?? null;
await unit.save();
logActivity(req.user?.user_id, 'update_unit', { entityType: 'unit', entityId: Number(unitId) });
return R.success(res, "Unit updated.", { data: unit });
} catch (err) {
console.error("[UNIT][UPDATE]", err);
return R.error(res, "Could not update unit.", 500);
}
};
exports.archiveUnit = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId, unitId } = req.params;
const record = await archiveOne(Unit, { unit_id: unitId, course_id: courseId, ...notDeleted }, req.user.user_id, t);
if (!record) return R.error(res, "Unit not found.", 404);
await t.commit();
logActivity(req.user.user_id, 'archive_unit', { entityType: 'unit', entityId: Number(unitId) });
return R.success(res, "Unit archived.");
} catch (err) {
await t.rollback();
console.error("[UNIT][ARCHIVE]", err);
return R.error(res, "Could not archive unit.", 500);
}
};
exports.bulkArchiveUnits = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId } = req.params;
const { ids = [] } = req.body;
if (!ids.length) return R.error(res, "No IDs provided.", 400);
const units = await Unit.findAll({ where: { unit_id: ids, course_id: courseId, ...notDeleted } });
const validIds = units.map((u) => u.unit_id);
const count = await archiveMany(Unit, "unit_id", validIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_archive_units', { entityType: 'unit', details: { ids: validIds, count } });
return R.success(res, `${count} unit${count !== 1 ? "s" : ""} archived.`);
} catch (err) {
await t.rollback();
console.error("[UNIT][BULK ARCHIVE]", err);
return R.error(res, "Could not archive units.", 500);
}
};
exports.getArchivedUnits = async (req, res) => {
try {
const { courseId } = req.params;
const course = await Course.findOne({ where: { course_id: courseId }, paranoid: false });
if (!course) return R.error(res, "Course not found.", 404);
const result = await paginate(Unit, req, {
excludeAttributes: adminExclude,
auditOptions: { mdl_Users, parentAlias: "Unit" },
context: "archived",
findOptions: {
where: { course_id: courseId, ...onlyDeleted },
paranoid: false,
order: [["order_index", "ASC"]],
},
});
return R.success(res, "Archived units retrieved.", result);
} catch (err) {
console.error("[UNIT][GET ARCHIVES]", err);
return R.error(res, "Could not retrieve archived units.", 500);
}
};
exports.getArchivedUnit = async (req, res) => {
try {
const { courseId, unitId } = req.params;
const unit = await Unit.findOne({
where: { unit_id: unitId, course_id: courseId, ...onlyDeleted },
paranoid: false,
});
if (!unit) return R.error(res, "Archived unit not found.", 404);
return R.success(res, "Archived unit retrieved.", { data: unit.toJSON() });
} catch (err) {
console.error("[UNIT][GET ARCHIVE ONE]", err);
return R.error(res, "Could not retrieve archived unit.", 500);
}
};
exports.restoreUnit = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId, unitId } = req.params;
const record = await restoreOne(Unit, { unit_id: unitId, course_id: courseId, ...onlyDeleted }, req.user.user_id, t);
if (!record) return R.error(res, "Archived unit not found.", 404);
await t.commit();
logActivity(req.user.user_id, 'restore_unit', { entityType: 'unit', entityId: Number(unitId) });
return R.success(res, "Unit restored.", { data: record });
} catch (err) {
await t.rollback();
console.error("[UNIT][RESTORE]", err);
return R.error(res, "Could not restore unit.", 500);
}
};
exports.bulkRestoreUnits = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId } = req.params;
const { ids = [] } = req.body;
if (!ids.length) return R.error(res, "No IDs provided.", 400);
const units = await Unit.findAll({ where: { unit_id: ids, course_id: courseId, ...onlyDeleted }, paranoid: false });
const validIds = units.map((u) => u.unit_id);
const count = await restoreMany(Unit, "unit_id", validIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_restore_units', { entityType: 'unit', details: { ids: validIds, count } });
return R.success(res, `${count} unit${count !== 1 ? "s" : ""} restored.`);
} catch (err) {
await t.rollback();
console.error("[UNIT][BULK RESTORE]", err);
return R.error(res, "Could not restore units.", 500);
}
};
// ══════════════════════════════════════════════════════════════════════════════
// LESSON
// ══════════════════════════════════════════════════════════════════════════════
exports.getLessons = async (req, res) => {
try {
const { courseId, unitId } = req.params;
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
const result = await paginate(Lesson, req, {
excludeAttributes: adminExclude,
auditOptions: { mdl_Users, parentAlias: "Lesson" },
context: "list",
findOptions: {
where: { unit_id: unitId, ...notDeleted },
order: [["order_index", "ASC"]],
},
});
return R.success(res, "Lessons retrieved.", result);
} catch (err) {
console.error("[LESSON][GET ALL]", err);
return R.error(res, "Could not retrieve lessons.", 500);
}
};
exports.getLesson = async (req, res) => {
try {
const { courseId, unitId, lessonId } = req.params;
const lesson = await Lesson.findOne({
where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted },
include: [
{ model: LessonPage, as: "page", required: false },
{ model: LessonObjective, as: "objectives", required: false, order: [["order_index", "ASC"]] },
{ model: Unit, as: "unit", where: { course_id: courseId, ...notDeleted } },
],
});
if (!lesson) return R.error(res, "Lesson not found.", 404);
return R.success(res, "Lesson retrieved.", { data: lesson.toJSON() });
} catch (err) {
console.error("[LESSON][GET ONE]", err);
return R.error(res, "Could not retrieve lesson.", 500);
}
};
exports.createLesson = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId, unitId } = req.params;
const { title, description, order, objectives = [], createdBy } = req.body;
if (!title) return R.error(res, "Title is required.", 400);
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
const lesson = await Lesson.create({
unit_id: unitId,
title,
description: description ?? null,
order_index: order ?? 0,
duration_seconds: 0,
createdBy: createdBy ?? null,
}, { transaction: t });
await LessonPage.create({
lesson_id: lesson.lesson_id,
blocks: [],
createdBy: createdBy ?? null,
}, { transaction: t });
await syncObjectivesCreate(LessonObjective, "lesson_id", lesson.lesson_id, objectives, t);
await t.commit();
logActivity(req.user?.user_id, 'create_lesson', { entityType: 'lesson', entityId: lesson.lesson_id, details: { title: lesson.title } });
return R.success(res, "Lesson created.", { data: lesson }, 201);
} catch (err) {
await t.rollback();
console.error("[LESSON][CREATE]", err);
return R.error(res, "Could not create lesson.", 500);
}
};
exports.updateLesson = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId, unitId, lessonId } = req.params;
const lesson = await Lesson.findOne({
where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted },
include: [{ model: Unit, as: "unit", where: { course_id: courseId, ...notDeleted } }],
});
if (!lesson) return R.error(res, "Lesson not found.", 404);
const { title, description, order, objectives, updatedBy } = req.body;
if (title !== undefined) lesson.title = title;
if (description !== undefined) lesson.description = description;
if (order !== undefined) lesson.order_index = order;
lesson.updatedBy = updatedBy ?? null;
await lesson.save({ transaction: t });
if (objectives !== undefined) {
await syncObjectivesUpdate(LessonObjective, "lesson_id", lessonId, objectives, t);
}
await t.commit();
const updated = await Lesson.findOne({
where: { lesson_id: lessonId },
include: [{ model: LessonObjective, as: "objectives", order: [["order_index", "ASC"]] }],
});
logActivity(req.user?.user_id, 'update_lesson', { entityType: 'lesson', entityId: Number(lessonId) });
return R.success(res, "Lesson updated.", { data: updated });
} catch (err) {
await t.rollback();
console.error("[LESSON][UPDATE]", err);
return R.error(res, "Could not update lesson.", 500);
}
};
exports.archiveLesson = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId, unitId, lessonId } = req.params;
const record = await archiveOne(Lesson, {
where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted },
include: [{ model: Unit, as: "unit", where: { course_id: courseId, ...notDeleted } }],
}, req.user.user_id, t);
if (!record) return R.error(res, "Lesson not found.", 404);
await t.commit();
logActivity(req.user.user_id, 'archive_lesson', { entityType: 'lesson', entityId: Number(lessonId) });
return R.success(res, "Lesson archived.");
} catch (err) {
await t.rollback();
console.error("[LESSON][ARCHIVE]", err);
return R.error(res, "Could not archive lesson.", 500);
}
};
exports.bulkArchiveLessons = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId, unitId } = req.params;
const { ids = [] } = req.body;
if (!ids.length) return R.error(res, "No IDs provided.", 400);
const lessons = await Lesson.findAll({
where: { lesson_id: ids, unit_id: unitId, ...notDeleted },
include: [{ model: Unit, as: "unit", where: { course_id: courseId, ...notDeleted } }],
});
const validIds = lessons.map((l) => l.lesson_id);
const count = await archiveMany(Lesson, "lesson_id", validIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_archive_lessons', { entityType: 'lesson', details: { ids: validIds, count } });
return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} archived.`);
} catch (err) {
await t.rollback();
console.error("[LESSON][BULK ARCHIVE]", err);
return R.error(res, "Could not archive lessons.", 500);
}
};
exports.getArchivedLessons = async (req, res) => {
try {
const { courseId, unitId } = req.params;
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId }, paranoid: false });
if (!unit) return R.error(res, "Unit not found.", 404);
const result = await paginate(Lesson, req, {
excludeAttributes: adminExclude,
auditOptions: { mdl_Users, parentAlias: "Lesson" },
context: "archived",
findOptions: {
where: { unit_id: unitId, ...onlyDeleted },
paranoid: false,
order: [["order_index", "ASC"]],
},
});
return R.success(res, "Archived lessons retrieved.", result);
} catch (err) {
console.error("[LESSON][GET ARCHIVES]", err);
return R.error(res, "Could not retrieve archived lessons.", 500);
}
};
exports.getArchivedLesson = async (req, res) => {
try {
const { courseId, unitId, lessonId } = req.params;
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId }, paranoid: false });
if (!unit) return R.error(res, "Unit not found.", 404);
const lesson = await Lesson.findOne({
where: { lesson_id: lessonId, unit_id: unitId, ...onlyDeleted },
paranoid: false,
});
if (!lesson) return R.error(res, "Archived lesson not found.", 404);
return R.success(res, "Archived lesson retrieved.", { data: lesson.toJSON() });
} catch (err) {
console.error("[LESSON][GET ARCHIVE ONE]", err);
return R.error(res, "Could not retrieve archived lesson.", 500);
}
};
exports.restoreLesson = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId, unitId, lessonId } = req.params;
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId }, paranoid: false });
if (!unit) return R.error(res, "Unit not found.", 404);
const record = await restoreOne(Lesson, { lesson_id: lessonId, unit_id: unitId, ...onlyDeleted }, req.user.user_id, t);
if (!record) return R.error(res, "Archived lesson not found.", 404);
await t.commit();
logActivity(req.user.user_id, 'restore_lesson', { entityType: 'lesson', entityId: Number(lessonId) });
return R.success(res, "Lesson restored.", { data: record });
} catch (err) {
await t.rollback();
console.error("[LESSON][RESTORE]", err);
return R.error(res, "Could not restore lesson.", 500);
}
};
exports.bulkRestoreLessons = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId, unitId } = req.params;
const { ids = [] } = req.body;
if (!ids.length) return R.error(res, "No IDs provided.", 400);
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId }, paranoid: false });
if (!unit) return R.error(res, "Unit not found.", 404);
const lessons = await Lesson.findAll({ where: { lesson_id: ids, unit_id: unitId, ...onlyDeleted }, paranoid: false });
const validIds = lessons.map((l) => l.lesson_id);
const count = await restoreMany(Lesson, "lesson_id", validIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_restore_lessons', { entityType: 'lesson', details: { ids: validIds, count } });
return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} restored.`);
} catch (err) {
await t.rollback();
console.error("[LESSON][BULK RESTORE]", err);
return R.error(res, "Could not restore lessons.", 500);
}
};
// ══════════════════════════════════════════════════════════════════════════════
// LESSON PAGE
// ══════════════════════════════════════════════════════════════════════════════
exports.getLessonPage = async (req, res) => {
try {
const { lessonId } = req.params;
const page = await LessonPage.findOne({ where: { lesson_id: lessonId } });
if (!page) return R.error(res, "Lesson page not found.", 404);
return R.success(res, "Lesson page retrieved.", { data: page });
} catch (err) {
console.error("[LESSON PAGE][GET]", err);
return R.error(res, "Could not retrieve lesson page.", 500);
}
};
exports.upsertLessonPage = async (req, res) => {
try {
const { lessonId } = req.params;
const { blocks } = req.body;
if (!Array.isArray(blocks)) return R.error(res, "blocks must be an array.", 400);
const lesson = await Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted } });
if (!lesson) return R.error(res, "Lesson not found.", 404);
const [page, created] = await LessonPage.upsert({
lesson_id: lessonId,
blocks,
updatedBy: req.body.updatedBy ?? null,
createdBy: req.body.updatedBy ?? null,
}, { returning: true });
try {
await recomputeDurations(lessonId);
} catch (durErr) {
console.error("[LESSON PAGE][DURATION]", durErr);
}
logActivity(req.user?.user_id, 'upsert_lesson_page', { entityType: 'lesson', entityId: Number(lessonId) });
return R.success(
res,
created ? "Lesson page created." : "Lesson page updated.",
{ data: page },
created ? 201 : 200,
);
} catch (err) {
console.error("[LESSON PAGE][UPSERT]", err);
return R.error(res, "Could not save lesson page.", 500);
}
};
// ══════════════════════════════════════════════════════════════════════════════
// UNIT QUIZ
// ══════════════════════════════════════════════════════════════════════════════
exports.getQuiz = async (req, res) => {
try {
const { courseId, unitId } = req.params;
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
const quiz = await UnitQuiz.findOne({
where: { unit_id: unitId, ...notDeleted },
include: [{
model: QuizQuestion, as: "questions",
where: notDeleted, required: false,
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
}],
});
if (!quiz) return R.error(res, "Quiz not found", 404);
return R.success(res, "Quiz retrieved.", { data: quiz });
} catch (err) {
console.error("[QUIZ][GET]", err);
return R.error(res, "Could not retrieve quiz.", 500);
}
};
exports.createQuiz = async (req, res) => {
try {
const { courseId, unitId } = req.params;
const { title, is_required, passing_score, max_questions, shuffle_questions, createdBy } = req.body;
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
const existing = await UnitQuiz.findOne({ where: { unit_id: unitId, ...notDeleted } });
if (existing) return R.error(res, "Quiz already exists for this unit.", 409);
const quiz = await UnitQuiz.create({
unit_id: unitId,
title: title ?? null,
is_required: is_required ?? false,
passing_score: passing_score ?? 70,
max_questions: max_questions ?? null,
shuffle_questions: shuffle_questions ?? false,
createdBy: createdBy ?? null,
});
logActivity(req.user?.user_id, 'create_quiz', { entityType: 'quiz', entityId: quiz.quiz_id });
return R.success(res, "Quiz created.", { data: quiz }, 201);
} catch (err) {
console.error("[QUIZ][CREATE]", err);
return R.error(res, "Could not create quiz.", 500);
}
};
exports.updateQuiz = async (req, res) => {
try {
const { courseId, unitId, quizId } = req.params;
const { title, is_required, passing_score, max_questions, shuffle_questions, updatedBy } = req.body;
const quiz = await UnitQuiz.findOne({ where: { quiz_id: quizId, unit_id: unitId, ...notDeleted } });
if (!quiz) return R.error(res, "Quiz not found.", 404);
if (title !== undefined) quiz.title = title;
if (is_required !== undefined) quiz.is_required = is_required;
if (passing_score !== undefined) quiz.passing_score = passing_score;
if (max_questions !== undefined) quiz.max_questions = max_questions;
if (shuffle_questions !== undefined) quiz.shuffle_questions = shuffle_questions;
quiz.updatedBy = updatedBy ?? null;
await quiz.save();
logActivity(req.user?.user_id, 'update_quiz', { entityType: 'quiz', entityId: Number(quizId) });
return R.success(res, "Quiz updated.", { data: quiz });
} catch (err) {
console.error("[QUIZ][UPDATE]", err);
return R.error(res, "Could not update quiz.", 500);
}
};
exports.deleteQuiz = async (req, res) => {
const t = await sequelize.transaction();
try {
const { unitId, quizId } = req.params;
const record = await archiveOne(UnitQuiz, { quiz_id: quizId, unit_id: unitId, ...notDeleted }, req.body.deletedBy, t);
if (!record) return R.error(res, "Quiz not found.", 404);
await t.commit();
logActivity(req.user?.user_id, 'archive_quiz', { entityType: 'quiz', entityId: Number(quizId) });
return R.success(res, "Quiz archived.");
} catch (err) {
await t.rollback();
console.error("[QUIZ][ARCHIVE]", err);
return R.error(res, "Could not archive quiz.", 500);
}
};
exports.getArchivedQuiz = async (req, res) => {
try {
const { courseId, unitId } = req.params;
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId }, paranoid: false });
if (!unit) return R.error(res, "Unit not found.", 404);
const quiz = await UnitQuiz.findOne({
where: { unit_id: unitId, ...onlyDeleted },
paranoid: false,
});
if (!quiz) return R.error(res, "Archived quiz not found.", 404);
return R.success(res, "Archived quiz retrieved.", { data: quiz.toJSON() });
} catch (err) {
console.error("[QUIZ][GET ARCHIVE]", err);
return R.error(res, "Could not retrieve archived quiz.", 500);
}
};
exports.restoreQuiz = async (req, res) => {
const t = await sequelize.transaction();
try {
const { unitId, quizId } = req.params;
const record = await restoreOne(UnitQuiz, { quiz_id: quizId, unit_id: unitId, ...onlyDeleted }, req.body.restoredBy, t);
if (!record) return R.error(res, "Archived quiz not found.", 404);
await t.commit();
logActivity(req.user?.user_id, 'restore_quiz', { entityType: 'quiz', entityId: Number(quizId) });
return R.success(res, "Quiz restored.", { data: record });
} catch (err) {
await t.rollback();
console.error("[QUIZ][RESTORE]", err);
return R.error(res, "Could not restore quiz.", 500);
}
};
// ══════════════════════════════════════════════════════════════════════════════
// QUIZ QUESTIONS (shared by unit quiz + course assessment)
// ══════════════════════════════════════════════════════════════════════════════
async function resolveQuestionParent(params) {
const { quizId, assessmentId } = params;
if (quizId) {
const rec = await UnitQuiz.findOne({ where: { quiz_id: quizId, ...notDeleted } });
return { parentField: "quiz_id", parentId: quizId, parentRecord: rec };
}
if (assessmentId) {
const rec = await CourseAssessment.findOne({ where: { assessment_id: assessmentId, ...notDeleted } });
return { parentField: "assessment_id", parentId: assessmentId, parentRecord: rec };
}
return null;
}
exports.getQuestions = async (req, res) => {
try {
const parent = await resolveQuestionParent(req.params);
if (!parent?.parentRecord) return R.error(res, "Parent not found.", 404);
const questions = await QuizQuestion.findAll({
where: { [parent.parentField]: parent.parentId, ...notDeleted },
order: [["order_index", "ASC"]],
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
});
return R.success(res, "Questions retrieved.", { data: questions });
} catch (err) {
console.error("[QUESTION][GET ALL]", err);
return R.error(res, "Could not retrieve questions.", 500);
}
};
exports.createQuestion = async (req, res) => {
const t = await sequelize.transaction();
try {
const parent = await resolveQuestionParent(req.params);
if (!parent?.parentRecord) return R.error(res, "Parent not found.", 404);
const { type, question, explanation, order_index, points, options = [], createdBy } = req.body;
const VALID_TYPES = ["true_false", "multiple_choice", "multi_select"];
if (!VALID_TYPES.includes(type)) return R.error(res, `Invalid type. Must be one of: ${VALID_TYPES.join(", ")}`, 400);
if (!question) return R.error(res, "question text is required.", 400);
let finalOptions = options;
if (type === "true_false" && !options.length) {
finalOptions = [
{ text: "True", is_correct: true, order_index: 0 },
{ text: "False", is_correct: false, order_index: 1 },
];
}
const q = await QuizQuestion.create({
[parent.parentField]: parent.parentId,
type,
question,
explanation: explanation ?? null,
order_index: order_index ?? 0,
points: points ?? 1,
createdBy: createdBy ?? null,
}, { transaction: t });
if (finalOptions.length) {
await QuizOption.bulkCreate(
finalOptions.map((o, i) => ({
question_id: q.question_id,
text: o.text,
is_correct: o.is_correct ?? false,
order_index: o.order_index ?? i,
})),
{ transaction: t }
);
}
await t.commit();
const created = await QuizQuestion.findOne({
where: { question_id: q.question_id },
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
});
logActivity(req.user?.user_id, 'create_question', { entityType: 'question', entityId: created.question_id });
return R.success(res, "Question created.", { data: created }, 201);
} catch (err) {
await t.rollback();
console.error("[QUESTION][CREATE]", err);
return R.error(res, "Could not create question.", 500);
}
};
exports.updateQuestion = async (req, res) => {
const t = await sequelize.transaction();
try {
const { questionId } = req.params;
const q = await QuizQuestion.findOne({ where: { question_id: questionId, ...notDeleted } });
if (!q) return R.error(res, "Question not found.", 404);
const { type, question, explanation, order_index, points, options, updatedBy } = req.body;
if (type !== undefined) q.type = type;
if (question !== undefined) q.question = question;
if (explanation !== undefined) q.explanation = explanation;
if (order_index !== undefined) q.order_index = order_index;
if (points !== undefined) q.points = points;
q.updatedBy = updatedBy ?? null;
await q.save({ transaction: t });
if (options !== undefined) {
await QuizOption.destroy({ where: { question_id: questionId }, transaction: t });
if (options.length) {
await QuizOption.bulkCreate(
options.map((o, i) => ({
question_id: questionId,
text: o.text,
is_correct: o.is_correct ?? false,
order_index: o.order_index ?? i,
})),
{ transaction: t }
);
}
}
await t.commit();
const updated = await QuizQuestion.findOne({
where: { question_id: questionId },
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
});
logActivity(req.user?.user_id, 'update_question', { entityType: 'question', entityId: Number(questionId) });
return R.success(res, "Question updated.", { data: updated });
} catch (err) {
await t.rollback();
console.error("[QUESTION][UPDATE]", err);
return R.error(res, "Could not update question.", 500);
}
};
exports.deleteQuestion = async (req, res) => {
const t = await sequelize.transaction();
try {
const { questionId } = req.params;
const record = await archiveOne(QuizQuestion, { question_id: questionId, ...notDeleted }, req.body.deletedBy, t);
if (!record) return R.error(res, "Question not found.", 404);
await t.commit();
logActivity(req.user?.user_id, 'archive_question', { entityType: 'question', entityId: Number(questionId) });
return R.success(res, "Question archived.");
} catch (err) {
await t.rollback();
console.error("[QUESTION][ARCHIVE]", err);
return R.error(res, "Could not archive question.", 500);
}
};
exports.getArchivedQuestion = async (req, res) => {
try {
const { questionId } = req.params;
const question = await QuizQuestion.findOne({
where: { question_id: questionId, ...onlyDeleted },
paranoid: false,
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
});
if (!question) return R.error(res, "Archived question not found.", 404);
return R.success(res, "Archived question retrieved.", { data: question.toJSON() });
} catch (err) {
console.error("[QUESTION][GET ARCHIVE ONE]", err);
return R.error(res, "Could not retrieve archived question.", 500);
}
};
exports.restoreQuestion = async (req, res) => {
const t = await sequelize.transaction();
try {
const { questionId } = req.params;
const record = await restoreOne(QuizQuestion, { question_id: questionId, ...onlyDeleted }, req.body.restoredBy, t);
if (!record) return R.error(res, "Archived question not found.", 404);
await t.commit();
logActivity(req.user?.user_id, 'restore_question', { entityType: 'question', entityId: Number(questionId) });
return R.success(res, "Question restored.", { data: record });
} catch (err) {
await t.rollback();
console.error("[QUESTION][RESTORE]", err);
return R.error(res, "Could not restore question.", 500);
}
};
exports.bulkArchiveQuestions = async (req, res) => {
const t = await sequelize.transaction();
try {
const parent = await resolveQuestionParent(req.params);
if (!parent?.parentRecord) return R.error(res, "Parent not found.", 404);
const { ids = [], deletedBy } = req.body;
if (!ids.length) return R.error(res, "No IDs provided.", 400);
// Verify all IDs belong to this parent
const questions = await QuizQuestion.findAll({
where: { question_id: ids, [parent.parentField]: parent.parentId, ...notDeleted },
});
const validIds = questions.map((q) => q.question_id);
const count = await archiveMany(QuizQuestion, "question_id", validIds, deletedBy, t);
await t.commit();
logActivity(req.user?.user_id, 'bulk_archive_questions', { entityType: 'question', details: { ids: validIds, count } });
return R.success(res, `${count} question${count !== 1 ? "s" : ""} archived.`);
} catch (err) {
await t.rollback();
console.error("[QUESTION][BULK ARCHIVE]", err);
return R.error(res, "Could not archive questions.", 500);
}
};
exports.bulkRestoreQuestions = async (req, res) => {
const t = await sequelize.transaction();
try {
const parent = await resolveQuestionParent(req.params);
if (!parent?.parentRecord) return R.error(res, "Parent not found.", 404);
const { ids = [], restoredBy } = req.body;
if (!ids.length) return R.error(res, "No IDs provided.", 400);
const questions = await QuizQuestion.findAll({
where: { question_id: ids, [parent.parentField]: parent.parentId, ...onlyDeleted },
paranoid: false,
});
const validIds = questions.map((q) => q.question_id);
const count = await restoreMany(QuizQuestion, "question_id", validIds, restoredBy, t);
await t.commit();
logActivity(req.user?.user_id, 'bulk_restore_questions', { entityType: 'question', details: { ids: validIds, count } });
return R.success(res, `${count} question${count !== 1 ? "s" : ""} restored.`);
} catch (err) {
await t.rollback();
console.error("[QUESTION][BULK RESTORE]", err);
return R.error(res, "Could not restore questions.", 500);
}
};
// ══════════════════════════════════════════════════════════════════════════════
// COURSE ASSESSMENT
// ══════════════════════════════════════════════════════════════════════════════
exports.getAssessment = async (req, res) => {
try {
const { courseId } = req.params;
const assessment = await CourseAssessment.findOne({
where: { course_id: courseId, ...notDeleted },
include: [{
model: QuizQuestion, as: "questions",
where: notDeleted, required: false,
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
}],
});
if (!assessment) return R.error(res, "Assessment not found.", 404);
return R.success(res, "Assessment retrieved.", { data: assessment });
} catch (err) {
console.error("[ASSESSMENT][GET]", err);
return R.error(res, "Could not retrieve assessment.", 500);
}
};
exports.createAssessment = async (req, res) => {
try {
const { courseId } = req.params;
const { title, is_required, passing_score, time_limit_minutes, max_questions, max_attempts, cooldown_hours, shuffle_questions, createdBy } = req.body;
const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted } });
if (!course) return R.error(res, "Course not found.", 404);
const existing = await CourseAssessment.findOne({ where: { course_id: courseId, ...notDeleted } });
if (existing) return R.error(res, "Assessment already exists for this course.", 409);
const assessment = await CourseAssessment.create({
course_id: courseId,
title: title ?? null,
is_required: is_required ?? false,
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,
shuffle_questions: shuffle_questions ?? false,
createdBy: createdBy ?? null,
});
logActivity(req.user?.user_id, 'create_assessment', { entityType: 'assessment', entityId: assessment.assessment_id });
return R.success(res, "Assessment created.", { data: assessment }, 201);
} catch (err) {
console.error("[ASSESSMENT][CREATE]", err);
return R.error(res, "Could not create assessment.", 500);
}
};
exports.updateAssessment = async (req, res) => {
try {
const { courseId, assessmentId } = req.params;
const { title, is_required, passing_score, time_limit_minutes, max_questions, max_attempts, cooldown_hours, shuffle_questions, 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 (time_limit_minutes !== undefined) assessment.time_limit_minutes = time_limit_minutes;
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;
if (shuffle_questions !== undefined) assessment.shuffle_questions = shuffle_questions;
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);
return R.error(res, "Could not update assessment.", 500);
}
};
exports.deleteAssessment = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId, assessmentId } = req.params;
const record = await archiveOne(CourseAssessment, { assessment_id: assessmentId, course_id: courseId, ...notDeleted }, req.body.deletedBy, t);
if (!record) return R.error(res, "Assessment not found.", 404);
await t.commit();
logActivity(req.user?.user_id, 'archive_assessment', { entityType: 'assessment', entityId: Number(assessmentId) });
return R.success(res, "Assessment archived.");
} catch (err) {
await t.rollback();
console.error("[ASSESSMENT][ARCHIVE]", err);
return R.error(res, "Could not archive assessment.", 500);
}
};
exports.getArchivedAssessment = async (req, res) => {
try {
const { courseId } = req.params;
const course = await Course.findOne({ where: { course_id: courseId }, paranoid: false });
if (!course) return R.error(res, "Course not found.", 404);
const assessment = await CourseAssessment.findOne({
where: { course_id: courseId, ...onlyDeleted },
paranoid: false,
});
if (!assessment) return R.error(res, "Archived assessment not found.", 404);
return R.success(res, "Archived assessment retrieved.", { data: assessment.toJSON() });
} catch (err) {
console.error("[ASSESSMENT][GET ARCHIVE]", err);
return R.error(res, "Could not retrieve archived assessment.", 500);
}
};
exports.restoreAssessment = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId, assessmentId } = req.params;
const record = await restoreOne(CourseAssessment, { assessment_id: assessmentId, course_id: courseId, ...onlyDeleted }, req.body.restoredBy, t);
if (!record) return R.error(res, "Archived assessment not found.", 404);
await t.commit();
logActivity(req.user?.user_id, 'restore_assessment', { entityType: 'assessment', entityId: Number(assessmentId) });
return R.success(res, "Assessment restored.", { data: record });
} catch (err) {
await t.rollback();
console.error("[ASSESSMENT][RESTORE]", err);
return R.error(res, "Could not restore assessment.", 500);
}
};
exports.getCourseFieldValues = getFieldValues(Course, "COURSE");
exports.getUnitFieldValues = getFieldValues(Unit, "UNIT");
exports.getLessonFieldValues = getFieldValues(Lesson, "LESSON");
// ── Flat lists for requirement builder dropdowns ───────────────────────────────
// Returns lightweight id+title arrays (no pagination) used when building task requirements.
exports.getCoursesFlat = async (req, res) => {
try {
const data = await Course.findAll({
where: notDeleted,
attributes: ["uuid", "title"],
order: [["title", "ASC"]],
});
return R.success(res, "Courses retrieved.", data);
} catch (err) {
console.error("[COURSE][GET FLAT]", err);
return R.error(res, "Could not retrieve courses.", 500);
}
};
exports.getCoursesBySubscription = async (req, res) => {
try {
const { slug } = req.query;
if (!slug) return R.error(res, 'slug query param is required.', 400);
const data = await Course.findAll({
where: { ...notDeleted, subscription: slug },
attributes: ['course_id', 'title', 'description', 'subscription'],
order: [['title', 'ASC']],
});
return R.success(res, 'Courses retrieved.', data);
} catch (err) {
console.error('[COURSE][BY SUBSCRIPTION]', err);
return R.error(res, 'Could not retrieve courses.', 500);
}
};
exports.getUnitsFlat = async (req, res) => {
try {
const rows = await Unit.findAll({
where: notDeleted,
attributes: ["uuid", "title", "order_index"],
include: [{
model: Course,
as: "course",
attributes: ["title"],
}],
order: [
[{ model: Course, as: "course" }, "title", "ASC"],
["order_index", "ASC"],
],
});
const data = rows.map((u) => ({
uuid: u.uuid,
title: u.title,
order_index: u.order_index ?? 0,
course_title: u.course?.title ?? "",
}));
return R.success(res, "Units retrieved.", data);
} catch (err) {
console.error("[UNIT][GET FLAT]", err);
return R.error(res, "Could not retrieve units.", 500);
}
};
exports.getLessonsFlat = async (req, res) => {
try {
const rows = await Lesson.findAll({
where: notDeleted,
attributes: ["uuid", "title", "order_index"],
include: [{
model: Unit,
as: "unit",
attributes: ["title", "order_index"],
include: [{
model: Course,
as: "course",
attributes: ["title"],
}],
}],
order: [
[{ model: Unit, as: "unit" }, { model: Course, as: "course" }, "title", "ASC"],
[{ model: Unit, as: "unit" }, "order_index", "ASC"],
["order_index", "ASC"],
],
});
const data = rows.map((l) => ({
uuid: l.uuid,
title: l.title,
order_index: l.order_index ?? 0,
unit_title: l.unit?.title ?? "",
unit_order: l.unit?.order_index ?? 0,
course_title: l.unit?.course?.title ?? "",
}));
return R.success(res, "Lessons retrieved.", data);
} catch (err) {
console.error("[LESSON][GET FLAT]", err);
return R.error(res, "Could not retrieve lessons.", 500);
}
};
// ══════════════════════════════════════════════════════════════════════════════
// COURSE INSTRUCTORS
// ══════════════════════════════════════════════════════════════════════════════
exports.getInstructors = async (req, res) => {
try {
const { courseId } = req.params;
const instructors = await CourseInstructor.findAll({
where: { course_id: courseId },
order: [["order_index", "ASC"]],
include: [{
model: mdl_Users,
as: "user",
attributes: ["user_id", "email", "acc_type", "personal_info"],
required: false,
}],
});
return R.success(res, "Instructors retrieved.", { data: instructors });
} catch (err) {
console.error("[INSTRUCTOR][GET]", err);
return R.error(res, "Could not retrieve instructors.", 500);
}
};
exports.syncInstructors = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId } = req.params;
const { instructors = [] } = req.body;
const actor_id = req.user?.user_id ?? null;
const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted } });
if (!course) return R.error(res, "Course not found.", 404);
// Validate any linked user_ids are staff or admin
const linkedIds = instructors.map(i => i.user_id).filter(Boolean);
if (linkedIds.length) {
const validUsers = await mdl_Users.findAll({
where: { user_id: linkedIds, acc_type: ["staff", "admin"], deletedAt: null },
attributes: ["user_id"],
});
const validSet = new Set(validUsers.map(u => String(u.user_id)));
const invalid = linkedIds.find(id => !validSet.has(String(id)));
if (invalid) {
await t.rollback();
return R.error(res, `User ${invalid} is not a staff or admin account.`, 422);
}
}
await CourseInstructor.destroy({ where: { course_id: courseId }, transaction: t });
if (instructors.length) {
await CourseInstructor.bulkCreate(
instructors.map((inst, i) => ({
course_id: courseId,
user_id: inst.user_id ?? null,
display_name: inst.display_name,
order_index: inst.order_index ?? i,
created_by: actor_id,
})),
{ transaction: t }
);
}
await t.commit();
logActivity(actor_id, 'sync_instructors', { entityType: 'course', entityId: Number(courseId), details: { count: instructors.length } });
return R.success(res, "Instructors updated.");
} catch (err) {
await t.rollback();
console.error("[INSTRUCTOR][SYNC]", err);
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, deleted: false };
return {
full_name: user.personal_info?.name?.full_name ?? null,
email: user.email ?? null,
avatar_url: user.personal_info?.avatar?.url ?? null,
deleted: !!user.deletedAt,
};
}
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, deleted } = extractUserInfo(a.user);
map.set(uid, {
user_id: a.user_id,
full_name,
email,
avatar_url,
deleted,
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;
/*
* ┌─────────────────────────────────────────────────────────────────┐
* │ AVG SCORE formula │
* │ │
* │ avg = Σ( max(score) per user ) / unique_user_count │
* │ │
* │ Each student contributes exactly once — their personal best. │
* │ A student who retries 3× before passing counts once at peak │
* │ performance, not three times. Mirrors the "Best Score" column │
* │ shown per student in the completions table. │
* │ │
* │ NOTE: CockroachDB returns INT8 columns as strings via the pg │
* │ driver. Coerce with Number() before any arithmetic. │
* └─────────────────────────────────────────────────────────────────┘
*/
const bestByUser = new Map();
for (const a of attempts) {
const uid = String(a.user_id);
const s = Number(a.score);
if (!bestByUser.has(uid) || s > bestByUser.get(uid)) bestByUser.set(uid, s);
}
const bestScores = [...bestByUser.values()];
const avg = bestScores.length
? Math.round(bestScores.reduce((sum, s) => sum + s, 0) / bestScores.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", "deletedAt"], paranoid: false }],
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", "deletedAt"], paranoid: false }],
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", "deletedAt"], paranoid: false }],
order: [["createdAt", "DESC"]],
});
const rows = sessions.map((s) => {
const j = s.toJSON();
const { full_name, email, avatar_url, deleted } = 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,
deleted,
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);
}
};
// ══════════════════════════════════════════════════════════════════════════════
// COURSE ACHIEVEMENTS
// ══════════════════════════════════════════════════════════════════════════════
exports.getCourseAchievements = async (req, res) => {
try {
const { courseId } = req.params;
const rows = await CourseAchievement.findAll({
where: { course_id: courseId },
order: [["order_index", "ASC"]],
});
return R.success(res, "Course achievements retrieved.", { data: rows });
} catch (err) {
console.error("[COURSE][ACHIEVEMENTS][GET]", err);
return R.error(res, "Could not retrieve course achievements.", 500);
}
};
exports.syncCourseAchievements = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId } = req.params;
const { achievement_keys = [] } = req.body;
if (achievement_keys.length > 3)
return R.error(res, "Maximum 3 achievements allowed per course.", 400);
await CourseAchievement.destroy({ where: { course_id: courseId }, transaction: t });
if (achievement_keys.length) {
await CourseAchievement.bulkCreate(
achievement_keys.map((key, i) => ({ course_id: courseId, achievement_key: key, order_index: i })),
{ transaction: t },
);
}
await t.commit();
const rows = await CourseAchievement.findAll({
where: { course_id: courseId },
order: [["order_index", "ASC"]],
});
return R.success(res, "Course achievements updated.", { data: rows });
} catch (err) {
await t.rollback();
console.error("[COURSE][ACHIEVEMENTS][SYNC]", err);
return R.error(res, "Could not update course achievements.", 500);
}
};