"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, recomputeUnitDuration, recomputeCourseDuration, 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 { permanentDeleteOne, permanentDeleteMany } = require("../../utils/courses/permanentDelete.util"); const { getFieldValues } = require("../../utils/fieldValues.util"); const logActivity = require('../../utils/logActivity.util'); const UserNotification = require('../../models/notifications/user_notification.mdl'); const { renderNotification } = require('../../services/notificationTemplate.service'); // ── Models ──────────────────────────────────────────────────────────────────── const { Course, CourseProductCategory: CourseProductCat, Unit, Lesson, LessonPage, CourseObjective, LessonObjective, CoursePrerequisite, CourseAssessment, UnitQuiz, QuizQuestion, QuizOption, QuizAttempt, AssessmentSession, CourseInstructor, CourseAchievement, UnitReadingProgress, LessonReadingProgress, } = require("../../models/courses/courses.associations"); const mdl_Users = require("../../models/users/users.mdl"); const { mdl_PlanCourses, mdl_TierPlans } = require("../../models/tiers/tier.associations"); 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, { key: "assessment_id", label: "Assessment ID", type: "text", literal: `(SELECT assessment_id FROM course_assessments WHERE course_id = "Course"."course_id" AND "deletedAt" IS NULL LIMIT 1)`, }, ], 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, 1).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); } }; exports.getCourseArchiveImpact = async (req, res) => { try { const { courseId } = req.params; const [activeCount, totalCount] = await Promise.all([ UnitReadingProgress.count({ where: { course_id: courseId, status: "in_progress" }, distinct: true, col: "user_id", }), UnitReadingProgress.count({ where: { course_id: courseId }, distinct: true, col: "user_id", }), ]); return R.success(res, "Impact retrieved.", { activeCount, totalCount }); } catch (err) { console.error("[COURSE][ARCHIVE IMPACT]", err); return R.error(res, "Could not retrieve impact.", 500); } }; exports.permanentlyDeleteCourse = async (req, res) => { const t = await sequelize.transaction(); try { const { courseId } = req.params; const record = await permanentDeleteOne(Course, { course_id: courseId }, t); if (record === null) return R.error(res, "Course not found.", 404); if (record === false) return R.error(res, "Course must be archived before it can be permanently deleted.", 400); await t.commit(); logActivity(req.user.user_id, 'permanently_delete_course', { entityType: 'course', entityId: Number(courseId) }); return R.success(res, "Course permanently deleted."); } catch (err) { await t.rollback(); console.error("[COURSE][PERMANENT DELETE]", err); return R.error(res, "Could not permanently delete course.", 500); } }; exports.bulkPermanentlyDeleteCourses = 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 permanentDeleteMany(Course, "course_id", ids, t); await t.commit(); logActivity(req.user.user_id, 'bulk_permanently_delete_courses', { entityType: 'course', details: { ids, count } }); return R.success(res, `${count} course${count !== 1 ? "s" : ""} permanently deleted.`); } catch (err) { await t.rollback(); console.error("[COURSE][BULK PERMANENT DELETE]", err); return R.error(res, "Could not permanently delete courses.", 500); } }; exports.getCoursePermanentDeleteImpact = async (req, res) => { try { const { courseId } = req.params; const unitCount = await Unit.count({ where: { course_id: courseId }, paranoid: false }); const units = await Unit.findAll({ where: { course_id: courseId }, attributes: ["unit_id"], paranoid: false }); const lessonCount = units.length ? await Lesson.count({ where: { unit_id: units.map((u) => u.unit_id) }, paranoid: false }) : 0; return R.success(res, "Impact retrieved.", { unitCount, lessonCount }); } catch (err) { console.error("[COURSE][PERMANENT DELETE IMPACT]", err); return R.error(res, "Could not retrieve impact.", 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.getUnitArchiveImpact = async (req, res) => { try { const { unitId } = req.params; const [completionCount, progressCount] = await Promise.all([ UnitReadingProgress.count({ where: { unit_id: unitId, status: "completed" } }), LessonReadingProgress.count({ where: { unit_id: unitId }, distinct: true, col: "user_id" }), ]); return R.success(res, "Impact retrieved.", { completionCount, progressCount }); } catch (err) { console.error("[UNIT][ARCHIVE IMPACT]", err); return R.error(res, "Could not retrieve impact.", 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(); try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][ARCHIVE][DURATION]", durErr); } 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(); try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][BULK ARCHIVE][DURATION]", durErr); } 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(); try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][RESTORE][DURATION]", durErr); } 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(); try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][BULK RESTORE][DURATION]", durErr); } 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); } }; exports.permanentlyDeleteUnit = async (req, res) => { const t = await sequelize.transaction(); try { const { courseId, unitId } = req.params; const record = await permanentDeleteOne(Unit, { unit_id: unitId, course_id: courseId }, t); if (record === null) return R.error(res, "Unit not found.", 404); if (record === false) return R.error(res, "Unit must be archived before it can be permanently deleted.", 400); await t.commit(); try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][PERMANENT DELETE][DURATION]", durErr); } logActivity(req.user.user_id, 'permanently_delete_unit', { entityType: 'unit', entityId: Number(unitId) }); return R.success(res, "Unit permanently deleted."); } catch (err) { await t.rollback(); console.error("[UNIT][PERMANENT DELETE]", err); return R.error(res, "Could not permanently delete unit.", 500); } }; exports.bulkPermanentlyDeleteUnits = 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 }, paranoid: false }); const validIds = units.map((u) => u.unit_id); const count = await permanentDeleteMany(Unit, "unit_id", validIds, t); await t.commit(); try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][BULK PERMANENT DELETE][DURATION]", durErr); } logActivity(req.user.user_id, 'bulk_permanently_delete_units', { entityType: 'unit', details: { ids: validIds, count } }); return R.success(res, `${count} unit${count !== 1 ? "s" : ""} permanently deleted.`); } catch (err) { await t.rollback(); console.error("[UNIT][BULK PERMANENT DELETE]", err); return R.error(res, "Could not permanently delete units.", 500); } }; exports.getUnitPermanentDeleteImpact = async (req, res) => { try { const { unitId } = req.params; const lessonCount = await Lesson.count({ where: { unit_id: unitId }, paranoid: false }); return R.success(res, "Impact retrieved.", { lessonCount }); } catch (err) { console.error("[UNIT][PERMANENT DELETE IMPACT]", err); return R.error(res, "Could not retrieve impact.", 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(); try { await recomputeUnitDuration(unitId); await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[LESSON][ARCHIVE][DURATION]", durErr); } 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(); try { await recomputeUnitDuration(unitId); await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[LESSON][BULK ARCHIVE][DURATION]", durErr); } 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(); try { await recomputeUnitDuration(unitId); await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[LESSON][RESTORE][DURATION]", durErr); } 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(); try { await recomputeUnitDuration(unitId); await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[LESSON][BULK RESTORE][DURATION]", durErr); } 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); } }; exports.permanentlyDeleteLesson = 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 permanentDeleteOne(Lesson, { lesson_id: lessonId, unit_id: unitId }, t); if (record === null) return R.error(res, "Lesson not found.", 404); if (record === false) return R.error(res, "Lesson must be archived before it can be permanently deleted.", 400); await t.commit(); try { await recomputeUnitDuration(unitId); await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[LESSON][PERMANENT DELETE][DURATION]", durErr); } logActivity(req.user.user_id, 'permanently_delete_lesson', { entityType: 'lesson', entityId: Number(lessonId) }); return R.success(res, "Lesson permanently deleted."); } catch (err) { await t.rollback(); console.error("[LESSON][PERMANENT DELETE]", err); return R.error(res, "Could not permanently delete lesson.", 500); } }; exports.bulkPermanentlyDeleteLessons = 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 }, paranoid: false }); const validIds = lessons.map((l) => l.lesson_id); const count = await permanentDeleteMany(Lesson, "lesson_id", validIds, t); await t.commit(); try { await recomputeUnitDuration(unitId); await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[LESSON][BULK PERMANENT DELETE][DURATION]", durErr); } logActivity(req.user.user_id, 'bulk_permanently_delete_lessons', { entityType: 'lesson', details: { ids: validIds, count } }); return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} permanently deleted.`); } catch (err) { await t.rollback(); console.error("[LESSON][BULK PERMANENT DELETE]", err); return R.error(res, "Could not permanently delete 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); } }; exports.bulkSyncQuestions = 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 { questions = [], updatedBy } = req.body; const existing = await QuizQuestion.findAll({ where: { [parent.parentField]: parent.parentId, ...notDeleted }, }); const existingIds = existing.map((q) => q.question_id); const incomingIds = questions.filter((q) => q.question_id).map((q) => q.question_id); const toArchive = existingIds.filter((id) => !incomingIds.includes(id)); if (toArchive.length) { await QuizQuestion.update( { deletedAt: new Date(), deletedBy: updatedBy ?? null }, { where: { question_id: toArchive }, transaction: t } ); } const result = []; for (let i = 0; i < questions.length; i++) { const { question_id, type, question, explanation, points, options = [] } = questions[i]; if (question_id && existingIds.includes(question_id)) { const q = existing.find((e) => e.question_id === question_id); q.type = type ?? q.type; q.question = question ?? q.question; q.explanation = explanation ?? null; q.order_index = i; q.points = points ?? q.points; q.updatedBy = updatedBy ?? null; await q.save({ transaction: t }); await QuizOption.destroy({ where: { question_id }, transaction: t }); if (options.length) { await QuizOption.bulkCreate( options.map((o, oi) => ({ question_id, text: o.text, is_correct: o.is_correct ?? false, order_index: oi })), { transaction: t } ); } result.push(question_id); } else { const q = await QuizQuestion.create({ [parent.parentField]: parent.parentId, type, question, explanation: explanation ?? null, order_index: i, points: points ?? 1, createdBy: updatedBy ?? null, }, { transaction: t }); if (options.length) { await QuizOption.bulkCreate( options.map((o, oi) => ({ question_id: q.question_id, text: o.text, is_correct: o.is_correct ?? false, order_index: oi })), { transaction: t } ); } result.push(q.question_id); } } await t.commit(); const synced = await QuizQuestion.findAll({ where: { question_id: result }, order: [["order_index", "ASC"]], include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }], }); logActivity(req.user?.user_id, 'bulk_sync_questions', { entityType: 'question', details: { parentField: parent.parentField, parentId: parent.parentId, count: synced.length } }); return R.success(res, "Questions synced.", { data: synced }); } catch (err) { await t.rollback(); console.error("[QUESTION][BULK SYNC]", err); return R.error(res, "Could not sync 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', 'uuid'], }); const notify = await renderNotification({ type: 'assessment_updated', data: { assessmentTitle: assessment.title, courseTitle: course?.title ?? null, courseUuid: course?.uuid ?? 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", "subscription", "duration_seconds"], 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 rows = await Course.findAll({ where: { ...notDeleted, subscription: slug }, attributes: ['course_id', 'title', 'description', 'subscription'], include: [{ model: mdl_PlanCourses, as: 'planCourse', required: false, attributes: ['plan_id'], include: [{ model: mdl_TierPlans, as: 'plan', attributes: ['plan_id', 'label'], }], }], order: [['title', 'ASC']], }); // Flatten so the frontend can just check `assigned_plan` — a course belongs // to at most one plan (UNIQUE constraint on plan_courses.course_id). const data = rows.map((c) => { const plain = c.toJSON(); const assigned_plan = plain.planCourse?.plan ?? null; delete plain.planCourse; return { ...plain, assigned_plan }; }); 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", "duration_seconds"], include: [{ model: Course, as: "course", attributes: ["title", "subscription"], paranoid: false, }], 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, duration_seconds: u.duration_seconds ?? 0, course_title: u.course?.title ?? "", subscription: u.course?.subscription ?? "free", })); 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", "duration_seconds"], include: [{ model: Unit, as: "unit", attributes: ["title", "order_index"], paranoid: false, include: [{ model: Course, as: "course", attributes: ["title", "subscription"], paranoid: false, }], }], 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, duration_seconds: l.duration_seconds ?? 0, unit_title: l.unit?.title ?? "", unit_order: l.unit?.order_index ?? 0, course_title: l.unit?.course?.title ?? "", subscription: l.unit?.course?.subscription ?? "free", })); 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 > 1) return R.error(res, "Maximum 1 achievement 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); } };