"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); } };