diff --git a/controllers/admin/courses.controller.js b/controllers/admin/courses.controller.js index 4fd1178..e0f8151 100644 --- a/controllers/admin/courses.controller.js +++ b/controllers/admin/courses.controller.js @@ -1,27 +1,46 @@ -const { Op } = require("sequelize"); +"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"); + +// ── Models ──────────────────────────────────────────────────────────────────── + +const { + Course, CourseProduct, CourseRole, CourseProductCategory: CourseProductCat, + Unit, Lesson, LessonPage, + CourseObjective, LessonObjective, + CoursePrerequisite, CourseAssessment, + UnitQuiz, QuizQuestion, QuizOption, +} = require("../../models/courses/courses.associations"); -const Course = require("../../models/courses/courses.mdl"); -const Unit = require("../../models/courses/units.mdl"); -const Lesson = require("../../models/courses/lessons.mdl"); -const LessonPage = require("../../models/courses/lesson-page.mdl"); const mdl_Users = require("../../models/users/users.mdl"); +const { + excludeAttributes: courseExclude, + computedAttributes: courseComputed, +} = require("../../models/courses/courses.attributes"); + const notDeleted = { deletedAt: null }; - -// ─── Helpers ────────────────────────────────────────────────────────────────── - +const onlyDeleted = { deletedAt: { [Op.not]: null } }; const auditByFields = ["createdBy", "updatedBy", "deletedBy"]; const adminExclude = []; -// ─── COURSE ─────────────────────────────────────────────────────────────────── +// ══════════════════════════════════════════════════════════════════════════════ +// COURSE +// ══════════════════════════════════════════════════════════════════════════════ exports.getCourses = async (req, res) => { try { const result = await paginate(Course, req, { - excludeAttributes: adminExclude, + excludeAttributes: courseExclude, + computedAttributes: courseComputed, auditOptions: { mdl_Users, parentAlias: "Course" }, context: "list", findOptions: { where: { ...notDeleted } }, @@ -41,19 +60,26 @@ exports.getCourse = async (req, res) => { where: { course_id: courseId, ...notDeleted }, include: [ { - model: Unit, - as: "units", - where: notDeleted, - required: false, - order_index: [["order", "ASC"]], + 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"], ], - order_index: [[{ model: Unit, as: "units" }, "order_index", "ASC"]], }); if (!course) return R.error(res, "Course not found.", 404); - - return R.success(res, "Course retrieved.", { data: course }); + 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); @@ -61,65 +87,233 @@ exports.getCourse = async (req, res) => { }; exports.createCourse = async (req, res) => { + const t = await sequelize.transaction(); try { - const { title, description, order_index } = req.body; + const { + title, description, order_index, + course_code, level, subscription, + objectives = [], + product_ids = [], role_ids = [], 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, - createdBy: req.body.createdBy ?? null, - }); + 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(CourseProduct, course.course_id, product_ids, "product_id", t); + await syncJunction(CourseRole, course.course_id, role_ids, "role_id", t); + await syncJunction(CourseProductCat, course.course_id, category_ids, "category_id", t); + + await t.commit(); 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 } = req.body; + const { + title, description, order_index, + course_code, level, subscription, + objectives, product_ids, role_ids, 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; - course.updatedBy = req.body.updatedBy ?? null; + 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 }); - await course.save(); + if (objectives !== undefined) await syncObjectivesUpdate(CourseObjective, "course_id", courseId, objectives, t); + if (product_ids !== undefined) await syncJunction(CourseProduct, courseId, product_ids, "product_id", t); + if (role_ids !== undefined) await syncJunction(CourseRole, courseId, role_ids, "role_id", t); + if (category_ids !== undefined) await syncJunction(CourseProductCat, courseId, category_ids, "category_id", t); + await t.commit(); 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.deleteCourse = async (req, res) => { +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(); + 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(); + 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(); + 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(); + 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 course.update({ deletedBy: req.body.deletedBy ?? null }); - await course.destroy(); + await CoursePrerequisite.destroy({ where: { course_id: courseId }, transaction: t }); - return R.success(res, "Course deleted."); + 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(); + return R.success(res, "Prerequisites updated."); } catch (err) { - console.error("[COURSE][DELETE]", err); - return R.error(res, "Could not delete course.", 500); + await t.rollback(); + console.error("[PREREQ][SYNC]", err); + return R.error(res, "Could not update prerequisites.", 500); } }; -// ─── UNIT ───────────────────────────────────────────────────────────────────── +// ══════════════════════════════════════════════════════════════════════════════ +// UNIT +// ══════════════════════════════════════════════════════════════════════════════ exports.getUnits = async (req, res) => { try { @@ -152,20 +346,14 @@ exports.getUnit = async (req, res) => { 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: 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 }); + 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); @@ -175,7 +363,7 @@ exports.getUnit = async (req, res) => { exports.createUnit = async (req, res) => { try { const { courseId } = req.params; - const { title, description, order_index } = req.body; + const { title, description, order, createdBy } = req.body; if (!title) return R.error(res, "Title is required.", 400); @@ -186,8 +374,9 @@ exports.createUnit = async (req, res) => { course_id: courseId, title, description: description ?? null, - order_index: order_index ?? 0, - createdBy: req.body.createdBy ?? null, + order_index: order ?? 0, + duration_seconds: 0, + createdBy: createdBy ?? null, }); return R.success(res, "Unit created.", { data: unit }, 201); @@ -201,20 +390,17 @@ exports.updateUnit = async (req, res) => { try { const { courseId, unitId } = req.params; - const unit = await Unit.findOne({ - where: { unit_id: unitId, course_id: courseId, ...notDeleted }, - }); + 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_index } = req.body; + const { title, description, order, updatedBy } = req.body; if (title !== undefined) unit.title = title; if (description !== undefined) unit.description = description; - if (order !== undefined) unit.order = order; - unit.updatedBy = req.body.updatedBy ?? null; + if (order !== undefined) unit.order_index = order; + unit.updatedBy = updatedBy ?? null; await unit.save(); - return R.success(res, "Unit updated.", { data: unit }); } catch (err) { console.error("[UNIT][UPDATE]", err); @@ -223,33 +409,124 @@ exports.updateUnit = async (req, res) => { }; exports.deleteUnit = async (req, res) => { + const t = await sequelize.transaction(); 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); - - await unit.update({ deletedBy: req.body.deletedBy ?? null }); - await unit.destroy(); - - return R.success(res, "Unit deleted."); + const record = await archiveOne(Unit, { unit_id: unitId, course_id: courseId, ...notDeleted }, req.body.deletedBy, t); + if (!record) return R.error(res, "Unit not found.", 404); + await t.commit(); + return R.success(res, "Unit archived."); } catch (err) { - console.error("[UNIT][DELETE]", err); - return R.error(res, "Could not delete unit.", 500); + await t.rollback(); + console.error("[UNIT][ARCHIVE]", err); + return R.error(res, "Could not archive unit.", 500); } }; -// ─── LESSON ─────────────────────────────────────────────────────────────────── +exports.bulkArchiveUnits = async (req, res) => { + const t = await sequelize.transaction(); + try { + const { courseId } = req.params; + const { ids = [], deletedBy } = 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, deletedBy, t); + await t.commit(); + 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: "list", + 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.body.restoredBy, t); + if (!record) return R.error(res, "Archived unit not found.", 404); + await t.commit(); + 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 = [], restoredBy } = 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, restoredBy, t); + await t.commit(); + 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 }, - }); + 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, { @@ -276,22 +553,14 @@ exports.getLesson = async (req, res) => { const lesson = await Lesson.findOne({ where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted }, include: [ - { - model: LessonPage, - as: "page", - required: false, - }, - { - model: Unit, - as: "unit", - where: { course_id: courseId, ...notDeleted }, - }, + { 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 }); + 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); @@ -299,109 +568,221 @@ exports.getLesson = async (req, res) => { }; exports.createLesson = async (req, res) => { + const t = await sequelize.transaction(); try { const { courseId, unitId } = req.params; - const { title, description, order_index } = req.body; + 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 }, - }); + 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_index ?? 0, - createdBy: req.body.createdBy ?? null, - }); + order_index: order ?? 0, + duration_seconds: 0, + createdBy: createdBy ?? null, + }, { transaction: t }); - // ── Auto-create an empty page for this lesson ───────────────────────────── await LessonPage.create({ lesson_id: lesson.lesson_id, blocks: [], - createdBy: req.body.createdBy ?? null, - }); + createdBy: createdBy ?? null, + }, { transaction: t }); + await syncObjectivesCreate(LessonObjective, "lesson_id", lesson.lesson_id, objectives, t); + + await t.commit(); 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 }, - }], + 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_index } = req.body; + const { title, description, order, objectives, updatedBy } = req.body; if (title !== undefined) lesson.title = title; if (description !== undefined) lesson.description = description; - if (order_index !== undefined) lesson.order_index = order_index; - lesson.updatedBy = req.body.updatedBy ?? null; + if (order !== undefined) lesson.order_index = order; + lesson.updatedBy = updatedBy ?? null; + await lesson.save({ transaction: t }); - await lesson.save(); + if (objectives !== undefined) { + await syncObjectivesUpdate(LessonObjective, "lesson_id", lessonId, objectives, t); + } - return R.success(res, "Lesson updated.", { data: lesson }); + await t.commit(); + + const updated = await Lesson.findOne({ + where: { lesson_id: lessonId }, + include: [{ model: LessonObjective, as: "objectives", order: [["order_index", "ASC"]] }], + }); + + 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.deleteLesson = 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 }, - }], + include: [{ model: Unit, as: "unit", where: { course_id: courseId, ...notDeleted } }], }); if (!lesson) return R.error(res, "Lesson not found.", 404); - await lesson.update({ deletedBy: req.body.deletedBy ?? null }); - await lesson.destroy(); + await lesson.update({ deletedBy: req.body.deletedBy ?? null }, { transaction: t }); + await lesson.destroy({ transaction: t }); - return R.success(res, "Lesson deleted."); + await t.commit(); + return R.success(res, "Lesson archived."); } catch (err) { - console.error("[LESSON][DELETE]", err); - return R.error(res, "Could not delete lesson.", 500); + await t.rollback(); + console.error("[LESSON][ARCHIVE]", err); + return R.error(res, "Could not archive lesson.", 500); } }; -// ─── LESSON PAGE ────────────────────────────────────────────────────────────── +exports.bulkArchiveLessons = async (req, res) => { + const t = await sequelize.transaction(); + try { + const { courseId, unitId } = req.params; + const { ids = [], deletedBy } = 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, deletedBy, t); + await t.commit(); + 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: "list", + 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.body.restoredBy, t); + if (!record) return R.error(res, "Archived lesson not found.", 404); + await t.commit(); + 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 = [], restoredBy } = 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, restoredBy, t); + await t.commit(); + 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 }, - }); - + 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); @@ -412,7 +793,7 @@ exports.getLessonPage = async (req, res) => { exports.upsertLessonPage = async (req, res) => { try { const { lessonId } = req.params; - const { blocks } = req.body; + const { blocks } = req.body; if (!Array.isArray(blocks)) return R.error(res, "blocks must be an array.", 400); @@ -424,9 +805,13 @@ exports.upsertLessonPage = async (req, res) => { blocks, updatedBy: req.body.updatedBy ?? null, createdBy: req.body.updatedBy ?? null, - }, { - returning: true, - }); + }, { returning: true }); + + try { + await recomputeDurations(lessonId); + } catch (durErr) { + console.error("[LESSON PAGE][DURATION]", durErr); + } return R.success( res, @@ -439,3 +824,541 @@ exports.upsertLessonPage = async (req, res) => { 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, + }); + + 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(); + 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(); + 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(); + 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"]] }], + }); + + 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"]] }], + }); + + 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(); + 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(); + 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(); + 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(); + 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, 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, // ← add + createdBy: createdBy ?? null, + }); + + 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, 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; + + assessment.updatedBy = updatedBy ?? null; + + await assessment.save(); + 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(); + 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(); + 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 = async (req, res) => { + try { + const { field } = req.query; + if (!field) return R.error(res, "Field is required.", 400); + + const allowedFields = Object.keys(Course.rawAttributes).filter( + (key) => Course.rawAttributes[key].filterable === true + ); + const dateFields = ["createdAt", "updatedAt", "deletedAt"]; + + if (!allowedFields.includes(field) && !dateFields.includes(field)) + return R.error(res, "Invalid or restricted field.", 400); + + if (auditByFields.includes(field)) { + const [rows] = await sequelize.query(` + SELECT DISTINCT u."personal_info"->'name'->>'full_name' AS value + FROM courses c + JOIN users u ON u.user_id = c."${field}" + WHERE c."${field}" IS NOT NULL + AND u."personal_info"->'name'->>'full_name' IS NOT NULL + ORDER BY value ASC + `); + return R.success(res, "Field values retrieved.", rows.map((r) => r.value).filter(Boolean)); + } + + if (dateFields.includes(field)) { + const results = await Course.findAll({ + attributes: [[Sequelize.fn("DISTINCT", Sequelize.fn("DATE", Sequelize.col(field))), "value"]], + where: { [field]: { [Op.ne]: null } }, + order: [[Sequelize.fn("DATE", Sequelize.col(field)), "DESC"]], + raw: true, + }); + return R.success(res, "Field values retrieved.", results.map((r) => r.value).filter(Boolean)); + } + + const results = await Course.findAll({ + attributes: [[Sequelize.fn("DISTINCT", Sequelize.col(field)), "value"]], + where: { [field]: { [Op.ne]: null } }, + raw: true, + }); + return R.success(res, "Field values retrieved.", results.map((r) => r.value).filter(Boolean).sort()); + + } catch (err) { + console.error("[COURSE][GET FIELD VALUES]", err); + return R.error(res, "Could not retrieve field values.", 500); + } +}; \ No newline at end of file diff --git a/controllers/admin/lessons.controller.js b/controllers/admin/lessons.controller.js new file mode 100644 index 0000000..04e2429 --- /dev/null +++ b/controllers/admin/lessons.controller.js @@ -0,0 +1,510 @@ +"use strict"; + +const { Op } = require("sequelize"); +const sequelize = require("../../config/db.config"); +const R = require("../../utils/response.util"); +const { paginate } = require("../../utils/paginate.util"); +const { recomputeDurations } = require("../../utils/duration.util"); +const { syncObjectivesCreate, syncObjectivesUpdate } = require("../../utils/courses/objectives.util"); +const { archiveOne, archiveMany } = require("../../utils/courses/archive.util"); +const { restoreOne, restoreMany } = require("../../utils/courses/restore.util"); + +// ── Models ──────────────────────────────────────────────────────────────────── + +const { + Unit, Lesson, LessonPage, + LessonObjective, + CourseAssessment, UnitQuiz, + QuizQuestion, QuizOption, +} = require("../../models/courses/courses.associations"); + +const mdl_Users = require("../../models/users/users.mdl"); + +const notDeleted = { deletedAt: null }; +const onlyDeleted = { deletedAt: { [Op.not]: null } }; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +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; +} + +// ══════════════════════════════════════════════════════════════════════════════ +// 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, { + 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(); + 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"]] }], + }); + + 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.deleteLesson = 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); + + await lesson.update({ deletedBy: req.body.deletedBy ?? null }, { transaction: t }); + await lesson.destroy({ transaction: t }); + + await t.commit(); + return R.success(res, "Lesson archived."); + } catch (err) { + await t.rollback(); + console.error("[LESSON][DELETE]", 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 = [], deletedBy } = 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, deletedBy, t); + await t.commit(); + 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, { + auditOptions: { mdl_Users, parentAlias: "Lesson" }, + context: "list", + 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.body.restoredBy, + t + ); + if (!record) return R.error(res, "Archived lesson not found.", 404); + await t.commit(); + 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 = [], restoredBy } = 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, restoredBy, t); + await t.commit(); + 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); + } + + 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); + } +}; + +// ══════════════════════════════════════════════════════════════════════════════ +// QUIZ QUESTIONS +// ══════════════════════════════════════════════════════════════════════════════ + +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"]] }], + }); + + 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"]] }], + }); + + 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(); + return R.success(res, "Question archived."); + } catch (err) { + await t.rollback(); + console.error("[QUESTION][DELETE]", err); + return R.error(res, "Could not archive question.", 500); + } +}; \ No newline at end of file diff --git a/controllers/admin/units.controller.js b/controllers/admin/units.controller.js new file mode 100644 index 0000000..415b9e2 --- /dev/null +++ b/controllers/admin/units.controller.js @@ -0,0 +1,335 @@ +"use strict"; + +const { Op } = require("sequelize"); +const sequelize = require("../../config/db.config"); +const R = require("../../utils/response.util"); +const { paginate } = require("../../utils/paginate.util"); +const { archiveOne, archiveMany } = require("../../utils/courses/archive.util"); +const { restoreOne, restoreMany } = require("../../utils/courses/restore.util"); + +// ── Models ──────────────────────────────────────────────────────────────────── + +const { + Course, Unit, Lesson, + UnitQuiz, QuizQuestion, QuizOption, +} = require("../../models/courses/courses.associations"); + +const mdl_Users = require("../../models/users/users.mdl"); + +const notDeleted = { deletedAt: null }; +const onlyDeleted = { deletedAt: { [Op.not]: null } }; + +// ══════════════════════════════════════════════════════════════════════════════ +// 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, { + 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 }, + { 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, + }); + + 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(); + 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.deleteUnit = 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.body.deletedBy, + t + ); + if (!record) return R.error(res, "Unit not found.", 404); + await t.commit(); + return R.success(res, "Unit archived."); + } catch (err) { + await t.rollback(); + console.error("[UNIT][DELETE]", 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 = [], deletedBy } = 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, deletedBy, t); + await t.commit(); + 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, { + auditOptions: { mdl_Users, parentAlias: "Unit" }, + context: "list", + 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.body.restoredBy, + t + ); + if (!record) return R.error(res, "Archived unit not found.", 404); + await t.commit(); + 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 = [], restoredBy } = 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, restoredBy, t); + await t.commit(); + 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); + } +}; + +// ══════════════════════════════════════════════════════════════════════════════ +// 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, 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, + createdBy: createdBy ?? null, + }); + + 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, 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; + quiz.updatedBy = updatedBy ?? null; + + await quiz.save(); + 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(); + return R.success(res, "Quiz archived."); + } catch (err) { + await t.rollback(); + console.error("[QUIZ][DELETE]", err); + return R.error(res, "Could not archive quiz.", 500); + } +}; \ No newline at end of file diff --git a/models/courses/course_assessment.mdl.js b/models/courses/course_assessment.mdl.js new file mode 100644 index 0000000..5dca457 --- /dev/null +++ b/models/courses/course_assessment.mdl.js @@ -0,0 +1,22 @@ +const { DataTypes } = require("sequelize"); +const sequelize = require("../../config/db.config"); + +const CourseAssessment = sequelize.define("CourseAssessment", { + assessment_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, unique: true }, + course_id: { type: DataTypes.BIGINT, allowNull: false, unique: true }, // one per course + title: { type: DataTypes.STRING(255), allowNull: true }, + is_required: { type: DataTypes.BOOLEAN, defaultValue: false }, + passing_score: { type: DataTypes.INTEGER, defaultValue: 70 }, + time_limit_minutes: { type: DataTypes.INTEGER, allowNull: true }, // null = no limit + max_questions: { type: DataTypes.INTEGER, allowNull: true }, // null = show all + createdBy: { type: DataTypes.BIGINT, allowNull: true }, + updatedBy: { type: DataTypes.BIGINT, allowNull: true }, + deletedBy: { type: DataTypes.BIGINT, allowNull: true }, +}, { + tableName: "course_assessments", + timestamps: true, + paranoid: true, +}); + +module.exports = CourseAssessment \ No newline at end of file diff --git a/models/courses/course_objective.mdl.js b/models/courses/course_objective.mdl.js new file mode 100644 index 0000000..74db58c --- /dev/null +++ b/models/courses/course_objective.mdl.js @@ -0,0 +1,15 @@ +const { DataTypes } = require("sequelize"); +const sequelize = require("../../config/db.config"); + +const CourseObjective = sequelize.define("CourseObjective", { + objective_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + course_id: { type: DataTypes.BIGINT, allowNull: false }, + text: { type: DataTypes.TEXT, allowNull: false }, + order_index: { type: DataTypes.INTEGER, defaultValue: 0 }, +}, { + tableName: "course_objectives", + timestamps: true, + paranoid: true, +}); + +module.exports = CourseObjective \ No newline at end of file diff --git a/models/courses/course_prerequisite.mdl.js b/models/courses/course_prerequisite.mdl.js new file mode 100644 index 0000000..3a7fcfe --- /dev/null +++ b/models/courses/course_prerequisite.mdl.js @@ -0,0 +1,15 @@ +const { DataTypes } = require("sequelize"); +const sequelize = require("../../config/db.config"); + +const CoursePrerequisite = sequelize.define("CoursePrerequisite", { + prereq_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + course_id: { type: DataTypes.BIGINT, allowNull: false }, // the course that HAS this prereq + ref_type: { type: DataTypes.ENUM("course", "unit", "lesson"), allowNull: false }, + ref_id: { type: DataTypes.BIGINT, allowNull: false }, // FK to courses/units/lessons + order_index:{ type: DataTypes.INTEGER, defaultValue: 0 }, +}, { + tableName: "course_prerequisites", + timestamps: true, +}); + +module.exports = CoursePrerequisite \ No newline at end of file diff --git a/models/courses/courses.associations.js b/models/courses/courses.associations.js new file mode 100644 index 0000000..3a03dbf --- /dev/null +++ b/models/courses/courses.associations.js @@ -0,0 +1,67 @@ +// models/courses/associations.js + +const { Course, CourseProduct, CourseRole, CourseProductCategory } = require("./courses.mdl"); +const Unit = require("./units.mdl"); +const Lesson = require("./lessons.mdl"); +const LessonPage = require("./lesson_page.mdl"); +const CourseObjective = require("./course_objective.mdl"); +const LessonObjective = require("./lesson_objective.mdl"); +const CoursePrerequisite = require("./course_prerequisite.mdl"); +const CourseAssessment = require("./course_assessment.mdl"); +const UnitQuiz = require("./unit_quiz.mdl"); +const QuizQuestion = require("./quiz_question.mdl"); +const QuizOption = require("./quiz_option.mdl"); +const mdl_Users = require("../users/users.mdl"); + +// ── Course ──────────────────────────────────────────────────────────────────── +Course.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" }); +Course.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" }); +Course.hasMany(Unit, { as: "units", foreignKey: "course_id" }); +Course.hasMany(CourseObjective, { as: "objectives", foreignKey: "course_id" }); +Course.hasMany(CoursePrerequisite, { as: "prerequisites", foreignKey: "course_id" }); +Course.hasOne(CourseAssessment, { as: "assessment", foreignKey: "course_id" }); +Course.hasMany(CourseProduct, { as: "products", foreignKey: "course_id" }); +Course.hasMany(CourseRole, { as: "roles", foreignKey: "course_id" }); +Course.hasMany(CourseProductCategory, { as: "categories", foreignKey: "course_id" }); + +// ── Unit ────────────────────────────────────────────────────────────────────── +Unit.belongsTo(Course, { as: "course", foreignKey: "course_id" }); +Unit.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" }); +Unit.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" }); +Unit.hasMany(Lesson, { as: "lessons", foreignKey: "unit_id" }); +Unit.hasOne(UnitQuiz, { as: "quiz", foreignKey: "unit_id" }); + +// ── Lesson ──────────────────────────────────────────────────────────────────── +Lesson.belongsTo(Unit, { as: "unit", foreignKey: "unit_id" }); +Lesson.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" }); +Lesson.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" }); +Lesson.hasOne(LessonPage, { as: "page", foreignKey: "lesson_id" }); +Lesson.hasMany(LessonObjective, { as: "objectives", foreignKey: "lesson_id" }); + +// ── UnitQuiz ────────────────────────────────────────────────────────────────── +UnitQuiz.belongsTo(Unit, { as: "unit", foreignKey: "unit_id" }); +UnitQuiz.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" }); +UnitQuiz.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" }); +UnitQuiz.hasMany(QuizQuestion, { as: "questions", foreignKey: "quiz_id" }); + +// ── CourseAssessment ────────────────────────────────────────────────────────── +CourseAssessment.belongsTo(Course, { as: "course", foreignKey: "course_id" }); +CourseAssessment.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" }); +CourseAssessment.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" }); +CourseAssessment.hasMany(QuizQuestion, { as: "questions", foreignKey: "assessment_id" }); + +// ── QuizQuestion ────────────────────────────────────────────────────────────── +QuizQuestion.belongsTo(UnitQuiz, { as: "quiz", foreignKey: "quiz_id" }); +QuizQuestion.belongsTo(CourseAssessment, { as: "assessment", foreignKey: "assessment_id" }); +QuizQuestion.hasMany(QuizOption, { as: "options", foreignKey: "question_id" }); + +// ── QuizOption ──────────────────────────────────────────────────────────────── +QuizOption.belongsTo(QuizQuestion, { as: "question", foreignKey: "question_id" }); + +module.exports = { + Course, CourseProduct, CourseRole, CourseProductCategory, + Unit, Lesson, LessonPage, + CourseObjective, LessonObjective, + CoursePrerequisite, CourseAssessment, + UnitQuiz, QuizQuestion, QuizOption, +}; \ No newline at end of file diff --git a/models/courses/courses.attributes.js b/models/courses/courses.attributes.js new file mode 100644 index 0000000..2c1e247 --- /dev/null +++ b/models/courses/courses.attributes.js @@ -0,0 +1,51 @@ +const excludeAttributes = [ +]; + +const jsonbSchemas = { + // Add here +}; + +// Different exclude sets per role +const adminExclude = [ + ...excludeAttributes, + // admins can see audit fields, so nothing extra excluded +]; + +const userExclude = [ + ...excludeAttributes, + // regular users cannot see audit trails + "created_by", "updated_by", "deleted_by", + "deleted_at", +]; + +const computedAttributes = [ + { + key: "unitCount", + label: "Units", + type: "number", + order: 5, + literal: `( + SELECT CAST(COUNT(*) AS INTEGER) + FROM "units" + WHERE "units"."course_id" = "Course"."course_id" + AND "units"."deletedAt" IS NULL + )`, + filterable: false, + }, + { + key: "lessonCount", + label: "Lessons", + type: "number", + order: 6, + literal: `( + SELECT CAST(COUNT(*) AS INTEGER) + FROM "lessons" + INNER JOIN "units" ON "lessons"."unit_id" = "units"."unit_id" + WHERE "units"."course_id" = "Course"."course_id" + AND "lessons"."deletedAt" IS NULL + )`, + filterable: false, + }, +]; + +module.exports = { excludeAttributes, adminExclude, userExclude, jsonbSchemas, computedAttributes }; \ No newline at end of file diff --git a/models/courses/courses.mdl.js b/models/courses/courses.mdl.js index 08af8b4..8b83f87 100644 --- a/models/courses/courses.mdl.js +++ b/models/courses/courses.mdl.js @@ -1,32 +1,48 @@ const { DataTypes } = require("sequelize"); const sequelize = require("../../config/db.config"); -const mdl_Users = require("../users/users.mdl"); const Course = sequelize.define("Course", { - - course_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, - uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true }, - - title: { type: DataTypes.STRING(255), allowNull: false, label: "Title" }, - description: { type: DataTypes.TEXT, allowNull: true, label: "Description" }, - order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Order" }, - - createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, - updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Updated By" }, - deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" }, - + course_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, hidden: true, order: 0, filterable: true }, + uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, hidden: true, order: 0, filterable: true }, + title: { type: DataTypes.STRING(255), allowNull: false, hidden: false, order: 2, filterable: true }, + description: { type: DataTypes.TEXT, allowNull: true, hidden: true, order: 0, filterable: true }, + course_code: { type: DataTypes.STRING(50), allowNull: true, unique: true, hidden: false, order: 1, filterable: true }, + order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, hidden: false, order: 7, filterable: false }, + level: { type: DataTypes.ENUM("beginner", "intermediate", "advanced"), allowNull: true, hidden: false, order: 3, filterable: true }, + subscription: { type: DataTypes.ENUM("free", "premium"), allowNull: false, defaultValue: "free", hidden: false, order: 4, filterable: true }, + duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, hidden: false, order: 8, filterable: false }, + createdBy: { type: DataTypes.BIGINT, allowNull: true }, + updatedBy: { type: DataTypes.BIGINT, allowNull: true }, + deletedBy: { type: DataTypes.BIGINT, allowNull: true }, }, { tableName: "courses", timestamps: true, paranoid: true, - indexes: [ - { fields: ["uuid"] }, - { fields: ["order_index"] }, - { fields: ["deletedAt"] }, - ], }); -Course.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" }); -Course.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" }); +// ── Junction Tables ─────────────────────────────────────────────────────────── -module.exports = Course; +const CourseProduct = sequelize.define("CourseProduct", { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + course_id: { type: DataTypes.BIGINT, allowNull: false }, + product_id: { type: DataTypes.BIGINT, allowNull: false }, +}, { tableName: "course_products", timestamps: true }); + +const CourseRole = sequelize.define("CourseRole", { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + course_id: { type: DataTypes.BIGINT, allowNull: false }, + role_id: { type: DataTypes.BIGINT, allowNull: false }, +}, { tableName: "course_roles", timestamps: true }); + +const CourseProductCategory = sequelize.define("CourseProductCategory", { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + course_id: { type: DataTypes.BIGINT, allowNull: false }, + category_id: { type: DataTypes.BIGINT, allowNull: false }, +}, { tableName: "course_product_categories", timestamps: true }); + +module.exports = { + Course, + CourseProduct, + CourseRole, + CourseProductCategory, +}; \ No newline at end of file diff --git a/models/courses/lesson_objective.mdl.js b/models/courses/lesson_objective.mdl.js new file mode 100644 index 0000000..dc6748c --- /dev/null +++ b/models/courses/lesson_objective.mdl.js @@ -0,0 +1,15 @@ +const { DataTypes } = require("sequelize"); +const sequelize = require("../../config/db.config"); + +const LessonObjective = sequelize.define("LessonObjective", { + objective_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + lesson_id: { type: DataTypes.BIGINT, allowNull: false }, + text: { type: DataTypes.TEXT, allowNull: false }, + order_index: { type: DataTypes.INTEGER, defaultValue: 0 }, +}, { + tableName: "lesson_objectives", + timestamps: true, + paranoid: true, +}); + +module.exports = LessonObjective \ No newline at end of file diff --git a/models/courses/lesson-page.mdl.js b/models/courses/lesson_page.mdl.js similarity index 61% rename from models/courses/lesson-page.mdl.js rename to models/courses/lesson_page.mdl.js index 4bac65d..cf71343 100644 --- a/models/courses/lesson-page.mdl.js +++ b/models/courses/lesson_page.mdl.js @@ -1,7 +1,5 @@ const { DataTypes } = require("sequelize"); const sequelize = require("../../config/db.config"); -const mdl_Users = require("../users/users.mdl"); -const Lesson = require("./lessons.mdl"); const LessonPage = sequelize.define("LessonPage", { page_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, @@ -15,9 +13,4 @@ const LessonPage = sequelize.define("LessonPage", { paranoid: false, }); -LessonPage.belongsTo(Lesson, { as: "lesson", foreignKey: "lesson_id" }); -LessonPage.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" }); -LessonPage.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" }); -Lesson.hasOne(LessonPage, { as: "page", foreignKey: "lesson_id" }); - module.exports = LessonPage; \ No newline at end of file diff --git a/models/courses/lessons.mdl.js b/models/courses/lessons.mdl.js index 5aac3df..8a8b62c 100644 --- a/models/courses/lessons.mdl.js +++ b/models/courses/lessons.mdl.js @@ -1,22 +1,24 @@ const { DataTypes } = require("sequelize"); -const sequelize = require("../../config/db.config"); -const mdl_Users = require("../users/users.mdl"); -const Unit = require("./units.mdl"); +const sequelize = require("../../config/db.config"); const Lesson = sequelize.define("Lesson", { - lesson_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, - uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true }, - unit_id: { type: DataTypes.BIGINT, allowNull: false }, - title: { type: DataTypes.STRING(255), allowNull: false, label: "Title" }, - description: { type: DataTypes.TEXT, allowNull: true, label: "Description" }, - order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Order" }, - createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, - updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Updated By" }, - deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" }, + lesson_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, hidden: true, order: 0 }, + uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, hidden: true, order: 0 }, + unit_id: { type: DataTypes.BIGINT, allowNull: false, hidden: true, order: 0 }, + + title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", hidden: false, order: 1 }, + description: { type: DataTypes.TEXT, allowNull: true, label: "Description", hidden: false, order: 2 }, + + duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0 }, // computed from blocks on save + + order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Order", hidden: false, order: 3 }, + createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, + updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Updated By" }, + deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" }, }, { - tableName: "lessons", + tableName: "lessons", timestamps: true, - paranoid: true, + paranoid: true, indexes: [ { fields: ["uuid"] }, { fields: ["unit_id"] }, @@ -25,9 +27,4 @@ const Lesson = sequelize.define("Lesson", { ], }); -Lesson.belongsTo(Unit, { as: "unit", foreignKey: "unit_id" }); -Lesson.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" }); -Lesson.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" }); -Unit.hasMany(Lesson, { as: "lessons", foreignKey: "unit_id" }); - module.exports = Lesson; \ No newline at end of file diff --git a/models/courses/quiz_option.mdl.js b/models/courses/quiz_option.mdl.js new file mode 100644 index 0000000..28c0a65 --- /dev/null +++ b/models/courses/quiz_option.mdl.js @@ -0,0 +1,15 @@ +const { DataTypes } = require("sequelize"); +const sequelize = require("../../config/db.config"); + +const QuizOption = sequelize.define("QuizOption", { + option_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + question_id: { type: DataTypes.BIGINT, allowNull: false }, + text: { type: DataTypes.TEXT, allowNull: false }, + is_correct: { type: DataTypes.BOOLEAN, defaultValue: false }, + order_index: { type: DataTypes.INTEGER, defaultValue: 0 }, +}, { + tableName: "quiz_options", + timestamps: true, +}); + +module.exports = QuizOption \ No newline at end of file diff --git a/models/courses/quiz_question.mdl.js b/models/courses/quiz_question.mdl.js new file mode 100644 index 0000000..222ce26 --- /dev/null +++ b/models/courses/quiz_question.mdl.js @@ -0,0 +1,28 @@ +const { DataTypes } = require("sequelize"); +const sequelize = require("../../config/db.config"); + +const QuizQuestion = sequelize.define("QuizQuestion", { + question_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, unique: true }, + + // polymorphic: belongs to either unit_quiz OR course_assessment + quiz_id: { type: DataTypes.BIGINT, allowNull: true }, // → unit_quizzes + assessment_id: { type: DataTypes.BIGINT, allowNull: true }, // → course_assessments + + type: { + type: DataTypes.ENUM("true_false", "multiple_choice", "multi_select"), + allowNull: false, + }, + question: { type: DataTypes.TEXT, allowNull: false }, + explanation: { type: DataTypes.TEXT, allowNull: true }, // shown after answer + order_index: { type: DataTypes.INTEGER, defaultValue: 0 }, + points: { type: DataTypes.INTEGER, defaultValue: 1 }, + createdBy: { type: DataTypes.BIGINT, allowNull: true }, + deletedBy: { type: DataTypes.BIGINT, allowNull: true }, +}, { + tableName: "quiz_questions", + timestamps: true, + paranoid: true, +}); + +module.exports = QuizQuestion \ No newline at end of file diff --git a/models/courses/unit_quiz.mdl.js b/models/courses/unit_quiz.mdl.js new file mode 100644 index 0000000..315fb61 --- /dev/null +++ b/models/courses/unit_quiz.mdl.js @@ -0,0 +1,21 @@ +const { DataTypes } = require("sequelize"); +const sequelize = require("../../config/db.config"); + +const UnitQuiz = sequelize.define("UnitQuiz", { + quiz_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, unique: true }, + unit_id: { type: DataTypes.BIGINT, allowNull: false, unique: true }, // one quiz per unit + title: { type: DataTypes.STRING(255), allowNull: true }, + is_required: { type: DataTypes.BOOLEAN, defaultValue: false }, + passing_score: { type: DataTypes.INTEGER, defaultValue: 70 }, // percentage + max_questions: { type: DataTypes.INTEGER, allowNull: true }, // null = show all + createdBy: { type: DataTypes.BIGINT, allowNull: true }, + updatedBy: { type: DataTypes.BIGINT, allowNull: true }, + deletedBy: { type: DataTypes.BIGINT, allowNull: true }, +}, { + tableName: "unit_quizzes", + timestamps: true, + paranoid: true, +}); + +module.exports = UnitQuiz \ No newline at end of file diff --git a/models/courses/units.mdl.js b/models/courses/units.mdl.js index f3b6ed3..a10ae34 100644 --- a/models/courses/units.mdl.js +++ b/models/courses/units.mdl.js @@ -1,22 +1,24 @@ const { DataTypes } = require("sequelize"); -const sequelize = require("../../config/db.config"); -const mdl_Users = require("../users/users.mdl"); -const Course = require("./courses.mdl"); +const sequelize = require("../../config/db.config"); const Unit = sequelize.define("Unit", { - unit_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, - uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true }, - course_id: { type: DataTypes.BIGINT, allowNull: false }, - title: { type: DataTypes.STRING(255), allowNull: false, label: "Title" }, - description: { type: DataTypes.TEXT, allowNull: true, label: "Description" }, - order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Order" }, - createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, - updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Updated By" }, - deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" }, + unit_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, hidden: true, order: 0 }, + uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, hidden: true, order: 0 }, + course_id: { type: DataTypes.BIGINT, allowNull: false, hidden: true, order: 0 }, + + title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", hidden: false, order: 1 }, + description: { type: DataTypes.TEXT, allowNull: true, label: "Description", hidden: false, order: 2 }, + order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Order", hidden: false, order: 3 }, + + duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0 }, + + createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, + updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Updated By" }, + deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" }, }, { - tableName: "units", + tableName: "units", timestamps: true, - paranoid: true, + paranoid: true, indexes: [ { fields: ["uuid"] }, { fields: ["course_id"] }, @@ -25,9 +27,4 @@ const Unit = sequelize.define("Unit", { ], }); -Unit.belongsTo(Course, { as: "course", foreignKey: "course_id" }); -Unit.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" }); -Unit.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" }); -Course.hasMany(Unit, { as: "units", foreignKey: "course_id" }); - module.exports = Unit; \ No newline at end of file diff --git a/routes/admin/admin.routes.js b/routes/admin/admin.routes.js index b116219..693a9d9 100644 --- a/routes/admin/admin.routes.js +++ b/routes/admin/admin.routes.js @@ -25,6 +25,7 @@ const dashboardRoutes = require('./dashboard.routes'); const usersRoutes = require('./users.routes'); const groupsRoutes = require('./groups.routes'); const assetsRoutes = require('./assets.routes'); +const coursesRoutes = require('./courses.routes') // ── Guards — applied to ALL admin routes ────────────────────────────────────── router.use(authenticate, requireAdmin(), adminLimiter); @@ -34,5 +35,6 @@ router.use('/dashboard', dashboardRoutes); router.use('/users', usersRoutes); router.use('/groups', groupsRoutes); router.use('/assets', assetsRoutes); +router.use('/courses', coursesRoutes); module.exports = router; \ No newline at end of file diff --git a/routes/admin/courses.routes.js b/routes/admin/courses.routes.js index 2c7b579..c14cf16 100644 --- a/routes/admin/courses.routes.js +++ b/routes/admin/courses.routes.js @@ -1,30 +1,135 @@ const router = require("express").Router(); -const controller = require("../../controllers/admin/courses.controller"); -const { sensitiveOpsLimiter } = require("../../middleware/rateLimiter.middleware"); +const ctrl = require("../../controllers/admin/courses.controller"); -// ─── Courses ────────────────────────────────────────────────────────────────── -router.get("/", controller.getCourses); -router.post("/", sensitiveOpsLimiter, controller.createCourse); -router.get("/:courseId", controller.getCourse); -router.patch("/:courseId", sensitiveOpsLimiter, controller.updateCourse); -router.delete("/:courseId", sensitiveOpsLimiter, controller.deleteCourse); +const { authenticate } = require("../../middleware/auth.middleware"); +const { requireAdmin } = require("../../middleware/rbac.middleware"); +const { adminLimiter } = require("../../middleware/rateLimiter.middleware"); -// ─── Units ──────────────────────────────────────────────────────────────────── -router.get("/:courseId/units", controller.getUnits); -router.post("/:courseId/units", sensitiveOpsLimiter, controller.createUnit); -router.get("/:courseId/units/:unitId", controller.getUnit); -router.patch("/:courseId/units/:unitId", sensitiveOpsLimiter, controller.updateUnit); -router.delete("/:courseId/units/:unitId", sensitiveOpsLimiter, controller.deleteUnit); +router.use(authenticate, requireAdmin(), adminLimiter); -// ─── Lessons ────────────────────────────────────────────────────────────────── -router.get("/:courseId/units/:unitId/lessons", controller.getLessons); -router.post("/:courseId/units/:unitId/lessons", sensitiveOpsLimiter, controller.createLesson); -router.get("/:courseId/units/:unitId/lessons/:lessonId", controller.getLesson); -router.patch("/:courseId/units/:unitId/lessons/:lessonId", sensitiveOpsLimiter, controller.updateLesson); -router.delete("/:courseId/units/:unitId/lessons/:lessonId", sensitiveOpsLimiter, controller.deleteLesson); +// ══════════════════════════════════════════════════════════════════════════════ +// COURSES +// ══════════════════════════════════════════════════════════════════════════════ -// ─── Lesson Page ────────────────────────────────────────────────────────────── -router.get("/:courseId/units/:unitId/lessons/:lessonId/page", controller.getLessonPage); -router.put("/:courseId/units/:unitId/lessons/:lessonId/page", sensitiveOpsLimiter, controller.upsertLessonPage); +router.get("/", ctrl.getCourses); +router.post("/", ctrl.createCourse); -module.exports = router; +// ── static segments first ──────────────────────────────────────────────────── +router.get("/field-values", ctrl.getCourseFieldValues); +router.delete("/bulk", ctrl.bulkArchiveCourses); +router.get("/archives", ctrl.getArchivedCourses); +router.patch("/restore/bulk", ctrl.bulkRestoreCourses); + +// ── then :courseId ──────────────────────────────────────────────────────────── +router.get("/archives/:courseId", ctrl.getArchivedCourse); +router.patch("/:courseId/restore", ctrl.restoreCourse); +router.get("/:courseId", ctrl.getCourse); +router.put("/:courseId", ctrl.updateCourse); +router.delete("/:courseId", ctrl.archiveCourse); + +// ══════════════════════════════════════════════════════════════════════════════ +// PREREQUISITES +// ══════════════════════════════════════════════════════════════════════════════ + +router.get("/:courseId/prerequisites", ctrl.getPrerequisites); +router.put("/:courseId/prerequisites", ctrl.syncPrerequisites); + +// ══════════════════════════════════════════════════════════════════════════════ +// COURSE ASSESSMENT +// ══════════════════════════════════════════════════════════════════════════════ + +router.get("/:courseId/assessment", ctrl.getAssessment); +router.post("/:courseId/assessment", ctrl.createAssessment); + +// ── static segments first ──────────────────────────────────────────────────── +router.get("/:courseId/assessment/archives", ctrl.getArchivedAssessment); + +// ── then :assessmentId ──────────────────────────────────────────────────────── +router.put("/:courseId/assessment/:assessmentId", ctrl.updateAssessment); +router.delete("/:courseId/assessment/:assessmentId", ctrl.deleteAssessment); +router.patch("/:courseId/assessment/:assessmentId/restore", ctrl.restoreAssessment); + +// ── Assessment Questions ────────────────────────────────────────────────────── +router.get("/:courseId/assessment/:assessmentId/questions", ctrl.getQuestions); +router.post("/:courseId/assessment/:assessmentId/questions", ctrl.createQuestion); + +// static before :questionId +router.delete("/:courseId/assessment/:assessmentId/questions/bulk", ctrl.bulkArchiveQuestions); +router.patch("/:courseId/assessment/:assessmentId/questions/restore/bulk", ctrl.bulkRestoreQuestions); +router.get("/:courseId/assessment/:assessmentId/questions/archives/:questionId", ctrl.getArchivedQuestion); + +router.put("/:courseId/assessment/:assessmentId/questions/:questionId", ctrl.updateQuestion); +router.delete("/:courseId/assessment/:assessmentId/questions/:questionId", ctrl.deleteQuestion); +router.patch("/:courseId/assessment/:assessmentId/questions/:questionId/restore", ctrl.restoreQuestion); + +// ══════════════════════════════════════════════════════════════════════════════ +// UNITS +// ══════════════════════════════════════════════════════════════════════════════ + +router.get("/:courseId/units", ctrl.getUnits); +router.post("/:courseId/units", ctrl.createUnit); + +// static before :unitId +router.delete("/:courseId/units/bulk", ctrl.bulkArchiveUnits); +router.get("/:courseId/units/archives", ctrl.getArchivedUnits); +router.patch("/:courseId/units/restore/bulk", ctrl.bulkRestoreUnits); + +router.get("/:courseId/units/archives/:unitId", ctrl.getArchivedUnit); +router.patch("/:courseId/units/:unitId/restore", ctrl.restoreUnit); +router.get("/:courseId/units/:unitId", ctrl.getUnit); +router.put("/:courseId/units/:unitId", ctrl.updateUnit); +router.delete("/:courseId/units/:unitId", ctrl.deleteUnit); + +// ══════════════════════════════════════════════════════════════════════════════ +// UNIT QUIZ +// ══════════════════════════════════════════════════════════════════════════════ + +router.get("/:courseId/units/:unitId/quiz", ctrl.getQuiz); +router.post("/:courseId/units/:unitId/quiz", ctrl.createQuiz); + +// static before :quizId +router.get("/:courseId/units/:unitId/quiz/archives", ctrl.getArchivedQuiz); + +router.put("/:courseId/units/:unitId/quiz/:quizId", ctrl.updateQuiz); +router.delete("/:courseId/units/:unitId/quiz/:quizId", ctrl.deleteQuiz); +router.patch("/:courseId/units/:unitId/quiz/:quizId/restore", ctrl.restoreQuiz); + +// ── Quiz Questions ──────────────────────────────────────────────────────────── +router.get("/:courseId/units/:unitId/quiz/:quizId/questions", ctrl.getQuestions); +router.post("/:courseId/units/:unitId/quiz/:quizId/questions", ctrl.createQuestion); + +// static before :questionId +router.delete("/:courseId/units/:unitId/quiz/:quizId/questions/bulk", ctrl.bulkArchiveQuestions); +router.patch("/:courseId/units/:unitId/quiz/:quizId/questions/restore/bulk", ctrl.bulkRestoreQuestions); +router.get("/:courseId/units/:unitId/quiz/:quizId/questions/archives/:questionId", ctrl.getArchivedQuestion); + +router.put("/:courseId/units/:unitId/quiz/:quizId/questions/:questionId", ctrl.updateQuestion); +router.delete("/:courseId/units/:unitId/quiz/:quizId/questions/:questionId", ctrl.deleteQuestion); +router.patch("/:courseId/units/:unitId/quiz/:quizId/questions/:questionId/restore", ctrl.restoreQuestion); + +// ══════════════════════════════════════════════════════════════════════════════ +// LESSONS +// ══════════════════════════════════════════════════════════════════════════════ + +router.get("/:courseId/units/:unitId/lessons", ctrl.getLessons); +router.post("/:courseId/units/:unitId/lessons", ctrl.createLesson); + +// static before :lessonId +router.delete("/:courseId/units/:unitId/lessons/bulk", ctrl.bulkArchiveLessons); +router.get("/:courseId/units/:unitId/lessons/archives", ctrl.getArchivedLessons); +router.patch("/:courseId/units/:unitId/lessons/restore/bulk", ctrl.bulkRestoreLessons); + +router.get("/:courseId/units/:unitId/lessons/archives/:lessonId", ctrl.getArchivedLesson); +router.patch("/:courseId/units/:unitId/lessons/:lessonId/restore", ctrl.restoreLesson); +router.get("/:courseId/units/:unitId/lessons/:lessonId", ctrl.getLesson); +router.put("/:courseId/units/:unitId/lessons/:lessonId", ctrl.updateLesson); +router.delete("/:courseId/units/:unitId/lessons/:lessonId", ctrl.deleteLesson); + +// ══════════════════════════════════════════════════════════════════════════════ +// LESSON PAGE +// ══════════════════════════════════════════════════════════════════════════════ + +router.get("/:courseId/units/:unitId/lessons/:lessonId/page", ctrl.getLessonPage); +router.put("/:courseId/units/:unitId/lessons/:lessonId/page", ctrl.upsertLessonPage); + +module.exports = router; \ No newline at end of file diff --git a/server.js b/server.js index ed9d330..511ab6e 100644 --- a/server.js +++ b/server.js @@ -33,7 +33,6 @@ const authRoutes = require('./routes/auth.routes'); const clientRoutes = require('./routes/client/client.routes'); const staffRoutes = require('./routes/staff/staff.routes'); const adminRoutes = require('./routes/admin/admin.routes'); -const courseRoutes = require('./routes/admin/courses.routes'); // ── Models (ensure associations are loaded) ──────────────────────────────────── require('./models/users/users.mdl'); @@ -97,7 +96,6 @@ app.use('/api/auth', authRoutes); app.use('/api/client', clientRoutes); app.use('/api/staff', staffRoutes); app.use('/api/admin', adminRoutes); -app.use('/api/admin/courses', courseRoutes); // Health check app.get('/api/health', (req, res) => { diff --git a/utils/courses/archive.util.js b/utils/courses/archive.util.js new file mode 100644 index 0000000..c357d2d --- /dev/null +++ b/utils/courses/archive.util.js @@ -0,0 +1,31 @@ +"use strict"; + +/** + * Single archive — soft delete one record + */ +async function archiveOne(Model, where, deletedBy, transaction) { + const record = await Model.findOne({ where }); + if (!record) return null; + await record.update({ deletedBy: deletedBy ?? null }, { transaction }); + await record.destroy({ transaction }); + return record; +} + +/** + * Bulk archive — soft delete multiple records by primary key + */ +async function archiveMany(Model, pkField, ids, deletedBy, transaction) { + if (!ids?.length) return 0; + + const records = await Model.findAll({ where: { [pkField]: ids, deletedAt: null } }); + if (!records.length) return 0; + + for (const record of records) { + await record.update({ deletedBy: deletedBy ?? null }, { transaction }); + await record.destroy({ transaction }); + } + + return records.length; +} + +module.exports = { archiveOne, archiveMany }; \ No newline at end of file diff --git a/utils/courses/junction.util.js b/utils/courses/junction.util.js new file mode 100644 index 0000000..3d3e707 --- /dev/null +++ b/utils/courses/junction.util.js @@ -0,0 +1,16 @@ +"use strict"; + +/** + * Replace all junction rows for a course in one shot (delete + bulk create). + */ +async function syncJunction(Model, courseId, ids, fkField, transaction) { + await Model.destroy({ where: { course_id: courseId }, transaction }); + if (ids?.length) { + await Model.bulkCreate( + ids.map((id) => ({ course_id: courseId, [fkField]: id })), + { transaction } + ); + } +} + +module.exports = { syncJunction }; \ No newline at end of file diff --git a/utils/courses/objectives.util.js b/utils/courses/objectives.util.js new file mode 100644 index 0000000..e5016ee --- /dev/null +++ b/utils/courses/objectives.util.js @@ -0,0 +1,62 @@ +"use strict"; + +/** + * For CREATE — plain text array, no IDs needed + * objectives: ["text1"] or [{ text: "text1" }] + */ +async function syncObjectivesCreate(Model, parentField, parentId, objectives, transaction) { + if (!objectives?.length) return; + + await Model.bulkCreate( + objectives.map((o, i) => ({ + [parentField]: parentId, + text: typeof o === "string" ? o : o.text, + order_index: i, + })), + { transaction } + ); +} + +/** + * For UPDATE — upsert by objective_id, hard delete removed ones + * objectives: [{ objective_id: "123", text: "text1" }, { text: "new" }] + */ +async function syncObjectivesUpdate(Model, parentField, parentId, objectives, transaction) { + if (!objectives?.length) { + await Model.destroy({ where: { [parentField]: parentId }, force: true, transaction }); + return; + } + + const existing = await Model.findAll({ where: { [parentField]: parentId }, transaction }); + const existingMap = new Map(existing.map((o) => [String(o.objective_id), o])); + const incomingIds = new Set( + objectives.filter((o) => o.objective_id).map((o) => String(o.objective_id)) + ); + + // Hard delete removed + const toDelete = existing.filter((o) => !incomingIds.has(String(o.objective_id))); + if (toDelete.length) { + await Model.destroy({ + where: { objective_id: toDelete.map((o) => o.objective_id) }, + force: true, + transaction, + }); + } + + // Update existing or create new + for (let i = 0; i < objectives.length; i++) { + const item = objectives[i]; + const record = item.objective_id ? existingMap.get(String(item.objective_id)) : null; + + if (record) { + await record.update({ text: item.text, order_index: i }, { transaction }); + } else { + await Model.create( + { [parentField]: parentId, text: item.text, order_index: i }, + { transaction } + ); + } + } +} + +module.exports = { syncObjectivesCreate, syncObjectivesUpdate }; \ No newline at end of file diff --git a/utils/courses/restore.util.js b/utils/courses/restore.util.js new file mode 100644 index 0000000..2cbff09 --- /dev/null +++ b/utils/courses/restore.util.js @@ -0,0 +1,44 @@ +"use strict"; + +const { Op } = require("sequelize"); + +const onlyDeleted = { deletedAt: { [Op.not]: null } }; + +/** + * Soft-restore a single record by clearing deletedAt / deletedBy. + * Returns the record on success, null if not found. + * + * @param {Model} Model - Sequelize model + * @param {object} where - Where clause (must include onlyDeleted or equivalent) + * @param {*} restoredBy - User ID stamped onto updatedBy + * @param {object} t - Sequelize transaction + */ +async function restoreOne(Model, where, restoredBy, t) { + const record = await Model.findOne({ where, paranoid: false }); + if (!record) return null; + + await record.restore(); + await record.update({ updatedBy: restoredBy, deletedBy: null }) + + return record; +} + +/** + * Soft-restore many records by their primary key column. + * Returns the count of restored records. + * + * @param {Model} Model - Sequelize model + * @param {string} pkColumn - Primary key column name (e.g. "course_id") + * @param {Array} ids - Array of primary key values to restore + * @param {*} restoredBy - User ID stamped onto updatedBy + * @param {object} t - Sequelize transaction + */ +async function restoreMany(Model, pkColumn, ids, restoredBy, t) { + const [count] = await Model.update( + { deletedAt: null, deletedBy: null, updatedBy: restoredBy ?? null }, + { where: { [pkColumn]: ids, ...onlyDeleted }, transaction: t, paranoid: false } + ); + return count; +} + +module.exports = { restoreOne, restoreMany }; \ No newline at end of file diff --git a/utils/duration.util.js b/utils/duration.util.js new file mode 100644 index 0000000..67bed58 --- /dev/null +++ b/utils/duration.util.js @@ -0,0 +1,112 @@ +// utils/duration.util.js + +const AVG_WORDS_PER_MINUTE = 200; + +function stripHtml(html) { + // Simple regex strip — no extra dependency needed + return (html ?? "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); +} + +/** + * Estimate seconds for a single lesson block + * Block shape: { type: 'text'|'image'|'video', content, word_count, video_duration_seconds } + */ +function estimateBlockDuration(block) { + const videoDuration = Number(block.video_duration_seconds) || 0; + + // Derive word count from HTML content on the fly + const getWordCount = (html) => { + const text = stripHtml(html); + return text ? text.split(" ").filter(Boolean).length : 0; + }; + + const readingSecs = (html) => + Math.ceil((getWordCount(html) / AVG_WORDS_PER_MINUTE) * 60); + + switch (block.type) { + case "text": + return readingSecs(block.content?.body); + + case "image": + return 60; + + case "video": + return videoDuration; + + case "text-image": + case "text_image": + return readingSecs(block.content?.body) + 60; + + case "text-video": + case "text_video": + return readingSecs(block.content?.body) + videoDuration; + + default: + return 0; + } +} + +/** + * Recompute and persist duration_seconds up the chain: + * blocks → lesson → unit → course + */ +async function recomputeDurations(lessonId) { + const Lesson = require("../models/courses/lessons.mdl"); + const Unit = require("../models/courses/units.mdl"); + const { Course } = require("../models/courses/courses.mdl"); + const LessonPage = require("../models/courses/lesson_page.mdl"); + + // 1. Lesson duration from blocks + const page = await LessonPage.findOne({ where: { lesson_id: lessonId } }); + const lessonSecs = (page?.blocks ?? []).reduce( + (sum, block) => sum + estimateBlockDuration(block), 0 + ); + + // Guard against NaN before DB write + const safeLessonSecs = isNaN(lessonSecs) ? 0 : lessonSecs; + await Lesson.update({ duration_seconds: safeLessonSecs }, { where: { lesson_id: lessonId } }); + + // 2. Unit duration — sum of its lessons + const lesson = await Lesson.findOne({ where: { lesson_id: lessonId } }); + const [unitResult] = await Lesson.sequelize.query(` + SELECT COALESCE(SUM(duration_seconds), 0) AS total + FROM lessons + WHERE unit_id = :unitId AND "deletedAt" IS NULL + `, { replacements: { unitId: lesson.unit_id }, type: Lesson.sequelize.QueryTypes.SELECT }); + + await Unit.update( + { duration_seconds: unitResult.total }, + { where: { unit_id: lesson.unit_id } } + ); + + // 3. Course duration — sum of its units + const unit = await Unit.findOne({ where: { unit_id: lesson.unit_id } }); + const [courseResult] = await Lesson.sequelize.query(` + SELECT COALESCE(SUM(duration_seconds), 0) AS total + FROM units + WHERE course_id = :courseId AND "deletedAt" IS NULL + `, { replacements: { courseId: unit.course_id }, type: Lesson.sequelize.QueryTypes.SELECT }); + + await Course.update( + { duration_seconds: courseResult.total }, + { where: { course_id: unit.course_id } } + ); +} + +/** + * Format seconds → human readable: "1 hr 10 mins", "45 mins", "30 secs" + */ +function formatDuration(seconds) { + if (!seconds) return "0 mins"; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + + const parts = []; + if (h) parts.push(`${h} hr`); + if (m) parts.push(`${m} min${m !== 1 ? "s" : ""}`); + if (!h && !m && s) parts.push(`${s} sec${s !== 1 ? "s" : ""}`); + return parts.join(" "); +} + +module.exports = { estimateBlockDuration, recomputeDurations, formatDuration }; \ No newline at end of file diff --git a/utils/modelToAttributes.util.js b/utils/modelToAttributes.util.js index 3442838..02e835b 100644 --- a/utils/modelToAttributes.util.js +++ b/utils/modelToAttributes.util.js @@ -72,6 +72,7 @@ function flattenJsonb(jsonbSchema = {}, prefix = "", exclude = []) { field: path, order: value?.order ?? Infinity, // ← carry order from schema hidden: value?.hidden ?? false, // ← carry hidden flag + filterable: value.filterable ?? true, options: {}, }); } @@ -141,14 +142,15 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa name: defaultTimestampLabels[field] ?? def.label ?? toLabel(field), type, field, - order: def.order ?? Infinity, // ← carry order from model - hidden: def.hidden ?? false, // ← carry hidden flag + order: def.order ?? Infinity, + hidden: def.hidden ?? false, + filterable: def.filterable ?? true, options: resolveOptions(def.type) }); } // ── Audit fields in correct sequence ──────────────────────────────────────── - for (const { field, type, order, hiddenOnList, hiddenOnArchived } of auditSequence) { + for (const { field, type, order, hiddenOnList, hiddenOnArchived, filterable } of auditSequence) { if (exclude.includes(field)) continue; if (!rawAttrs[field]) continue; // skip if field doesn't exist on model @@ -161,6 +163,7 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa order, hidden: isArchived ? hiddenOnArchived : hiddenOnList, options: resolveOptions(rawAttrs[field]?.type), + filterable: filterable ?? true, }); } diff --git a/utils/paginate.util.js b/utils/paginate.util.js index 91e60e0..3352a9f 100644 --- a/utils/paginate.util.js +++ b/utils/paginate.util.js @@ -151,12 +151,13 @@ async function paginate(model, req, { const totalPages = Math.ceil(count / limit); // ← Append computed metadata - const computedMeta = computedAttributes.map(({ key, label, type, order: ord }) => ({ + const computedMeta = computedAttributes.map(({ key, label, type, order: ord, filterable }) => ({ name: label ?? key, type: type ?? 'text', field: key, order: ord ?? Infinity, options: {}, + filterable: filterable })); // ← Merge, sort, THEN strip order