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