"use strict"; /*********************************************************************************************************************************************************************** * File Name: lessons.controller.js (admin) * Type of Program: Controller * Description: Standalone Lesson library — Lessons live independently of Units. * * /admin/lessons → library CRUD (list / create / update / archive / restore / permanent delete) * /admin/lessons/:lessonId/page → the lesson's block content (unchanged contract) * * Membership in a unit is a unit_lessons row (managed from the unit editor / * course builder); archiving here removes the Lesson from every unit at once. * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jul. 7, 2026 (junction revamp — Units/Lessons run independently) ***********************************************************************************************************************************************************************/ const { Op } = require("sequelize"); const sequelize = require("../../config/db.config"); const R = require("../../utils/response.util"); const { paginate } = require("../../utils/paginate.util"); const { recomputeDurations, recomputeUnitDuration, recomputeCourseDuration } = 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"); const { permanentDeleteOne, permanentDeleteMany } = require("../../utils/courses/permanentDelete.util"); const { getFieldValues } = require("../../utils/fieldValues.util"); const { nextOrderIndex } = require("../../utils/courses/hierarchy.util"); const logActivity = require("../../utils/logActivity.util"); // ── Models ──────────────────────────────────────────────────────────────────── const { Unit, Lesson, LessonPage, CourseUnit, UnitLesson, LessonObjective, } = require("../../models/courses/courses.associations"); const mdl_Users = require("../../models/users/users.mdl"); const notDeleted = { deletedAt: null }; const onlyDeleted = { deletedAt: { [Op.not]: null } }; // Refresh every unit this lesson is attached to + the courses above them. async function recomputeParentDurations(lessonId) { const links = await UnitLesson.findAll({ where: { lesson_id: lessonId }, attributes: ["unit_id"] }); const unitIds = [...new Set(links.map((l) => String(l.unit_id)))]; for (const unitId of unitIds) await recomputeUnitDuration(unitId); if (unitIds.length) { const courseLinks = await CourseUnit.findAll({ where: { unit_id: unitIds }, attributes: ["course_id"] }); for (const courseId of new Set(courseLinks.map((l) => String(l.course_id)))) { await recomputeCourseDuration(courseId); } } } const LESSON_LIST_COMPUTED = [ { // Not its own column — consumed by the Title cell on the frontend to // prefix "(UNIT)" when a lesson is already attached to at least one Unit. key: "unit_count", label: "Unit Count", type: "number", hidden: true, filterable: false, literal: `( SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL WHERE ul.lesson_id = "Lesson"."lesson_id" )`, }, { key: "course_count", label: "Affiliated", type: "number", order: 2, // 1: Title, 2: Affiliated, 3: Course Status — see lessons.mdl.js literal: `( SELECT CAST(COUNT(DISTINCT c.course_id) AS INTEGER) FROM unit_lessons ul JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL JOIN course_units cu ON cu.unit_id = u.unit_id JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL WHERE ul.lesson_id = "Lesson"."lesson_id" )`, }, { key: "course_status", label: "Course Status", type: "text", order: 3, // 1: Title, 2: Affiliated, 3: Course Status — see lessons.mdl.js hidden: true, filterable: false, literal: `( CASE WHEN NOT EXISTS ( SELECT 1 FROM unit_lessons ul JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL JOIN course_units cu ON cu.unit_id = u.unit_id JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL WHERE ul.lesson_id = "Lesson"."lesson_id" ) THEN 'standalone' WHEN EXISTS ( SELECT 1 FROM unit_lessons ul JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL JOIN course_units cu ON cu.unit_id = u.unit_id JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL WHERE ul.lesson_id = "Lesson"."lesson_id" AND c.status = 'published' ) THEN 'published' ELSE 'draft' END )`, }, ]; // ══════════════════════════════════════════════════════════════════════════════ // LESSON LIBRARY // ══════════════════════════════════════════════════════════════════════════════ exports.getLessons = async (req, res) => { try { const result = await paginate(Lesson, req, { auditOptions: { mdl_Users, parentAlias: "Lesson" }, context: "list", computedAttributes: LESSON_LIST_COMPUTED, findOptions: { where: { ...notDeleted }, order: [["createdAt", "DESC"]], }, }); return R.success(res, "Lessons retrieved.", result); } catch (err) { console.error("[LESSON LIB][GET ALL]", err); return R.error(res, "Could not retrieve lessons.", 500); } }; // Lightweight list for attach pickers exports.getLessonsFlat = async (req, res) => { try { const rows = await sequelize.query(` SELECT l.lesson_id, l.uuid, l.title, l.description, l.duration_seconds, (SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL WHERE ul.lesson_id = l.lesson_id) AS unit_count FROM lessons l WHERE l."deletedAt" IS NULL ORDER BY l.title ASC `, { type: sequelize.QueryTypes.SELECT }); return R.success(res, "Lessons retrieved.", rows); } catch (err) { console.error("[LESSON LIB][GET FLAT]", err); return R.error(res, "Could not retrieve lessons.", 500); } }; exports.getLesson = async (req, res) => { try { const { lessonId } = req.params; const lesson = await Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted }, include: [ { model: LessonPage, as: "page", required: false }, { model: LessonObjective, as: "objectives", required: false, order: [["order_index", "ASC"]] }, { model: Unit, as: "units", where: notDeleted, required: false, attributes: ["unit_id", "uuid", "title"], through: { attributes: ["order_index"] } }, ], }); if (!lesson) return R.error(res, "Lesson not found.", 404); return R.success(res, "Lesson retrieved.", { data: lesson.toJSON() }); } catch (err) { console.error("[LESSON LIB][GET ONE]", err); return R.error(res, "Could not retrieve lesson.", 500); } }; exports.createLesson = async (req, res) => { const t = await sequelize.transaction(); try { const { title, description, unit_id, order, objectives = [], createdBy } = req.body; if (!title) return R.error(res, "Title is required.", 400); const lesson = await Lesson.create({ title, description: description ?? null, duration_seconds: 0, createdBy: createdBy ?? req.user?.user_id ?? null, }, { transaction: t }); await LessonPage.create({ lesson_id: lesson.lesson_id, blocks: [], createdBy: createdBy ?? req.user?.user_id ?? null, }, { transaction: t }); await syncObjectivesCreate(LessonObjective, "lesson_id", lesson.lesson_id, objectives, t); // Optional immediate attach — lets the unit editor create-and-attach in one call if (unit_id) { const unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, transaction: t }); if (!unit) { await t.rollback(); return R.error(res, "Unit not found.", 404); } const order_index = order ?? await nextOrderIndex(UnitLesson, { unit_id }, t); await UnitLesson.create({ unit_id, lesson_id: lesson.lesson_id, order_index, createdBy: createdBy ?? req.user?.user_id ?? null, }, { transaction: t }); } await t.commit(); logActivity(req.user?.user_id, "create_lesson", { entityType: "lesson", entityId: lesson.lesson_id, details: { title: lesson.title, attached_unit_id: unit_id ?? null } }); return R.success(res, "Lesson created.", { data: lesson }, 201); } catch (err) { await t.rollback(); console.error("[LESSON LIB][CREATE]", err); return R.error(res, "Could not create lesson.", 500); } }; exports.updateLesson = async (req, res) => { const t = await sequelize.transaction(); try { const { lessonId } = req.params; const lesson = await Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted } }); if (!lesson) return R.error(res, "Lesson not found.", 404); const { title, description, objectives, updatedBy } = req.body; if (title !== undefined) lesson.title = title; if (description !== undefined) lesson.description = description; lesson.updatedBy = updatedBy ?? req.user?.user_id ?? null; await lesson.save({ transaction: t }); if (objectives !== undefined) { await syncObjectivesUpdate(LessonObjective, "lesson_id", lessonId, objectives, t); } await t.commit(); const updated = await Lesson.findOne({ where: { lesson_id: lessonId }, include: [{ model: LessonObjective, as: "objectives", order: [["order_index", "ASC"]] }], }); logActivity(req.user?.user_id, "update_lesson", { entityType: "lesson", entityId: Number(lessonId) }); return R.success(res, "Lesson updated.", { data: updated }); } catch (err) { await t.rollback(); console.error("[LESSON LIB][UPDATE]", err); return R.error(res, "Could not update lesson.", 500); } }; exports.archiveLesson = async (req, res) => { const t = await sequelize.transaction(); try { const { lessonId } = req.params; const record = await archiveOne(Lesson, { lesson_id: lessonId, ...notDeleted }, req.user.user_id, t); if (!record) return R.error(res, "Lesson not found.", 404); await t.commit(); try { await recomputeParentDurations(lessonId); } catch (durErr) { console.error("[LESSON LIB][ARCHIVE][DURATION]", durErr); } logActivity(req.user.user_id, "archive_lesson", { entityType: "lesson", entityId: Number(lessonId) }); return R.success(res, "Lesson archived."); } catch (err) { await t.rollback(); console.error("[LESSON LIB][ARCHIVE]", err); return R.error(res, "Could not archive lesson.", 500); } }; exports.bulkArchiveLessons = 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 lessons = await Lesson.findAll({ where: { lesson_id: ids, ...notDeleted } }); const validIds = lessons.map((l) => l.lesson_id); const count = await archiveMany(Lesson, "lesson_id", validIds, req.user.user_id, t); await t.commit(); for (const id of validIds) { try { await recomputeParentDurations(id); } catch (durErr) { console.error("[LESSON LIB][BULK ARCHIVE][DURATION]", durErr); } } logActivity(req.user.user_id, "bulk_archive_lessons", { entityType: "lesson", details: { ids: validIds, count } }); return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} archived.`); } catch (err) { await t.rollback(); console.error("[LESSON LIB][BULK ARCHIVE]", err); return R.error(res, "Could not archive lessons.", 500); } }; exports.getArchivedLessons = async (req, res) => { try { const result = await paginate(Lesson, req, { auditOptions: { mdl_Users, parentAlias: "Lesson" }, context: "archived", findOptions: { where: { ...onlyDeleted }, paranoid: false, order: [["deletedAt", "DESC"]], }, }); return R.success(res, "Archived lessons retrieved.", result); } catch (err) { console.error("[LESSON LIB][GET ARCHIVES]", err); return R.error(res, "Could not retrieve archived lessons.", 500); } }; exports.getArchivedLesson = async (req, res) => { try { const { lessonId } = req.params; const lesson = await Lesson.findOne({ where: { lesson_id: lessonId, ...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 LIB][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 { lessonId } = req.params; const record = await restoreOne(Lesson, { lesson_id: lessonId, ...onlyDeleted }, req.user.user_id, t); if (!record) return R.error(res, "Archived lesson not found.", 404); await t.commit(); try { await recomputeParentDurations(lessonId); } catch (durErr) { console.error("[LESSON LIB][RESTORE][DURATION]", durErr); } logActivity(req.user.user_id, "restore_lesson", { entityType: "lesson", entityId: Number(lessonId) }); return R.success(res, "Lesson restored.", { data: record }); } catch (err) { await t.rollback(); console.error("[LESSON LIB][RESTORE]", err); return R.error(res, "Could not restore lesson.", 500); } }; exports.bulkRestoreLessons = 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 lessons = await Lesson.findAll({ where: { lesson_id: ids, ...onlyDeleted }, paranoid: false }); const validIds = lessons.map((l) => l.lesson_id); const count = await restoreMany(Lesson, "lesson_id", validIds, req.user.user_id, t); await t.commit(); for (const id of validIds) { try { await recomputeParentDurations(id); } catch (durErr) { console.error("[LESSON LIB][BULK RESTORE][DURATION]", durErr); } } logActivity(req.user.user_id, "bulk_restore_lessons", { entityType: "lesson", details: { ids: validIds, count } }); return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} restored.`); } catch (err) { await t.rollback(); console.error("[LESSON LIB][BULK RESTORE]", err); return R.error(res, "Could not restore lessons.", 500); } }; exports.getLessonPermanentDeleteImpact = async (req, res) => { try { const { lessonId } = req.params; const unitCount = await UnitLesson.count({ where: { lesson_id: lessonId } }); return R.success(res, "Impact retrieved.", { unitCount }); } catch (err) { console.error("[LESSON LIB][PERMANENT DELETE IMPACT]", err); return R.error(res, "Could not retrieve impact.", 500); } }; exports.permanentlyDeleteLesson = async (req, res) => { const t = await sequelize.transaction(); try { const { lessonId } = req.params; const record = await permanentDeleteOne(Lesson, { lesson_id: lessonId }, t); if (record === null) return R.error(res, "Lesson not found.", 404); if (record === false) return R.error(res, "Lesson must be archived before it can be permanently deleted.", 400); await UnitLesson.destroy({ where: { lesson_id: lessonId }, transaction: t }); await t.commit(); logActivity(req.user.user_id, "permanently_delete_lesson", { entityType: "lesson", entityId: Number(lessonId) }); return R.success(res, "Lesson permanently deleted."); } catch (err) { await t.rollback(); console.error("[LESSON LIB][PERMANENT DELETE]", err); return R.error(res, "Could not permanently delete lesson.", 500); } }; exports.bulkPermanentlyDeleteLessons = async (req, res) => { const t = await sequelize.transaction(); try { const { ids = [] } = req.body; if (!ids.length) return R.error(res, "No IDs provided.", 400); const lessons = await Lesson.findAll({ where: { lesson_id: ids }, paranoid: false }); const validIds = lessons.map((l) => l.lesson_id); const count = await permanentDeleteMany(Lesson, "lesson_id", validIds, t); await UnitLesson.destroy({ where: { lesson_id: validIds }, transaction: t }); await t.commit(); logActivity(req.user.user_id, "bulk_permanently_delete_lessons", { entityType: "lesson", details: { ids: validIds, count } }); return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} permanently deleted.`); } catch (err) { await t.rollback(); console.error("[LESSON LIB][BULK PERMANENT DELETE]", err); return R.error(res, "Could not permanently delete lessons.", 500); } }; exports.getLessonFieldValues = getFieldValues(Lesson, "LESSON"); // ══════════════════════════════════════════════════════════════════════════════ // LESSON PAGE (same contract as before — keyed by lessonId only) // ══════════════════════════════════════════════════════════════════════════════ 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 LIB][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 ?? req.user?.user_id ?? null, createdBy: req.body.updatedBy ?? req.user?.user_id ?? null, }, { returning: true }); try { // Pass the blocks we just wrote directly instead of re-reading the page — // avoids depending on read-after-write visibility of the upsert we just did. await recomputeDurations(lessonId, blocks); } catch (durErr) { console.error("[LESSON LIB][PAGE][DURATION]", durErr); } logActivity(req.user?.user_id, "upsert_lesson_page", { entityType: "lesson", entityId: Number(lessonId) }); return R.success( res, created ? "Lesson page created." : "Lesson page updated.", { data: page }, created ? 201 : 200, ); } catch (err) { console.error("[LESSON LIB][PAGE][UPSERT]", err); return R.error(res, "Could not save lesson page.", 500); } };