"use strict"; /*********************************************************************************************************************************************************************** * File Name: units.controller.js (admin) * Type of Program: Controller * Description: Standalone Unit library — Units live independently of Courses. * * /admin/units → library CRUD (list / create / update / archive / restore / permanent delete) * /admin/units/:unitId/lessons → attach / detach / reorder standalone Lessons on this Unit * /admin/units/:unitId/quiz → the Unit's quiz (travels with the Unit into every course it's attached to) * * Membership in a course is a course_units row (managed from the course builder); * archiving here removes the Unit from every course view 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 { 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 { flattenLessons, nextOrderIndex, reorderJunction } = require("../../utils/courses/hierarchy.util"); const { recomputeUnitDuration, recomputeCourseDuration } = require("../../utils/duration.util"); const logActivity = require("../../utils/logActivity.util"); // ── Models ──────────────────────────────────────────────────────────────────── const { Course, Unit, Lesson, CourseUnit, UnitLesson, UnitQuiz, QuizQuestion, QuizOption, UnitReadingProgress, LessonReadingProgress, } = require("../../models/courses/courses.associations"); const mdl_Users = require("../../models/users/users.mdl"); const notDeleted = { deletedAt: null }; const onlyDeleted = { deletedAt: { [Op.not]: null } }; // Refresh duration of every course this unit is attached to (post-archive/restore). async function recomputeParentCourseDurations(unitId) { const links = await CourseUnit.findAll({ where: { unit_id: unitId }, attributes: ["course_id"] }); for (const courseId of new Set(links.map((l) => String(l.course_id)))) { await recomputeCourseDuration(courseId); } } const UNIT_LIST_COMPUTED = [ { key: "quiz_id", label: "Quiz ID", type: "text", literal: `(SELECT quiz_id FROM unit_quizzes WHERE unit_id = "Unit"."unit_id" AND "deletedAt" IS NULL LIMIT 1)`, }, { key: "lesson_count", label: "Lessons", type: "number", literal: `( SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul JOIN lessons l ON l.lesson_id = ul.lesson_id AND l."deletedAt" IS NULL WHERE ul.unit_id = "Unit"."unit_id" )`, }, { key: "course_count", label: "Used in courses", type: "number", literal: `( SELECT CAST(COUNT(*) AS INTEGER) FROM course_units cu JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL WHERE cu.unit_id = "Unit"."unit_id" )`, }, ]; // ══════════════════════════════════════════════════════════════════════════════ // UNIT LIBRARY // ══════════════════════════════════════════════════════════════════════════════ exports.getUnits = async (req, res) => { try { const result = await paginate(Unit, req, { auditOptions: { mdl_Users, parentAlias: "Unit" }, context: "list", computedAttributes: UNIT_LIST_COMPUTED, findOptions: { where: { ...notDeleted }, order: [["createdAt", "DESC"]], }, }); return R.success(res, "Units retrieved.", result); } catch (err) { console.error("[UNIT LIB][GET ALL]", err); return R.error(res, "Could not retrieve units.", 500); } }; // Lightweight list for attach pickers: { unit_id, uuid, title, lesson_count, course_count } exports.getUnitsFlat = async (req, res) => { try { const rows = await sequelize.query(` SELECT u.unit_id, u.uuid, u.title, u.description, u.duration_seconds, (SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul JOIN lessons l ON l.lesson_id = ul.lesson_id AND l."deletedAt" IS NULL WHERE ul.unit_id = u.unit_id) AS lesson_count, (SELECT CAST(COUNT(*) AS INTEGER) FROM course_units cu JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL WHERE cu.unit_id = u.unit_id) AS course_count FROM units u WHERE u."deletedAt" IS NULL ORDER BY u.title ASC `, { type: sequelize.QueryTypes.SELECT }); return R.success(res, "Units retrieved.", rows); } catch (err) { console.error("[UNIT LIB][GET FLAT]", err); return R.error(res, "Could not retrieve units.", 500); } }; exports.getUnit = async (req, res) => { try { const { unitId } = req.params; const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, include: [ { model: Lesson, as: "lessons", where: notDeleted, required: false, through: { attributes: ["order_index"] } }, { model: UnitQuiz, as: "quiz", required: false }, { model: Course, as: "courses", where: notDeleted, required: false, attributes: ["course_id", "uuid", "title", "subscription"], through: { attributes: ["order_index"] } }, ], }); if (!unit) return R.error(res, "Unit not found.", 404); const plain = unit.toJSON(); plain.lessons = flattenLessons(plain.lessons); return R.success(res, "Unit retrieved.", { data: plain }); } catch (err) { console.error("[UNIT LIB][GET ONE]", err); return R.error(res, "Could not retrieve unit.", 500); } }; exports.createUnit = async (req, res) => { const t = await sequelize.transaction(); try { const { title, description, subscription, course_id, order, createdBy } = req.body; if (!title) return R.error(res, "Title is required.", 400); const unit = await Unit.create({ title, subscription: subscription || null, description: description ?? null, duration_seconds: 0, createdBy: createdBy ?? req.user?.user_id ?? null, }, { transaction: t }); // Optional immediate attach — lets the course builder create-and-attach in one call if (course_id) { const course = await Course.findOne({ where: { course_id, ...notDeleted }, transaction: t }); if (!course) { await t.rollback(); return R.error(res, "Course not found.", 404); } const order_index = order ?? await nextOrderIndex(CourseUnit, { course_id }, t); await CourseUnit.create({ course_id, unit_id: unit.unit_id, order_index, createdBy: createdBy ?? req.user?.user_id ?? null, }, { transaction: t }); } await t.commit(); logActivity(req.user?.user_id, "create_unit", { entityType: "unit", entityId: unit.unit_id, details: { title: unit.title, attached_course_id: course_id ?? null } }); return R.success(res, "Unit created.", { data: unit }, 201); } catch (err) { await t.rollback(); console.error("[UNIT LIB][CREATE]", err); return R.error(res, "Could not create unit.", 500); } }; exports.updateUnit = async (req, res) => { try { const { unitId } = req.params; const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted } }); if (!unit) return R.error(res, "Unit not found.", 404); const { title, description, subscription, updatedBy } = req.body; if (title !== undefined) unit.title = title; if (description !== undefined) unit.description = description; if (subscription !== undefined) unit.subscription = subscription || null; unit.updatedBy = updatedBy ?? req.user?.user_id ?? null; await unit.save(); logActivity(req.user?.user_id, "update_unit", { entityType: "unit", entityId: Number(unitId) }); return R.success(res, "Unit updated.", { data: unit }); } catch (err) { console.error("[UNIT LIB][UPDATE]", err); return R.error(res, "Could not update unit.", 500); } }; exports.getUnitArchiveImpact = async (req, res) => { try { const { unitId } = req.params; const [completionCount, progressCount, courseCount] = await Promise.all([ UnitReadingProgress.count({ where: { unit_id: unitId, status: "completed" } }), LessonReadingProgress.count({ where: { unit_id: unitId }, distinct: true, col: "user_id" }), CourseUnit.count({ where: { unit_id: unitId } }), ]); return R.success(res, "Impact retrieved.", { completionCount, progressCount, courseCount }); } catch (err) { console.error("[UNIT LIB][ARCHIVE IMPACT]", err); return R.error(res, "Could not retrieve impact.", 500); } }; exports.archiveUnit = async (req, res) => { const t = await sequelize.transaction(); try { const { unitId } = req.params; const record = await archiveOne(Unit, { unit_id: unitId, ...notDeleted }, req.user.user_id, t); if (!record) return R.error(res, "Unit not found.", 404); await t.commit(); try { await recomputeParentCourseDurations(unitId); } catch (durErr) { console.error("[UNIT LIB][ARCHIVE][DURATION]", durErr); } logActivity(req.user.user_id, "archive_unit", { entityType: "unit", entityId: Number(unitId) }); return R.success(res, "Unit archived."); } catch (err) { await t.rollback(); console.error("[UNIT LIB][ARCHIVE]", err); return R.error(res, "Could not archive unit.", 500); } }; exports.bulkArchiveUnits = 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 units = await Unit.findAll({ where: { unit_id: ids, ...notDeleted } }); const validIds = units.map((u) => u.unit_id); const count = await archiveMany(Unit, "unit_id", validIds, req.user.user_id, t); await t.commit(); for (const id of validIds) { try { await recomputeParentCourseDurations(id); } catch (durErr) { console.error("[UNIT LIB][BULK ARCHIVE][DURATION]", durErr); } } logActivity(req.user.user_id, "bulk_archive_units", { entityType: "unit", details: { ids: validIds, count } }); return R.success(res, `${count} unit${count !== 1 ? "s" : ""} archived.`); } catch (err) { await t.rollback(); console.error("[UNIT LIB][BULK ARCHIVE]", err); return R.error(res, "Could not archive units.", 500); } }; exports.getArchivedUnits = async (req, res) => { try { const result = await paginate(Unit, req, { auditOptions: { mdl_Users, parentAlias: "Unit" }, context: "archived", findOptions: { where: { ...onlyDeleted }, paranoid: false, order: [["deletedAt", "DESC"]], }, }); return R.success(res, "Archived units retrieved.", result); } catch (err) { console.error("[UNIT LIB][GET ARCHIVES]", err); return R.error(res, "Could not retrieve archived units.", 500); } }; exports.getArchivedUnit = async (req, res) => { try { const { unitId } = req.params; const unit = await Unit.findOne({ where: { unit_id: unitId, ...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 LIB][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 { unitId } = req.params; const record = await restoreOne(Unit, { unit_id: unitId, ...onlyDeleted }, req.user.user_id, t); if (!record) return R.error(res, "Archived unit not found.", 404); await t.commit(); try { await recomputeParentCourseDurations(unitId); } catch (durErr) { console.error("[UNIT LIB][RESTORE][DURATION]", durErr); } logActivity(req.user.user_id, "restore_unit", { entityType: "unit", entityId: Number(unitId) }); return R.success(res, "Unit restored.", { data: record }); } catch (err) { await t.rollback(); console.error("[UNIT LIB][RESTORE]", err); return R.error(res, "Could not restore unit.", 500); } }; exports.bulkRestoreUnits = 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 units = await Unit.findAll({ where: { unit_id: ids, ...onlyDeleted }, paranoid: false }); const validIds = units.map((u) => u.unit_id); const count = await restoreMany(Unit, "unit_id", validIds, req.user.user_id, t); await t.commit(); for (const id of validIds) { try { await recomputeParentCourseDurations(id); } catch (durErr) { console.error("[UNIT LIB][BULK RESTORE][DURATION]", durErr); } } logActivity(req.user.user_id, "bulk_restore_units", { entityType: "unit", details: { ids: validIds, count } }); return R.success(res, `${count} unit${count !== 1 ? "s" : ""} restored.`); } catch (err) { await t.rollback(); console.error("[UNIT LIB][BULK RESTORE]", err); return R.error(res, "Could not restore units.", 500); } }; exports.getUnitPermanentDeleteImpact = async (req, res) => { try { const { unitId } = req.params; const [lessonCount, courseCount] = await Promise.all([ UnitLesson.count({ where: { unit_id: unitId } }), CourseUnit.count({ where: { unit_id: unitId } }), ]); return R.success(res, "Impact retrieved.", { lessonCount, courseCount }); } catch (err) { console.error("[UNIT LIB][PERMANENT DELETE IMPACT]", err); return R.error(res, "Could not retrieve impact.", 500); } }; exports.permanentlyDeleteUnit = async (req, res) => { const t = await sequelize.transaction(); try { const { unitId } = req.params; const record = await permanentDeleteOne(Unit, { unit_id: unitId }, t); if (record === null) return R.error(res, "Unit not found.", 404); if (record === false) return R.error(res, "Unit must be archived before it can be permanently deleted.", 400); // Junction rows don't cascade from a paranoid destroy — clean them explicitly await CourseUnit.destroy({ where: { unit_id: unitId }, transaction: t }); await UnitLesson.destroy({ where: { unit_id: unitId }, transaction: t }); await t.commit(); logActivity(req.user.user_id, "permanently_delete_unit", { entityType: "unit", entityId: Number(unitId) }); return R.success(res, "Unit permanently deleted."); } catch (err) { await t.rollback(); console.error("[UNIT LIB][PERMANENT DELETE]", err); return R.error(res, "Could not permanently delete unit.", 500); } }; exports.bulkPermanentlyDeleteUnits = async (req, res) => { const t = await sequelize.transaction(); try { const { ids = [] } = req.body; if (!ids.length) return R.error(res, "No IDs provided.", 400); const units = await Unit.findAll({ where: { unit_id: ids }, paranoid: false }); const validIds = units.map((u) => u.unit_id); const count = await permanentDeleteMany(Unit, "unit_id", validIds, t); await CourseUnit.destroy({ where: { unit_id: validIds }, transaction: t }); await UnitLesson.destroy({ where: { unit_id: validIds }, transaction: t }); await t.commit(); logActivity(req.user.user_id, "bulk_permanently_delete_units", { entityType: "unit", details: { ids: validIds, count } }); return R.success(res, `${count} unit${count !== 1 ? "s" : ""} permanently deleted.`); } catch (err) { await t.rollback(); console.error("[UNIT LIB][BULK PERMANENT DELETE]", err); return R.error(res, "Could not permanently delete units.", 500); } }; exports.getUnitFieldValues = getFieldValues(Unit, "UNIT"); // ══════════════════════════════════════════════════════════════════════════════ // UNIT ⇄ LESSON MEMBERSHIP (attach / detach / reorder) // ══════════════════════════════════════════════════════════════════════════════ // POST /admin/units/:unitId/lessons { lesson_ids: [..] } — append existing lessons exports.attachLessons = async (req, res) => { const t = await sequelize.transaction(); try { const { unitId } = req.params; const { lesson_ids = [] } = req.body; if (!lesson_ids.length) return R.error(res, "lesson_ids is required.", 400); const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, transaction: t }); if (!unit) return R.error(res, "Unit not found.", 404); const lessons = await Lesson.findAll({ where: { lesson_id: lesson_ids, ...notDeleted }, transaction: t }); if (lessons.length !== lesson_ids.length) { await t.rollback(); return R.error(res, "One or more lessons were not found.", 404); } const existing = await UnitLesson.findAll({ where: { unit_id: unitId, lesson_id: lesson_ids }, transaction: t }); const existingSet = new Set(existing.map((r) => String(r.lesson_id))); const toAttach = lesson_ids.filter((id) => !existingSet.has(String(id))); let order = await nextOrderIndex(UnitLesson, { unit_id: unitId }, t); await UnitLesson.bulkCreate( toAttach.map((lesson_id) => ({ unit_id: unitId, lesson_id, order_index: order++, createdBy: req.user?.user_id ?? null, })), { transaction: t } ); await t.commit(); try { await recomputeUnitDuration(unitId); await recomputeParentCourseDurations(unitId); } catch (durErr) { console.error("[UNIT LIB][ATTACH LESSONS][DURATION]", durErr); } logActivity(req.user?.user_id, "attach_lessons", { entityType: "unit", entityId: Number(unitId), details: { lesson_ids: toAttach } }); return R.success(res, `${toAttach.length} lesson${toAttach.length !== 1 ? "s" : ""} attached.`, { attached: toAttach, skipped: lesson_ids.filter((id) => existingSet.has(String(id))) }); } catch (err) { await t.rollback(); console.error("[UNIT LIB][ATTACH LESSONS]", err); return R.error(res, "Could not attach lessons.", 500); } }; // DELETE /admin/units/:unitId/lessons/:lessonId — detach (lesson survives in the library) exports.detachLesson = async (req, res) => { try { const { unitId, lessonId } = req.params; const removed = await UnitLesson.destroy({ where: { unit_id: unitId, lesson_id: lessonId } }); if (!removed) return R.error(res, "Lesson is not attached to this unit.", 404); try { await recomputeUnitDuration(unitId); await recomputeParentCourseDurations(unitId); } catch (durErr) { console.error("[UNIT LIB][DETACH LESSON][DURATION]", durErr); } logActivity(req.user?.user_id, "detach_lesson", { entityType: "unit", entityId: Number(unitId), details: { lesson_id: Number(lessonId) } }); return R.success(res, "Lesson detached."); } catch (err) { console.error("[UNIT LIB][DETACH LESSON]", err); return R.error(res, "Could not detach lesson.", 500); } }; // PUT /admin/units/:unitId/lessons/order { lesson_ids: [orderedIds] } exports.reorderLessons = async (req, res) => { const t = await sequelize.transaction(); try { const { unitId } = req.params; const { lesson_ids = [] } = req.body; if (!lesson_ids.length) return R.error(res, "lesson_ids is required.", 400); const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, transaction: t }); if (!unit) return R.error(res, "Unit not found.", 404); await reorderJunction(UnitLesson, "unit_id", unitId, "lesson_id", lesson_ids, t); await t.commit(); logActivity(req.user?.user_id, "reorder_lessons", { entityType: "unit", entityId: Number(unitId), details: { lesson_ids } }); return R.success(res, "Lesson order updated."); } catch (err) { await t.rollback(); console.error("[UNIT LIB][REORDER LESSONS]", err); return R.error(res, "Could not reorder lessons.", 500); } }; // ══════════════════════════════════════════════════════════════════════════════ // UNIT QUIZ (1:1 with the Unit — travels with it into every attached course) // ══════════════════════════════════════════════════════════════════════════════ exports.getQuiz = async (req, res) => { try { const { unitId } = req.params; const unit = await Unit.findOne({ where: { unit_id: unitId, ...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("[UNIT LIB][QUIZ][GET]", err); return R.error(res, "Could not retrieve quiz.", 500); } }; exports.createQuiz = async (req, res) => { try { const { unitId } = req.params; const { title, is_required, passing_score, max_questions, shuffle_questions, createdBy } = req.body; const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted } }); if (!unit) return R.error(res, "Unit not found.", 404); const existing = await UnitQuiz.findOne({ where: { unit_id: unitId, ...notDeleted } }); if (existing) return R.error(res, "Quiz already exists for this unit.", 409); const quiz = await UnitQuiz.create({ unit_id: unitId, title: title ?? null, is_required: is_required ?? false, passing_score: passing_score ?? 70, max_questions: max_questions ?? null, shuffle_questions: shuffle_questions ?? false, createdBy: createdBy ?? req.user?.user_id ?? null, }); logActivity(req.user?.user_id, "create_quiz", { entityType: "quiz", entityId: quiz.quiz_id }); return R.success(res, "Quiz created.", { data: quiz }, 201); } catch (err) { console.error("[UNIT LIB][QUIZ][CREATE]", err); return R.error(res, "Could not create quiz.", 500); } }; exports.updateQuiz = async (req, res) => { try { const { unitId, quizId } = req.params; const { title, is_required, passing_score, max_questions, shuffle_questions, updatedBy } = req.body; const quiz = await UnitQuiz.findOne({ where: { quiz_id: quizId, unit_id: unitId, ...notDeleted } }); if (!quiz) return R.error(res, "Quiz not found.", 404); if (title !== undefined) quiz.title = title; if (is_required !== undefined) quiz.is_required = is_required; if (passing_score !== undefined) quiz.passing_score = passing_score; if (max_questions !== undefined) quiz.max_questions = max_questions; if (shuffle_questions !== undefined) quiz.shuffle_questions = shuffle_questions; quiz.updatedBy = updatedBy ?? req.user?.user_id ?? null; await quiz.save(); logActivity(req.user?.user_id, "update_quiz", { entityType: "quiz", entityId: Number(quizId) }); return R.success(res, "Quiz updated.", { data: quiz }); } catch (err) { console.error("[UNIT LIB][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.user?.user_id, t); if (!record) return R.error(res, "Quiz not found.", 404); await t.commit(); logActivity(req.user?.user_id, "archive_quiz", { entityType: "quiz", entityId: Number(quizId) }); return R.success(res, "Quiz archived."); } catch (err) { await t.rollback(); console.error("[UNIT LIB][QUIZ][ARCHIVE]", err); return R.error(res, "Could not archive quiz.", 500); } }; exports.getArchivedQuiz = async (req, res) => { try { const { unitId } = req.params; const unit = await Unit.findOne({ where: { unit_id: unitId }, 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("[UNIT LIB][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.user?.user_id, t); if (!record) return R.error(res, "Archived quiz not found.", 404); await t.commit(); logActivity(req.user?.user_id, "restore_quiz", { entityType: "quiz", entityId: Number(quizId) }); return R.success(res, "Quiz restored.", { data: record }); } catch (err) { await t.rollback(); console.error("[UNIT LIB][QUIZ][RESTORE]", err); return R.error(res, "Could not restore quiz.", 500); } };