Files
starr-philproperties/controllers/admin/courses.controller.js
T
kennethobsequio 9b8577b79b perform test
test to courses

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-06-24 12:48:51 +08:00

1713 lines
66 KiB
JavaScript

"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,
} = 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 = [],
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,
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);
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,
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;
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, 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,
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, 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;
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, 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,
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, 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;
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.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 };
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);
}
};