From 7ba03ad4dbb430b70211ba31c65bf001a62c0e93 Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Sat, 4 Jul 2026 08:26:01 +0800 Subject: [PATCH] duration fix when data is deleted for courses Signed-off-by: Kenneth Obsequio --- controllers/admin/courses.controller.js | 32 +++++++++++- utils/duration.util.js | 69 +++++++++++++++++-------- 2 files changed, 78 insertions(+), 23 deletions(-) diff --git a/controllers/admin/courses.controller.js b/controllers/admin/courses.controller.js index 028216c..a457a03 100644 --- a/controllers/admin/courses.controller.js +++ b/controllers/admin/courses.controller.js @@ -4,7 +4,7 @@ const { Op, Sequelize } = require("sequelize"); const sequelize = require("../../config/db.config"); const R = require("../../utils/response.util"); const { paginate } = require("../../utils/paginate.util"); -const { recomputeDurations, formatDuration } = require("../../utils/duration.util"); +const { recomputeDurations, recomputeUnitDuration, recomputeCourseDuration, formatDuration } = require("../../utils/duration.util"); const { syncObjectivesCreate, syncObjectivesUpdate } = require("../../utils/courses/objectives.util"); const { syncJunction } = require("../../utils/courses/junction.util"); const { archiveOne, archiveMany } = require("../../utils/courses/archive.util"); @@ -545,6 +545,7 @@ exports.archiveUnit = async (req, res) => { 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."); } catch (err) { @@ -566,6 +567,7 @@ exports.bulkArchiveUnits = async (req, res) => { 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.`); } catch (err) { @@ -622,6 +624,7 @@ exports.restoreUnit = async (req, res) => { const record = await restoreOne(Unit, { unit_id: unitId, course_id: courseId, ...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); } logActivity(req.user.user_id, 'restore_unit', { entityType: 'unit', entityId: Number(unitId) }); return R.success(res, "Unit restored.", { data: record }); } catch (err) { @@ -643,6 +646,7 @@ exports.bulkRestoreUnits = async (req, res) => { const count = await restoreMany(Unit, "unit_id", validIds, req.user.user_id, t); await t.commit(); + try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][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) { @@ -660,6 +664,7 @@ exports.permanentlyDeleteUnit = async (req, res) => { 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 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) }); return R.success(res, "Unit permanently deleted."); } catch (err) { @@ -681,6 +686,7 @@ exports.bulkPermanentlyDeleteUnits = async (req, res) => { const count = await permanentDeleteMany(Unit, "unit_id", validIds, 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 } }); return R.success(res, `${count} unit${count !== 1 ? "s" : ""} permanently deleted.`); } catch (err) { @@ -841,6 +847,10 @@ exports.archiveLesson = async (req, res) => { 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 (err) { @@ -865,6 +875,10 @@ exports.bulkArchiveLessons = async (req, res) => { 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 (err) { @@ -929,6 +943,10 @@ exports.restoreLesson = async (req, res) => { const record = await restoreOne(Lesson, { lesson_id: lessonId, unit_id: unitId, ...onlyDeleted }, req.user.user_id, t); if (!record) return R.error(res, "Archived lesson not found.", 404); await t.commit(); + try { + await recomputeUnitDuration(unitId); + await recomputeCourseDuration(courseId); + } catch (durErr) { console.error("[LESSON][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) { @@ -953,6 +971,10 @@ exports.bulkRestoreLessons = async (req, res) => { const count = await restoreMany(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 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) { @@ -974,6 +996,10 @@ exports.permanentlyDeleteLesson = async (req, res) => { 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 t.commit(); + try { + await recomputeUnitDuration(unitId); + await recomputeCourseDuration(courseId); + } catch (durErr) { console.error("[LESSON][PERMANENT DELETE][DURATION]", durErr); } logActivity(req.user.user_id, 'permanently_delete_lesson', { entityType: 'lesson', entityId: Number(lessonId) }); return R.success(res, "Lesson permanently deleted."); } catch (err) { @@ -998,6 +1024,10 @@ exports.bulkPermanentlyDeleteLessons = async (req, res) => { const count = await permanentDeleteMany(Lesson, "lesson_id", validIds, t); await t.commit(); + try { + await recomputeUnitDuration(unitId); + await recomputeCourseDuration(courseId); + } catch (durErr) { console.error("[LESSON][BULK PERMANENT DELETE][DURATION]", durErr); } 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) { diff --git a/utils/duration.util.js b/utils/duration.util.js index 00d1181..a0c8aa5 100644 --- a/utils/duration.util.js +++ b/utils/duration.util.js @@ -47,6 +47,44 @@ function estimateBlockDuration(block) { } } +/** + * Recompute and persist a unit's duration_seconds from the sum of its + * non-archived lessons. Used both by recomputeDurations() (a lesson's + * content changed) and directly by archive/restore/delete handlers (a + * lesson was added to or removed from the "counts toward duration" set + * without any of its content changing). + */ +async function recomputeUnitDuration(unitId) { + const Lesson = require("../models/courses/lessons.mdl"); + 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 + `, { replacements: { unitId }, type: Lesson.sequelize.QueryTypes.SELECT }); + + await Unit.update({ duration_seconds: unitResult.total }, { where: { unit_id: unitId } }); +} + +/** + * Recompute and persist a course's duration_seconds from the sum of its + * non-archived units. See recomputeUnitDuration() above for why this is + * split out on its own instead of only living inside recomputeDurations(). + */ +async function recomputeCourseDuration(courseId) { + const Unit = require("../models/courses/units.mdl"); + 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 + `, { replacements: { courseId }, type: Unit.sequelize.QueryTypes.SELECT }); + + await Course.update({ duration_seconds: courseResult.total }, { where: { course_id: courseId } }); +} + /** * Recompute and persist duration_seconds up the chain: * blocks → lesson → unit → course @@ -54,7 +92,6 @@ function estimateBlockDuration(block) { async function recomputeDurations(lessonId) { const Lesson = require("../models/courses/lessons.mdl"); const Unit = require("../models/courses/units.mdl"); - const { Course } = require("../models/courses/courses.mdl"); const LessonPage = require("../models/courses/lesson_page.mdl"); // 1. Lesson duration from blocks @@ -69,29 +106,11 @@ async function recomputeDurations(lessonId) { // 2. Unit duration — sum of its lessons const lesson = await Lesson.findOne({ where: { lesson_id: lessonId } }); - const [unitResult] = await Lesson.sequelize.query(` - SELECT COALESCE(SUM(duration_seconds), 0) AS total - FROM lessons - WHERE unit_id = :unitId AND "deletedAt" IS NULL - `, { replacements: { unitId: lesson.unit_id }, type: Lesson.sequelize.QueryTypes.SELECT }); - - await Unit.update( - { duration_seconds: unitResult.total }, - { where: { unit_id: lesson.unit_id } } - ); + await recomputeUnitDuration(lesson.unit_id); // 3. Course duration — sum of its units const unit = await Unit.findOne({ where: { unit_id: lesson.unit_id } }); - const [courseResult] = await Lesson.sequelize.query(` - SELECT COALESCE(SUM(duration_seconds), 0) AS total - FROM units - WHERE course_id = :courseId AND "deletedAt" IS NULL - `, { replacements: { courseId: unit.course_id }, type: Lesson.sequelize.QueryTypes.SELECT }); - - await Course.update( - { duration_seconds: courseResult.total }, - { where: { course_id: unit.course_id } } - ); + await recomputeCourseDuration(unit.course_id); } /** @@ -110,4 +129,10 @@ function formatDuration(seconds) { return parts.join(" "); } -module.exports = { estimateBlockDuration, recomputeDurations, formatDuration }; \ No newline at end of file +module.exports = { + estimateBlockDuration, + recomputeDurations, + recomputeUnitDuration, + recomputeCourseDuration, + formatDuration, +}; \ No newline at end of file