diff --git a/controllers/admin/course_reading_progress.controller.js b/controllers/admin/course_reading_progress.controller.js index 95a5c5a..0c9000e 100644 --- a/controllers/admin/course_reading_progress.controller.js +++ b/controllers/admin/course_reading_progress.controller.js @@ -17,8 +17,9 @@ const { Op } = require('sequelize'); const R = require('../../utils/response.util'); const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl'); -const { Course, Unit, Lesson } = require('../../models/courses/courses.associations'); +const { Course, Unit, Lesson, CourseUnit } = require('../../models/courses/courses.associations'); const mdl_Users = require('../../models/users/users.mdl'); +const { countCourseLessons, getCourseUnitIds, flattenUnits } = require('../../utils/courses/hierarchy.util'); const notDeleted = { deletedAt: null }; @@ -40,27 +41,13 @@ exports.getCourseReadingProgress = async (req, res) => { }); if (!course) return R.error(res, 'Course not found.', 404); - // Count total lessons and units in the course (structure totals) - const [units, allLessons] = await Promise.all([ - Unit.findAll({ - where: { course_id: courseId, ...notDeleted }, - attributes: ['unit_id', 'uuid'], - }), - Lesson.findAll({ - include: [{ - model: Unit, - as: 'unit', - where: { course_id: courseId, ...notDeleted }, - attributes: [], - required: true, - }], - where: { ...notDeleted }, - attributes: ['lesson_id', 'uuid'], - }), + // Count total lessons and units in the course (structure totals via junctions) + const [unitIds, lessons_total] = await Promise.all([ + getCourseUnitIds(courseId), + countCourseLessons(courseId), ]); - const units_total = units.length; - const lessons_total = allLessons.length; + const units_total = unitIds.length; // All progress rows for this course, grouped per user const rows = await CourseReadingProgress.findAll({ @@ -147,20 +134,26 @@ exports.getUserReadingProgress = async (req, res) => { try { const { courseId, userId } = req.params; - const [units, progressRows] = await Promise.all([ + const [unitRows, progressRows] = await Promise.all([ Unit.findAll({ - where: { course_id: courseId, ...notDeleted }, - attributes: ['unit_id', 'uuid', 'title', 'order_index'], - include: [{ - model: Lesson, - as: 'lessons', - where: notDeleted, - required: false, - attributes: ['lesson_id', 'uuid', 'title', 'order_index'], - }], - order: [ - ['order_index', 'ASC'], - [{ model: Lesson, as: 'lessons' }, 'order_index', 'ASC'], + where: notDeleted, + attributes: ['unit_id', 'uuid', 'title'], + include: [ + { + model: CourseUnit, + as: 'courseLinks', + where: { course_id: courseId }, + required: true, + attributes: ['order_index'], + }, + { + model: Lesson, + as: 'lessons', + where: notDeleted, + required: false, + attributes: ['lesson_id', 'uuid', 'title'], + through: { attributes: ['order_index'] }, + }, ], }), CourseReadingProgress.findAll({ @@ -169,6 +162,14 @@ exports.getUserReadingProgress = async (req, res) => { }), ]); + // Sort by junction order (course-level, then unit-level for lessons) + const units = flattenUnits(unitRows.map((u) => { + const plain = u.toJSON(); + plain.CourseUnit = { order_index: plain.courseLinks?.[0]?.order_index ?? 0 }; + delete plain.courseLinks; + return plain; + })); + // Build a quick lookup: { [reference_id (uuid)]: status } const progressMap = Object.fromEntries( progressRows.map((r) => [r.reference_id, { status: r.status, completed_at: r.completed_at }]) diff --git a/controllers/admin/courses.controller.js b/controllers/admin/courses.controller.js index a457a03..669c7b8 100644 --- a/controllers/admin/courses.controller.js +++ b/controllers/admin/courses.controller.js @@ -10,6 +10,7 @@ const { syncJunction } = require("../../utils/courses/junction.util"); const { archiveOne, archiveMany } = require("../../utils/courses/archive.util"); const { restoreOne, restoreMany } = require("../../utils/courses/restore.util"); const { permanentDeleteOne, permanentDeleteMany } = require("../../utils/courses/permanentDelete.util"); +const { flattenUnits, flattenLessons, nextOrderIndex, reorderJunction } = require("../../utils/courses/hierarchy.util"); const { getFieldValues } = require("../../utils/fieldValues.util"); const logActivity = require('../../utils/logActivity.util'); const UserNotification = require('../../models/notifications/user_notification.mdl'); @@ -20,6 +21,7 @@ const { renderNotification } = require('../../services/notificationTemplate.serv const { Course, CourseProductCategory: CourseProductCat, Unit, Lesson, LessonPage, + CourseUnit, UnitLesson, CourseObjective, LessonObjective, CoursePrerequisite, CourseAssessment, UnitQuiz, QuizQuestion, QuizOption, @@ -42,6 +44,18 @@ const onlyDeleted = { deletedAt: { [Op.not]: null } }; const auditByFields = ["createdBy", "updatedBy", "deletedBy"]; const adminExclude = []; +// ── Junction guards ─────────────────────────────────────────────────────────── +// A unit "belongs to" a course when a course_units row links them; same for +// lessons via unit_lessons. Nested routes validate membership through these. + +async function getCourseUnitLink(courseId, unitId, transaction) { + return CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId }, transaction }); +} + +async function getUnitLessonLink(unitId, lessonId, transaction) { + return UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId }, transaction }); +} + // ══════════════════════════════════════════════════════════════════════════════ // COURSE // ══════════════════════════════════════════════════════════════════════════════ @@ -80,8 +94,9 @@ exports.getCourse = async (req, res) => { { model: Unit, as: "units", where: notDeleted, required: false, + through: { attributes: ["order_index"] }, include: [ - { model: Lesson, as: "lessons", where: notDeleted, required: false }, + { model: Lesson, as: "lessons", where: notDeleted, required: false, through: { attributes: ["order_index"] } }, { model: UnitQuiz, as: "quiz", required: false }, ], }, @@ -90,14 +105,15 @@ exports.getCourse = async (req, res) => { { model: CourseAssessment, as: "assessment", required: false }, ], order: [ - [{ model: Unit, as: "units" }, "order_index", "ASC"], - [{ model: Unit, as: "units" }, { model: Lesson, as: "lessons" }, "order_index", "ASC"], [{ model: CourseObjective, as: "objectives" }, "order_index", "ASC"], ], }); if (!course) return R.error(res, "Course not found.", 404); - return R.success(res, "Course retrieved.", { data: course.toJSON() }); + + const plain = course.toJSON(); + plain.units = flattenUnits(plain.units); // junction order_index → flat field, sorted + return R.success(res, "Course retrieved.", { data: plain }); } catch (err) { console.error("[COURSE][GET ONE]", err); return R.error(res, "Could not retrieve course.", 500); @@ -321,6 +337,7 @@ exports.permanentlyDeleteCourse = async (req, res) => { const record = await permanentDeleteOne(Course, { course_id: courseId }, t); if (record === null) return R.error(res, "Course not found.", 404); if (record === false) return R.error(res, "Course must be archived before it can be permanently deleted.", 400); + await CourseUnit.destroy({ where: { course_id: courseId }, transaction: t }); // detach all units — they survive in the library await t.commit(); logActivity(req.user.user_id, 'permanently_delete_course', { entityType: 'course', entityId: Number(courseId) }); return R.success(res, "Course permanently deleted."); @@ -338,6 +355,7 @@ exports.bulkPermanentlyDeleteCourses = async (req, res) => { if (!ids.length) return R.error(res, "No IDs provided.", 400); const count = await permanentDeleteMany(Course, "course_id", ids, t); + await CourseUnit.destroy({ where: { course_id: ids }, transaction: t }); await t.commit(); logActivity(req.user.user_id, 'bulk_permanently_delete_courses', { entityType: 'course', details: { ids, count } }); return R.success(res, `${count} course${count !== 1 ? "s" : ""} permanently deleted.`); @@ -352,12 +370,14 @@ exports.getCoursePermanentDeleteImpact = async (req, res) => { try { const { courseId } = req.params; - const unitCount = await Unit.count({ where: { course_id: courseId }, paranoid: false }); - const units = await Unit.findAll({ where: { course_id: courseId }, attributes: ["unit_id"], paranoid: false }); - const lessonCount = units.length - ? await Lesson.count({ where: { unit_id: units.map((u) => u.unit_id) }, paranoid: false }) + const links = await CourseUnit.findAll({ where: { course_id: courseId }, attributes: ["unit_id"] }); + const unitIds = links.map((l) => l.unit_id); + const unitCount = unitIds.length; + const lessonCount = unitIds.length + ? await UnitLesson.count({ where: { unit_id: unitIds }, distinct: true, col: "lesson_id" }) : 0; + // Deleting a course only removes attachments — units/lessons survive in the library return R.success(res, "Impact retrieved.", { unitCount, lessonCount }); } catch (err) { console.error("[COURSE][PERMANENT DELETE IMPACT]", err); @@ -424,12 +444,24 @@ exports.syncPrerequisites = async (req, res) => { }; // ══════════════════════════════════════════════════════════════════════════════ -// UNIT +// UNIT (course-scoped — membership through course_units) +// +// Units are standalone now. Under a course these endpoints manage the +// attachment (course_units row): "delete" detaches, ordering lives on the +// junction row, and existing units can be attached without being re-created. +// Entity-level archive/permanent-delete lives in /admin/units (the library). // ══════════════════════════════════════════════════════════════════════════════ +// Literal fragments that resolve a unit's order within THIS course. +const courseOrderLiteral = (courseId) => + `(SELECT cu.order_index FROM course_units cu WHERE cu.course_id = ${Number(courseId)} AND cu.unit_id = "Unit"."unit_id" LIMIT 1)`; +const courseMembershipLiteral = (courseId) => + `(SELECT cu.unit_id FROM course_units cu WHERE cu.course_id = ${Number(courseId)})`; + exports.getUnits = async (req, res) => { try { const { courseId } = req.params; + if (!Number.isFinite(Number(courseId))) return R.error(res, "Course not found.", 404); const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted } }); if (!course) return R.error(res, "Course not found.", 404); @@ -438,9 +470,26 @@ exports.getUnits = async (req, res) => { excludeAttributes: adminExclude, auditOptions: { mdl_Users, parentAlias: "Unit" }, context: "list", + computedAttributes: [ + { + key: "order_index", + label: "Order", + type: "number", + literal: courseOrderLiteral(courseId), + }, + { + 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)`, + }, + ], findOptions: { - where: { course_id: courseId, ...notDeleted }, - order: [["order_index", "ASC"]], + where: { + unit_id: { [Op.in]: Sequelize.literal(courseMembershipLiteral(courseId)) }, + ...notDeleted, + }, + order: [[Sequelize.literal(courseOrderLiteral(courseId)), "ASC"]], }, }); @@ -451,6 +500,71 @@ exports.getUnits = async (req, res) => { } }; +// POST /:courseId/units/attach { unit_ids: [..] } — attach existing library units +exports.attachUnits = async (req, res) => { + const t = await sequelize.transaction(); + try { + const { courseId } = req.params; + const { unit_ids = [] } = req.body; + if (!unit_ids.length) return R.error(res, "unit_ids is required.", 400); + + const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted }, transaction: t }); + if (!course) return R.error(res, "Course not found.", 404); + + const units = await Unit.findAll({ where: { unit_id: unit_ids, ...notDeleted }, transaction: t }); + if (units.length !== unit_ids.length) { + await t.rollback(); + return R.error(res, "One or more units were not found.", 404); + } + + const existing = await CourseUnit.findAll({ where: { course_id: courseId, unit_id: unit_ids }, transaction: t }); + const existingSet = new Set(existing.map((r) => String(r.unit_id))); + const toAttach = unit_ids.filter((id) => !existingSet.has(String(id))); + + let order = await nextOrderIndex(CourseUnit, { course_id: courseId }, t); + await CourseUnit.bulkCreate( + toAttach.map((unit_id) => ({ + course_id: courseId, + unit_id, + order_index: order++, + createdBy: req.user?.user_id ?? null, + })), + { transaction: t } + ); + + await t.commit(); + try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][ATTACH][DURATION]", durErr); } + logActivity(req.user?.user_id, "attach_units", { entityType: "course", entityId: Number(courseId), details: { unit_ids: toAttach } }); + return R.success(res, `${toAttach.length} unit${toAttach.length !== 1 ? "s" : ""} attached.`, { attached: toAttach, skipped: unit_ids.filter((id) => existingSet.has(String(id))) }); + } catch (err) { + await t.rollback(); + console.error("[UNIT][ATTACH]", err); + return R.error(res, "Could not attach units.", 500); + } +}; + +// PUT /:courseId/units/order { unit_ids: [orderedIds] } +exports.reorderUnits = async (req, res) => { + const t = await sequelize.transaction(); + try { + const { courseId } = req.params; + const { unit_ids = [] } = req.body; + if (!unit_ids.length) return R.error(res, "unit_ids is required.", 400); + + const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted }, transaction: t }); + if (!course) return R.error(res, "Course not found.", 404); + + await reorderJunction(CourseUnit, "course_id", courseId, "unit_id", unit_ids, t); + await t.commit(); + logActivity(req.user?.user_id, "reorder_units", { entityType: "course", entityId: Number(courseId), details: { unit_ids } }); + return R.success(res, "Unit order updated."); + } catch (err) { + await t.rollback(); + console.error("[UNIT][REORDER]", err); + return R.error(res, "Could not reorder units.", 500); + } +}; + exports.getUnitArchiveImpact = async (req, res) => { try { const { unitId } = req.params; @@ -471,45 +585,76 @@ exports.getUnit = async (req, res) => { try { const { courseId, unitId } = req.params; + const link = await getCourseUnitLink(courseId, unitId); + if (!link) return R.error(res, "Unit not found.", 404); + const unit = await Unit.findOne({ - where: { unit_id: unitId, course_id: courseId, ...notDeleted }, + where: { unit_id: unitId, ...notDeleted }, include: [ - { model: Lesson, as: "lessons", where: notDeleted, required: false, order: [["order_index", "ASC"]] }, + { model: Lesson, as: "lessons", where: notDeleted, required: false, through: { attributes: ["order_index"] } }, { 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() }); + + const plain = unit.toJSON(); + plain.order_index = link.order_index; + plain.lessons = flattenLessons(plain.lessons); + return R.success(res, "Unit retrieved.", { data: plain }); } catch (err) { console.error("[UNIT][GET ONE]", err); return R.error(res, "Could not retrieve unit.", 500); } }; +// POST /:courseId/units — body { unit_id } attaches an existing library unit; +// otherwise creates a new standalone unit AND attaches it (back-compat shape). exports.createUnit = async (req, res) => { + const t = await sequelize.transaction(); try { const { courseId } = req.params; - const { title, description, order, createdBy } = req.body; + const { unit_id, 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 } }); + const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted }, transaction: t }); 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, - }); + let unit; + if (unit_id) { + unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, transaction: t }); + if (!unit) { + await t.rollback(); + return R.error(res, "Unit not found.", 404); + } + const already = await getCourseUnitLink(courseId, unit_id, t); + if (already) { + await t.rollback(); + return R.error(res, "Unit is already attached to this course.", 409); + } + } else { + if (!title) return R.error(res, "Title is required.", 400); + unit = await Unit.create({ + title, + description: description ?? null, + duration_seconds: 0, + createdBy: createdBy ?? req.user?.user_id ?? null, + }, { transaction: t }); + } - logActivity(req.user?.user_id, 'create_unit', { entityType: 'unit', entityId: unit.unit_id, details: { title: unit.title } }); - return R.success(res, "Unit created.", { data: unit }, 201); + const order_index = order ?? await nextOrderIndex(CourseUnit, { course_id: courseId }, t); + await CourseUnit.create({ + course_id: courseId, + unit_id: unit.unit_id, + order_index, + createdBy: createdBy ?? req.user?.user_id ?? null, + }, { transaction: t }); + + await t.commit(); + try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][CREATE][DURATION]", durErr); } + logActivity(req.user?.user_id, 'create_unit', { entityType: 'unit', entityId: unit.unit_id, details: { title: unit.title, course_id: Number(courseId), attached_existing: !!unit_id } }); + return R.success(res, unit_id ? "Unit attached." : "Unit created.", { data: { ...unit.toJSON(), order_index } }, 201); } catch (err) { + await t.rollback(); console.error("[UNIT][CREATE]", err); return R.error(res, "Could not create unit.", 500); } @@ -519,79 +664,86 @@ 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 link = await getCourseUnitLink(courseId, unitId); + if (!link) return R.error(res, "Unit not found.", 404); + + const unit = await Unit.findOne({ where: { unit_id: unitId, ...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; - + unit.updatedBy = updatedBy ?? req.user?.user_id ?? null; await unit.save(); + + if (order !== undefined) { + await link.update({ order_index: order, updatedBy: updatedBy ?? req.user?.user_id ?? null }); + } + logActivity(req.user?.user_id, 'update_unit', { entityType: 'unit', entityId: Number(unitId) }); - return R.success(res, "Unit updated.", { data: unit }); + return R.success(res, "Unit updated.", { data: { ...unit.toJSON(), order_index: order !== undefined ? order : link.order_index } }); } catch (err) { console.error("[UNIT][UPDATE]", err); return R.error(res, "Could not update unit.", 500); } }; +// DELETE /:courseId/units/:unitId — detaches the unit from this course. +// The unit itself survives in the library (archive it from /admin/units). exports.archiveUnit = async (req, res) => { - const t = await sequelize.transaction(); try { const { courseId, unitId } = req.params; - const record = await archiveOne(Unit, { unit_id: unitId, course_id: courseId, ...notDeleted }, req.user.user_id, t); - if (!record) return R.error(res, "Unit not found.", 404); - await t.commit(); - try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][ARCHIVE][DURATION]", durErr); } - logActivity(req.user.user_id, 'archive_unit', { entityType: 'unit', entityId: Number(unitId) }); - return R.success(res, "Unit archived."); + + const removed = await CourseUnit.destroy({ where: { course_id: courseId, unit_id: unitId } }); + if (!removed) return R.error(res, "Unit is not attached to this course.", 404); + + try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][DETACH][DURATION]", durErr); } + logActivity(req.user.user_id, 'detach_unit', { entityType: 'unit', entityId: Number(unitId), details: { course_id: Number(courseId) } }); + return R.success(res, "Unit removed from course."); } catch (err) { - await t.rollback(); - console.error("[UNIT][ARCHIVE]", err); - return R.error(res, "Could not archive unit.", 500); + console.error("[UNIT][DETACH]", err); + return R.error(res, "Could not remove unit from course.", 500); } }; exports.bulkArchiveUnits = async (req, res) => { - const t = await sequelize.transaction(); try { const { courseId } = req.params; const { ids = [] } = req.body; if (!ids.length) return R.error(res, "No IDs provided.", 400); - const units = await Unit.findAll({ where: { unit_id: ids, course_id: courseId, ...notDeleted } }); - const validIds = units.map((u) => u.unit_id); + const count = await CourseUnit.destroy({ where: { course_id: courseId, unit_id: ids } }); - const count = await archiveMany(Unit, "unit_id", validIds, req.user.user_id, t); - await t.commit(); - try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][BULK ARCHIVE][DURATION]", durErr); } - logActivity(req.user.user_id, 'bulk_archive_units', { entityType: 'unit', details: { ids: validIds, count } }); - return R.success(res, `${count} unit${count !== 1 ? "s" : ""} archived.`); + try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][BULK DETACH][DURATION]", durErr); } + logActivity(req.user.user_id, 'bulk_detach_units', { entityType: 'unit', details: { ids, count, course_id: Number(courseId) } }); + return R.success(res, `${count} unit${count !== 1 ? "s" : ""} removed from course.`); } catch (err) { - await t.rollback(); - console.error("[UNIT][BULK ARCHIVE]", err); - return R.error(res, "Could not archive units.", 500); + console.error("[UNIT][BULK DETACH]", err); + return R.error(res, "Could not remove units from course.", 500); } }; exports.getArchivedUnits = async (req, res) => { try { const { courseId } = req.params; + if (!Number.isFinite(Number(courseId))) return R.error(res, "Course not found.", 404); const course = await Course.findOne({ where: { course_id: courseId }, paranoid: false }); if (!course) return R.error(res, "Course not found.", 404); + // Archived units that are still attached to this course const result = await paginate(Unit, req, { excludeAttributes: adminExclude, auditOptions: { mdl_Users, parentAlias: "Unit" }, context: "archived", findOptions: { - where: { course_id: courseId, ...onlyDeleted }, + where: { + unit_id: { [Op.in]: Sequelize.literal(courseMembershipLiteral(courseId)) }, + ...onlyDeleted, + }, paranoid: false, - order: [["order_index", "ASC"]], + order: [[Sequelize.literal(courseOrderLiteral(courseId)), "ASC"]], }, }); @@ -605,12 +757,16 @@ exports.getArchivedUnits = async (req, res) => { exports.getArchivedUnit = async (req, res) => { try { const { courseId, unitId } = req.params; + + const link = await getCourseUnitLink(courseId, unitId); + if (!link) return R.error(res, "Archived unit not found.", 404); + const unit = await Unit.findOne({ - where: { unit_id: unitId, course_id: courseId, ...onlyDeleted }, + 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() }); + return R.success(res, "Archived unit retrieved.", { data: { ...unit.toJSON(), order_index: link.order_index } }); } catch (err) { console.error("[UNIT][GET ARCHIVE ONE]", err); return R.error(res, "Could not retrieve archived unit.", 500); @@ -621,7 +777,11 @@ exports.restoreUnit = async (req, res) => { const t = await sequelize.transaction(); try { const { courseId, unitId } = req.params; - const record = await restoreOne(Unit, { unit_id: unitId, course_id: courseId, ...onlyDeleted }, req.user.user_id, t); + + const link = await getCourseUnitLink(courseId, unitId, t); + if (!link) return R.error(res, "Archived unit not found.", 404); + + 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 recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][RESTORE][DURATION]", durErr); } @@ -641,7 +801,10 @@ exports.bulkRestoreUnits = async (req, res) => { const { ids = [] } = req.body; if (!ids.length) return R.error(res, "No IDs provided.", 400); - const units = await Unit.findAll({ where: { unit_id: ids, course_id: courseId, ...onlyDeleted }, paranoid: false }); + const links = await CourseUnit.findAll({ where: { course_id: courseId, unit_id: ids }, transaction: t }); + const linkedIds = links.map((l) => l.unit_id); + + const units = await Unit.findAll({ where: { unit_id: linkedIds, ...onlyDeleted }, paranoid: false, transaction: t }); const validIds = units.map((u) => u.unit_id); const count = await restoreMany(Unit, "unit_id", validIds, req.user.user_id, t); @@ -660,9 +823,17 @@ exports.permanentlyDeleteUnit = async (req, res) => { const t = await sequelize.transaction(); try { const { courseId, unitId } = req.params; - const record = await permanentDeleteOne(Unit, { unit_id: unitId, course_id: courseId }, t); + + const link = await getCourseUnitLink(courseId, unitId, t); + if (!link) return R.error(res, "Unit not found.", 404); + + 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); + + await CourseUnit.destroy({ where: { unit_id: unitId }, transaction: t }); + await UnitLesson.destroy({ where: { unit_id: unitId }, transaction: t }); + await t.commit(); try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][PERMANENT DELETE][DURATION]", durErr); } logActivity(req.user.user_id, 'permanently_delete_unit', { entityType: 'unit', entityId: Number(unitId) }); @@ -681,10 +852,13 @@ exports.bulkPermanentlyDeleteUnits = async (req, res) => { const { ids = [] } = req.body; if (!ids.length) return R.error(res, "No IDs provided.", 400); - const units = await Unit.findAll({ where: { unit_id: ids, course_id: courseId }, paranoid: false }); - const validIds = units.map((u) => u.unit_id); + const links = await CourseUnit.findAll({ where: { course_id: courseId, unit_id: ids }, transaction: t }); + const validIds = links.map((l) => l.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(); try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][BULK PERMANENT DELETE][DURATION]", durErr); } logActivity(req.user.user_id, 'bulk_permanently_delete_units', { entityType: 'unit', details: { ids: validIds, count } }); @@ -700,9 +874,12 @@ exports.getUnitPermanentDeleteImpact = async (req, res) => { try { const { unitId } = req.params; - const lessonCount = await Lesson.count({ where: { unit_id: unitId }, paranoid: false }); + 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 }); + return R.success(res, "Impact retrieved.", { lessonCount, courseCount }); } catch (err) { console.error("[UNIT][PERMANENT DELETE IMPACT]", err); return R.error(res, "Could not retrieve impact.", 500); @@ -710,23 +887,45 @@ exports.getUnitPermanentDeleteImpact = async (req, res) => { }; // ══════════════════════════════════════════════════════════════════════════════ -// LESSON +// LESSON (unit-scoped — membership through unit_lessons) +// +// Lessons are standalone now. Under a unit these endpoints manage the +// attachment (unit_lessons row): "delete" detaches, ordering lives on the +// junction row, and existing lessons can be attached without being re-created. +// Entity-level archive/permanent-delete lives in /admin/lessons (the library). // ══════════════════════════════════════════════════════════════════════════════ +const unitOrderLiteral = (unitId) => + `(SELECT ul.order_index FROM unit_lessons ul WHERE ul.unit_id = ${Number(unitId)} AND ul.lesson_id = "Lesson"."lesson_id" LIMIT 1)`; +const unitMembershipLiteral = (unitId) => + `(SELECT ul.lesson_id FROM unit_lessons ul WHERE ul.unit_id = ${Number(unitId)})`; + exports.getLessons = async (req, res) => { try { const { courseId, unitId } = req.params; + if (!Number.isFinite(Number(unitId))) return R.error(res, "Unit not found.", 404); - const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } }); - if (!unit) return R.error(res, "Unit not found.", 404); + const link = await getCourseUnitLink(courseId, unitId); + if (!link) return R.error(res, "Unit not found.", 404); const result = await paginate(Lesson, req, { excludeAttributes: adminExclude, auditOptions: { mdl_Users, parentAlias: "Lesson" }, context: "list", + computedAttributes: [ + { + key: "order_index", + label: "Order", + type: "number", + literal: unitOrderLiteral(unitId), + }, + ], findOptions: { - where: { unit_id: unitId, ...notDeleted }, - order: [["order_index", "ASC"]], + where: { + lesson_id: { [Op.in]: Sequelize.literal(unitMembershipLiteral(unitId)) }, + ...notDeleted, + }, + order: [[Sequelize.literal(unitOrderLiteral(unitId)), "ASC"]], }, }); @@ -741,54 +940,84 @@ exports.getLesson = async (req, res) => { try { const { courseId, unitId, lessonId } = req.params; + const courseLink = await getCourseUnitLink(courseId, unitId); + if (!courseLink) return R.error(res, "Unit not found.", 404); + + const lessonLink = await getUnitLessonLink(unitId, lessonId); + if (!lessonLink) return R.error(res, "Lesson not found.", 404); + const lesson = await Lesson.findOne({ - where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted }, + 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: "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() }); + return R.success(res, "Lesson retrieved.", { data: { ...lesson.toJSON(), order_index: lessonLink.order_index } }); } catch (err) { console.error("[LESSON][GET ONE]", err); return R.error(res, "Could not retrieve lesson.", 500); } }; +// POST /:courseId/units/:unitId/lessons — body { lesson_id } attaches an existing +// library lesson; otherwise creates a new standalone lesson AND attaches it. exports.createLesson = async (req, res) => { const t = await sequelize.transaction(); try { const { courseId, unitId } = req.params; - const { title, description, order, objectives = [], createdBy } = req.body; + const { lesson_id, title, description, order, objectives = [], createdBy } = req.body; - if (!title) return R.error(res, "Title is required.", 400); + const courseLink = await getCourseUnitLink(courseId, unitId, t); + if (!courseLink) return R.error(res, "Unit not found.", 404); - const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } }); - if (!unit) return R.error(res, "Unit not found.", 404); + let lesson; + if (lesson_id) { + lesson = await Lesson.findOne({ where: { lesson_id, ...notDeleted }, transaction: t }); + if (!lesson) { + await t.rollback(); + return R.error(res, "Lesson not found.", 404); + } + const already = await getUnitLessonLink(unitId, lesson_id, t); + if (already) { + await t.rollback(); + return R.error(res, "Lesson is already attached to this unit.", 409); + } + } else { + if (!title) return R.error(res, "Title is required.", 400); + lesson = await Lesson.create({ + title, + description: description ?? null, + duration_seconds: 0, + createdBy: createdBy ?? req.user?.user_id ?? null, + }, { transaction: t }); - const lesson = await Lesson.create({ + 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); + } + + const order_index = order ?? await nextOrderIndex(UnitLesson, { unit_id: unitId }, t); + await UnitLesson.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, + order_index, + createdBy: createdBy ?? req.user?.user_id ?? null, }, { transaction: t }); - await syncObjectivesCreate(LessonObjective, "lesson_id", lesson.lesson_id, objectives, t); - await t.commit(); - logActivity(req.user?.user_id, 'create_lesson', { entityType: 'lesson', entityId: lesson.lesson_id, details: { title: lesson.title } }); - return R.success(res, "Lesson created.", { data: lesson }, 201); + try { + await recomputeUnitDuration(unitId); + await recomputeCourseDuration(courseId); + } catch (durErr) { console.error("[LESSON][CREATE][DURATION]", durErr); } + logActivity(req.user?.user_id, 'create_lesson', { entityType: 'lesson', entityId: lesson.lesson_id, details: { title: lesson.title, unit_id: Number(unitId), attached_existing: !!lesson_id } }); + return R.success(res, lesson_id ? "Lesson attached." : "Lesson created.", { data: { ...lesson.toJSON(), order_index } }, 201); } catch (err) { await t.rollback(); console.error("[LESSON][CREATE]", err); @@ -796,25 +1025,53 @@ exports.createLesson = async (req, res) => { } }; +// PUT /:courseId/units/:unitId/lessons/order { lesson_ids: [orderedIds] } +exports.reorderLessons = async (req, res) => { + const t = await sequelize.transaction(); + try { + const { courseId, unitId } = req.params; + const { lesson_ids = [] } = req.body; + if (!lesson_ids.length) return R.error(res, "lesson_ids is required.", 400); + + const courseLink = await getCourseUnitLink(courseId, unitId, t); + if (!courseLink) 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("[LESSON][REORDER]", err); + return R.error(res, "Could not reorder lessons.", 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 } }], - }); + const courseLink = await getCourseUnitLink(courseId, unitId, t); + if (!courseLink) return R.error(res, "Unit not found.", 404); + + const lessonLink = await getUnitLessonLink(unitId, lessonId, t); + if (!lessonLink) return R.error(res, "Lesson not found.", 404); + + const lesson = await Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted }, transaction: t }); 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; + lesson.updatedBy = updatedBy ?? req.user?.user_id ?? null; await lesson.save({ transaction: t }); + if (order !== undefined) { + await lessonLink.update({ order_index: order, updatedBy: updatedBy ?? req.user?.user_id ?? null }, { transaction: t }); + } + if (objectives !== undefined) { await syncObjectivesUpdate(LessonObjective, "lesson_id", lessonId, objectives, t); } @@ -827,7 +1084,7 @@ exports.updateLesson = async (req, res) => { }); logActivity(req.user?.user_id, 'update_lesson', { entityType: 'lesson', entityId: Number(lessonId) }); - return R.success(res, "Lesson updated.", { data: updated }); + return R.success(res, "Lesson updated.", { data: { ...updated.toJSON(), order_index: order !== undefined ? order : lessonLink.order_index } }); } catch (err) { await t.rollback(); console.error("[LESSON][UPDATE]", err); @@ -835,74 +1092,73 @@ exports.updateLesson = async (req, res) => { } }; +// DELETE /:courseId/units/:unitId/lessons/:lessonId — detaches the lesson from +// this unit. The lesson survives in the library (archive it from /admin/lessons). exports.archiveLesson = async (req, res) => { - const t = await sequelize.transaction(); try { const { courseId, unitId, lessonId } = req.params; - const record = await archiveOne(Lesson, { - where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted }, - include: [{ model: Unit, as: "unit", where: { course_id: courseId, ...notDeleted } }], - }, req.user.user_id, t); + const courseLink = await getCourseUnitLink(courseId, unitId); + if (!courseLink) return R.error(res, "Unit not found.", 404); + + 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); - if (!record) return R.error(res, "Lesson not found.", 404); - await t.commit(); try { await recomputeUnitDuration(unitId); await recomputeCourseDuration(courseId); - } catch (durErr) { console.error("[LESSON][ARCHIVE][DURATION]", durErr); } - logActivity(req.user.user_id, 'archive_lesson', { entityType: 'lesson', entityId: Number(lessonId) }); - return R.success(res, "Lesson archived."); + } catch (durErr) { console.error("[LESSON][DETACH][DURATION]", durErr); } + logActivity(req.user.user_id, 'detach_lesson', { entityType: 'lesson', entityId: Number(lessonId), details: { unit_id: Number(unitId) } }); + return R.success(res, "Lesson removed from unit."); } catch (err) { - await t.rollback(); - console.error("[LESSON][ARCHIVE]", err); - return R.error(res, "Could not archive lesson.", 500); + console.error("[LESSON][DETACH]", err); + return R.error(res, "Could not remove lesson from unit.", 500); } }; exports.bulkArchiveLessons = async (req, res) => { - const t = await sequelize.transaction(); try { const { courseId, unitId } = req.params; const { ids = [] } = req.body; if (!ids.length) return R.error(res, "No IDs provided.", 400); - const lessons = await Lesson.findAll({ - where: { lesson_id: ids, unit_id: unitId, ...notDeleted }, - include: [{ model: Unit, as: "unit", where: { course_id: courseId, ...notDeleted } }], - }); - const validIds = lessons.map((l) => l.lesson_id); + const courseLink = await getCourseUnitLink(courseId, unitId); + if (!courseLink) return R.error(res, "Unit not found.", 404); + + const count = await UnitLesson.destroy({ where: { unit_id: unitId, lesson_id: ids } }); - const count = await archiveMany(Lesson, "lesson_id", validIds, req.user.user_id, t); - await t.commit(); try { await recomputeUnitDuration(unitId); await recomputeCourseDuration(courseId); - } catch (durErr) { console.error("[LESSON][BULK ARCHIVE][DURATION]", durErr); } - logActivity(req.user.user_id, 'bulk_archive_lessons', { entityType: 'lesson', details: { ids: validIds, count } }); - return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} archived.`); + } catch (durErr) { console.error("[LESSON][BULK DETACH][DURATION]", durErr); } + logActivity(req.user.user_id, 'bulk_detach_lessons', { entityType: 'lesson', details: { ids, count, unit_id: Number(unitId) } }); + return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} removed from unit.`); } catch (err) { - await t.rollback(); - console.error("[LESSON][BULK ARCHIVE]", err); - return R.error(res, "Could not archive lessons.", 500); + console.error("[LESSON][BULK DETACH]", err); + return R.error(res, "Could not remove lessons from unit.", 500); } }; exports.getArchivedLessons = async (req, res) => { try { const { courseId, unitId } = req.params; + if (!Number.isFinite(Number(unitId))) return R.error(res, "Unit not found.", 404); - 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 link = await getCourseUnitLink(courseId, unitId); + if (!link) return R.error(res, "Unit not found.", 404); + // Archived lessons that are still attached to this unit const result = await paginate(Lesson, req, { excludeAttributes: adminExclude, auditOptions: { mdl_Users, parentAlias: "Lesson" }, context: "archived", findOptions: { - where: { unit_id: unitId, ...onlyDeleted }, + where: { + lesson_id: { [Op.in]: Sequelize.literal(unitMembershipLiteral(unitId)) }, + ...onlyDeleted, + }, paranoid: false, - order: [["order_index", "ASC"]], + order: [[Sequelize.literal(unitOrderLiteral(unitId)), "ASC"]], }, }); @@ -915,17 +1171,17 @@ exports.getArchivedLessons = async (req, res) => { exports.getArchivedLesson = async (req, res) => { try { - const { courseId, unitId, lessonId } = req.params; + const { 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 lessonLink = await getUnitLessonLink(unitId, lessonId); + if (!lessonLink) return R.error(res, "Archived lesson not found.", 404); const lesson = await Lesson.findOne({ - where: { lesson_id: lessonId, unit_id: unitId, ...onlyDeleted }, + 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() }); + return R.success(res, "Archived lesson retrieved.", { data: { ...lesson.toJSON(), order_index: lessonLink.order_index } }); } catch (err) { console.error("[LESSON][GET ARCHIVE ONE]", err); return R.error(res, "Could not retrieve archived lesson.", 500); @@ -937,10 +1193,10 @@ exports.restoreLesson = 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 lessonLink = await getUnitLessonLink(unitId, lessonId, t); + if (!lessonLink) return R.error(res, "Archived lesson not found.", 404); - const record = await restoreOne(Lesson, { lesson_id: lessonId, unit_id: unitId, ...onlyDeleted }, req.user.user_id, t); + 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 { @@ -963,10 +1219,10 @@ exports.bulkRestoreLessons = async (req, res) => { const { ids = [] } = req.body; if (!ids.length) return R.error(res, "No IDs provided.", 400); - const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId }, paranoid: false }); - if (!unit) return R.error(res, "Unit not found.", 404); + const links = await UnitLesson.findAll({ where: { unit_id: unitId, lesson_id: ids }, transaction: t }); + const linkedIds = links.map((l) => l.lesson_id); - const lessons = await Lesson.findAll({ where: { lesson_id: ids, unit_id: unitId, ...onlyDeleted }, paranoid: false }); + const lessons = await Lesson.findAll({ where: { lesson_id: linkedIds, ...onlyDeleted }, paranoid: false, transaction: t }); const validIds = lessons.map((l) => l.lesson_id); const count = await restoreMany(Lesson, "lesson_id", validIds, req.user.user_id, t); @@ -989,12 +1245,15 @@ exports.permanentlyDeleteLesson = 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 lessonLink = await getUnitLessonLink(unitId, lessonId, t); + if (!lessonLink) return R.error(res, "Lesson not found.", 404); - const record = await permanentDeleteOne(Lesson, { lesson_id: lessonId, unit_id: unitId }, t); + 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(); try { await recomputeUnitDuration(unitId); @@ -1016,13 +1275,12 @@ exports.bulkPermanentlyDeleteLessons = async (req, res) => { const { ids = [] } = req.body; if (!ids.length) return R.error(res, "No IDs provided.", 400); - const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId }, paranoid: false }); - if (!unit) return R.error(res, "Unit not found.", 404); - - const lessons = await Lesson.findAll({ where: { lesson_id: ids, unit_id: unitId }, paranoid: false }); - const validIds = lessons.map((l) => l.lesson_id); + const links = await UnitLesson.findAll({ where: { unit_id: unitId, lesson_id: ids }, transaction: t }); + const validIds = links.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(); try { await recomputeUnitDuration(unitId); @@ -1097,8 +1355,8 @@ 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 link = await getCourseUnitLink(courseId, unitId); + if (!link) return R.error(res, "Unit not found.", 404); const quiz = await UnitQuiz.findOne({ where: { unit_id: unitId, ...notDeleted }, @@ -1122,8 +1380,8 @@ exports.createQuiz = async (req, res) => { const { courseId, unitId } = req.params; const { title, is_required, passing_score, max_questions, shuffle_questions, createdBy } = req.body; - const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } }); - if (!unit) return R.error(res, "Unit not found.", 404); + const link = await getCourseUnitLink(courseId, unitId); + if (!link) 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); @@ -1191,8 +1449,8 @@ 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 link = await getCourseUnitLink(courseId, unitId); + if (!link) return R.error(res, "Unit not found.", 404); const quiz = await UnitQuiz.findOne({ where: { unit_id: unitId, ...onlyDeleted }, @@ -1792,29 +2050,30 @@ exports.getCoursesBySubscription = async (req, res) => { } }; +// One row per (course, unit) attachment; unattached units get a standalone row +// with course_title "" so they remain selectable in requirement builders. exports.getUnitsFlat = async (req, res) => { try { - const rows = await Unit.findAll({ - where: notDeleted, - attributes: ["uuid", "title", "order_index", "duration_seconds"], - include: [{ - model: Course, - as: "course", - attributes: ["title", "subscription"], - paranoid: false, - }], - order: [ - [{ model: Course, as: "course" }, "title", "ASC"], - ["order_index", "ASC"], - ], - }); - const data = rows.map((u) => ({ - uuid: u.uuid, - title: u.title, - order_index: u.order_index ?? 0, - duration_seconds: u.duration_seconds ?? 0, - course_title: u.course?.title ?? "", - subscription: u.course?.subscription ?? "free", + const rows = await sequelize.query(` + SELECT + u.uuid, u.title, u.duration_seconds, + COALESCE(cu.order_index, 0) AS order_index, + COALESCE(c.title, '') AS course_title, + COALESCE(c.subscription, 'free') AS subscription + FROM units u + LEFT JOIN course_units cu ON cu.unit_id = u.unit_id + LEFT JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL + WHERE u."deletedAt" IS NULL + ORDER BY course_title ASC, order_index ASC, u.title ASC + `, { type: sequelize.QueryTypes.SELECT }); + + const data = rows.map((r) => ({ + uuid: r.uuid, + title: r.title, + order_index: Number(r.order_index ?? 0), + duration_seconds: Number(r.duration_seconds ?? 0), + course_title: r.course_title ?? "", + subscription: r.subscription ?? "free", })); return R.success(res, "Units retrieved.", data); } catch (err) { @@ -1823,38 +2082,36 @@ exports.getUnitsFlat = async (req, res) => { } }; +// One row per (course, unit, lesson) attachment chain; standalone lessons keep +// empty unit/course labels. exports.getLessonsFlat = async (req, res) => { try { - const rows = await Lesson.findAll({ - where: notDeleted, - attributes: ["uuid", "title", "order_index", "duration_seconds"], - include: [{ - model: Unit, - as: "unit", - attributes: ["title", "order_index"], - paranoid: false, - include: [{ - model: Course, - as: "course", - attributes: ["title", "subscription"], - paranoid: false, - }], - }], - order: [ - [{ model: Unit, as: "unit" }, { model: Course, as: "course" }, "title", "ASC"], - [{ model: Unit, as: "unit" }, "order_index", "ASC"], - ["order_index", "ASC"], - ], - }); - const data = rows.map((l) => ({ - uuid: l.uuid, - title: l.title, - order_index: l.order_index ?? 0, - duration_seconds: l.duration_seconds ?? 0, - unit_title: l.unit?.title ?? "", - unit_order: l.unit?.order_index ?? 0, - course_title: l.unit?.course?.title ?? "", - subscription: l.unit?.course?.subscription ?? "free", + const rows = await sequelize.query(` + SELECT + l.uuid, l.title, l.duration_seconds, + COALESCE(ul.order_index, 0) AS order_index, + COALESCE(u.title, '') AS unit_title, + COALESCE(cu.order_index, 0) AS unit_order, + COALESCE(c.title, '') AS course_title, + COALESCE(c.subscription, 'free') AS subscription + FROM lessons l + LEFT JOIN unit_lessons ul ON ul.lesson_id = l.lesson_id + LEFT JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL + LEFT JOIN course_units cu ON cu.unit_id = u.unit_id + LEFT JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL + WHERE l."deletedAt" IS NULL + ORDER BY course_title ASC, unit_order ASC, order_index ASC, l.title ASC + `, { type: sequelize.QueryTypes.SELECT }); + + const data = rows.map((r) => ({ + uuid: r.uuid, + title: r.title, + order_index: Number(r.order_index ?? 0), + duration_seconds: Number(r.duration_seconds ?? 0), + unit_title: r.unit_title ?? "", + unit_order: Number(r.unit_order ?? 0), + course_title: r.course_title ?? "", + subscription: r.subscription ?? "free", })); return R.success(res, "Lessons retrieved.", data); } catch (err) { diff --git a/controllers/admin/lessons.controller.js b/controllers/admin/lessons.controller.js index 04e2429..59d75c8 100644 --- a/controllers/admin/lessons.controller.js +++ b/controllers/admin/lessons.controller.js @@ -1,21 +1,39 @@ "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 } = require("../../utils/duration.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, - CourseAssessment, UnitQuiz, - QuizQuestion, QuizOption, } = require("../../models/courses/courses.associations"); const mdl_Users = require("../../models/users/users.mdl"); @@ -23,65 +41,92 @@ 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 }; +// 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); + } } - if (assessmentId) { - const rec = await CourseAssessment.findOne({ where: { assessment_id: assessmentId, ...notDeleted } }); - return { parentField: "assessment_id", parentId: assessmentId, parentRecord: rec }; - } - return null; } +const LESSON_LIST_COMPUTED = [ + { + key: "unit_count", + label: "Used in units", + type: "number", + 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" + )`, + }, +]; + // ══════════════════════════════════════════════════════════════════════════════ -// LESSON +// LESSON LIBRARY // ══════════════════════════════════════════════════════════════════════════════ 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", + computedAttributes: LESSON_LIST_COMPUTED, findOptions: { - where: { unit_id: unitId, ...notDeleted }, - order: [["order_index", "ASC"]], + where: { ...notDeleted }, + order: [["createdAt", "DESC"]], }, }); - return R.success(res, "Lessons retrieved.", result); } catch (err) { - console.error("[LESSON][GET ALL]", 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 { courseId, unitId, lessonId } = req.params; + const { lessonId } = req.params; const lesson = await Lesson.findOne({ - where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted }, + 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: "unit", where: { course_id: courseId, ...notDeleted } }, + { 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][GET ONE]", err); + console.error("[LESSON LIB][GET ONE]", err); return R.error(res, "Could not retrieve lesson.", 500); } }; @@ -89,36 +134,46 @@ 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, objectives = [], createdBy } = req.body; - + const { title, description, unit_id, 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, + createdBy: createdBy ?? req.user?.user_id ?? null, }, { transaction: t }); await LessonPage.create({ lesson_id: lesson.lesson_id, blocks: [], - createdBy: createdBy ?? null, + 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][CREATE]", err); + console.error("[LESSON LIB][CREATE]", err); return R.error(res, "Could not create lesson.", 500); } }; @@ -126,20 +181,16 @@ exports.createLesson = async (req, res) => { exports.updateLesson = async (req, res) => { const t = await sequelize.transaction(); try { - const { courseId, unitId, lessonId } = req.params; + const { 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 } }], - }); + const lesson = await Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted } }); if (!lesson) return R.error(res, "Lesson not found.", 404); - const { title, description, order, objectives, updatedBy } = req.body; + const { title, description, 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; + lesson.updatedBy = updatedBy ?? req.user?.user_id ?? null; await lesson.save({ transaction: t }); if (objectives !== undefined) { @@ -153,33 +204,28 @@ exports.updateLesson = async (req, res) => { include: [{ model: LessonObjective, as: "objectives", order: [["order_index", "ASC"]] }], }); + logActivity(req.user?.user_id, "update_lesson", { entityType: "lesson", entityId: Number(lessonId) }); return R.success(res, "Lesson updated.", { data: updated }); } catch (err) { await t.rollback(); - console.error("[LESSON][UPDATE]", err); + console.error("[LESSON LIB][UPDATE]", err); return R.error(res, "Could not update lesson.", 500); } }; -exports.deleteLesson = async (req, res) => { +exports.archiveLesson = 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 }); - + 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][DELETE]", err); + console.error("[LESSON LIB][ARCHIVE]", err); return R.error(res, "Could not archive lesson.", 500); } }; @@ -187,71 +233,55 @@ exports.deleteLesson = async (req, res) => { exports.bulkArchiveLessons = async (req, res) => { const t = await sequelize.transaction(); try { - const { courseId, unitId } = req.params; - const { ids = [], deletedBy } = req.body; + const { ids = [] } = req.body; if (!ids.length) return R.error(res, "No IDs provided.", 400); - const lessons = await Lesson.findAll({ - where: { lesson_id: ids, unit_id: unitId, ...notDeleted }, - include: [{ model: Unit, as: "unit", where: { course_id: courseId, ...notDeleted } }], - }); + const 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, deletedBy, t); + 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][BULK ARCHIVE]", err); + console.error("[LESSON LIB][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", + context: "archived", findOptions: { - where: { unit_id: unitId, ...onlyDeleted }, + where: { ...onlyDeleted }, paranoid: false, - order: [["order_index", "ASC"]], + order: [["deletedAt", "DESC"]], }, }); - return R.success(res, "Archived lessons retrieved.", result); } catch (err) { - console.error("[LESSON][GET ARCHIVES]", 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 { 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 { lessonId } = req.params; const lesson = await Lesson.findOne({ - where: { lesson_id: lessonId, unit_id: unitId, ...onlyDeleted }, + 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][GET ARCHIVE ONE]", err); + console.error("[LESSON LIB][GET ARCHIVE ONE]", err); return R.error(res, "Could not retrieve archived lesson.", 500); } }; @@ -259,26 +289,16 @@ exports.getArchivedLesson = async (req, res) => { 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 - ); + 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][RESTORE]", err); + console.error("[LESSON LIB][RESTORE]", err); return R.error(res, "Could not restore lesson.", 500); } }; @@ -286,34 +306,83 @@ exports.restoreLesson = async (req, res) => { exports.bulkRestoreLessons = async (req, res) => { const t = await sequelize.transaction(); try { - const { courseId, unitId } = req.params; - const { ids = [], restoredBy } = req.body; + const { ids = [] } = req.body; if (!ids.length) return R.error(res, "No IDs provided.", 400); - const unit = await Unit.findOne({ - where: { unit_id: unitId, course_id: courseId }, - paranoid: false, - }); - if (!unit) return R.error(res, "Unit not found.", 404); - - const lessons = await Lesson.findAll({ - where: { lesson_id: ids, unit_id: unitId, ...onlyDeleted }, - paranoid: false, - }); + const 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, restoredBy, t); + 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][BULK RESTORE]", err); + 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 +// LESSON PAGE (same contract as before — keyed by lessonId only) // ══════════════════════════════════════════════════════════════════════════════ exports.getLessonPage = async (req, res) => { @@ -323,7 +392,7 @@ exports.getLessonPage = async (req, res) => { 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); + console.error("[LESSON LIB][PAGE][GET]", err); return R.error(res, "Could not retrieve lesson page.", 500); } }; @@ -341,16 +410,17 @@ exports.upsertLessonPage = async (req, res) => { const [page, created] = await LessonPage.upsert({ lesson_id: lessonId, blocks, - updatedBy: req.body.updatedBy ?? null, - createdBy: req.body.updatedBy ?? null, + updatedBy: req.body.updatedBy ?? req.user?.user_id ?? null, + createdBy: req.body.updatedBy ?? req.user?.user_id ?? null, }, { returning: true }); try { await recomputeDurations(lessonId); } catch (durErr) { - console.error("[LESSON PAGE][DURATION]", 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.", @@ -358,153 +428,7 @@ exports.upsertLessonPage = async (req, res) => { created ? 201 : 200, ); } catch (err) { - console.error("[LESSON PAGE][UPSERT]", err); + console.error("[LESSON LIB][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 index 199588f..863ca6d 100644 --- a/controllers/admin/units.controller.js +++ b/controllers/admin/units.controller.js @@ -1,17 +1,40 @@ "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"); @@ -19,126 +42,205 @@ 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 +// UNIT LIBRARY // ══════════════════════════════════════════════════════════════════════════════ 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", + computedAttributes: UNIT_LIST_COMPUTED, findOptions: { - where: { course_id: courseId, ...notDeleted }, - order: [["order_index", "ASC"]], + where: { ...notDeleted }, + order: [["createdAt", "DESC"]], }, - computedAttributes: [ - { - 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)`, - }, - ], }); - return R.success(res, "Units retrieved.", result); } catch (err) { - console.error("[UNIT][GET ALL]", 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 { courseId, unitId } = req.params; + const { unitId } = req.params; const unit = await Unit.findOne({ - where: { unit_id: unitId, course_id: courseId, ...notDeleted }, + where: { unit_id: unitId, ...notDeleted }, include: [ - { model: Lesson, as: "lessons", where: notDeleted, required: false }, + { 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"] } }, ], - 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() }); + + const plain = unit.toJSON(); + plain.lessons = flattenLessons(plain.lessons); + return R.success(res, "Unit retrieved.", { data: plain }); } catch (err) { - console.error("[UNIT][GET ONE]", 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 { courseId } = req.params; - const { title, description, order, createdBy } = req.body; - + const { title, description, course_id, 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, - }); + 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) { - console.error("[UNIT][CREATE]", 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 { courseId, unitId } = req.params; + const { 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, ...notDeleted } }); if (!unit) return R.error(res, "Unit not found.", 404); - const { title, description, order, updatedBy } = req.body; + const { title, description, 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; + 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][UPDATE]", err); + console.error("[UNIT LIB][UPDATE]", err); return R.error(res, "Could not update unit.", 500); } }; -exports.deleteUnit = async (req, res) => { +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 { courseId, unitId } = req.params; - const record = await archiveOne( - Unit, - { unit_id: unitId, course_id: courseId, ...notDeleted }, - req.body.deletedBy, - t - ); + 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][DELETE]", err); + console.error("[UNIT LIB][ARCHIVE]", err); return R.error(res, "Could not archive unit.", 500); } }; @@ -146,60 +248,55 @@ exports.deleteUnit = async (req, res) => { exports.bulkArchiveUnits = async (req, res) => { const t = await sequelize.transaction(); try { - const { courseId } = req.params; - const { ids = [], deletedBy } = req.body; + const { ids = [] } = req.body; if (!ids.length) return R.error(res, "No IDs provided.", 400); - const units = await Unit.findAll({ - where: { unit_id: ids, course_id: courseId, ...notDeleted }, - }); + const 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, deletedBy, t); + 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][BULK ARCHIVE]", err); + console.error("[UNIT LIB][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", + context: "archived", findOptions: { - where: { course_id: courseId, ...onlyDeleted }, + where: { ...onlyDeleted }, paranoid: false, - order: [["order_index", "ASC"]], + order: [["deletedAt", "DESC"]], }, }); - return R.success(res, "Archived units retrieved.", result); } catch (err) { - console.error("[UNIT][GET ARCHIVES]", 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 { courseId, unitId } = req.params; + const { unitId } = req.params; const unit = await Unit.findOne({ - where: { unit_id: unitId, course_id: courseId, ...onlyDeleted }, + 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][GET ARCHIVE ONE]", err); + console.error("[UNIT LIB][GET ARCHIVE ONE]", err); return R.error(res, "Could not retrieve archived unit.", 500); } }; @@ -207,19 +304,16 @@ exports.getArchivedUnit = async (req, res) => { 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 - ); + 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][RESTORE]", err); + console.error("[UNIT LIB][RESTORE]", err); return R.error(res, "Could not restore unit.", 500); } }; @@ -227,35 +321,184 @@ exports.restoreUnit = async (req, res) => { exports.bulkRestoreUnits = async (req, res) => { const t = await sequelize.transaction(); try { - const { courseId } = req.params; - const { ids = [], restoredBy } = req.body; + const { ids = [] } = req.body; if (!ids.length) return R.error(res, "No IDs provided.", 400); - const units = await Unit.findAll({ - where: { unit_id: ids, course_id: courseId, ...onlyDeleted }, - paranoid: false, - }); + const 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, restoredBy, t); + 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][BULK RESTORE]", err); + 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 QUIZ +// 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 { courseId, unitId } = req.params; + const { 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, ...notDeleted } }); if (!unit) return R.error(res, "Unit not found.", 404); const quiz = await UnitQuiz.findOne({ @@ -270,54 +513,60 @@ exports.getQuiz = async (req, res) => { 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); + console.error("[UNIT LIB][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 { unitId } = req.params; + const { title, is_required, passing_score, max_questions, shuffle_questions, createdBy } = req.body; - const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } }); + 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, - createdBy: createdBy ?? null, + 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("[QUIZ][CREATE]", err); + console.error("[UNIT LIB][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 { 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; - quiz.updatedBy = updatedBy ?? null; + 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("[QUIZ][UPDATE]", err); + console.error("[UNIT LIB][QUIZ][UPDATE]", err); return R.error(res, "Could not update quiz.", 500); } }; @@ -326,18 +575,49 @@ 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 - ); + 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("[QUIZ][DELETE]", err); + console.error("[UNIT LIB][QUIZ][ARCHIVE]", err); return R.error(res, "Could not archive quiz.", 500); } -}; \ No newline at end of file +}; + +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); + } +}; diff --git a/controllers/client/course_reading_progress.controller.js b/controllers/client/course_reading_progress.controller.js index 35728f1..d7c981d 100644 --- a/controllers/client/course_reading_progress.controller.js +++ b/controllers/client/course_reading_progress.controller.js @@ -35,8 +35,10 @@ const CourseReadingProgress = require('../../models/courses/course_readi const Certificate = require('../../models/courses/certificate.mdl'); const { Course, Unit, Lesson, + CourseUnit, UnitLesson, UnitQuiz, CourseAssessment, QuizAttempt, } = require('../../models/courses/courses.associations'); +const { countCourseLessons, getCourseUnitIds, flattenUnits } = require('../../utils/courses/hierarchy.util'); const { Task, TaskRequirement, TaskListGroup } = require('../../models/task/task.mdl'); const { TaskProgress } = require('../../models/task/task_progress.mdl'); @@ -192,15 +194,7 @@ exports.getMyInProgressCourses = async (req, res) => { const courseId = row.course_id; const [lessons_total, lessons_completed] = await Promise.all([ - Lesson.count({ - include: [{ - model: Unit, - as: 'unit', - where: { course_id: courseId, ...notDeleted }, - required: true, - }], - where: notDeleted, - }), + countCourseLessons(courseId), CourseReadingProgress.count({ where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' }, }), @@ -215,17 +209,18 @@ exports.getMyInProgressCourses = async (req, res) => { let assessment_configured = true; if (readingDone) { - const unitQuizzes = await UnitQuiz.findAll({ + const courseUnitIds = await getCourseUnitIds(courseId); + const unitQuizzes = courseUnitIds.length ? await UnitQuiz.findAll({ attributes: ['quiz_id', 'title', 'is_required', 'passing_score'], include: [{ model: Unit, as: 'unit', attributes: ['unit_id', 'title'], - where: { course_id: courseId, ...notDeleted }, + where: notDeleted, required: true, }], - where: notDeleted, - }); + where: { unit_id: courseUnitIds, ...notDeleted }, + }) : []; for (const quiz of unitQuizzes) { const [hasPassed, attemptCount] = await Promise.all([ @@ -302,15 +297,7 @@ exports.getCourseProgressSummary = async (req, res) => { if (!course) return R.error(res, 'Course not found.', 404); const [lessons_total, lessons_completed, courseRow] = await Promise.all([ - Lesson.count({ - include: [{ - model: Unit, - as: 'unit', - where: { course_id: courseId, ...notDeleted }, - required: true, - }], - where: notDeleted, - }), + countCourseLessons(courseId), CourseReadingProgress.count({ where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' }, }), @@ -385,12 +372,14 @@ exports.getCourseTaskContext = async (req, res) => { where: notDeleted, required: false, attributes: ['unit_id', 'uuid'], + through: { attributes: [] }, include: [{ model: Lesson, as: 'lessons', where: notDeleted, required: false, attributes: ['lesson_id', 'uuid'], + through: { attributes: [] }, }], }], }); @@ -475,24 +464,26 @@ exports.upsertLessonProgress = async (req, res) => { const userId = req.user.user_id; const status = req.body.status === 'completed' ? 'completed' : 'in_progress'; - const [course, unit, lesson] = await Promise.all([ + const [course, unit, lesson, courseLink, lessonLink] = await Promise.all([ Course.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: ['course_id', 'uuid'], }), Unit.findOne({ - where: { unit_id: unitId, course_id: courseId, ...notDeleted }, + where: { unit_id: unitId, ...notDeleted }, attributes: ['unit_id', 'uuid'], }), Lesson.findOne({ - where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted }, + where: { lesson_id: lessonId, ...notDeleted }, attributes: ['lesson_id', 'uuid'], }), + CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }), + UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId } }), ]); - if (!course) return R.error(res, 'Course not found.', 404); - if (!unit) return R.error(res, 'Unit not found.', 404); - if (!lesson) return R.error(res, 'Lesson not found.', 404); + if (!course) return R.error(res, 'Course not found.', 404); + if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404); + if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404); // ── 1. Primary write: course_reading_progress ───────────────────────── const result = await upsertLessonRead(userId, { diff --git a/controllers/client/courses.controller.js b/controllers/client/courses.controller.js index 191f86e..75f1be1 100644 --- a/controllers/client/courses.controller.js +++ b/controllers/client/courses.controller.js @@ -23,11 +23,13 @@ const mdl_Category = require("../../models/courses/categories.mdl"); const { Course, Unit, Lesson, LessonPage, + CourseUnit, UnitLesson, CourseObjective, LessonObjective, CoursePrerequisite, CourseAssessment, UnitQuiz, QuizQuestion, QuizOption, QuizAttempt, - AssessmentSession, QuizSession, + AssessmentSession, QuizSession, LessonReadingProgress, } = require("../../models/courses/courses.associations"); +const { flattenUnits, flattenLessons } = require("../../utils/courses/hierarchy.util"); const mdl_TierCategories = require("../../models/tiers/tier_categories.mdl"); const { gradeSubmission } = require("../../utils/courses/grading.util"); const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util"); @@ -120,6 +122,34 @@ async function canAccessCourse(user_id, course_id) { return !!hasPurchase; } +// ─── Standalone access checks (junction revamp) ────────────────────────────── +// A Unit attached to no course is open to every authenticated user; a Unit +// attached to one or more courses is open when the user can access ANY of them. +// Lessons resolve through their parent units the same way. This keeps paid +// content locked while letting genuinely standalone content run independently. + +async function canAccessUnit(user_id, unit_id) { + const links = await CourseUnit.findAll({ where: { unit_id }, attributes: ['course_id'] }); + if (!links.length) return true; + for (const link of links) { + if (await canAccessCourse(user_id, link.course_id)) return true; + } + return false; +} + +async function canAccessLesson(user_id, lesson_id) { + const unitLinks = await UnitLesson.findAll({ where: { lesson_id }, attributes: ['unit_id'] }); + if (!unitLinks.length) return true; + for (const link of unitLinks) { + if (await canAccessUnit(user_id, link.unit_id)) return true; + } + return false; +} + +exports.canAccessCourse = canAccessCourse; +exports.canAccessUnit = canAccessUnit; +exports.canAccessLesson = canAccessLesson; + const COURSE_LIST_ATTRS = [ "course_id", "uuid", "title", "description", "course_code", "level", "subscription", @@ -254,16 +284,18 @@ exports.getCourse = async (req, res) => { where: notDeleted, required: false, attributes: [ "unit_id", "uuid", "title", "description", - "order_index", "duration_seconds", + "duration_seconds", ], + through: { attributes: ["order_index"] }, include: [ { model: Lesson, as: "lessons", where: notDeleted, required: false, attributes: [ "lesson_id", "uuid", "title", "description", - "order_index", "duration_seconds", + "duration_seconds", ], + through: { attributes: ["order_index"] }, }, { model: UnitQuiz, as: "quiz", @@ -308,8 +340,6 @@ exports.getCourse = async (req, res) => { }, ], 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"], [{ model: CoursePrerequisite, as: "prerequisites" }, "order_index", "ASC"], ], @@ -318,6 +348,7 @@ exports.getCourse = async (req, res) => { if (!course) return R.error(res, "Course not found.", 404); const plain = course.toJSON(); + plain.units = flattenUnits(plain.units); // junction order_index → flat field, sorted // Attach has_passed to each unit's quiz in one query const quizIds = plain.units @@ -398,11 +429,14 @@ exports.getUnit = async (req, res) => { try { const { courseId, unitId } = req.params; + const link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }); + if (!link) return R.error(res, "Unit not found.", 404); + const unit = await Unit.findOne({ - where: { unit_id: unitId, course_id: courseId, ...notDeleted }, + where: { unit_id: unitId, ...notDeleted }, attributes: [ "unit_id", "uuid", "title", "description", - "order_index", "duration_seconds", + "duration_seconds", ], include: [ { @@ -410,8 +444,9 @@ exports.getUnit = async (req, res) => { where: notDeleted, required: false, attributes: [ "lesson_id", "uuid", "title", "description", - "order_index", "duration_seconds", + "duration_seconds", ], + through: { attributes: ["order_index"] }, }, { model: UnitQuiz, as: "quiz", @@ -422,11 +457,14 @@ exports.getUnit = async (req, res) => { ], }, ], - order: [[{ model: Lesson, as: "lessons" }, "order_index", "ASC"]], }); if (!unit) return R.error(res, "Unit not found.", 404); - return R.success(res, "Unit retrieved.", unit); + + const plain = unit.toJSON(); + plain.order_index = link.order_index; + plain.lessons = flattenLessons(plain.lessons); + return R.success(res, "Unit retrieved.", plain); } catch (err) { console.error("[CLIENT][UNIT][GET ONE]", err); return R.error(res, "Could not retrieve unit.", 500); @@ -439,18 +477,19 @@ exports.getLesson = async (req, res) => { try { const { courseId, unitId, lessonId } = req.params; + const [courseLink, lessonLink] = await Promise.all([ + CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }), + UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId } }), + ]); + if (!courseLink || !lessonLink) return R.error(res, "Lesson not found.", 404); + const lesson = await Lesson.findOne({ - where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted }, + where: { lesson_id: lessonId, ...notDeleted }, attributes: [ - "lesson_id", "uuid", "unit_id", "title", - "description", "order_index", "duration_seconds", + "lesson_id", "uuid", "title", + "description", "duration_seconds", ], include: [ - { - model: Unit, as: "unit", - where: { course_id: courseId, ...notDeleted }, - attributes: [], - }, { model: LessonPage, as: "page", required: false, @@ -466,7 +505,7 @@ exports.getLesson = async (req, res) => { }); if (!lesson) return R.error(res, "Lesson not found.", 404); - return R.success(res, "Lesson retrieved.", lesson); + return R.success(res, "Lesson retrieved.", { ...lesson.toJSON(), unit_id: Number(unitId), order_index: lessonLink.order_index }); } catch (err) { console.error("[CLIENT][LESSON][GET ONE]", err); return R.error(res, "Could not retrieve lesson.", 500); @@ -479,8 +518,8 @@ exports.getUnitQuiz = 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 link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }); + if (!link) return R.error(res, "Unit not found.", 404); const quiz = await UnitQuiz.findOne({ where: { unit_id: unitId, ...notDeleted }, @@ -779,8 +818,8 @@ exports.submitUnitQuiz = async (req, res) => { const { answers = {} } = req.body; const user_id = req.user.user_id; - const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } }); - if (!unit) return R.error(res, "Unit not found.", 404); + const link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }); + if (!link) return R.error(res, "Unit not found.", 404); const quiz = await UnitQuiz.findOne({ where: { quiz_id: quizId, unit_id: unitId, ...notDeleted }, @@ -1016,71 +1055,123 @@ exports.getUnitByUuid = async (req, res) => { const unit = await Unit.findOne({ where: { uuid, ...notDeleted }, attributes: ["unit_id", "uuid", "title", "description"], - include: [{ model: Course, as: "course", attributes: ["course_id", "title", "subscription"] }], + include: [{ + model: Course, as: "courses", + where: notDeleted, required: false, + attributes: ["course_id", "title", "subscription"], + through: { attributes: [] }, + }], }); if (!unit) return R.error(res, "Unit not found.", 404); - if (!unit.course) return R.error(res, "Unit has no associated course.", 404); - if (!await canAccessCourse(req.user.user_id, unit.course.course_id)) { + // Standalone units (no attached course) are open; otherwise any accessible course grants entry + if (!await canAccessUnit(req.user.user_id, unit.unit_id)) { + const first = unit.courses?.[0] ?? null; return res.status(403).json({ status: "error", - message: "You do not have access to this course.", - course: { title: unit.course.title, subscription: unit.course.subscription }, + message: "You do not have access to this unit.", + course: first ? { title: first.title, subscription: first.subscription } : null, }); } - return R.success(res, "Unit retrieved.", unit); + const plain = unit.toJSON(); + plain.course = plain.courses?.[0] ?? null; // back-compat singular field + return R.success(res, "Unit retrieved.", plain); } catch (err) { console.error("[CLIENT][UNITS][BY UUID]", err); return R.error(res, "Could not retrieve unit.", 500); } }; +// "Units → Lessons (returns all data)" — a Unit resolves all of its lesson +// content in one call, with or without a parent course. exports.getLessonsByUnitUuid = async (req, res) => { try { const { uuid } = req.params; const unit = await Unit.findOne({ where: { uuid, ...notDeleted }, - attributes: ["unit_id", "uuid", "title", "description", "order_index"], + attributes: ["unit_id", "uuid", "title", "description", "duration_seconds"], include: [ - { model: Course, as: "course", attributes: ["course_id", "title", "subscription"] }, + { + model: Course, as: "courses", + where: notDeleted, required: false, + attributes: ["course_id", "title", "subscription"], + through: { attributes: [] }, + }, { model: Lesson, as: "lessons", where: notDeleted, required: false, - attributes: ["lesson_id", "uuid", "title", "description", "order_index"], + attributes: ["lesson_id", "uuid", "title", "description", "duration_seconds"], + through: { attributes: ["order_index"] }, include: [{ model: LessonPage, as: "page", attributes: ["blocks"], required: false }], - order: [["order_index", "ASC"]], + }, + { + model: UnitQuiz, as: "quiz", + required: false, + attributes: ["quiz_id", "uuid", "title", "is_required", "passing_score"], }, ], }); if (!unit) return R.error(res, "Unit not found.", 404); - if (!unit.course) return R.error(res, "Unit has no associated course.", 404); - if (!await canAccessCourse(req.user.user_id, unit.course.course_id)) { + if (!await canAccessUnit(req.user.user_id, unit.unit_id)) { + const first = unit.courses?.[0] ?? null; return res.status(403).json({ status: "error", - message: "You do not have access to this course.", - course: { title: unit.course.title, subscription: unit.course.subscription }, + message: "You do not have access to this unit.", + course: first ? { title: first.title, subscription: first.subscription } : null, }); } - const lessons = (unit.lessons ?? []) - .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) - .map((l) => ({ - lesson_id: l.lesson_id, - uuid: l.uuid, - title: l.title, - description: l.description, - order_index: l.order_index ?? 0, - blocks: l.page?.blocks ?? [], - })); + + const plain = unit.toJSON(); + + // Per-lesson completion for the requesting user. NOTE: lesson_reading_progress + // upserts on (user_id, lesson_id) only — a lesson's completion is a property + // of the lesson itself, not scoped to whichever unit it was read under. + const flatLessons = flattenLessons(plain.lessons); + const progressRows = flatLessons.length + ? await LessonReadingProgress.findAll({ + where: { user_id: req.user.user_id, lesson_id: flatLessons.map((l) => l.lesson_id) }, + attributes: ["lesson_id", "status", "completed_at"], + }) + : []; + const progressMap = new Map(progressRows.map((p) => [String(p.lesson_id), p])); + + const lessons = flatLessons.map((l) => ({ + lesson_id: l.lesson_id, + uuid: l.uuid, + title: l.title, + description: l.description, + order_index: l.order_index ?? 0, + duration_seconds: l.duration_seconds ?? 0, + blocks: l.page?.blocks ?? [], + status: progressMap.get(String(l.lesson_id))?.status ?? "not_started", + completed_at: progressMap.get(String(l.lesson_id))?.completed_at ?? null, + })); + + // Attach has_passed to the quiz stub — same pattern as getCourse's unit list. + let quiz = null; + if (plain.quiz) { + const passedAttempt = await QuizAttempt.findOne({ + where: { quiz_id: plain.quiz.quiz_id, user_id: req.user.user_id, passed: true }, + }); + quiz = { ...plain.quiz, has_passed: !!passedAttempt }; + } + + const is_completed = lessons.length > 0 && lessons.every((l) => l.status === "completed"); + return R.success(res, "Unit lessons retrieved.", { - unit_id: unit.unit_id, - uuid: unit.uuid, - title: unit.title, - description: unit.description, - course: unit.course ?? null, + unit_id: unit.unit_id, + uuid: unit.uuid, + title: unit.title, + description: unit.description, + duration_seconds: plain.duration_seconds ?? 0, + course: plain.courses?.[0] ?? null, // back-compat singular field + courses: plain.courses ?? [], + quiz, + is_completed, lessons, }); } catch (err) { @@ -1089,6 +1180,8 @@ exports.getLessonsByUnitUuid = async (req, res) => { } }; +// "Lessons (per data runs independently)" — a Lesson resolves on its own, +// with or without parent units/courses. exports.getLessonByUuid = async (req, res) => { try { const { uuid } = req.params; @@ -1104,30 +1197,40 @@ exports.getLessonByUuid = async (req, res) => { }, { model: Unit, - as: "unit", - attributes: ["unit_id", "title", "order_index"], - include: [{ model: Course, as: "course", attributes: ["course_id", "title", "subscription"] }], + as: "units", + where: notDeleted, required: false, + attributes: ["unit_id", "uuid", "title"], + through: { attributes: ["order_index"] }, + include: [{ + model: Course, as: "courses", + where: notDeleted, required: false, + attributes: ["course_id", "title", "subscription"], + through: { attributes: [] }, + }], }, ], }); if (!lesson) return R.error(res, "Lesson not found.", 404); - if (!lesson.unit) return R.error(res, "Lesson has no associated unit.", 404); - if (!lesson.unit.course) return R.error(res, "Unit has no associated course.", 404); - if (!await canAccessCourse(req.user.user_id, lesson.unit.course.course_id)) { + if (!await canAccessLesson(req.user.user_id, lesson.lesson_id)) { + const firstCourse = lesson.units?.[0]?.courses?.[0] ?? null; return res.status(403).json({ status: "error", - message: "You do not have access to this course.", - course: { title: lesson.unit.course.title, subscription: lesson.unit.course.subscription }, + message: "You do not have access to this lesson.", + course: firstCourse ? { title: firstCourse.title, subscription: firstCourse.subscription } : null, }); } + + const plain = lesson.toJSON(); + const firstUnit = plain.units?.[0] ?? null; const data = { - lesson_id: lesson.lesson_id, - uuid: lesson.uuid, - title: lesson.title, - description: lesson.description, - blocks: lesson.page?.blocks ?? [], - unit: lesson.unit ?? null, + lesson_id: plain.lesson_id, + uuid: plain.uuid, + title: plain.title, + description: plain.description, + blocks: plain.page?.blocks ?? [], + unit: firstUnit ? { unit_id: firstUnit.unit_id, title: firstUnit.title, course: firstUnit.courses?.[0] ?? null } : null, // back-compat singular field + units: plain.units ?? [], }; return R.success(res, "Lesson retrieved.", data); } catch (err) { diff --git a/controllers/client/units.controller.js b/controllers/client/units.controller.js new file mode 100644 index 0000000..2401855 --- /dev/null +++ b/controllers/client/units.controller.js @@ -0,0 +1,327 @@ +/*********************************************************************************************************************************************************************** + * File Name: units.controller.js (client) + * Type of Program: Controller + * Description: Standalone Unit / Lesson consumption — the junction revamp lets + * learners run Units and Lessons outside any Course: + * + * GET /client/units → all units w/ lesson counts + is_locked + * GET /client/units/:uuid → unit metadata (shared handler) + * GET /client/units/:uuid/lessons → unit + ALL lesson data (shared handler) + * GET /client/units/:uuid/quiz → the unit's quiz, no course context + * POST /client/units/:uuid/quiz/:quizId/submit→ graded attempt with course_id NULL + * GET /client/lessons/:uuid → single lesson, runs independently (shared handler) + * POST /client/lessons/:uuid/progress → standalone reading progress (course NULL, unit optional) + * + * Access rule: a unit attached to no course is open; otherwise the user must + * be able to access at least one attached course. Lessons resolve through + * their parent units the same way. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 7, 2026 + ***********************************************************************************************************************************************************************/ +"use strict"; + +const R = require("../../utils/response.util"); +const logActivity = require("../../utils/logActivity.util"); +const sequelize = require("../../config/db.config"); + +const { + Unit, Lesson, + CourseUnit, UnitLesson, + UnitQuiz, QuizQuestion, QuizOption, QuizAttempt, QuizSession, +} = require("../../models/courses/courses.associations"); + +const coursesCtrl = require("./courses.controller"); // canAccessUnit / canAccessLesson / shared uuid handlers +const { gradeSubmission } = require("../../utils/courses/grading.util"); +const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util"); +const { upsertLessonRead } = require("../../services/reading_progress.service"); + +const notDeleted = { deletedAt: null }; + +// Strip correct-answer data (same policy as course-scoped quiz endpoints) +function sanitizeQuestions(questions = []) { + return questions.map((q) => { + const plain = q.toJSON ? q.toJSON() : { ...q }; + if (plain.type === "multi_select") { + plain.correct_count = (plain.options ?? []).filter((o) => o.is_correct).length; + } + plain.options = (plain.options ?? []).map(({ is_correct: _drop, ...o }) => o); + delete plain.explanation; + return plain; + }); +} + +// ─── UNIT LIBRARY (learner view) ────────────────────────────────────────────── + +exports.getUnits = 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, + (SELECT quiz_id FROM unit_quizzes q + WHERE q.unit_id = u.unit_id AND q."deletedAt" IS NULL LIMIT 1) AS quiz_id + FROM units u + WHERE u."deletedAt" IS NULL + ORDER BY u.title ASC + `, { type: sequelize.QueryTypes.SELECT }); + + // Batch-fetch attached courses for every returned unit in one query, so the + // learner-facing upsell modal can say which course(s)/tier(s) unlock a unit + // (a unit may sit under several courses at different tiers — no single "Buy"). + const unitIds = rows.map((r) => r.unit_id); + const courseLinkRows = unitIds.length ? await sequelize.query(` + SELECT cu.unit_id, c.course_id, c.uuid, c.title, c.subscription + FROM course_units cu + JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL + WHERE cu.unit_id IN (:unitIds) + `, { replacements: { unitIds }, type: sequelize.QueryTypes.SELECT }) : []; + + const coursesByUnit = new Map(); + for (const row of courseLinkRows) { + const list = coursesByUnit.get(row.unit_id) ?? []; + list.push({ course_id: row.course_id, uuid: row.uuid, title: row.title, subscription: row.subscription }); + coursesByUnit.set(row.unit_id, list); + } + + // is_locked mirrors canAccessUnit: standalone units are open, attached units + // need at least one accessible course. + const result = []; + for (const row of rows) { + const is_locked = Number(row.course_count) > 0 + ? !(await coursesCtrl.canAccessUnit(req.user.user_id, row.unit_id)) + : false; + result.push({ ...row, courses: coursesByUnit.get(row.unit_id) ?? [], is_locked }); + } + + return R.success(res, "Units retrieved.", result); + } catch (err) { + console.error("[CLIENT][UNITS][GET ALL]", err); + return R.error(res, "Could not retrieve units.", 500); + } +}; + +// ─── STANDALONE UNIT QUIZ ───────────────────────────────────────────────────── + +exports.getUnitQuiz = async (req, res) => { + try { + const { uuid } = req.params; + + const unit = await Unit.findOne({ where: { uuid, ...notDeleted } }); + if (!unit) return R.error(res, "Unit not found.", 404); + + if (!await coursesCtrl.canAccessUnit(req.user.user_id, unit.unit_id)) { + return R.error(res, "You do not have access to this unit.", 403); + } + + const quiz = await UnitQuiz.findOne({ + where: { unit_id: unit.unit_id, ...notDeleted }, + attributes: [ + "quiz_id", "uuid", "title", + "is_required", "passing_score", "max_questions", "shuffle_questions", + ], + include: [{ + model: QuizQuestion, as: "questions", + where: notDeleted, required: false, + attributes: ["question_id", "uuid", "type", "question", "order_index", "points"], + include: [{ + model: QuizOption, as: "options", + attributes: ["option_id", "text", "order_index", "is_correct"], + }], + }], + order: [[{ model: QuizQuestion, as: "questions" }, "order_index", "ASC"]], + }); + + if (!quiz) return R.error(res, "Quiz not found.", 404); + + const plain = quiz.toJSON(); + let qs = sanitizeQuestions(plain.questions ?? []); + if (plain.shuffle_questions) qs = shuffleQuestions(qs); + plain.questions = shuffleOptions(qs); + + const attempts = await QuizAttempt.findAll({ + where: { quiz_id: quiz.quiz_id, user_id: req.user.user_id }, + attributes: ["attempt_id", "attempt_number", "score", "passing_score", "passed", "createdAt"], + }); + + const status = getAttemptStatus(attempts, "quiz"); + plain.attempt_count = status.attempt_count; + plain.has_passed = status.has_passed; + plain.best_attempt = status.best_attempt; + plain.attempts_remaining = status.attempts_remaining; + plain.cooldown_until = status.cooldown_until; + plain.window_reset_at = status.window_reset_at; + plain.can_attempt = status.can_attempt; + + const activeSession = await QuizSession.findOne({ + where: { quiz_id: quiz.quiz_id, user_id: req.user.user_id, status: "in_progress" }, + attributes: ["session_id", "draft_answers", "started_at", "last_saved_at"], + }); + plain.active_session = activeSession ?? null; + + return R.success(res, "Quiz retrieved.", plain); + } catch (err) { + console.error("[CLIENT][UNITS][QUIZ][GET]", err); + return R.error(res, "Could not retrieve quiz.", 500); + } +}; + +exports.submitUnitQuiz = async (req, res) => { + try { + const { uuid, quizId } = req.params; + const { answers = {} } = req.body; + const user_id = req.user.user_id; + + const unit = await Unit.findOne({ where: { uuid, ...notDeleted } }); + if (!unit) return R.error(res, "Unit not found.", 404); + + if (!await coursesCtrl.canAccessUnit(user_id, unit.unit_id)) { + return R.error(res, "You do not have access to this unit.", 403); + } + + const quiz = await UnitQuiz.findOne({ + where: { quiz_id: quizId, unit_id: unit.unit_id, ...notDeleted }, + include: [{ + model: QuizQuestion, as: "questions", + where: notDeleted, required: false, + include: [{ model: QuizOption, as: "options" }], + }], + }); + if (!quiz) return R.error(res, "Quiz not found.", 404); + + const priorAttempts = await QuizAttempt.findAll({ + where: { quiz_id: quiz.quiz_id, user_id }, + attributes: ["attempt_id", "score", "passed", "createdAt"], + }); + + const { totalPoints, earnedPoints, score } = gradeSubmission(quiz.questions ?? [], answers); + const passed = score >= (quiz.passing_score ?? 70); + + const attempt = await QuizAttempt.create({ + user_id, + quiz_id: quiz.quiz_id, + course_id: null, // standalone — no course context + attempt_number: priorAttempts.length + 1, + answers, + total_points: totalPoints, + earned_points: earnedPoints, + score, + passing_score: quiz.passing_score ?? 70, + passed, + }); + + await QuizSession.update( + { status: "submitted" }, + { where: { quiz_id: quiz.quiz_id, user_id, status: "in_progress" } } + ); + + return R.success(res, "Quiz submitted.", { + attempt_id: attempt.attempt_id, + attempt_number: attempt.attempt_number, + score, + passed, + passing_score: attempt.passing_score, + total_points: totalPoints, + earned_points: earnedPoints, + }); + } catch (err) { + console.error("[CLIENT][UNITS][QUIZ][SUBMIT]", err); + return R.error(res, "Could not submit quiz.", 500); + } +}; + +// ─── STANDALONE UNIT QUIZ DRAFT ─────────────────────────────────────────────── +// PATCH /client/units/:uuid/quiz/:quizId/draft — mirrors the course-scoped +// saveQuizDraft in courses.controller.js; only quiz_id + user_id are needed to +// locate the session, course_id/unit_id are just extra nullable columns on it. + +exports.saveUnitQuizDraft = async (req, res) => { + try { + const { uuid, quizId } = req.params; + const { answers = {} } = req.body; + const user_id = req.user.user_id; + + const unit = await Unit.findOne({ where: { uuid, ...notDeleted } }); + if (!unit) return R.error(res, "Unit not found.", 404); + + const [updatedCount] = await QuizSession.update( + { draft_answers: answers, last_saved_at: new Date() }, + { where: { quiz_id: quizId, user_id, status: "in_progress" } } + ); + + if (updatedCount === 0) { + await QuizSession.create({ + quiz_id: quizId, + user_id, + course_id: null, + unit_id: unit.unit_id, + draft_answers: answers, + started_at: new Date(), + }); + } + + return res.status(204).end(); + } catch (err) { + console.error("[CLIENT][UNITS][QUIZ][DRAFT]", err); + return R.error(res, "Could not save quiz draft.", 500); + } +}; + +// ─── STANDALONE LESSON PROGRESS ─────────────────────────────────────────────── +// POST /client/lessons/:uuid/progress Body: { status, unit_uuid? } +// Writes lesson_reading_progress with course_id NULL. When unit_uuid is given +// (unit context, still no course) the parent unit row is derived + upserted too. + +exports.upsertStandaloneLessonProgress = async (req, res) => { + try { + const { uuid } = req.params; + const userId = req.user.user_id; + const status = req.body.status === "completed" ? "completed" : "in_progress"; + const unitUuid = req.body.unit_uuid ?? null; + + const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid"] }); + if (!lesson) return R.error(res, "Lesson not found.", 404); + + if (!await coursesCtrl.canAccessLesson(userId, lesson.lesson_id)) { + return R.error(res, "You do not have access to this lesson.", 403); + } + + let unitId = null; + if (unitUuid) { + const unit = await Unit.findOne({ where: { uuid: unitUuid, ...notDeleted }, attributes: ["unit_id"] }); + if (!unit) return R.error(res, "Unit not found.", 404); + const link = await UnitLesson.findOne({ where: { unit_id: unit.unit_id, lesson_id: lesson.lesson_id } }); + if (!link) return R.error(res, "Lesson is not attached to this unit.", 404); + unitId = unit.unit_id; + } + + const result = await upsertLessonRead(userId, { + courseId: null, + unitId, + lessonId: lesson.lesson_id, + lessonStatus: status, + }); + + logActivity(userId, "lesson_read", { + entityType: "lesson", + entityId: lesson.lesson_id, + details: { lesson_uuid: lesson.uuid, status, standalone: true }, + }); + + return R.success(res, "Progress updated.", result, 200); + } catch (err) { + console.error("[CLIENT][LESSONS][STANDALONE PROGRESS]", err); + return R.error(res, "Could not update progress.", 500); + } +}; + +// ─── Shared UUID handlers re-exported for the standalone routes ─────────────── + +exports.getUnitByUuid = coursesCtrl.getUnitByUuid; +exports.getLessonsByUnitUuid = coursesCtrl.getLessonsByUnitUuid; +exports.getLessonByUuid = coursesCtrl.getLessonByUuid; diff --git a/controllers/staff/scores.ctrl.js b/controllers/staff/scores.ctrl.js index 2b8e29d..378d1c6 100644 --- a/controllers/staff/scores.ctrl.js +++ b/controllers/staff/scores.ctrl.js @@ -138,7 +138,7 @@ const getQuizScores = async (req, res) => { const memberIds = [...new Set(memberships.map(m => m.user_id))]; const quiz = await UnitQuiz.findByPk(quizId, { - include: [{ model: Unit, as: 'unit', attributes: ['title', 'course_id'] }], + include: [{ model: Unit, as: 'unit', attributes: ['unit_id', 'title'] }], }); if (!quiz) return res.status(404).json({ success: false, message: 'Quiz not found.' }); diff --git a/database/junction_revamp.sql b/database/junction_revamp.sql new file mode 100644 index 0000000..1ecfb37 --- /dev/null +++ b/database/junction_revamp.sql @@ -0,0 +1,79 @@ +-- ═══════════════════════════════════════════════════════════════════════════ +-- JUNCTION REVAMP — Units and Lessons run independently (Jul. 7, 2026) +-- +-- courses ⇄ course_units ⇄ units ⇄ unit_lessons ⇄ lessons +-- +-- Run order matters: create → backfill → relax progress FKs → drop old columns. +-- Postgres / CockroachDB compatible. Take a backup before running. +-- ═══════════════════════════════════════════════════════════════════════════ + +BEGIN; + +-- ── 1. Junction tables ─────────────────────────────────────────────────────── + +CREATE TABLE course_units ( + course_unit_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + course_id BIGINT NOT NULL REFERENCES courses (course_id) ON DELETE CASCADE, + unit_id BIGINT NOT NULL REFERENCES units (unit_id) ON DELETE CASCADE, + order_index INTEGER NOT NULL DEFAULT 0, + "createdBy" BIGINT NULL, + "updatedBy" BIGINT NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_course_units_course_unit UNIQUE (course_id, unit_id) +); + +CREATE INDEX idx_course_units_course_id ON course_units (course_id); +CREATE INDEX idx_course_units_unit_id ON course_units (unit_id); +CREATE INDEX idx_course_units_order ON course_units (order_index); + +CREATE TABLE unit_lessons ( + unit_lesson_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + unit_id BIGINT NOT NULL REFERENCES units (unit_id) ON DELETE CASCADE, + lesson_id BIGINT NOT NULL REFERENCES lessons (lesson_id) ON DELETE CASCADE, + order_index INTEGER NOT NULL DEFAULT 0, + "createdBy" BIGINT NULL, + "updatedBy" BIGINT NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_unit_lessons_unit_lesson UNIQUE (unit_id, lesson_id) +); + +CREATE INDEX idx_unit_lessons_unit_id ON unit_lessons (unit_id); +CREATE INDEX idx_unit_lessons_lesson_id ON unit_lessons (lesson_id); +CREATE INDEX idx_unit_lessons_order ON unit_lessons (order_index); + +-- ── 2. Backfill from the old direct FKs (preserves ordering) ───────────────── + +INSERT INTO course_units (course_id, unit_id, order_index, "createdBy", "createdAt", "updatedAt") +SELECT u.course_id, u.unit_id, COALESCE(u.order_index, 0), u."createdBy", CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM units u +WHERE u.course_id IS NOT NULL; + +INSERT INTO unit_lessons (unit_id, lesson_id, order_index, "createdBy", "createdAt", "updatedAt") +SELECT l.unit_id, l.lesson_id, COALESCE(l.order_index, 0), l."createdBy", CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM lessons l +WHERE l.unit_id IS NOT NULL; + +-- ── 3. Progress tables — standalone reads carry NULL course/unit context ───── + +ALTER TABLE unit_reading_progress ALTER COLUMN course_id DROP NOT NULL; +ALTER TABLE lesson_reading_progress ALTER COLUMN course_id DROP NOT NULL; +ALTER TABLE lesson_reading_progress ALTER COLUMN unit_id DROP NOT NULL; + +-- ── 4. Drop the old FK + order columns ─────────────────────────────────────── +-- (dependent indexes/constraints on these columns are dropped automatically) + +ALTER TABLE units DROP COLUMN course_id; +ALTER TABLE units DROP COLUMN order_index; +ALTER TABLE lessons DROP COLUMN unit_id; +ALTER TABLE lessons DROP COLUMN order_index; + +COMMIT; + +-- ── Sanity checks (run after commit) ───────────────────────────────────────── +-- SELECT COUNT(*) FROM course_units; -- should equal old COUNT(*) FROM units WHERE course_id IS NOT NULL +-- SELECT COUNT(*) FROM unit_lessons; -- should equal old COUNT(*) FROM lessons WHERE unit_id IS NOT NULL +-- SELECT c.title, u.title, cu.order_index FROM course_units cu +-- JOIN courses c USING (course_id) JOIN units u USING (unit_id) +-- ORDER BY c.title, cu.order_index LIMIT 20; diff --git a/database/migrations/20260707000001-junction-revamp-course-units-lessons.js b/database/migrations/20260707000001-junction-revamp-course-units-lessons.js new file mode 100644 index 0000000..01928e9 --- /dev/null +++ b/database/migrations/20260707000001-junction-revamp-course-units-lessons.js @@ -0,0 +1,101 @@ +'use strict'; + +/** + * Junction revamp — Units and Lessons run independently. + * + * courses ⇄ course_units ⇄ units ⇄ unit_lessons ⇄ lessons + * + * 1. Create course_units + unit_lessons (ordering lives on the junction rows) + * 2. Backfill from the old direct FKs (units.course_id / lessons.unit_id), + * preserving each row's order_index + * 3. Progress tables accept NULL course/unit context (standalone reads) + * 4. Drop the old FK + order columns from units / lessons + */ +module.exports = { + async up(queryInterface, Sequelize) { + // ── 1. Junction tables ──────────────────────────────────────────────────── + await queryInterface.createTable('course_units', { + course_unit_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' }, + unit_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'units', key: 'unit_id' }, onDelete: 'CASCADE' }, + order_index: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + createdBy: { type: Sequelize.BIGINT, allowNull: true }, + updatedBy: { type: Sequelize.BIGINT, allowNull: true }, + createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') }, + updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') }, + }); + await queryInterface.addIndex('course_units', { fields: ['course_id', 'unit_id'], unique: true, name: 'uq_course_units_course_unit' }); + await queryInterface.addIndex('course_units', { fields: ['course_id'], name: 'idx_course_units_course_id' }); + await queryInterface.addIndex('course_units', { fields: ['unit_id'], name: 'idx_course_units_unit_id' }); + await queryInterface.addIndex('course_units', { fields: ['order_index'], name: 'idx_course_units_order' }); + + await queryInterface.createTable('unit_lessons', { + unit_lesson_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + unit_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'units', key: 'unit_id' }, onDelete: 'CASCADE' }, + lesson_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'lessons', key: 'lesson_id' }, onDelete: 'CASCADE' }, + order_index: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + createdBy: { type: Sequelize.BIGINT, allowNull: true }, + updatedBy: { type: Sequelize.BIGINT, allowNull: true }, + createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') }, + updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') }, + }); + await queryInterface.addIndex('unit_lessons', { fields: ['unit_id', 'lesson_id'], unique: true, name: 'uq_unit_lessons_unit_lesson' }); + await queryInterface.addIndex('unit_lessons', { fields: ['unit_id'], name: 'idx_unit_lessons_unit_id' }); + await queryInterface.addIndex('unit_lessons', { fields: ['lesson_id'], name: 'idx_unit_lessons_lesson_id' }); + await queryInterface.addIndex('unit_lessons', { fields: ['order_index'], name: 'idx_unit_lessons_order' }); + + // ── 2. Backfill from the old direct FKs ─────────────────────────────────── + await queryInterface.sequelize.query(` + INSERT INTO course_units (course_id, unit_id, order_index, "createdBy", "createdAt", "updatedAt") + SELECT u.course_id, u.unit_id, COALESCE(u.order_index, 0), u."createdBy", CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + FROM units u + WHERE u.course_id IS NOT NULL + `); + await queryInterface.sequelize.query(` + INSERT INTO unit_lessons (unit_id, lesson_id, order_index, "createdBy", "createdAt", "updatedAt") + SELECT l.unit_id, l.lesson_id, COALESCE(l.order_index, 0), l."createdBy", CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + FROM lessons l + WHERE l.unit_id IS NOT NULL + `); + + // ── 3. Progress tables — allow standalone (course-less / unit-less) reads ── + await queryInterface.changeColumn('unit_reading_progress', 'course_id', { type: Sequelize.BIGINT, allowNull: true }); + await queryInterface.changeColumn('lesson_reading_progress', 'course_id', { type: Sequelize.BIGINT, allowNull: true }); + await queryInterface.changeColumn('lesson_reading_progress', 'unit_id', { type: Sequelize.BIGINT, allowNull: true }); + + // ── 4. Drop the old FK + order columns ──────────────────────────────────── + await queryInterface.removeColumn('units', 'course_id'); + await queryInterface.removeColumn('units', 'order_index'); + await queryInterface.removeColumn('lessons', 'unit_id'); + await queryInterface.removeColumn('lessons', 'order_index'); + }, + + async down(queryInterface, Sequelize) { + // Recreate the direct FK columns and restore one parent per child + await queryInterface.addColumn('units', 'course_id', { type: Sequelize.BIGINT, allowNull: true }); + await queryInterface.addColumn('units', 'order_index', { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }); + await queryInterface.addColumn('lessons', 'unit_id', { type: Sequelize.BIGINT, allowNull: true }); + await queryInterface.addColumn('lessons', 'order_index', { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }); + + // Keep the FIRST attachment per child (multi-parent data collapses) + await queryInterface.sequelize.query(` + UPDATE units u SET course_id = cu.course_id, order_index = cu.order_index + FROM (SELECT DISTINCT ON (unit_id) unit_id, course_id, order_index + FROM course_units ORDER BY unit_id, course_unit_id) cu + WHERE u.unit_id = cu.unit_id + `); + await queryInterface.sequelize.query(` + UPDATE lessons l SET unit_id = ul.unit_id, order_index = ul.order_index + FROM (SELECT DISTINCT ON (lesson_id) lesson_id, unit_id, order_index + FROM unit_lessons ORDER BY lesson_id, unit_lesson_id) ul + WHERE l.lesson_id = ul.lesson_id + `); + + await queryInterface.changeColumn('unit_reading_progress', 'course_id', { type: Sequelize.BIGINT, allowNull: false }); + await queryInterface.changeColumn('lesson_reading_progress', 'course_id', { type: Sequelize.BIGINT, allowNull: false }); + await queryInterface.changeColumn('lesson_reading_progress', 'unit_id', { type: Sequelize.BIGINT, allowNull: false }); + + await queryInterface.dropTable('unit_lessons'); + await queryInterface.dropTable('course_units'); + }, +}; diff --git a/models/courses/course_units.mdl.js b/models/courses/course_units.mdl.js new file mode 100644 index 0000000..7dc5b66 --- /dev/null +++ b/models/courses/course_units.mdl.js @@ -0,0 +1,32 @@ +/*********************************************************************************************************************************************************************** + * File Name: course_units.mdl.js + * Type of Program: Model (junction) + * Description: Attaches a standalone Unit to a Course. A Unit can live in many + * Courses; per-course ordering lives here (order_index), not on the + * Unit itself. Detaching removes the row — the Unit survives. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 7, 2026 + ***********************************************************************************************************************************************************************/ +const { DataTypes } = require("sequelize"); +const sequelize = require("../../config/db.config"); + +const CourseUnit = sequelize.define("CourseUnit", { + course_unit_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + course_id: { type: DataTypes.BIGINT, allowNull: false }, + unit_id: { type: DataTypes.BIGINT, allowNull: false }, + order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 }, + createdBy: { type: DataTypes.BIGINT, allowNull: true }, + updatedBy: { type: DataTypes.BIGINT, allowNull: true }, +}, { + tableName: "course_units", + timestamps: true, + indexes: [ + { unique: true, fields: ["course_id", "unit_id"], name: "uq_course_units_course_unit" }, + { fields: ["course_id"], name: "idx_course_units_course_id" }, + { fields: ["unit_id"], name: "idx_course_units_unit_id" }, + { fields: ["order_index"], name: "idx_course_units_order" }, + ], +}); + +module.exports = CourseUnit; diff --git a/models/courses/courses.associations.js b/models/courses/courses.associations.js index 2e6aeb5..0a77ef9 100644 --- a/models/courses/courses.associations.js +++ b/models/courses/courses.associations.js @@ -3,6 +3,8 @@ const { Course, CourseProductCategory } = require("./courses.mdl"); const Unit = require("./units.mdl"); const Lesson = require("./lessons.mdl"); +const CourseUnit = require("./course_units.mdl"); +const UnitLesson = require("./unit_lessons.mdl"); const LessonPage = require("./lesson_page.mdl"); const CourseObjective = require("./course_objective.mdl"); const LessonObjective = require("./lesson_objective.mdl"); @@ -44,10 +46,27 @@ LessonReadingProgress.belongsTo(Lesson, { foreignKey: 'lesson_id', as: 'lesso mdl_Users.hasMany(LessonReadingProgress, { foreignKey: 'user_id', as: 'lessonReadingProgress' }); Lesson.hasMany(LessonReadingProgress, { foreignKey: 'lesson_id', as: 'readingProgress' }); +// ── Course ⇄ Unit / Unit ⇄ Lesson (junctions) ───────────────────────────────── +// Units and Lessons are standalone entities. Membership + ordering live on the +// course_units / unit_lessons junction rows (order_index). +Course.belongsToMany(Unit, { through: CourseUnit, foreignKey: "course_id", otherKey: "unit_id", as: "units" }); +Unit.belongsToMany(Course, { through: CourseUnit, foreignKey: "unit_id", otherKey: "course_id", as: "courses" }); +Unit.belongsToMany(Lesson, { through: UnitLesson, foreignKey: "unit_id", otherKey: "lesson_id", as: "lessons" }); +Lesson.belongsToMany(Unit, { through: UnitLesson, foreignKey: "lesson_id", otherKey: "unit_id", as: "units" }); + +// Direct junction access (attach / detach / reorder / count queries) +Course.hasMany(CourseUnit, { as: "unitLinks", foreignKey: "course_id" }); +CourseUnit.belongsTo(Course, { as: "course", foreignKey: "course_id" }); +CourseUnit.belongsTo(Unit, { as: "unit", foreignKey: "unit_id" }); +Unit.hasMany(CourseUnit, { as: "courseLinks", foreignKey: "unit_id" }); +Unit.hasMany(UnitLesson, { as: "lessonLinks", foreignKey: "unit_id" }); +UnitLesson.belongsTo(Unit, { as: "unit", foreignKey: "unit_id" }); +UnitLesson.belongsTo(Lesson, { as: "lesson", foreignKey: "lesson_id" }); +Lesson.hasMany(UnitLesson, { as: "unitLinks", foreignKey: "lesson_id" }); + // ── 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" }); @@ -61,14 +80,11 @@ Course.belongsToMany(mdl_Category, { through: CourseProductCategory, foreignKey: mdl_Category.belongsToMany(Course, { through: CourseProductCategory, foreignKey: 'category_id', otherKey: 'course_id', as: 'courses' }); // ── 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" }); @@ -114,6 +130,7 @@ UnitQuiz.hasMany(QuizSession, { as: "sessions", foreignKey: "quiz_id" }); module.exports = { Course, CourseProductCategory, Unit, Lesson, LessonPage, + CourseUnit, UnitLesson, CourseObjective, LessonObjective, CoursePrerequisite, CourseAssessment, UnitQuiz, QuizQuestion, QuizOption, QuizAttempt, diff --git a/models/courses/courses.attributes.js b/models/courses/courses.attributes.js index 2c1e247..11605a6 100644 --- a/models/courses/courses.attributes.js +++ b/models/courses/courses.attributes.js @@ -27,7 +27,8 @@ const computedAttributes = [ literal: `( SELECT CAST(COUNT(*) AS INTEGER) FROM "units" - WHERE "units"."course_id" = "Course"."course_id" + INNER JOIN "course_units" ON "course_units"."unit_id" = "units"."unit_id" + WHERE "course_units"."course_id" = "Course"."course_id" AND "units"."deletedAt" IS NULL )`, filterable: false, @@ -38,10 +39,12 @@ const computedAttributes = [ type: "number", order: 6, literal: `( - SELECT CAST(COUNT(*) AS INTEGER) + SELECT CAST(COUNT(DISTINCT "lessons"."lesson_id") AS INTEGER) FROM "lessons" - INNER JOIN "units" ON "lessons"."unit_id" = "units"."unit_id" - WHERE "units"."course_id" = "Course"."course_id" + INNER JOIN "unit_lessons" ON "unit_lessons"."lesson_id" = "lessons"."lesson_id" + INNER JOIN "units" ON "unit_lessons"."unit_id" = "units"."unit_id" AND "units"."deletedAt" IS NULL + INNER JOIN "course_units" ON "course_units"."unit_id" = "units"."unit_id" + WHERE "course_units"."course_id" = "Course"."course_id" AND "lessons"."deletedAt" IS NULL )`, filterable: false, diff --git a/models/courses/lesson_reading_progress.mdl.js b/models/courses/lesson_reading_progress.mdl.js index 3174985..3da2aa2 100644 --- a/models/courses/lesson_reading_progress.mdl.js +++ b/models/courses/lesson_reading_progress.mdl.js @@ -28,13 +28,13 @@ const LessonReadingProgress = sequelize.define('LessonReadingProgress', { }, course_id: { type: DataTypes.BIGINT, - allowNull: false, + allowNull: true, // NULL when the lesson is read standalone (outside any course) references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE', }, unit_id: { type: DataTypes.BIGINT, - allowNull: false, + allowNull: true, // NULL when the lesson is read standalone (outside any unit) references: { model: 'units', key: 'unit_id' }, onDelete: 'CASCADE', }, diff --git a/models/courses/lessons.mdl.js b/models/courses/lessons.mdl.js index 8a8b62c..c87e33e 100644 --- a/models/courses/lessons.mdl.js +++ b/models/courses/lessons.mdl.js @@ -1,17 +1,17 @@ const { DataTypes } = require("sequelize"); const sequelize = require("../../config/db.config"); +// Standalone entity — no unit_id / order_index here. A Lesson is attached to +// zero or more Units through unit_lessons, where per-unit ordering lives. const Lesson = sequelize.define("Lesson", { 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" }, @@ -21,10 +21,8 @@ const Lesson = sequelize.define("Lesson", { paranoid: true, indexes: [ { fields: ["uuid"] }, - { fields: ["unit_id"] }, - { fields: ["order_index"] }, { fields: ["deletedAt"] }, ], }); -module.exports = Lesson; \ No newline at end of file +module.exports = Lesson; diff --git a/models/courses/unit_lessons.mdl.js b/models/courses/unit_lessons.mdl.js new file mode 100644 index 0000000..9597fab --- /dev/null +++ b/models/courses/unit_lessons.mdl.js @@ -0,0 +1,32 @@ +/*********************************************************************************************************************************************************************** + * File Name: unit_lessons.mdl.js + * Type of Program: Model (junction) + * Description: Attaches a standalone Lesson to a Unit. A Lesson can live in many + * Units; per-unit ordering lives here (order_index), not on the + * Lesson itself. Detaching removes the row — the Lesson survives. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 7, 2026 + ***********************************************************************************************************************************************************************/ +const { DataTypes } = require("sequelize"); +const sequelize = require("../../config/db.config"); + +const UnitLesson = sequelize.define("UnitLesson", { + unit_lesson_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + unit_id: { type: DataTypes.BIGINT, allowNull: false }, + lesson_id: { type: DataTypes.BIGINT, allowNull: false }, + order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 }, + createdBy: { type: DataTypes.BIGINT, allowNull: true }, + updatedBy: { type: DataTypes.BIGINT, allowNull: true }, +}, { + tableName: "unit_lessons", + timestamps: true, + indexes: [ + { unique: true, fields: ["unit_id", "lesson_id"], name: "uq_unit_lessons_unit_lesson" }, + { fields: ["unit_id"], name: "idx_unit_lessons_unit_id" }, + { fields: ["lesson_id"], name: "idx_unit_lessons_lesson_id" }, + { fields: ["order_index"], name: "idx_unit_lessons_order" }, + ], +}); + +module.exports = UnitLesson; diff --git a/models/courses/unit_reading_progress.mdl.js b/models/courses/unit_reading_progress.mdl.js index ac39e27..60ab30b 100644 --- a/models/courses/unit_reading_progress.mdl.js +++ b/models/courses/unit_reading_progress.mdl.js @@ -28,7 +28,7 @@ const UnitReadingProgress = sequelize.define('UnitReadingProgress', { }, course_id: { type: DataTypes.BIGINT, - allowNull: false, + allowNull: true, // NULL when the unit is read standalone (outside any course) references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE', }, diff --git a/models/courses/units.mdl.js b/models/courses/units.mdl.js index 3bb5160..b7312fa 100644 --- a/models/courses/units.mdl.js +++ b/models/courses/units.mdl.js @@ -1,14 +1,14 @@ const { DataTypes } = require("sequelize"); const sequelize = require("../../config/db.config"); +// Standalone entity — no course_id / order_index here. A Unit is attached to +// zero or more Courses through course_units, where per-course ordering lives. const Unit = sequelize.define("Unit", { unit_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, hidden: true, order: 0, filterable: false }, uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, hidden: true, order: 0, filterable: false }, - course_id: { type: DataTypes.BIGINT, allowNull: false, hidden: true, order: 0, filterable: false }, title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", hidden: false, order: 1, filterable: true }, description: { type: DataTypes.TEXT, allowNull: true, label: "Description", hidden: true, order: 0, filterable: false }, - order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Order", hidden: false, order: 2, filterable: false }, - duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, hidden: false, order: 3, filterable: false }, + duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, hidden: false, order: 2, filterable: false }, createdBy: { type: DataTypes.BIGINT, allowNull: true, filterable: true }, updatedBy: { type: DataTypes.BIGINT, allowNull: true, filterable: true }, deletedBy: { type: DataTypes.BIGINT, allowNull: true, filterable: true }, @@ -18,10 +18,8 @@ const Unit = sequelize.define("Unit", { paranoid: true, indexes: [ { fields: ["uuid"] }, - { fields: ["course_id"] }, - { fields: ["order_index"] }, { fields: ["deletedAt"] }, ], }); -module.exports = Unit; \ No newline at end of file +module.exports = Unit; diff --git a/routes/admin/admin.routes.js b/routes/admin/admin.routes.js index c7b2c1a..8c46022 100644 --- a/routes/admin/admin.routes.js +++ b/routes/admin/admin.routes.js @@ -29,6 +29,8 @@ const usersRoutes = require('./users.routes'); const groupsRoutes = require('./groups.routes'); const assetsRoutes = require('./assets.routes'); const coursesRoutes = require('./courses.routes'); +const unitsRoutes = require('./units.routes'); +const lessonsRoutes = require('./lessons.routes'); const taskRoutes = require('./task.routes'); const tiersRoutes = require('./tiers.routes'); const tierPoliciesRoutes = require('./tier_policies.routes'); @@ -54,6 +56,8 @@ router.use('/users', usersRoutes); router.use('/groups', groupsRoutes); router.use('/assets', assetsRoutes); router.use('/courses', coursesRoutes); +router.use('/units', unitsRoutes); // standalone Unit library (junction revamp) +router.use('/lessons', lessonsRoutes); // standalone Lesson library (junction revamp) router.use('/task-lists', taskRoutes); router.use('/tiers/categories', tierCategoriesRoutes); router.use('/tiers', tiersRoutes); diff --git a/routes/admin/courses.routes.js b/routes/admin/courses.routes.js index 5a848d8..fce8c6f 100644 --- a/routes/admin/courses.routes.js +++ b/routes/admin/courses.routes.js @@ -96,9 +96,11 @@ router.get("/:courseId/assessment/:assessmentId/sessions", ctrl.getAssessment router.get("/:courseId/field-values", ctrl.getUnitFieldValues); router.get("/:courseId/units", ctrl.getUnits); -router.post("/:courseId/units", ctrl.createUnit); +router.post("/:courseId/units", ctrl.createUnit); // create new unit + attach, or attach existing via body { unit_id } // static before :unitId +router.post("/:courseId/units/attach", ctrl.attachUnits); // bulk-attach existing library units { unit_ids } +router.put("/:courseId/units/order", ctrl.reorderUnits); // persist course-level unit ordering { unit_ids } router.delete("/:courseId/units/bulk", ctrl.bulkArchiveUnits); router.delete("/:courseId/units/bulk/permanent", ctrl.bulkPermanentlyDeleteUnits); router.get("/:courseId/units/archives", ctrl.getArchivedUnits); @@ -150,9 +152,10 @@ router.get("/:courseId/units/:unitId/quiz/:quizId/completions", ctrl.getQuizComp router.get("/:courseId/units/:unitId/field-values", ctrl.getLessonFieldValues); router.get("/:courseId/units/:unitId/lessons", ctrl.getLessons); -router.post("/:courseId/units/:unitId/lessons", ctrl.createLesson); +router.post("/:courseId/units/:unitId/lessons", ctrl.createLesson); // create new lesson + attach, or attach existing via body { lesson_id } // static before :lessonId +router.put("/:courseId/units/:unitId/lessons/order", ctrl.reorderLessons); // persist unit-level lesson ordering { lesson_ids } router.delete("/:courseId/units/:unitId/lessons/bulk", ctrl.bulkArchiveLessons); router.delete("/:courseId/units/:unitId/lessons/bulk/permanent", ctrl.bulkPermanentlyDeleteLessons); router.get("/:courseId/units/:unitId/lessons/archives", ctrl.getArchivedLessons); diff --git a/routes/admin/lessons.routes.js b/routes/admin/lessons.routes.js new file mode 100644 index 0000000..e55d368 --- /dev/null +++ b/routes/admin/lessons.routes.js @@ -0,0 +1,39 @@ +/*********************************************************************************************************************************************************************** + * File Name: lessons.routes.js (admin) + * Type of Program: Router + * Description: Standalone Lesson library — Lessons run independently of Units. + * Unit membership (attach/detach/reorder) lives under + * /admin/units/:unitId/lessons. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 7, 2026 + ***********************************************************************************************************************************************************************/ +const router = require("express").Router(); +const ctrl = require("../../controllers/admin/lessons.controller"); + +// Guards are applied by admin.routes.js (authenticate → requireAdmin → adminLimiter) + +// ── static segments first ───────────────────────────────────────────────────── +router.get("/", ctrl.getLessons); +router.post("/", ctrl.createLesson); +router.get("/flat", ctrl.getLessonsFlat); +router.get("/field-values", ctrl.getLessonFieldValues); +router.delete("/bulk", ctrl.bulkArchiveLessons); +router.delete("/bulk/permanent", ctrl.bulkPermanentlyDeleteLessons); +router.get("/archives", ctrl.getArchivedLessons); +router.patch("/restore/bulk", ctrl.bulkRestoreLessons); +router.get("/archives/:lessonId", ctrl.getArchivedLesson); + +// ── then :lessonId ──────────────────────────────────────────────────────────── +router.patch("/:lessonId/restore", ctrl.restoreLesson); +router.get("/:lessonId/permanent-delete-impact", ctrl.getLessonPermanentDeleteImpact); +router.get("/:lessonId", ctrl.getLesson); +router.put("/:lessonId", ctrl.updateLesson); +router.delete("/:lessonId", ctrl.archiveLesson); +router.delete("/:lessonId/permanent", ctrl.permanentlyDeleteLesson); + +// ── Lesson page (block content) ─────────────────────────────────────────────── +router.get("/:lessonId/page", ctrl.getLessonPage); +router.put("/:lessonId/page", ctrl.upsertLessonPage); + +module.exports = router; diff --git a/routes/admin/units.routes.js b/routes/admin/units.routes.js new file mode 100644 index 0000000..a91b01c --- /dev/null +++ b/routes/admin/units.routes.js @@ -0,0 +1,64 @@ +/*********************************************************************************************************************************************************************** + * File Name: units.routes.js (admin) + * Type of Program: Router + * Description: Standalone Unit library — Units run independently of Courses. + * Course membership (attach/detach/reorder) stays under + * /admin/courses/:courseId/units. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 7, 2026 + ***********************************************************************************************************************************************************************/ +const router = require("express").Router(); +const ctrl = require("../../controllers/admin/units.controller"); +const questionCtrl = require("../../controllers/admin/courses.controller"); // shared quiz-question handlers (keyed by quizId) + +// Guards are applied by admin.routes.js (authenticate → requireAdmin → adminLimiter) + +// ── static segments first ───────────────────────────────────────────────────── +router.get("/", ctrl.getUnits); +router.post("/", ctrl.createUnit); +router.get("/flat", ctrl.getUnitsFlat); +router.get("/field-values", ctrl.getUnitFieldValues); +router.delete("/bulk", ctrl.bulkArchiveUnits); +router.delete("/bulk/permanent", ctrl.bulkPermanentlyDeleteUnits); +router.get("/archives", ctrl.getArchivedUnits); +router.patch("/restore/bulk", ctrl.bulkRestoreUnits); +router.get("/archives/:unitId", ctrl.getArchivedUnit); + +// ── then :unitId ────────────────────────────────────────────────────────────── +router.patch("/:unitId/restore", ctrl.restoreUnit); +router.get("/:unitId/archive-impact", ctrl.getUnitArchiveImpact); +router.get("/:unitId/permanent-delete-impact", ctrl.getUnitPermanentDeleteImpact); +router.get("/:unitId", ctrl.getUnit); +router.put("/:unitId", ctrl.updateUnit); +router.delete("/:unitId", ctrl.archiveUnit); +router.delete("/:unitId/permanent", ctrl.permanentlyDeleteUnit); + +// ── Lesson membership (attach / detach / reorder) ───────────────────────────── +router.post("/:unitId/lessons", ctrl.attachLessons); +router.put("/:unitId/lessons/order", ctrl.reorderLessons); +router.delete("/:unitId/lessons/:lessonId", ctrl.detachLesson); + +// ── Unit quiz (travels with the Unit) ───────────────────────────────────────── +router.get("/:unitId/quiz", ctrl.getQuiz); +router.post("/:unitId/quiz", ctrl.createQuiz); +router.get("/:unitId/quiz/archives", ctrl.getArchivedQuiz); +router.patch("/:unitId/quiz/:quizId", ctrl.updateQuiz); +router.delete("/:unitId/quiz/:quizId", ctrl.deleteQuiz); +router.patch("/:unitId/quiz/:quizId/restore", ctrl.restoreQuiz); + +// ── Quiz questions (shared handlers — keyed by quizId only) ─────────────────── +router.get("/:unitId/quiz/:quizId/questions", questionCtrl.getQuestions); +router.post("/:unitId/quiz/:quizId/questions", questionCtrl.createQuestion); +router.delete("/:unitId/quiz/:quizId/questions/bulk", questionCtrl.bulkArchiveQuestions); +router.patch("/:unitId/quiz/:quizId/questions/restore/bulk", questionCtrl.bulkRestoreQuestions); +router.put("/:unitId/quiz/:quizId/questions/bulk-sync", questionCtrl.bulkSyncQuestions); +router.get("/:unitId/quiz/:quizId/questions/archives/:questionId", questionCtrl.getArchivedQuestion); +router.patch("/:unitId/quiz/:quizId/questions/:questionId", questionCtrl.updateQuestion); +router.delete("/:unitId/quiz/:quizId/questions/:questionId", questionCtrl.deleteQuestion); +router.patch("/:unitId/quiz/:quizId/questions/:questionId/restore", questionCtrl.restoreQuestion); + +// ── Quiz completions ────────────────────────────────────────────────────────── +router.get("/:unitId/quiz/:quizId/completions", questionCtrl.getQuizCompletions); + +module.exports = router; diff --git a/routes/client/client.routes.js b/routes/client/client.routes.js index a6864f2..a35cb33 100644 --- a/routes/client/client.routes.js +++ b/routes/client/client.routes.js @@ -28,6 +28,8 @@ const { handleAvatarUpload } = require('../../middleware/avatar_upload.middlewar const tiersRoutes = require('./tiers.routes'); const coursesRoutes = require('./courses.routes'); +const unitsRoutes = require('./units.routes'); +const lessonsRoutes = require('./lessons.routes'); const coursePurchasesRoutes = require('./course_purchases.routes'); const mediaRoutes = require("./media.routes"); const groupsRoutes = require("./groups.routes"); @@ -58,6 +60,8 @@ router.delete('/sessions/:id', profileCtrl.revokeSession); router.use('/tiers', tiersRoutes); router.use('/courses', coursesRoutes); +router.use('/units', unitsRoutes); // standalone Unit consumption (junction revamp) +router.use('/lessons', lessonsRoutes); // standalone Lesson consumption (junction revamp) router.use('/course-purchases', coursePurchasesRoutes); router.use('/groups', groupsRoutes); router.use('/advertisements', advertisementRoutes); diff --git a/routes/client/lessons.routes.js b/routes/client/lessons.routes.js new file mode 100644 index 0000000..7623c52 --- /dev/null +++ b/routes/client/lessons.routes.js @@ -0,0 +1,20 @@ +/*********************************************************************************************************************************************************************** + * File Name: lessons.routes.js (client) + * Type of Program: Router + * Description: Standalone Lesson consumption — each Lesson runs independently. + * + * GET /client/lessons/:uuid → lesson with full block content + * POST /client/lessons/:uuid/progress → standalone reading progress + * (course NULL; body.unit_uuid optional) + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 7, 2026 + ***********************************************************************************************************************************************************************/ +const express = require('express'); +const router = express.Router(); +const ctrl = require('../../controllers/client/units.controller'); + +router.get('/:uuid', ctrl.getLessonByUuid); +router.post('/:uuid/progress', ctrl.upsertStandaloneLessonProgress); + +module.exports = router; diff --git a/routes/client/units.routes.js b/routes/client/units.routes.js new file mode 100644 index 0000000..176a833 --- /dev/null +++ b/routes/client/units.routes.js @@ -0,0 +1,26 @@ +/*********************************************************************************************************************************************************************** + * File Name: units.routes.js (client) + * Type of Program: Router + * Description: Standalone Unit consumption — Units run independently of Courses. + * + * GET /client/units → learner-facing unit library + * GET /client/units/:uuid → unit metadata + * GET /client/units/:uuid/lessons → unit + ALL lesson data + * GET /client/units/:uuid/quiz → quiz (answers stripped) + * POST /client/units/:uuid/quiz/:quizId/submit → graded attempt (no course context) + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 7, 2026 + ***********************************************************************************************************************************************************************/ +const express = require('express'); +const router = express.Router(); +const ctrl = require('../../controllers/client/units.controller'); + +router.get('/', ctrl.getUnits); +router.get('/:uuid/lessons', ctrl.getLessonsByUnitUuid); +router.get('/:uuid/quiz', ctrl.getUnitQuiz); +router.patch('/:uuid/quiz/:quizId/draft', ctrl.saveUnitQuizDraft); +router.post('/:uuid/quiz/:quizId/submit', ctrl.submitUnitQuiz); +router.get('/:uuid', ctrl.getUnitByUuid); + +module.exports = router; diff --git a/services/course_reading_progress.service.js b/services/course_reading_progress.service.js index 77a8349..5c5f479 100644 --- a/services/course_reading_progress.service.js +++ b/services/course_reading_progress.service.js @@ -25,6 +25,8 @@ const sequelize = require('../config/db.config'); const CourseReadingProgress = require('../models/courses/course_reading_progress.mdl'); const Lesson = require('../models/courses/lessons.mdl'); const Unit = require('../models/courses/units.mdl'); +const CourseUnit = require('../models/courses/course_units.mdl'); +const UnitLesson = require('../models/courses/unit_lessons.mdl'); const CourseAssessment = require('../models/courses/course_assessment.mdl'); const QuizAttempt = require('../models/courses/quiz_attempt.mdl'); @@ -71,10 +73,18 @@ async function upsertProgress({ userId, courseId, type, referenceId, status }, t // ─── Derivation helpers ─────────────────────────────────────────────────────── -// Unit is completed when every non-deleted lesson under it has a completed row for this user. +// Unit is completed when every non-deleted lesson attached to it (via unit_lessons) +// has a completed row for this user. async function deriveUnitStatus(userId, courseId, unitId, t) { - const lessons = await Lesson.findAll({ + const links = await UnitLesson.findAll({ where: { unit_id: unitId }, + attributes: ['lesson_id'], + transaction: t, + }); + if (!links.length) return 'in_progress'; + + const lessons = await Lesson.findAll({ + where: { lesson_id: links.map(l => l.lesson_id) }, attributes: ['uuid'], transaction: t, }); @@ -98,8 +108,15 @@ async function deriveUnitStatus(userId, courseId, unitId, t) { // Course is completed when every non-deleted unit under it has a completed row for this user // AND the course's assessment (if one has been built) has been passed by this user. async function deriveCourseStatus(userId, courseId, t) { - const units = await Unit.findAll({ + const links = await CourseUnit.findAll({ where: { course_id: courseId }, + attributes: ['unit_id'], + transaction: t, + }); + if (!links.length) return 'in_progress'; + + const units = await Unit.findAll({ + where: { unit_id: links.map(l => l.unit_id) }, attributes: ['uuid'], transaction: t, }); diff --git a/services/reading_progress.service.js b/services/reading_progress.service.js index 707e1ab..9af4016 100644 --- a/services/reading_progress.service.js +++ b/services/reading_progress.service.js @@ -23,6 +23,7 @@ const sequelize = require('../config/db.config'); const LessonReadingProgress = require('../models/courses/lesson_reading_progress.mdl'); const UnitReadingProgress = require('../models/courses/unit_reading_progress.mdl'); +const UnitLesson = require('../models/courses/unit_lessons.mdl'); const Lesson = require('../models/courses/lessons.mdl'); // ─── Core UPSERTs ──────────────────────────────────────────────────────────── @@ -32,8 +33,8 @@ async function upsertLessonProgress({ userId, courseId, unitId, lessonId, status const [record] = await LessonReadingProgress.upsert( { user_id: userId, - course_id: courseId, - unit_id: unitId, + course_id: courseId ?? null, // NULL when read standalone + unit_id: unitId ?? null, // NULL when read standalone lesson_id: lessonId, status, completed_at: status === 'completed' ? now : null, @@ -55,7 +56,7 @@ async function upsertUnitProgress({ userId, courseId, unitId, status }, t) { const [record] = await UnitReadingProgress.upsert( { user_id: userId, - course_id: courseId, + course_id: courseId ?? null, // NULL when read standalone unit_id: unitId, status, completed_at: status === 'completed' ? now : null, @@ -74,13 +75,21 @@ async function upsertUnitProgress({ userId, courseId, unitId, status }, t) { // ─── Derivation helper ──────────────────────────────────────────────────────── -// Unit is completed when every non-deleted lesson under it has a completed row for this user. +// Unit is completed when every non-deleted lesson attached to it (via unit_lessons) +// has a completed row for this user. async function deriveUnitStatus(userId, unitId, t) { - const lessons = await Lesson.findAll({ + const links = await UnitLesson.findAll({ where: { unit_id: unitId }, attributes: ['lesson_id'], transaction: t, }); + if (!links.length) return 'in_progress'; + + const lessons = await Lesson.findAll({ + where: { lesson_id: links.map(l => l.lesson_id) }, + attributes: ['lesson_id'], + transaction: t, + }); if (!lessons.length) return 'in_progress'; const lessonIds = lessons.map(l => l.lesson_id); @@ -110,7 +119,7 @@ async function deriveUnitStatus(userId, unitId, t) { * @param {string} payload.lessonStatus — 'in_progress' | 'completed' * @returns {{ lesson, unit }} — status snapshot for each level */ -async function upsertLessonRead(userId, { courseId, unitId, lessonId, lessonStatus = 'in_progress' }) { +async function upsertLessonRead(userId, { courseId = null, unitId = null, lessonId, lessonStatus = 'in_progress' }) { const t = await sequelize.transaction(); try { // 1. Lesson @@ -122,20 +131,23 @@ async function upsertLessonRead(userId, { courseId, unitId, lessonId, lessonStat status: lessonStatus, }, t); - // 2. Unit — derived from all sibling lessons - const unitStatus = await deriveUnitStatus(userId, unitId, t); - await upsertUnitProgress({ - userId, - courseId, - unitId, - status: unitStatus, - }, t); + // 2. Unit — derived from all sibling lessons (skipped for standalone lesson reads) + let unitStatus = null; + if (unitId) { + unitStatus = await deriveUnitStatus(userId, unitId, t); + await upsertUnitProgress({ + userId, + courseId, + unitId, + status: unitStatus, + }, t); + } await t.commit(); return { lesson: { lesson_id: lessonId, status: lessonStatus }, - unit: { unit_id: unitId, status: unitStatus }, + unit: unitId ? { unit_id: unitId, status: unitStatus } : null, }; } catch (err) { await t.rollback(); diff --git a/utils/courses/hierarchy.util.js b/utils/courses/hierarchy.util.js new file mode 100644 index 0000000..de26c00 --- /dev/null +++ b/utils/courses/hierarchy.util.js @@ -0,0 +1,114 @@ +"use strict"; + +/*********************************************************************************************************************************************************************** + * File Name: hierarchy.util.js + * Type of Program: Utility + * Description: Helpers for the junction-based course hierarchy + * (courses ⇄ course_units ⇄ units ⇄ unit_lessons ⇄ lessons). + * + * Sequelize belongsToMany includes surface the junction row under the through- + * model key ("CourseUnit" / "UnitLesson"). These helpers flatten that back to + * the flat `order_index` field the API has always exposed, so response shapes + * stay identical to the pre-junction era. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 7, 2026 + ***********************************************************************************************************************************************************************/ + +const sequelize = require("../../config/db.config"); + +// ── Response flattening ─────────────────────────────────────────────────────── + +/** Sort lessons by their unit_lessons.order_index and flatten it onto each row. */ +function flattenLessons(lessons = []) { + return [...lessons] + .map((l) => { + const { UnitLesson: link, ...rest } = l; + return { ...rest, order_index: link?.order_index ?? rest.order_index ?? 0 }; + }) + .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)); +} + +/** Sort units by their course_units.order_index, flatten it, and recurse into lessons. */ +function flattenUnits(units = []) { + return [...units] + .map((u) => { + const { CourseUnit: link, ...rest } = u; + return { + ...rest, + order_index: link?.order_index ?? rest.order_index ?? 0, + ...(rest.lessons ? { lessons: flattenLessons(rest.lessons) } : {}), + }; + }) + .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)); +} + +// ── Junction row maintenance ────────────────────────────────────────────────── + +/** Next order_index for appending to a parent (max + 1, or 0 when empty). */ +async function nextOrderIndex(JunctionModel, where, transaction) { + const max = await JunctionModel.max("order_index", { where, transaction }); + return Number.isFinite(max) ? max + 1 : 0; +} + +/** + * Persist a full ordering: ids[i] gets order_index i. + * Ignores ids without an existing junction row. + */ +async function reorderJunction(JunctionModel, parentField, parentId, childField, orderedIds = [], transaction) { + await Promise.all(orderedIds.map((id, i) => + JunctionModel.update( + { order_index: i }, + { where: { [parentField]: parentId, [childField]: id }, transaction } + ) + )); +} + +// ── Structure counts (raw SQL — junction traversals) ────────────────────────── + +/** Count non-archived lessons reachable from a course through its attached units. */ +async function countCourseLessons(courseId) { + const [row] = await sequelize.query(` + SELECT CAST(COUNT(DISTINCT l.lesson_id) AS INTEGER) AS total + FROM lessons l + JOIN unit_lessons ul ON ul.lesson_id = l.lesson_id + 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 AND cu.course_id = :courseId + WHERE l."deletedAt" IS NULL + `, { replacements: { courseId }, type: sequelize.QueryTypes.SELECT }); + return Number(row?.total ?? 0); +} + +/** Non-archived unit ids attached to a course, in course order. */ +async function getCourseUnitIds(courseId) { + const rows = await sequelize.query(` + SELECT u.unit_id + FROM units u + JOIN course_units cu ON cu.unit_id = u.unit_id AND cu.course_id = :courseId + WHERE u."deletedAt" IS NULL + ORDER BY cu.order_index ASC + `, { replacements: { courseId }, type: sequelize.QueryTypes.SELECT }); + return rows.map((r) => r.unit_id); +} + +/** Non-archived lesson ids attached to a unit, in unit order. */ +async function getUnitLessonIds(unitId) { + const rows = await sequelize.query(` + SELECT l.lesson_id + FROM lessons l + JOIN unit_lessons ul ON ul.lesson_id = l.lesson_id AND ul.unit_id = :unitId + WHERE l."deletedAt" IS NULL + ORDER BY ul.order_index ASC + `, { replacements: { unitId }, type: sequelize.QueryTypes.SELECT }); + return rows.map((r) => r.lesson_id); +} + +module.exports = { + flattenLessons, + flattenUnits, + nextOrderIndex, + reorderJunction, + countCourseLessons, + getCourseUnitIds, + getUnitLessonIds, +}; diff --git a/utils/duration.util.js b/utils/duration.util.js index a0c8aa5..9764975 100644 --- a/utils/duration.util.js +++ b/utils/duration.util.js @@ -59,9 +59,10 @@ async function recomputeUnitDuration(unitId) { const Unit = require("../models/courses/units.mdl"); const [unitResult] = await Lesson.sequelize.query(` - SELECT COALESCE(SUM(duration_seconds), 0) AS total - FROM lessons - WHERE unit_id = :unitId AND "deletedAt" IS NULL + SELECT COALESCE(SUM(l.duration_seconds), 0) AS total + FROM lessons l + JOIN unit_lessons ul ON ul.lesson_id = l.lesson_id + WHERE ul.unit_id = :unitId AND l."deletedAt" IS NULL `, { replacements: { unitId }, type: Lesson.sequelize.QueryTypes.SELECT }); await Unit.update({ duration_seconds: unitResult.total }, { where: { unit_id: unitId } }); @@ -77,9 +78,10 @@ async function recomputeCourseDuration(courseId) { const { Course } = require("../models/courses/courses.mdl"); const [courseResult] = await Unit.sequelize.query(` - SELECT COALESCE(SUM(duration_seconds), 0) AS total - FROM units - WHERE course_id = :courseId AND "deletedAt" IS NULL + SELECT COALESCE(SUM(u.duration_seconds), 0) AS total + FROM units u + JOIN course_units cu ON cu.unit_id = u.unit_id + WHERE cu.course_id = :courseId AND u."deletedAt" IS NULL `, { replacements: { courseId }, type: Unit.sequelize.QueryTypes.SELECT }); await Course.update({ duration_seconds: courseResult.total }, { where: { course_id: courseId } }); @@ -87,12 +89,14 @@ async function recomputeCourseDuration(courseId) { /** * Recompute and persist duration_seconds up the chain: - * blocks → lesson → unit → course + * blocks → lesson → every attached unit → every course those units are attached to. + * A lesson may live in many units, and a unit in many courses, so all parents refresh. */ async function recomputeDurations(lessonId) { const Lesson = require("../models/courses/lessons.mdl"); - const Unit = require("../models/courses/units.mdl"); const LessonPage = require("../models/courses/lesson_page.mdl"); + const UnitLesson = require("../models/courses/unit_lessons.mdl"); + const CourseUnit = require("../models/courses/course_units.mdl"); // 1. Lesson duration from blocks const page = await LessonPage.findOne({ where: { lesson_id: lessonId } }); @@ -104,13 +108,17 @@ async function recomputeDurations(lessonId) { 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 } }); - await recomputeUnitDuration(lesson.unit_id); + // 2. Every unit that contains this lesson + const unitLinks = await UnitLesson.findAll({ where: { lesson_id: lessonId }, attributes: ["unit_id"] }); + const unitIds = [...new Set(unitLinks.map((l) => String(l.unit_id)))]; + for (const unitId of unitIds) await recomputeUnitDuration(unitId); - // 3. Course duration — sum of its units - const unit = await Unit.findOne({ where: { unit_id: lesson.unit_id } }); - await recomputeCourseDuration(unit.course_id); + // 3. Every course that contains those units + if (unitIds.length) { + const courseLinks = await CourseUnit.findAll({ where: { unit_id: unitIds }, attributes: ["course_id"] }); + const courseIds = [...new Set(courseLinks.map((l) => String(l.course_id)))]; + for (const courseId of courseIds) await recomputeCourseDuration(courseId); + } } /**