mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
738 lines
31 KiB
JavaScript
738 lines
31 KiB
JavaScript
"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 { syncObjectivesCreate } = require("../../utils/courses/objectives.util");
|
|
const logActivity = require("../../utils/logActivity.util");
|
|
|
|
// ── Models ────────────────────────────────────────────────────────────────────
|
|
|
|
const {
|
|
Course, Unit, Lesson, LessonPage,
|
|
CourseUnit, UnitLesson, LessonObjective,
|
|
UnitQuiz, QuizQuestion, QuizOption,
|
|
UnitReadingProgress, LessonReadingProgress,
|
|
} = require("../../models/courses/courses.associations");
|
|
|
|
const mdl_Users = require("../../models/users/users.mdl");
|
|
|
|
const notDeleted = { deletedAt: null };
|
|
const onlyDeleted = { deletedAt: { [Op.not]: null } };
|
|
|
|
// Refresh duration of every course this unit is attached to (post-archive/restore).
|
|
async function recomputeParentCourseDurations(unitId) {
|
|
const links = await CourseUnit.findAll({ where: { unit_id: unitId }, attributes: ["course_id"] });
|
|
for (const courseId of new Set(links.map((l) => String(l.course_id)))) {
|
|
await recomputeCourseDuration(courseId);
|
|
}
|
|
}
|
|
|
|
const UNIT_LIST_COMPUTED = [
|
|
{
|
|
key: "quiz_id",
|
|
label: "Quiz ID",
|
|
type: "text",
|
|
literal: `(SELECT quiz_id FROM unit_quizzes WHERE unit_id = "Unit"."unit_id" AND "deletedAt" IS NULL LIMIT 1)`,
|
|
},
|
|
{
|
|
key: "lesson_count",
|
|
label: "Lessons",
|
|
type: "number",
|
|
literal: `(
|
|
SELECT CAST(COUNT(*) AS INTEGER)
|
|
FROM unit_lessons ul
|
|
JOIN lessons l ON l.lesson_id = ul.lesson_id AND l."deletedAt" IS NULL
|
|
WHERE ul.unit_id = "Unit"."unit_id"
|
|
)`,
|
|
},
|
|
{
|
|
key: "course_count",
|
|
label: "Used in courses",
|
|
type: "number",
|
|
literal: `(
|
|
SELECT CAST(COUNT(*) AS INTEGER)
|
|
FROM course_units cu
|
|
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
|
WHERE cu.unit_id = "Unit"."unit_id"
|
|
)`,
|
|
},
|
|
{
|
|
key: "course_status",
|
|
label: "Course Status",
|
|
type: "text",
|
|
filterable: false,
|
|
literal: `(
|
|
CASE
|
|
WHEN NOT EXISTS (
|
|
SELECT 1 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"
|
|
) THEN 'standalone'
|
|
WHEN EXISTS (
|
|
SELECT 1 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" AND c.status = 'published'
|
|
) THEN 'published'
|
|
ELSE 'draft'
|
|
END
|
|
)`,
|
|
},
|
|
];
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
// UNIT LIBRARY
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
exports.getUnits = async (req, res) => {
|
|
try {
|
|
const result = await paginate(Unit, req, {
|
|
auditOptions: { mdl_Users, parentAlias: "Unit" },
|
|
context: "list",
|
|
computedAttributes: UNIT_LIST_COMPUTED,
|
|
findOptions: {
|
|
where: { ...notDeleted },
|
|
order: [["createdAt", "DESC"]],
|
|
},
|
|
});
|
|
return R.success(res, "Units retrieved.", result);
|
|
} catch (err) {
|
|
console.error("[UNIT LIB][GET ALL]", err);
|
|
return R.error(res, "Could not retrieve units.", 500);
|
|
}
|
|
};
|
|
|
|
// Lightweight list for attach pickers: { unit_id, uuid, title, lesson_count, course_count, course_title }
|
|
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,
|
|
(SELECT c.title 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
|
|
LIMIT 1) AS course_title
|
|
FROM units u
|
|
WHERE u."deletedAt" IS NULL
|
|
ORDER BY u.title ASC
|
|
`, { type: sequelize.QueryTypes.SELECT });
|
|
return R.success(res, "Units retrieved.", rows);
|
|
} catch (err) {
|
|
console.error("[UNIT LIB][GET FLAT]", err);
|
|
return R.error(res, "Could not retrieve units.", 500);
|
|
}
|
|
};
|
|
|
|
exports.getUnit = async (req, res) => {
|
|
try {
|
|
const { unitId } = req.params;
|
|
|
|
const unit = await Unit.findOne({
|
|
where: { unit_id: unitId, ...notDeleted },
|
|
include: [
|
|
{ model: Lesson, as: "lessons", where: notDeleted, required: false, through: { attributes: ["order_index"] } },
|
|
{ model: UnitQuiz, as: "quiz", required: false },
|
|
{ model: Course, as: "courses", where: notDeleted, required: false, attributes: ["course_id", "uuid", "title", "subscription"], through: { attributes: ["order_index"] } },
|
|
],
|
|
});
|
|
|
|
if (!unit) return R.error(res, "Unit not found.", 404);
|
|
|
|
const plain = unit.toJSON();
|
|
plain.lessons = flattenLessons(plain.lessons);
|
|
return R.success(res, "Unit retrieved.", { data: plain });
|
|
} catch (err) {
|
|
console.error("[UNIT LIB][GET ONE]", err);
|
|
return R.error(res, "Could not retrieve unit.", 500);
|
|
}
|
|
};
|
|
|
|
exports.createUnit = async (req, res) => {
|
|
const t = await sequelize.transaction();
|
|
try {
|
|
const { title, description, subscription, course_id, order, createdBy } = req.body;
|
|
if (!title) return R.error(res, "Title is required.", 400);
|
|
|
|
const unit = await Unit.create({
|
|
title,
|
|
subscription: subscription || null,
|
|
description: description ?? null,
|
|
duration_seconds: 0,
|
|
createdBy: createdBy ?? req.user?.user_id ?? null,
|
|
}, { transaction: t });
|
|
|
|
// Optional immediate attach — lets the course builder create-and-attach in one call
|
|
if (course_id) {
|
|
const course = await Course.findOne({ where: { course_id, ...notDeleted }, transaction: t });
|
|
if (!course) {
|
|
await t.rollback();
|
|
return R.error(res, "Course not found.", 404);
|
|
}
|
|
const order_index = order ?? await nextOrderIndex(CourseUnit, { course_id }, t);
|
|
await CourseUnit.create({
|
|
course_id,
|
|
unit_id: unit.unit_id,
|
|
order_index,
|
|
createdBy: createdBy ?? req.user?.user_id ?? null,
|
|
}, { transaction: t });
|
|
}
|
|
|
|
await t.commit();
|
|
logActivity(req.user?.user_id, "create_unit", { entityType: "unit", entityId: unit.unit_id, details: { title: unit.title, attached_course_id: course_id ?? null } });
|
|
return R.success(res, "Unit created.", { data: unit }, 201);
|
|
} catch (err) {
|
|
await t.rollback();
|
|
console.error("[UNIT LIB][CREATE]", err);
|
|
return R.error(res, "Could not create unit.", 500);
|
|
}
|
|
};
|
|
|
|
// One consolidated call: creates the unit, its lessons (with objectives + page
|
|
// blocks), and attaches each lesson to the unit — all in a single transaction.
|
|
// Body: { title, description, subscription,
|
|
// lessons: [{ title, description, objectives?: string[], blocks?: [] }],
|
|
// createdBy }
|
|
const CREATE_UNIT_FULL_LIMITS = { maxLessons: 50, maxObjectives: 20, maxBlocks: 100 };
|
|
|
|
exports.createUnitFull = async (req, res) => {
|
|
const t = await sequelize.transaction();
|
|
try {
|
|
const { title, description, subscription, lessons = [], createdBy } = req.body;
|
|
|
|
if (!title) return R.error(res, "Title is required.", 400);
|
|
if (!Array.isArray(lessons)) return R.error(res, "Lessons must be a list.", 400);
|
|
if (lessons.length > CREATE_UNIT_FULL_LIMITS.maxLessons) {
|
|
return R.error(res, `A unit can have at most ${CREATE_UNIT_FULL_LIMITS.maxLessons} lessons.`, 400);
|
|
}
|
|
|
|
for (const lessonInput of lessons) {
|
|
if (!lessonInput?.title) return R.error(res, "Each lesson needs a title.", 400);
|
|
if (lessonInput.objectives !== undefined && !Array.isArray(lessonInput.objectives)) {
|
|
return R.error(res, "Lesson objectives must be a list.", 400);
|
|
}
|
|
if (lessonInput.objectives?.length > CREATE_UNIT_FULL_LIMITS.maxObjectives) {
|
|
return R.error(res, `A lesson can have at most ${CREATE_UNIT_FULL_LIMITS.maxObjectives} objectives.`, 400);
|
|
}
|
|
if (lessonInput.blocks !== undefined && !Array.isArray(lessonInput.blocks)) {
|
|
return R.error(res, "Lesson page blocks must be a list.", 400);
|
|
}
|
|
if (lessonInput.blocks?.length > CREATE_UNIT_FULL_LIMITS.maxBlocks) {
|
|
return R.error(res, `A lesson page can have at most ${CREATE_UNIT_FULL_LIMITS.maxBlocks} blocks.`, 400);
|
|
}
|
|
}
|
|
|
|
const by = createdBy ?? req.user?.user_id ?? null;
|
|
|
|
const unit = await Unit.create({
|
|
title,
|
|
subscription: subscription || null,
|
|
description: description ?? null,
|
|
duration_seconds: 0,
|
|
createdBy: by,
|
|
}, { transaction: t });
|
|
|
|
for (let i = 0; i < lessons.length; i++) {
|
|
const lessonInput = lessons[i];
|
|
|
|
const lesson = await Lesson.create({
|
|
title: lessonInput.title,
|
|
description: lessonInput.description ?? null,
|
|
duration_seconds: 0,
|
|
createdBy: by,
|
|
}, { transaction: t });
|
|
|
|
await LessonPage.create({
|
|
lesson_id: lesson.lesson_id,
|
|
blocks: lessonInput.blocks ?? [],
|
|
createdBy: by,
|
|
}, { transaction: t });
|
|
|
|
await syncObjectivesCreate(LessonObjective, "lesson_id", lesson.lesson_id, lessonInput.objectives ?? [], t);
|
|
|
|
await UnitLesson.create({
|
|
unit_id: unit.unit_id,
|
|
lesson_id: lesson.lesson_id,
|
|
order_index: i,
|
|
createdBy: by,
|
|
}, { transaction: t });
|
|
}
|
|
|
|
await t.commit();
|
|
|
|
if (lessons.length) {
|
|
try { await recomputeUnitDuration(unit.unit_id); }
|
|
catch (durErr) { console.error("[UNIT LIB][CREATE FULL][DURATION]", durErr); }
|
|
}
|
|
|
|
logActivity(req.user?.user_id, "create_unit", { entityType: "unit", entityId: unit.unit_id, details: { title: unit.title, lessons: lessons.length } });
|
|
return R.success(res, "Unit created.", { data: unit }, 201);
|
|
} catch (err) {
|
|
await t.rollback();
|
|
console.error("[UNIT LIB][CREATE FULL]", err);
|
|
return R.error(res, "Could not create unit.", 500);
|
|
}
|
|
};
|
|
|
|
exports.updateUnit = async (req, res) => {
|
|
try {
|
|
const { unitId } = req.params;
|
|
|
|
const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted } });
|
|
if (!unit) return R.error(res, "Unit not found.", 404);
|
|
|
|
const { title, description, subscription, updatedBy } = req.body;
|
|
|
|
if (title !== undefined) unit.title = title;
|
|
if (description !== undefined) unit.description = description;
|
|
if (subscription !== undefined) unit.subscription = subscription || null;
|
|
unit.updatedBy = updatedBy ?? req.user?.user_id ?? null;
|
|
|
|
await unit.save();
|
|
logActivity(req.user?.user_id, "update_unit", { entityType: "unit", entityId: Number(unitId) });
|
|
return R.success(res, "Unit updated.", { data: unit });
|
|
} catch (err) {
|
|
console.error("[UNIT LIB][UPDATE]", err);
|
|
return R.error(res, "Could not update unit.", 500);
|
|
}
|
|
};
|
|
|
|
exports.getUnitArchiveImpact = async (req, res) => {
|
|
try {
|
|
const { unitId } = req.params;
|
|
|
|
const [completionCount, progressCount, courseCount] = await Promise.all([
|
|
UnitReadingProgress.count({ where: { unit_id: unitId, status: "completed" } }),
|
|
LessonReadingProgress.count({ where: { unit_id: unitId }, distinct: true, col: "user_id" }),
|
|
CourseUnit.count({ where: { unit_id: unitId } }),
|
|
]);
|
|
|
|
return R.success(res, "Impact retrieved.", { completionCount, progressCount, courseCount });
|
|
} catch (err) {
|
|
console.error("[UNIT LIB][ARCHIVE IMPACT]", err);
|
|
return R.error(res, "Could not retrieve impact.", 500);
|
|
}
|
|
};
|
|
|
|
exports.archiveUnit = async (req, res) => {
|
|
const t = await sequelize.transaction();
|
|
try {
|
|
const { unitId } = req.params;
|
|
const record = await archiveOne(Unit, { unit_id: unitId, ...notDeleted }, req.user.user_id, t);
|
|
if (!record) return R.error(res, "Unit not found.", 404);
|
|
await t.commit();
|
|
try { await recomputeParentCourseDurations(unitId); } catch (durErr) { console.error("[UNIT LIB][ARCHIVE][DURATION]", durErr); }
|
|
logActivity(req.user.user_id, "archive_unit", { entityType: "unit", entityId: Number(unitId) });
|
|
return R.success(res, "Unit archived.");
|
|
} catch (err) {
|
|
await t.rollback();
|
|
console.error("[UNIT LIB][ARCHIVE]", err);
|
|
return R.error(res, "Could not archive unit.", 500);
|
|
}
|
|
};
|
|
|
|
exports.bulkArchiveUnits = async (req, res) => {
|
|
const t = await sequelize.transaction();
|
|
try {
|
|
const { ids = [] } = req.body;
|
|
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
|
|
|
const units = await Unit.findAll({ where: { unit_id: ids, ...notDeleted } });
|
|
const validIds = units.map((u) => u.unit_id);
|
|
|
|
const count = await archiveMany(Unit, "unit_id", validIds, req.user.user_id, t);
|
|
await t.commit();
|
|
for (const id of validIds) {
|
|
try { await recomputeParentCourseDurations(id); } catch (durErr) { console.error("[UNIT LIB][BULK ARCHIVE][DURATION]", durErr); }
|
|
}
|
|
logActivity(req.user.user_id, "bulk_archive_units", { entityType: "unit", details: { ids: validIds, count } });
|
|
return R.success(res, `${count} unit${count !== 1 ? "s" : ""} archived.`);
|
|
} catch (err) {
|
|
await t.rollback();
|
|
console.error("[UNIT LIB][BULK ARCHIVE]", err);
|
|
return R.error(res, "Could not archive units.", 500);
|
|
}
|
|
};
|
|
|
|
exports.getArchivedUnits = async (req, res) => {
|
|
try {
|
|
const result = await paginate(Unit, req, {
|
|
auditOptions: { mdl_Users, parentAlias: "Unit" },
|
|
context: "archived",
|
|
findOptions: {
|
|
where: { ...onlyDeleted },
|
|
paranoid: false,
|
|
order: [["deletedAt", "DESC"]],
|
|
},
|
|
});
|
|
return R.success(res, "Archived units retrieved.", result);
|
|
} catch (err) {
|
|
console.error("[UNIT LIB][GET ARCHIVES]", err);
|
|
return R.error(res, "Could not retrieve archived units.", 500);
|
|
}
|
|
};
|
|
|
|
exports.getArchivedUnit = async (req, res) => {
|
|
try {
|
|
const { unitId } = req.params;
|
|
const unit = await Unit.findOne({
|
|
where: { unit_id: unitId, ...onlyDeleted },
|
|
paranoid: false,
|
|
});
|
|
if (!unit) return R.error(res, "Archived unit not found.", 404);
|
|
return R.success(res, "Archived unit retrieved.", { data: unit.toJSON() });
|
|
} catch (err) {
|
|
console.error("[UNIT LIB][GET ARCHIVE ONE]", err);
|
|
return R.error(res, "Could not retrieve archived unit.", 500);
|
|
}
|
|
};
|
|
|
|
exports.restoreUnit = async (req, res) => {
|
|
const t = await sequelize.transaction();
|
|
try {
|
|
const { unitId } = req.params;
|
|
const record = await restoreOne(Unit, { unit_id: unitId, ...onlyDeleted }, req.user.user_id, t);
|
|
if (!record) return R.error(res, "Archived unit not found.", 404);
|
|
await t.commit();
|
|
try { await recomputeParentCourseDurations(unitId); } catch (durErr) { console.error("[UNIT LIB][RESTORE][DURATION]", durErr); }
|
|
logActivity(req.user.user_id, "restore_unit", { entityType: "unit", entityId: Number(unitId) });
|
|
return R.success(res, "Unit restored.", { data: record });
|
|
} catch (err) {
|
|
await t.rollback();
|
|
console.error("[UNIT LIB][RESTORE]", err);
|
|
return R.error(res, "Could not restore unit.", 500);
|
|
}
|
|
};
|
|
|
|
exports.bulkRestoreUnits = async (req, res) => {
|
|
const t = await sequelize.transaction();
|
|
try {
|
|
const { ids = [] } = req.body;
|
|
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
|
|
|
const units = await Unit.findAll({ where: { unit_id: ids, ...onlyDeleted }, paranoid: false });
|
|
const validIds = units.map((u) => u.unit_id);
|
|
|
|
const count = await restoreMany(Unit, "unit_id", validIds, req.user.user_id, t);
|
|
await t.commit();
|
|
for (const id of validIds) {
|
|
try { await recomputeParentCourseDurations(id); } catch (durErr) { console.error("[UNIT LIB][BULK RESTORE][DURATION]", durErr); }
|
|
}
|
|
logActivity(req.user.user_id, "bulk_restore_units", { entityType: "unit", details: { ids: validIds, count } });
|
|
return R.success(res, `${count} unit${count !== 1 ? "s" : ""} restored.`);
|
|
} catch (err) {
|
|
await t.rollback();
|
|
console.error("[UNIT LIB][BULK RESTORE]", err);
|
|
return R.error(res, "Could not restore units.", 500);
|
|
}
|
|
};
|
|
|
|
exports.getUnitPermanentDeleteImpact = async (req, res) => {
|
|
try {
|
|
const { unitId } = req.params;
|
|
|
|
const [lessonCount, courseCount] = await Promise.all([
|
|
UnitLesson.count({ where: { unit_id: unitId } }),
|
|
CourseUnit.count({ where: { unit_id: unitId } }),
|
|
]);
|
|
|
|
return R.success(res, "Impact retrieved.", { lessonCount, courseCount });
|
|
} catch (err) {
|
|
console.error("[UNIT LIB][PERMANENT DELETE IMPACT]", err);
|
|
return R.error(res, "Could not retrieve impact.", 500);
|
|
}
|
|
};
|
|
|
|
exports.permanentlyDeleteUnit = async (req, res) => {
|
|
const t = await sequelize.transaction();
|
|
try {
|
|
const { unitId } = req.params;
|
|
const record = await permanentDeleteOne(Unit, { unit_id: unitId }, t);
|
|
if (record === null) return R.error(res, "Unit not found.", 404);
|
|
if (record === false) return R.error(res, "Unit must be archived before it can be permanently deleted.", 400);
|
|
|
|
// Junction rows don't cascade from a paranoid destroy — clean them explicitly
|
|
await CourseUnit.destroy({ where: { unit_id: unitId }, transaction: t });
|
|
await UnitLesson.destroy({ where: { unit_id: unitId }, transaction: t });
|
|
|
|
await t.commit();
|
|
logActivity(req.user.user_id, "permanently_delete_unit", { entityType: "unit", entityId: Number(unitId) });
|
|
return R.success(res, "Unit permanently deleted.");
|
|
} catch (err) {
|
|
await t.rollback();
|
|
console.error("[UNIT LIB][PERMANENT DELETE]", err);
|
|
return R.error(res, "Could not permanently delete unit.", 500);
|
|
}
|
|
};
|
|
|
|
exports.bulkPermanentlyDeleteUnits = async (req, res) => {
|
|
const t = await sequelize.transaction();
|
|
try {
|
|
const { ids = [] } = req.body;
|
|
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
|
|
|
const units = await Unit.findAll({ where: { unit_id: ids }, paranoid: false });
|
|
const validIds = units.map((u) => u.unit_id);
|
|
|
|
const count = await permanentDeleteMany(Unit, "unit_id", validIds, t);
|
|
await CourseUnit.destroy({ where: { unit_id: validIds }, transaction: t });
|
|
await UnitLesson.destroy({ where: { unit_id: validIds }, transaction: t });
|
|
|
|
await t.commit();
|
|
logActivity(req.user.user_id, "bulk_permanently_delete_units", { entityType: "unit", details: { ids: validIds, count } });
|
|
return R.success(res, `${count} unit${count !== 1 ? "s" : ""} permanently deleted.`);
|
|
} catch (err) {
|
|
await t.rollback();
|
|
console.error("[UNIT LIB][BULK PERMANENT DELETE]", err);
|
|
return R.error(res, "Could not permanently delete units.", 500);
|
|
}
|
|
};
|
|
|
|
exports.getUnitFieldValues = getFieldValues(Unit, "UNIT");
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
// UNIT ⇄ LESSON MEMBERSHIP (attach / detach / reorder)
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
// POST /admin/units/:unitId/lessons { lesson_ids: [..] } — append existing lessons
|
|
exports.attachLessons = async (req, res) => {
|
|
const t = await sequelize.transaction();
|
|
try {
|
|
const { unitId } = req.params;
|
|
const { lesson_ids = [] } = req.body;
|
|
if (!lesson_ids.length) return R.error(res, "lesson_ids is required.", 400);
|
|
|
|
const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, transaction: t });
|
|
if (!unit) return R.error(res, "Unit not found.", 404);
|
|
|
|
const lessons = await Lesson.findAll({ where: { lesson_id: lesson_ids, ...notDeleted }, transaction: t });
|
|
if (lessons.length !== lesson_ids.length) {
|
|
await t.rollback();
|
|
return R.error(res, "One or more lessons were not found.", 404);
|
|
}
|
|
|
|
const existing = await UnitLesson.findAll({ where: { unit_id: unitId, lesson_id: lesson_ids }, transaction: t });
|
|
const existingSet = new Set(existing.map((r) => String(r.lesson_id)));
|
|
const toAttach = lesson_ids.filter((id) => !existingSet.has(String(id)));
|
|
|
|
let order = await nextOrderIndex(UnitLesson, { unit_id: unitId }, t);
|
|
await UnitLesson.bulkCreate(
|
|
toAttach.map((lesson_id) => ({
|
|
unit_id: unitId,
|
|
lesson_id,
|
|
order_index: order++,
|
|
createdBy: req.user?.user_id ?? null,
|
|
})),
|
|
{ transaction: t }
|
|
);
|
|
|
|
await t.commit();
|
|
try { await recomputeUnitDuration(unitId); await recomputeParentCourseDurations(unitId); } catch (durErr) { console.error("[UNIT LIB][ATTACH LESSONS][DURATION]", durErr); }
|
|
logActivity(req.user?.user_id, "attach_lessons", { entityType: "unit", entityId: Number(unitId), details: { lesson_ids: toAttach } });
|
|
return R.success(res, `${toAttach.length} lesson${toAttach.length !== 1 ? "s" : ""} attached.`, { attached: toAttach, skipped: lesson_ids.filter((id) => existingSet.has(String(id))) });
|
|
} catch (err) {
|
|
await t.rollback();
|
|
console.error("[UNIT LIB][ATTACH LESSONS]", err);
|
|
return R.error(res, "Could not attach lessons.", 500);
|
|
}
|
|
};
|
|
|
|
// DELETE /admin/units/:unitId/lessons/:lessonId — detach (lesson survives in the library)
|
|
exports.detachLesson = async (req, res) => {
|
|
try {
|
|
const { unitId, lessonId } = req.params;
|
|
|
|
const removed = await UnitLesson.destroy({ where: { unit_id: unitId, lesson_id: lessonId } });
|
|
if (!removed) return R.error(res, "Lesson is not attached to this unit.", 404);
|
|
|
|
try { await recomputeUnitDuration(unitId); await recomputeParentCourseDurations(unitId); } catch (durErr) { console.error("[UNIT LIB][DETACH LESSON][DURATION]", durErr); }
|
|
logActivity(req.user?.user_id, "detach_lesson", { entityType: "unit", entityId: Number(unitId), details: { lesson_id: Number(lessonId) } });
|
|
return R.success(res, "Lesson detached.");
|
|
} catch (err) {
|
|
console.error("[UNIT LIB][DETACH LESSON]", err);
|
|
return R.error(res, "Could not detach lesson.", 500);
|
|
}
|
|
};
|
|
|
|
// PUT /admin/units/:unitId/lessons/order { lesson_ids: [orderedIds] }
|
|
exports.reorderLessons = async (req, res) => {
|
|
const t = await sequelize.transaction();
|
|
try {
|
|
const { unitId } = req.params;
|
|
const { lesson_ids = [] } = req.body;
|
|
if (!lesson_ids.length) return R.error(res, "lesson_ids is required.", 400);
|
|
|
|
const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, transaction: t });
|
|
if (!unit) return R.error(res, "Unit not found.", 404);
|
|
|
|
await reorderJunction(UnitLesson, "unit_id", unitId, "lesson_id", lesson_ids, t);
|
|
await t.commit();
|
|
logActivity(req.user?.user_id, "reorder_lessons", { entityType: "unit", entityId: Number(unitId), details: { lesson_ids } });
|
|
return R.success(res, "Lesson order updated.");
|
|
} catch (err) {
|
|
await t.rollback();
|
|
console.error("[UNIT LIB][REORDER LESSONS]", err);
|
|
return R.error(res, "Could not reorder lessons.", 500);
|
|
}
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
// UNIT QUIZ (1:1 with the Unit — travels with it into every attached course)
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
exports.getQuiz = async (req, res) => {
|
|
try {
|
|
const { unitId } = req.params;
|
|
|
|
const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted } });
|
|
if (!unit) return R.error(res, "Unit not found.", 404);
|
|
|
|
const quiz = await UnitQuiz.findOne({
|
|
where: { unit_id: unitId, ...notDeleted },
|
|
include: [{
|
|
model: QuizQuestion, as: "questions",
|
|
where: notDeleted, required: false,
|
|
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
|
|
}],
|
|
});
|
|
|
|
if (!quiz) return R.error(res, "Quiz not found.", 404);
|
|
return R.success(res, "Quiz retrieved.", { data: quiz });
|
|
} catch (err) {
|
|
console.error("[UNIT LIB][QUIZ][GET]", err);
|
|
return R.error(res, "Could not retrieve quiz.", 500);
|
|
}
|
|
};
|
|
|
|
exports.createQuiz = async (req, res) => {
|
|
try {
|
|
const { unitId } = req.params;
|
|
const { title, is_required, passing_score, max_questions, shuffle_questions, createdBy } = req.body;
|
|
|
|
const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted } });
|
|
if (!unit) return R.error(res, "Unit not found.", 404);
|
|
|
|
const existing = await UnitQuiz.findOne({ where: { unit_id: unitId, ...notDeleted } });
|
|
if (existing) return R.error(res, "Quiz already exists for this unit.", 409);
|
|
|
|
const quiz = await UnitQuiz.create({
|
|
unit_id: unitId,
|
|
title: title ?? null,
|
|
is_required: is_required ?? false,
|
|
passing_score: passing_score ?? 70,
|
|
max_questions: max_questions ?? null,
|
|
shuffle_questions: shuffle_questions ?? false,
|
|
createdBy: createdBy ?? req.user?.user_id ?? null,
|
|
});
|
|
|
|
logActivity(req.user?.user_id, "create_quiz", { entityType: "quiz", entityId: quiz.quiz_id });
|
|
return R.success(res, "Quiz created.", { data: quiz }, 201);
|
|
} catch (err) {
|
|
console.error("[UNIT LIB][QUIZ][CREATE]", err);
|
|
return R.error(res, "Could not create quiz.", 500);
|
|
}
|
|
};
|
|
|
|
exports.updateQuiz = async (req, res) => {
|
|
try {
|
|
const { unitId, quizId } = req.params;
|
|
const { title, is_required, passing_score, max_questions, shuffle_questions, updatedBy } = req.body;
|
|
|
|
const quiz = await UnitQuiz.findOne({ where: { quiz_id: quizId, unit_id: unitId, ...notDeleted } });
|
|
if (!quiz) return R.error(res, "Quiz not found.", 404);
|
|
|
|
if (title !== undefined) quiz.title = title;
|
|
if (is_required !== undefined) quiz.is_required = is_required;
|
|
if (passing_score !== undefined) quiz.passing_score = passing_score;
|
|
if (max_questions !== undefined) quiz.max_questions = max_questions;
|
|
if (shuffle_questions !== undefined) quiz.shuffle_questions = shuffle_questions;
|
|
quiz.updatedBy = updatedBy ?? req.user?.user_id ?? null;
|
|
|
|
await quiz.save();
|
|
logActivity(req.user?.user_id, "update_quiz", { entityType: "quiz", entityId: Number(quizId) });
|
|
return R.success(res, "Quiz updated.", { data: quiz });
|
|
} catch (err) {
|
|
console.error("[UNIT LIB][QUIZ][UPDATE]", err);
|
|
return R.error(res, "Could not update quiz.", 500);
|
|
}
|
|
};
|
|
|
|
exports.deleteQuiz = async (req, res) => {
|
|
const t = await sequelize.transaction();
|
|
try {
|
|
const { unitId, quizId } = req.params;
|
|
const record = await archiveOne(UnitQuiz, { quiz_id: quizId, unit_id: unitId, ...notDeleted }, req.user?.user_id, t);
|
|
if (!record) return R.error(res, "Quiz not found.", 404);
|
|
await t.commit();
|
|
logActivity(req.user?.user_id, "archive_quiz", { entityType: "quiz", entityId: Number(quizId) });
|
|
return R.success(res, "Quiz archived.");
|
|
} catch (err) {
|
|
await t.rollback();
|
|
console.error("[UNIT LIB][QUIZ][ARCHIVE]", err);
|
|
return R.error(res, "Could not archive quiz.", 500);
|
|
}
|
|
};
|
|
|
|
exports.getArchivedQuiz = async (req, res) => {
|
|
try {
|
|
const { unitId } = req.params;
|
|
|
|
const unit = await Unit.findOne({ where: { unit_id: unitId }, paranoid: false });
|
|
if (!unit) return R.error(res, "Unit not found.", 404);
|
|
|
|
const quiz = await UnitQuiz.findOne({
|
|
where: { unit_id: unitId, ...onlyDeleted },
|
|
paranoid: false,
|
|
});
|
|
if (!quiz) return R.error(res, "Archived quiz not found.", 404);
|
|
return R.success(res, "Archived quiz retrieved.", { data: quiz.toJSON() });
|
|
} catch (err) {
|
|
console.error("[UNIT LIB][QUIZ][GET ARCHIVE]", err);
|
|
return R.error(res, "Could not retrieve archived quiz.", 500);
|
|
}
|
|
};
|
|
|
|
exports.restoreQuiz = async (req, res) => {
|
|
const t = await sequelize.transaction();
|
|
try {
|
|
const { unitId, quizId } = req.params;
|
|
const record = await restoreOne(UnitQuiz, { quiz_id: quizId, unit_id: unitId, ...onlyDeleted }, req.user?.user_id, t);
|
|
if (!record) return R.error(res, "Archived quiz not found.", 404);
|
|
await t.commit();
|
|
logActivity(req.user?.user_id, "restore_quiz", { entityType: "quiz", entityId: Number(quizId) });
|
|
return R.success(res, "Quiz restored.", { data: record });
|
|
} catch (err) {
|
|
await t.rollback();
|
|
console.error("[UNIT LIB][QUIZ][RESTORE]", err);
|
|
return R.error(res, "Could not restore quiz.", 500);
|
|
}
|
|
};
|