mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -17,8 +17,9 @@
|
|||||||
const { Op } = require('sequelize');
|
const { Op } = require('sequelize');
|
||||||
const R = require('../../utils/response.util');
|
const R = require('../../utils/response.util');
|
||||||
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
|
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 mdl_Users = require('../../models/users/users.mdl');
|
||||||
|
const { countCourseLessons, getCourseUnitIds, flattenUnits } = require('../../utils/courses/hierarchy.util');
|
||||||
|
|
||||||
const notDeleted = { deletedAt: null };
|
const notDeleted = { deletedAt: null };
|
||||||
|
|
||||||
@@ -40,27 +41,13 @@ exports.getCourseReadingProgress = async (req, res) => {
|
|||||||
});
|
});
|
||||||
if (!course) return R.error(res, 'Course not found.', 404);
|
if (!course) return R.error(res, 'Course not found.', 404);
|
||||||
|
|
||||||
// Count total lessons and units in the course (structure totals)
|
// Count total lessons and units in the course (structure totals via junctions)
|
||||||
const [units, allLessons] = await Promise.all([
|
const [unitIds, lessons_total] = await Promise.all([
|
||||||
Unit.findAll({
|
getCourseUnitIds(courseId),
|
||||||
where: { course_id: courseId, ...notDeleted },
|
countCourseLessons(courseId),
|
||||||
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'],
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const units_total = units.length;
|
const units_total = unitIds.length;
|
||||||
const lessons_total = allLessons.length;
|
|
||||||
|
|
||||||
// All progress rows for this course, grouped per user
|
// All progress rows for this course, grouped per user
|
||||||
const rows = await CourseReadingProgress.findAll({
|
const rows = await CourseReadingProgress.findAll({
|
||||||
@@ -147,20 +134,26 @@ exports.getUserReadingProgress = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { courseId, userId } = req.params;
|
const { courseId, userId } = req.params;
|
||||||
|
|
||||||
const [units, progressRows] = await Promise.all([
|
const [unitRows, progressRows] = await Promise.all([
|
||||||
Unit.findAll({
|
Unit.findAll({
|
||||||
where: { course_id: courseId, ...notDeleted },
|
where: notDeleted,
|
||||||
attributes: ['unit_id', 'uuid', 'title', 'order_index'],
|
attributes: ['unit_id', 'uuid', 'title'],
|
||||||
include: [{
|
include: [
|
||||||
|
{
|
||||||
|
model: CourseUnit,
|
||||||
|
as: 'courseLinks',
|
||||||
|
where: { course_id: courseId },
|
||||||
|
required: true,
|
||||||
|
attributes: ['order_index'],
|
||||||
|
},
|
||||||
|
{
|
||||||
model: Lesson,
|
model: Lesson,
|
||||||
as: 'lessons',
|
as: 'lessons',
|
||||||
where: notDeleted,
|
where: notDeleted,
|
||||||
required: false,
|
required: false,
|
||||||
attributes: ['lesson_id', 'uuid', 'title', 'order_index'],
|
attributes: ['lesson_id', 'uuid', 'title'],
|
||||||
}],
|
through: { attributes: ['order_index'] },
|
||||||
order: [
|
},
|
||||||
['order_index', 'ASC'],
|
|
||||||
[{ model: Lesson, as: 'lessons' }, 'order_index', 'ASC'],
|
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
CourseReadingProgress.findAll({
|
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 }
|
// Build a quick lookup: { [reference_id (uuid)]: status }
|
||||||
const progressMap = Object.fromEntries(
|
const progressMap = Object.fromEntries(
|
||||||
progressRows.map((r) => [r.reference_id, { status: r.status, completed_at: r.completed_at }])
|
progressRows.map((r) => [r.reference_id, { status: r.status, completed_at: r.completed_at }])
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,39 @@
|
|||||||
"use strict";
|
"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 { Op } = require("sequelize");
|
||||||
const sequelize = require("../../config/db.config");
|
const sequelize = require("../../config/db.config");
|
||||||
const R = require("../../utils/response.util");
|
const R = require("../../utils/response.util");
|
||||||
const { paginate } = require("../../utils/paginate.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 { syncObjectivesCreate, syncObjectivesUpdate } = require("../../utils/courses/objectives.util");
|
||||||
const { archiveOne, archiveMany } = require("../../utils/courses/archive.util");
|
const { archiveOne, archiveMany } = require("../../utils/courses/archive.util");
|
||||||
const { restoreOne, restoreMany } = require("../../utils/courses/restore.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 ────────────────────────────────────────────────────────────────────
|
// ── Models ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const {
|
const {
|
||||||
Unit, Lesson, LessonPage,
|
Unit, Lesson, LessonPage,
|
||||||
|
CourseUnit, UnitLesson,
|
||||||
LessonObjective,
|
LessonObjective,
|
||||||
CourseAssessment, UnitQuiz,
|
|
||||||
QuizQuestion, QuizOption,
|
|
||||||
} = require("../../models/courses/courses.associations");
|
} = require("../../models/courses/courses.associations");
|
||||||
|
|
||||||
const mdl_Users = require("../../models/users/users.mdl");
|
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 notDeleted = { deletedAt: null };
|
||||||
const onlyDeleted = { deletedAt: { [Op.not]: null } };
|
const onlyDeleted = { deletedAt: { [Op.not]: null } };
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// Refresh every unit this lesson is attached to + the courses above them.
|
||||||
|
async function recomputeParentDurations(lessonId) {
|
||||||
async function resolveQuestionParent(params) {
|
const links = await UnitLesson.findAll({ where: { lesson_id: lessonId }, attributes: ["unit_id"] });
|
||||||
const { quizId, assessmentId } = params;
|
const unitIds = [...new Set(links.map((l) => String(l.unit_id)))];
|
||||||
if (quizId) {
|
for (const unitId of unitIds) await recomputeUnitDuration(unitId);
|
||||||
const rec = await UnitQuiz.findOne({ where: { quiz_id: quizId, ...notDeleted } });
|
if (unitIds.length) {
|
||||||
return { parentField: "quiz_id", parentId: quizId, parentRecord: rec };
|
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) => {
|
exports.getLessons = async (req, res) => {
|
||||||
try {
|
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, {
|
const result = await paginate(Lesson, req, {
|
||||||
auditOptions: { mdl_Users, parentAlias: "Lesson" },
|
auditOptions: { mdl_Users, parentAlias: "Lesson" },
|
||||||
context: "list",
|
context: "list",
|
||||||
|
computedAttributes: LESSON_LIST_COMPUTED,
|
||||||
findOptions: {
|
findOptions: {
|
||||||
where: { unit_id: unitId, ...notDeleted },
|
where: { ...notDeleted },
|
||||||
order: [["order_index", "ASC"]],
|
order: [["createdAt", "DESC"]],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return R.success(res, "Lessons retrieved.", result);
|
return R.success(res, "Lessons retrieved.", result);
|
||||||
} catch (err) {
|
} 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);
|
return R.error(res, "Could not retrieve lessons.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.getLesson = async (req, res) => {
|
exports.getLesson = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { courseId, unitId, lessonId } = req.params;
|
const { lessonId } = req.params;
|
||||||
|
|
||||||
const lesson = await Lesson.findOne({
|
const lesson = await Lesson.findOne({
|
||||||
where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted },
|
where: { lesson_id: lessonId, ...notDeleted },
|
||||||
include: [
|
include: [
|
||||||
{ model: LessonPage, as: "page", required: false },
|
{ model: LessonPage, as: "page", required: false },
|
||||||
{ model: LessonObjective, as: "objectives", required: false, order: [["order_index", "ASC"]] },
|
{ 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);
|
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() });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[LESSON][GET ONE]", err);
|
console.error("[LESSON LIB][GET ONE]", err);
|
||||||
return R.error(res, "Could not retrieve lesson.", 500);
|
return R.error(res, "Could not retrieve lesson.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -89,36 +134,46 @@ exports.getLesson = async (req, res) => {
|
|||||||
exports.createLesson = async (req, res) => {
|
exports.createLesson = async (req, res) => {
|
||||||
const t = await sequelize.transaction();
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const { courseId, unitId } = req.params;
|
const { title, description, unit_id, order, objectives = [], createdBy } = req.body;
|
||||||
const { title, description, order, objectives = [], createdBy } = req.body;
|
|
||||||
|
|
||||||
if (!title) return R.error(res, "Title is required.", 400);
|
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({
|
const lesson = await Lesson.create({
|
||||||
unit_id: unitId,
|
|
||||||
title,
|
title,
|
||||||
description: description ?? null,
|
description: description ?? null,
|
||||||
order_index: order ?? 0,
|
|
||||||
duration_seconds: 0,
|
duration_seconds: 0,
|
||||||
createdBy: createdBy ?? null,
|
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||||||
}, { transaction: t });
|
}, { transaction: t });
|
||||||
|
|
||||||
await LessonPage.create({
|
await LessonPage.create({
|
||||||
lesson_id: lesson.lesson_id,
|
lesson_id: lesson.lesson_id,
|
||||||
blocks: [],
|
blocks: [],
|
||||||
createdBy: createdBy ?? null,
|
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||||||
}, { transaction: t });
|
}, { transaction: t });
|
||||||
|
|
||||||
await syncObjectivesCreate(LessonObjective, "lesson_id", lesson.lesson_id, objectives, 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();
|
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);
|
return R.success(res, "Lesson created.", { data: lesson }, 201);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await t.rollback();
|
await t.rollback();
|
||||||
console.error("[LESSON][CREATE]", err);
|
console.error("[LESSON LIB][CREATE]", err);
|
||||||
return R.error(res, "Could not create lesson.", 500);
|
return R.error(res, "Could not create lesson.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -126,20 +181,16 @@ exports.createLesson = async (req, res) => {
|
|||||||
exports.updateLesson = async (req, res) => {
|
exports.updateLesson = async (req, res) => {
|
||||||
const t = await sequelize.transaction();
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const { courseId, unitId, lessonId } = req.params;
|
const { lessonId } = req.params;
|
||||||
|
|
||||||
const lesson = await Lesson.findOne({
|
const lesson = await Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted } });
|
||||||
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);
|
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 (title !== undefined) lesson.title = title;
|
||||||
if (description !== undefined) lesson.description = description;
|
if (description !== undefined) lesson.description = description;
|
||||||
if (order !== undefined) lesson.order_index = order;
|
lesson.updatedBy = updatedBy ?? req.user?.user_id ?? null;
|
||||||
lesson.updatedBy = updatedBy ?? null;
|
|
||||||
await lesson.save({ transaction: t });
|
await lesson.save({ transaction: t });
|
||||||
|
|
||||||
if (objectives !== undefined) {
|
if (objectives !== undefined) {
|
||||||
@@ -153,33 +204,28 @@ exports.updateLesson = async (req, res) => {
|
|||||||
include: [{ model: LessonObjective, as: "objectives", order: [["order_index", "ASC"]] }],
|
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 });
|
return R.success(res, "Lesson updated.", { data: updated });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await t.rollback();
|
await t.rollback();
|
||||||
console.error("[LESSON][UPDATE]", err);
|
console.error("[LESSON LIB][UPDATE]", err);
|
||||||
return R.error(res, "Could not update lesson.", 500);
|
return R.error(res, "Could not update lesson.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.deleteLesson = async (req, res) => {
|
exports.archiveLesson = async (req, res) => {
|
||||||
const t = await sequelize.transaction();
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const { courseId, unitId, lessonId } = req.params;
|
const { lessonId } = req.params;
|
||||||
|
const record = await archiveOne(Lesson, { lesson_id: lessonId, ...notDeleted }, req.user.user_id, t);
|
||||||
const lesson = await Lesson.findOne({
|
if (!record) return R.error(res, "Lesson not found.", 404);
|
||||||
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 });
|
|
||||||
|
|
||||||
await t.commit();
|
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.");
|
return R.success(res, "Lesson archived.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await t.rollback();
|
await t.rollback();
|
||||||
console.error("[LESSON][DELETE]", err);
|
console.error("[LESSON LIB][ARCHIVE]", err);
|
||||||
return R.error(res, "Could not archive lesson.", 500);
|
return R.error(res, "Could not archive lesson.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -187,71 +233,55 @@ exports.deleteLesson = async (req, res) => {
|
|||||||
exports.bulkArchiveLessons = async (req, res) => {
|
exports.bulkArchiveLessons = async (req, res) => {
|
||||||
const t = await sequelize.transaction();
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const { courseId, unitId } = req.params;
|
const { ids = [] } = req.body;
|
||||||
const { ids = [], deletedBy } = req.body;
|
|
||||||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||||||
|
|
||||||
const lessons = await Lesson.findAll({
|
const lessons = await Lesson.findAll({ where: { lesson_id: ids, ...notDeleted } });
|
||||||
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 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();
|
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.`);
|
return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} archived.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await t.rollback();
|
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);
|
return R.error(res, "Could not archive lessons.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.getArchivedLessons = async (req, res) => {
|
exports.getArchivedLessons = async (req, res) => {
|
||||||
try {
|
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, {
|
const result = await paginate(Lesson, req, {
|
||||||
auditOptions: { mdl_Users, parentAlias: "Lesson" },
|
auditOptions: { mdl_Users, parentAlias: "Lesson" },
|
||||||
context: "list",
|
context: "archived",
|
||||||
findOptions: {
|
findOptions: {
|
||||||
where: { unit_id: unitId, ...onlyDeleted },
|
where: { ...onlyDeleted },
|
||||||
paranoid: false,
|
paranoid: false,
|
||||||
order: [["order_index", "ASC"]],
|
order: [["deletedAt", "DESC"]],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return R.success(res, "Archived lessons retrieved.", result);
|
return R.success(res, "Archived lessons retrieved.", result);
|
||||||
} catch (err) {
|
} 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);
|
return R.error(res, "Could not retrieve archived lessons.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.getArchivedLesson = async (req, res) => {
|
exports.getArchivedLesson = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { courseId, unitId, lessonId } = req.params;
|
const { 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 lesson = await Lesson.findOne({
|
const lesson = await Lesson.findOne({
|
||||||
where: { lesson_id: lessonId, unit_id: unitId, ...onlyDeleted },
|
where: { lesson_id: lessonId, ...onlyDeleted },
|
||||||
paranoid: false,
|
paranoid: false,
|
||||||
});
|
});
|
||||||
if (!lesson) return R.error(res, "Archived lesson not found.", 404);
|
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() });
|
||||||
} catch (err) {
|
} 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);
|
return R.error(res, "Could not retrieve archived lesson.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -259,26 +289,16 @@ exports.getArchivedLesson = async (req, res) => {
|
|||||||
exports.restoreLesson = async (req, res) => {
|
exports.restoreLesson = async (req, res) => {
|
||||||
const t = await sequelize.transaction();
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const { courseId, unitId, lessonId } = req.params;
|
const { lessonId } = req.params;
|
||||||
|
const record = await restoreOne(Lesson, { lesson_id: lessonId, ...onlyDeleted }, req.user.user_id, t);
|
||||||
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
|
|
||||||
);
|
|
||||||
if (!record) return R.error(res, "Archived lesson not found.", 404);
|
if (!record) return R.error(res, "Archived lesson not found.", 404);
|
||||||
await t.commit();
|
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 });
|
return R.success(res, "Lesson restored.", { data: record });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await t.rollback();
|
await t.rollback();
|
||||||
console.error("[LESSON][RESTORE]", err);
|
console.error("[LESSON LIB][RESTORE]", err);
|
||||||
return R.error(res, "Could not restore lesson.", 500);
|
return R.error(res, "Could not restore lesson.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -286,34 +306,83 @@ exports.restoreLesson = async (req, res) => {
|
|||||||
exports.bulkRestoreLessons = async (req, res) => {
|
exports.bulkRestoreLessons = async (req, res) => {
|
||||||
const t = await sequelize.transaction();
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const { courseId, unitId } = req.params;
|
const { ids = [] } = req.body;
|
||||||
const { ids = [], restoredBy } = req.body;
|
|
||||||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||||||
|
|
||||||
const unit = await Unit.findOne({
|
const lessons = await Lesson.findAll({ where: { lesson_id: ids, ...onlyDeleted }, paranoid: false });
|
||||||
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 validIds = lessons.map((l) => l.lesson_id);
|
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();
|
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.`);
|
return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} restored.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await t.rollback();
|
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);
|
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) => {
|
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);
|
if (!page) return R.error(res, "Lesson page not found.", 404);
|
||||||
return R.success(res, "Lesson page retrieved.", { data: page });
|
return R.success(res, "Lesson page retrieved.", { data: page });
|
||||||
} catch (err) {
|
} 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);
|
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({
|
const [page, created] = await LessonPage.upsert({
|
||||||
lesson_id: lessonId,
|
lesson_id: lessonId,
|
||||||
blocks,
|
blocks,
|
||||||
updatedBy: req.body.updatedBy ?? null,
|
updatedBy: req.body.updatedBy ?? req.user?.user_id ?? null,
|
||||||
createdBy: req.body.updatedBy ?? null,
|
createdBy: req.body.updatedBy ?? req.user?.user_id ?? null,
|
||||||
}, { returning: true });
|
}, { returning: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await recomputeDurations(lessonId);
|
await recomputeDurations(lessonId);
|
||||||
} catch (durErr) {
|
} 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(
|
return R.success(
|
||||||
res,
|
res,
|
||||||
created ? "Lesson page created." : "Lesson page updated.",
|
created ? "Lesson page created." : "Lesson page updated.",
|
||||||
@@ -358,153 +428,7 @@ exports.upsertLessonPage = async (req, res) => {
|
|||||||
created ? 201 : 200,
|
created ? 201 : 200,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} 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);
|
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,17 +1,40 @@
|
|||||||
"use strict";
|
"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 { Op } = require("sequelize");
|
||||||
const sequelize = require("../../config/db.config");
|
const sequelize = require("../../config/db.config");
|
||||||
const R = require("../../utils/response.util");
|
const R = require("../../utils/response.util");
|
||||||
const { paginate } = require("../../utils/paginate.util");
|
const { paginate } = require("../../utils/paginate.util");
|
||||||
const { archiveOne, archiveMany } = require("../../utils/courses/archive.util");
|
const { archiveOne, archiveMany } = require("../../utils/courses/archive.util");
|
||||||
const { restoreOne, restoreMany } = require("../../utils/courses/restore.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 ────────────────────────────────────────────────────────────────────
|
// ── Models ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const {
|
const {
|
||||||
Course, Unit, Lesson,
|
Course, Unit, Lesson,
|
||||||
|
CourseUnit, UnitLesson,
|
||||||
UnitQuiz, QuizQuestion, QuizOption,
|
UnitQuiz, QuizQuestion, QuizOption,
|
||||||
|
UnitReadingProgress, LessonReadingProgress,
|
||||||
} = require("../../models/courses/courses.associations");
|
} = require("../../models/courses/courses.associations");
|
||||||
|
|
||||||
const mdl_Users = require("../../models/users/users.mdl");
|
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 notDeleted = { deletedAt: null };
|
||||||
const onlyDeleted = { deletedAt: { [Op.not]: null } };
|
const onlyDeleted = { deletedAt: { [Op.not]: null } };
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
// Refresh duration of every course this unit is attached to (post-archive/restore).
|
||||||
// UNIT
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
exports.getUnits = async (req, res) => {
|
const UNIT_LIST_COMPUTED = [
|
||||||
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",
|
|
||||||
findOptions: {
|
|
||||||
where: { course_id: courseId, ...notDeleted },
|
|
||||||
order: [["order_index", "ASC"]],
|
|
||||||
},
|
|
||||||
computedAttributes: [
|
|
||||||
{
|
{
|
||||||
key: "quiz_id",
|
key: "quiz_id",
|
||||||
label: "Quiz ID",
|
label: "Quiz ID",
|
||||||
type: "text",
|
type: "text",
|
||||||
literal: `(SELECT quiz_id FROM unit_quizzes WHERE unit_id = "Unit"."unit_id" AND "deletedAt" IS NULL LIMIT 1)`,
|
literal: `(SELECT quiz_id FROM unit_quizzes WHERE unit_id = "Unit"."unit_id" AND "deletedAt" IS NULL LIMIT 1)`,
|
||||||
},
|
},
|
||||||
],
|
{
|
||||||
});
|
key: "lesson_count",
|
||||||
|
label: "Lessons",
|
||||||
|
type: "number",
|
||||||
|
literal: `(
|
||||||
|
SELECT CAST(COUNT(*) AS INTEGER)
|
||||||
|
FROM unit_lessons ul
|
||||||
|
JOIN lessons l ON l.lesson_id = ul.lesson_id AND l."deletedAt" IS NULL
|
||||||
|
WHERE ul.unit_id = "Unit"."unit_id"
|
||||||
|
)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "course_count",
|
||||||
|
label: "Used in courses",
|
||||||
|
type: "number",
|
||||||
|
literal: `(
|
||||||
|
SELECT CAST(COUNT(*) AS INTEGER)
|
||||||
|
FROM course_units cu
|
||||||
|
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||||||
|
WHERE cu.unit_id = "Unit"."unit_id"
|
||||||
|
)`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// UNIT LIBRARY
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
exports.getUnits = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await paginate(Unit, req, {
|
||||||
|
auditOptions: { mdl_Users, parentAlias: "Unit" },
|
||||||
|
context: "list",
|
||||||
|
computedAttributes: UNIT_LIST_COMPUTED,
|
||||||
|
findOptions: {
|
||||||
|
where: { ...notDeleted },
|
||||||
|
order: [["createdAt", "DESC"]],
|
||||||
|
},
|
||||||
|
});
|
||||||
return R.success(res, "Units retrieved.", result);
|
return R.success(res, "Units retrieved.", result);
|
||||||
} catch (err) {
|
} 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);
|
return R.error(res, "Could not retrieve units.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.getUnit = async (req, res) => {
|
exports.getUnit = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { courseId, unitId } = req.params;
|
const { unitId } = req.params;
|
||||||
|
|
||||||
const unit = await Unit.findOne({
|
const unit = await Unit.findOne({
|
||||||
where: { unit_id: unitId, course_id: courseId, ...notDeleted },
|
where: { unit_id: unitId, ...notDeleted },
|
||||||
include: [
|
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: 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);
|
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) {
|
} catch (err) {
|
||||||
console.error("[UNIT][GET ONE]", err);
|
console.error("[UNIT LIB][GET ONE]", err);
|
||||||
return R.error(res, "Could not retrieve unit.", 500);
|
return R.error(res, "Could not retrieve unit.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.createUnit = async (req, res) => {
|
exports.createUnit = async (req, res) => {
|
||||||
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const { courseId } = req.params;
|
const { title, description, course_id, order, createdBy } = req.body;
|
||||||
const { title, description, order, createdBy } = req.body;
|
|
||||||
|
|
||||||
if (!title) return R.error(res, "Title is required.", 400);
|
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({
|
const unit = await Unit.create({
|
||||||
course_id: courseId,
|
|
||||||
title,
|
title,
|
||||||
description: description ?? null,
|
description: description ?? null,
|
||||||
order_index: order ?? 0,
|
|
||||||
duration_seconds: 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);
|
return R.success(res, "Unit created.", { data: unit }, 201);
|
||||||
} catch (err) {
|
} 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);
|
return R.error(res, "Could not create unit.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.updateUnit = async (req, res) => {
|
exports.updateUnit = async (req, res) => {
|
||||||
try {
|
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);
|
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 (title !== undefined) unit.title = title;
|
||||||
if (description !== undefined) unit.description = description;
|
if (description !== undefined) unit.description = description;
|
||||||
if (order !== undefined) unit.order_index = order;
|
unit.updatedBy = updatedBy ?? req.user?.user_id ?? null;
|
||||||
unit.updatedBy = updatedBy ?? null;
|
|
||||||
|
|
||||||
await unit.save();
|
await unit.save();
|
||||||
|
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 });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[UNIT][UPDATE]", err);
|
console.error("[UNIT LIB][UPDATE]", err);
|
||||||
return R.error(res, "Could not update unit.", 500);
|
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();
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const { courseId, unitId } = req.params;
|
const { unitId } = req.params;
|
||||||
const record = await archiveOne(
|
const record = await archiveOne(Unit, { unit_id: unitId, ...notDeleted }, req.user.user_id, t);
|
||||||
Unit,
|
|
||||||
{ unit_id: unitId, course_id: courseId, ...notDeleted },
|
|
||||||
req.body.deletedBy,
|
|
||||||
t
|
|
||||||
);
|
|
||||||
if (!record) return R.error(res, "Unit not found.", 404);
|
if (!record) return R.error(res, "Unit not found.", 404);
|
||||||
await t.commit();
|
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.");
|
return R.success(res, "Unit archived.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await t.rollback();
|
await t.rollback();
|
||||||
console.error("[UNIT][DELETE]", err);
|
console.error("[UNIT LIB][ARCHIVE]", err);
|
||||||
return R.error(res, "Could not archive unit.", 500);
|
return R.error(res, "Could not archive unit.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -146,60 +248,55 @@ exports.deleteUnit = async (req, res) => {
|
|||||||
exports.bulkArchiveUnits = async (req, res) => {
|
exports.bulkArchiveUnits = async (req, res) => {
|
||||||
const t = await sequelize.transaction();
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const { courseId } = req.params;
|
const { ids = [] } = req.body;
|
||||||
const { ids = [], deletedBy } = req.body;
|
|
||||||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||||||
|
|
||||||
const units = await Unit.findAll({
|
const units = await Unit.findAll({ where: { unit_id: ids, ...notDeleted } });
|
||||||
where: { unit_id: ids, course_id: courseId, ...notDeleted },
|
|
||||||
});
|
|
||||||
const validIds = units.map((u) => u.unit_id);
|
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();
|
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.`);
|
return R.success(res, `${count} unit${count !== 1 ? "s" : ""} archived.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await t.rollback();
|
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);
|
return R.error(res, "Could not archive units.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.getArchivedUnits = async (req, res) => {
|
exports.getArchivedUnits = async (req, res) => {
|
||||||
try {
|
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, {
|
const result = await paginate(Unit, req, {
|
||||||
auditOptions: { mdl_Users, parentAlias: "Unit" },
|
auditOptions: { mdl_Users, parentAlias: "Unit" },
|
||||||
context: "list",
|
context: "archived",
|
||||||
findOptions: {
|
findOptions: {
|
||||||
where: { course_id: courseId, ...onlyDeleted },
|
where: { ...onlyDeleted },
|
||||||
paranoid: false,
|
paranoid: false,
|
||||||
order: [["order_index", "ASC"]],
|
order: [["deletedAt", "DESC"]],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return R.success(res, "Archived units retrieved.", result);
|
return R.success(res, "Archived units retrieved.", result);
|
||||||
} catch (err) {
|
} 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);
|
return R.error(res, "Could not retrieve archived units.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.getArchivedUnit = async (req, res) => {
|
exports.getArchivedUnit = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { courseId, unitId } = req.params;
|
const { unitId } = req.params;
|
||||||
const unit = await Unit.findOne({
|
const unit = await Unit.findOne({
|
||||||
where: { unit_id: unitId, course_id: courseId, ...onlyDeleted },
|
where: { unit_id: unitId, ...onlyDeleted },
|
||||||
paranoid: false,
|
paranoid: false,
|
||||||
});
|
});
|
||||||
if (!unit) return R.error(res, "Archived unit not found.", 404);
|
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() });
|
||||||
} catch (err) {
|
} 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);
|
return R.error(res, "Could not retrieve archived unit.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -207,19 +304,16 @@ exports.getArchivedUnit = async (req, res) => {
|
|||||||
exports.restoreUnit = async (req, res) => {
|
exports.restoreUnit = async (req, res) => {
|
||||||
const t = await sequelize.transaction();
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const { courseId, unitId } = req.params;
|
const { unitId } = req.params;
|
||||||
const record = await restoreOne(
|
const record = await restoreOne(Unit, { unit_id: unitId, ...onlyDeleted }, req.user.user_id, t);
|
||||||
Unit,
|
|
||||||
{ unit_id: unitId, course_id: courseId, ...onlyDeleted },
|
|
||||||
req.body.restoredBy,
|
|
||||||
t
|
|
||||||
);
|
|
||||||
if (!record) return R.error(res, "Archived unit not found.", 404);
|
if (!record) return R.error(res, "Archived unit not found.", 404);
|
||||||
await t.commit();
|
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 });
|
return R.success(res, "Unit restored.", { data: record });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await t.rollback();
|
await t.rollback();
|
||||||
console.error("[UNIT][RESTORE]", err);
|
console.error("[UNIT LIB][RESTORE]", err);
|
||||||
return R.error(res, "Could not restore unit.", 500);
|
return R.error(res, "Could not restore unit.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -227,35 +321,184 @@ exports.restoreUnit = async (req, res) => {
|
|||||||
exports.bulkRestoreUnits = async (req, res) => {
|
exports.bulkRestoreUnits = async (req, res) => {
|
||||||
const t = await sequelize.transaction();
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const { courseId } = req.params;
|
const { ids = [] } = req.body;
|
||||||
const { ids = [], restoredBy } = req.body;
|
|
||||||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||||||
|
|
||||||
const units = await Unit.findAll({
|
const units = await Unit.findAll({ where: { unit_id: ids, ...onlyDeleted }, paranoid: false });
|
||||||
where: { unit_id: ids, course_id: courseId, ...onlyDeleted },
|
|
||||||
paranoid: false,
|
|
||||||
});
|
|
||||||
const validIds = units.map((u) => u.unit_id);
|
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();
|
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.`);
|
return R.success(res, `${count} unit${count !== 1 ? "s" : ""} restored.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await t.rollback();
|
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);
|
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) => {
|
exports.getQuiz = async (req, res) => {
|
||||||
try {
|
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);
|
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||||
|
|
||||||
const quiz = await UnitQuiz.findOne({
|
const quiz = await UnitQuiz.findOne({
|
||||||
@@ -270,17 +513,17 @@ exports.getQuiz = async (req, res) => {
|
|||||||
if (!quiz) return R.error(res, "Quiz not found.", 404);
|
if (!quiz) return R.error(res, "Quiz not found.", 404);
|
||||||
return R.success(res, "Quiz retrieved.", { data: quiz });
|
return R.success(res, "Quiz retrieved.", { data: quiz });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[QUIZ][GET]", err);
|
console.error("[UNIT LIB][QUIZ][GET]", err);
|
||||||
return R.error(res, "Could not retrieve quiz.", 500);
|
return R.error(res, "Could not retrieve quiz.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.createQuiz = async (req, res) => {
|
exports.createQuiz = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { courseId, unitId } = req.params;
|
const { unitId } = req.params;
|
||||||
const { title, is_required, passing_score, createdBy } = req.body;
|
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);
|
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||||
|
|
||||||
const existing = await UnitQuiz.findOne({ where: { unit_id: unitId, ...notDeleted } });
|
const existing = await UnitQuiz.findOne({ where: { unit_id: unitId, ...notDeleted } });
|
||||||
@@ -291,20 +534,23 @@ exports.createQuiz = async (req, res) => {
|
|||||||
title: title ?? null,
|
title: title ?? null,
|
||||||
is_required: is_required ?? false,
|
is_required: is_required ?? false,
|
||||||
passing_score: passing_score ?? 70,
|
passing_score: passing_score ?? 70,
|
||||||
createdBy: createdBy ?? null,
|
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);
|
return R.success(res, "Quiz created.", { data: quiz }, 201);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[QUIZ][CREATE]", err);
|
console.error("[UNIT LIB][QUIZ][CREATE]", err);
|
||||||
return R.error(res, "Could not create quiz.", 500);
|
return R.error(res, "Could not create quiz.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.updateQuiz = async (req, res) => {
|
exports.updateQuiz = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { courseId, unitId, quizId } = req.params;
|
const { unitId, quizId } = req.params;
|
||||||
const { title, is_required, passing_score, updatedBy } = req.body;
|
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 } });
|
const quiz = await UnitQuiz.findOne({ where: { quiz_id: quizId, unit_id: unitId, ...notDeleted } });
|
||||||
if (!quiz) return R.error(res, "Quiz not found.", 404);
|
if (!quiz) return R.error(res, "Quiz not found.", 404);
|
||||||
@@ -312,12 +558,15 @@ exports.updateQuiz = async (req, res) => {
|
|||||||
if (title !== undefined) quiz.title = title;
|
if (title !== undefined) quiz.title = title;
|
||||||
if (is_required !== undefined) quiz.is_required = is_required;
|
if (is_required !== undefined) quiz.is_required = is_required;
|
||||||
if (passing_score !== undefined) quiz.passing_score = passing_score;
|
if (passing_score !== undefined) quiz.passing_score = passing_score;
|
||||||
quiz.updatedBy = updatedBy ?? null;
|
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();
|
await quiz.save();
|
||||||
|
logActivity(req.user?.user_id, "update_quiz", { entityType: "quiz", entityId: Number(quizId) });
|
||||||
return R.success(res, "Quiz updated.", { data: quiz });
|
return R.success(res, "Quiz updated.", { data: quiz });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[QUIZ][UPDATE]", err);
|
console.error("[UNIT LIB][QUIZ][UPDATE]", err);
|
||||||
return R.error(res, "Could not update quiz.", 500);
|
return R.error(res, "Could not update quiz.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -326,18 +575,49 @@ exports.deleteQuiz = async (req, res) => {
|
|||||||
const t = await sequelize.transaction();
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const { unitId, quizId } = req.params;
|
const { unitId, quizId } = req.params;
|
||||||
const record = await archiveOne(
|
const record = await archiveOne(UnitQuiz, { quiz_id: quizId, unit_id: unitId, ...notDeleted }, req.user?.user_id, t);
|
||||||
UnitQuiz,
|
|
||||||
{ quiz_id: quizId, unit_id: unitId, ...notDeleted },
|
|
||||||
req.body.deletedBy,
|
|
||||||
t
|
|
||||||
);
|
|
||||||
if (!record) return R.error(res, "Quiz not found.", 404);
|
if (!record) return R.error(res, "Quiz not found.", 404);
|
||||||
await t.commit();
|
await t.commit();
|
||||||
|
logActivity(req.user?.user_id, "archive_quiz", { entityType: "quiz", entityId: Number(quizId) });
|
||||||
return R.success(res, "Quiz archived.");
|
return R.success(res, "Quiz archived.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await t.rollback();
|
await t.rollback();
|
||||||
console.error("[QUIZ][DELETE]", err);
|
console.error("[UNIT LIB][QUIZ][ARCHIVE]", err);
|
||||||
return R.error(res, "Could not archive quiz.", 500);
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -35,8 +35,10 @@ const CourseReadingProgress = require('../../models/courses/course_readi
|
|||||||
const Certificate = require('../../models/courses/certificate.mdl');
|
const Certificate = require('../../models/courses/certificate.mdl');
|
||||||
const {
|
const {
|
||||||
Course, Unit, Lesson,
|
Course, Unit, Lesson,
|
||||||
|
CourseUnit, UnitLesson,
|
||||||
UnitQuiz, CourseAssessment, QuizAttempt,
|
UnitQuiz, CourseAssessment, QuizAttempt,
|
||||||
} = require('../../models/courses/courses.associations');
|
} = require('../../models/courses/courses.associations');
|
||||||
|
const { countCourseLessons, getCourseUnitIds, flattenUnits } = require('../../utils/courses/hierarchy.util');
|
||||||
|
|
||||||
const { Task, TaskRequirement, TaskListGroup } = require('../../models/task/task.mdl');
|
const { Task, TaskRequirement, TaskListGroup } = require('../../models/task/task.mdl');
|
||||||
const { TaskProgress } = require('../../models/task/task_progress.mdl');
|
const { TaskProgress } = require('../../models/task/task_progress.mdl');
|
||||||
@@ -192,15 +194,7 @@ exports.getMyInProgressCourses = async (req, res) => {
|
|||||||
const courseId = row.course_id;
|
const courseId = row.course_id;
|
||||||
|
|
||||||
const [lessons_total, lessons_completed] = await Promise.all([
|
const [lessons_total, lessons_completed] = await Promise.all([
|
||||||
Lesson.count({
|
countCourseLessons(courseId),
|
||||||
include: [{
|
|
||||||
model: Unit,
|
|
||||||
as: 'unit',
|
|
||||||
where: { course_id: courseId, ...notDeleted },
|
|
||||||
required: true,
|
|
||||||
}],
|
|
||||||
where: notDeleted,
|
|
||||||
}),
|
|
||||||
CourseReadingProgress.count({
|
CourseReadingProgress.count({
|
||||||
where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' },
|
where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' },
|
||||||
}),
|
}),
|
||||||
@@ -215,17 +209,18 @@ exports.getMyInProgressCourses = async (req, res) => {
|
|||||||
let assessment_configured = true;
|
let assessment_configured = true;
|
||||||
|
|
||||||
if (readingDone) {
|
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'],
|
attributes: ['quiz_id', 'title', 'is_required', 'passing_score'],
|
||||||
include: [{
|
include: [{
|
||||||
model: Unit,
|
model: Unit,
|
||||||
as: 'unit',
|
as: 'unit',
|
||||||
attributes: ['unit_id', 'title'],
|
attributes: ['unit_id', 'title'],
|
||||||
where: { course_id: courseId, ...notDeleted },
|
where: notDeleted,
|
||||||
required: true,
|
required: true,
|
||||||
}],
|
}],
|
||||||
where: notDeleted,
|
where: { unit_id: courseUnitIds, ...notDeleted },
|
||||||
});
|
}) : [];
|
||||||
|
|
||||||
for (const quiz of unitQuizzes) {
|
for (const quiz of unitQuizzes) {
|
||||||
const [hasPassed, attemptCount] = await Promise.all([
|
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);
|
if (!course) return R.error(res, 'Course not found.', 404);
|
||||||
|
|
||||||
const [lessons_total, lessons_completed, courseRow] = await Promise.all([
|
const [lessons_total, lessons_completed, courseRow] = await Promise.all([
|
||||||
Lesson.count({
|
countCourseLessons(courseId),
|
||||||
include: [{
|
|
||||||
model: Unit,
|
|
||||||
as: 'unit',
|
|
||||||
where: { course_id: courseId, ...notDeleted },
|
|
||||||
required: true,
|
|
||||||
}],
|
|
||||||
where: notDeleted,
|
|
||||||
}),
|
|
||||||
CourseReadingProgress.count({
|
CourseReadingProgress.count({
|
||||||
where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' },
|
where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' },
|
||||||
}),
|
}),
|
||||||
@@ -385,12 +372,14 @@ exports.getCourseTaskContext = async (req, res) => {
|
|||||||
where: notDeleted,
|
where: notDeleted,
|
||||||
required: false,
|
required: false,
|
||||||
attributes: ['unit_id', 'uuid'],
|
attributes: ['unit_id', 'uuid'],
|
||||||
|
through: { attributes: [] },
|
||||||
include: [{
|
include: [{
|
||||||
model: Lesson,
|
model: Lesson,
|
||||||
as: 'lessons',
|
as: 'lessons',
|
||||||
where: notDeleted,
|
where: notDeleted,
|
||||||
required: false,
|
required: false,
|
||||||
attributes: ['lesson_id', 'uuid'],
|
attributes: ['lesson_id', 'uuid'],
|
||||||
|
through: { attributes: [] },
|
||||||
}],
|
}],
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
@@ -475,24 +464,26 @@ exports.upsertLessonProgress = async (req, res) => {
|
|||||||
const userId = req.user.user_id;
|
const userId = req.user.user_id;
|
||||||
const status = req.body.status === 'completed' ? 'completed' : 'in_progress';
|
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({
|
Course.findOne({
|
||||||
where: { course_id: courseId, ...notDeleted },
|
where: { course_id: courseId, ...notDeleted },
|
||||||
attributes: ['course_id', 'uuid'],
|
attributes: ['course_id', 'uuid'],
|
||||||
}),
|
}),
|
||||||
Unit.findOne({
|
Unit.findOne({
|
||||||
where: { unit_id: unitId, course_id: courseId, ...notDeleted },
|
where: { unit_id: unitId, ...notDeleted },
|
||||||
attributes: ['unit_id', 'uuid'],
|
attributes: ['unit_id', 'uuid'],
|
||||||
}),
|
}),
|
||||||
Lesson.findOne({
|
Lesson.findOne({
|
||||||
where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted },
|
where: { lesson_id: lessonId, ...notDeleted },
|
||||||
attributes: ['lesson_id', 'uuid'],
|
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 (!course) return R.error(res, 'Course not found.', 404);
|
||||||
if (!unit) return R.error(res, 'Unit not found.', 404);
|
if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404);
|
||||||
if (!lesson) return R.error(res, 'Lesson not found.', 404);
|
if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404);
|
||||||
|
|
||||||
// ── 1. Primary write: course_reading_progress ─────────────────────────
|
// ── 1. Primary write: course_reading_progress ─────────────────────────
|
||||||
const result = await upsertLessonRead(userId, {
|
const result = await upsertLessonRead(userId, {
|
||||||
|
|||||||
@@ -23,11 +23,13 @@ const mdl_Category = require("../../models/courses/categories.mdl");
|
|||||||
const {
|
const {
|
||||||
Course,
|
Course,
|
||||||
Unit, Lesson, LessonPage,
|
Unit, Lesson, LessonPage,
|
||||||
|
CourseUnit, UnitLesson,
|
||||||
CourseObjective, LessonObjective,
|
CourseObjective, LessonObjective,
|
||||||
CoursePrerequisite, CourseAssessment,
|
CoursePrerequisite, CourseAssessment,
|
||||||
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt,
|
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt,
|
||||||
AssessmentSession, QuizSession,
|
AssessmentSession, QuizSession, LessonReadingProgress,
|
||||||
} = require("../../models/courses/courses.associations");
|
} = require("../../models/courses/courses.associations");
|
||||||
|
const { flattenUnits, flattenLessons } = require("../../utils/courses/hierarchy.util");
|
||||||
const mdl_TierCategories = require("../../models/tiers/tier_categories.mdl");
|
const mdl_TierCategories = require("../../models/tiers/tier_categories.mdl");
|
||||||
const { gradeSubmission } = require("../../utils/courses/grading.util");
|
const { gradeSubmission } = require("../../utils/courses/grading.util");
|
||||||
const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util");
|
const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util");
|
||||||
@@ -120,6 +122,34 @@ async function canAccessCourse(user_id, course_id) {
|
|||||||
return !!hasPurchase;
|
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 = [
|
const COURSE_LIST_ATTRS = [
|
||||||
"course_id", "uuid", "title", "description",
|
"course_id", "uuid", "title", "description",
|
||||||
"course_code", "level", "subscription",
|
"course_code", "level", "subscription",
|
||||||
@@ -254,16 +284,18 @@ exports.getCourse = async (req, res) => {
|
|||||||
where: notDeleted, required: false,
|
where: notDeleted, required: false,
|
||||||
attributes: [
|
attributes: [
|
||||||
"unit_id", "uuid", "title", "description",
|
"unit_id", "uuid", "title", "description",
|
||||||
"order_index", "duration_seconds",
|
"duration_seconds",
|
||||||
],
|
],
|
||||||
|
through: { attributes: ["order_index"] },
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: Lesson, as: "lessons",
|
model: Lesson, as: "lessons",
|
||||||
where: notDeleted, required: false,
|
where: notDeleted, required: false,
|
||||||
attributes: [
|
attributes: [
|
||||||
"lesson_id", "uuid", "title", "description",
|
"lesson_id", "uuid", "title", "description",
|
||||||
"order_index", "duration_seconds",
|
"duration_seconds",
|
||||||
],
|
],
|
||||||
|
through: { attributes: ["order_index"] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: UnitQuiz, as: "quiz",
|
model: UnitQuiz, as: "quiz",
|
||||||
@@ -308,8 +340,6 @@ exports.getCourse = async (req, res) => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
order: [
|
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: CourseObjective, as: "objectives" }, "order_index", "ASC"],
|
||||||
[{ model: CoursePrerequisite, as: "prerequisites" }, "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);
|
if (!course) return R.error(res, "Course not found.", 404);
|
||||||
|
|
||||||
const plain = course.toJSON();
|
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
|
// Attach has_passed to each unit's quiz in one query
|
||||||
const quizIds = plain.units
|
const quizIds = plain.units
|
||||||
@@ -398,11 +429,14 @@ exports.getUnit = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { courseId, unitId } = req.params;
|
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({
|
const unit = await Unit.findOne({
|
||||||
where: { unit_id: unitId, course_id: courseId, ...notDeleted },
|
where: { unit_id: unitId, ...notDeleted },
|
||||||
attributes: [
|
attributes: [
|
||||||
"unit_id", "uuid", "title", "description",
|
"unit_id", "uuid", "title", "description",
|
||||||
"order_index", "duration_seconds",
|
"duration_seconds",
|
||||||
],
|
],
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
@@ -410,8 +444,9 @@ exports.getUnit = async (req, res) => {
|
|||||||
where: notDeleted, required: false,
|
where: notDeleted, required: false,
|
||||||
attributes: [
|
attributes: [
|
||||||
"lesson_id", "uuid", "title", "description",
|
"lesson_id", "uuid", "title", "description",
|
||||||
"order_index", "duration_seconds",
|
"duration_seconds",
|
||||||
],
|
],
|
||||||
|
through: { attributes: ["order_index"] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: UnitQuiz, as: "quiz",
|
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);
|
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) {
|
} catch (err) {
|
||||||
console.error("[CLIENT][UNIT][GET ONE]", err);
|
console.error("[CLIENT][UNIT][GET ONE]", err);
|
||||||
return R.error(res, "Could not retrieve unit.", 500);
|
return R.error(res, "Could not retrieve unit.", 500);
|
||||||
@@ -439,18 +477,19 @@ exports.getLesson = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { courseId, unitId, lessonId } = req.params;
|
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({
|
const lesson = await Lesson.findOne({
|
||||||
where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted },
|
where: { lesson_id: lessonId, ...notDeleted },
|
||||||
attributes: [
|
attributes: [
|
||||||
"lesson_id", "uuid", "unit_id", "title",
|
"lesson_id", "uuid", "title",
|
||||||
"description", "order_index", "duration_seconds",
|
"description", "duration_seconds",
|
||||||
],
|
],
|
||||||
include: [
|
include: [
|
||||||
{
|
|
||||||
model: Unit, as: "unit",
|
|
||||||
where: { course_id: courseId, ...notDeleted },
|
|
||||||
attributes: [],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
model: LessonPage, as: "page",
|
model: LessonPage, as: "page",
|
||||||
required: false,
|
required: false,
|
||||||
@@ -466,7 +505,7 @@ exports.getLesson = async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!lesson) return R.error(res, "Lesson not found.", 404);
|
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) {
|
} catch (err) {
|
||||||
console.error("[CLIENT][LESSON][GET ONE]", err);
|
console.error("[CLIENT][LESSON][GET ONE]", err);
|
||||||
return R.error(res, "Could not retrieve lesson.", 500);
|
return R.error(res, "Could not retrieve lesson.", 500);
|
||||||
@@ -479,8 +518,8 @@ exports.getUnitQuiz = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { courseId, unitId } = req.params;
|
const { courseId, unitId } = req.params;
|
||||||
|
|
||||||
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } });
|
const link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } });
|
||||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
if (!link) return R.error(res, "Unit not found.", 404);
|
||||||
|
|
||||||
const quiz = await UnitQuiz.findOne({
|
const quiz = await UnitQuiz.findOne({
|
||||||
where: { unit_id: unitId, ...notDeleted },
|
where: { unit_id: unitId, ...notDeleted },
|
||||||
@@ -779,8 +818,8 @@ exports.submitUnitQuiz = async (req, res) => {
|
|||||||
const { answers = {} } = req.body;
|
const { answers = {} } = req.body;
|
||||||
const user_id = req.user.user_id;
|
const user_id = req.user.user_id;
|
||||||
|
|
||||||
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } });
|
const link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } });
|
||||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
if (!link) return R.error(res, "Unit not found.", 404);
|
||||||
|
|
||||||
const quiz = await UnitQuiz.findOne({
|
const quiz = await UnitQuiz.findOne({
|
||||||
where: { quiz_id: quizId, unit_id: unitId, ...notDeleted },
|
where: { quiz_id: quizId, unit_id: unitId, ...notDeleted },
|
||||||
@@ -1016,71 +1055,123 @@ exports.getUnitByUuid = async (req, res) => {
|
|||||||
const unit = await Unit.findOne({
|
const unit = await Unit.findOne({
|
||||||
where: { uuid, ...notDeleted },
|
where: { uuid, ...notDeleted },
|
||||||
attributes: ["unit_id", "uuid", "title", "description"],
|
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) 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({
|
return res.status(403).json({
|
||||||
status: "error",
|
status: "error",
|
||||||
message: "You do not have access to this course.",
|
message: "You do not have access to this unit.",
|
||||||
course: { title: unit.course.title, subscription: unit.course.subscription },
|
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) {
|
} catch (err) {
|
||||||
console.error("[CLIENT][UNITS][BY UUID]", err);
|
console.error("[CLIENT][UNITS][BY UUID]", err);
|
||||||
return R.error(res, "Could not retrieve unit.", 500);
|
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) => {
|
exports.getLessonsByUnitUuid = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { uuid } = req.params;
|
const { uuid } = req.params;
|
||||||
const unit = await Unit.findOne({
|
const unit = await Unit.findOne({
|
||||||
where: { uuid, ...notDeleted },
|
where: { uuid, ...notDeleted },
|
||||||
attributes: ["unit_id", "uuid", "title", "description", "order_index"],
|
attributes: ["unit_id", "uuid", "title", "description", "duration_seconds"],
|
||||||
include: [
|
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,
|
model: Lesson,
|
||||||
as: "lessons",
|
as: "lessons",
|
||||||
where: notDeleted,
|
where: notDeleted,
|
||||||
required: false,
|
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 }],
|
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) 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({
|
return res.status(403).json({
|
||||||
status: "error",
|
status: "error",
|
||||||
message: "You do not have access to this course.",
|
message: "You do not have access to this unit.",
|
||||||
course: { title: unit.course.title, subscription: unit.course.subscription },
|
course: first ? { title: first.title, subscription: first.subscription } : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const lessons = (unit.lessons ?? [])
|
|
||||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
const plain = unit.toJSON();
|
||||||
.map((l) => ({
|
|
||||||
|
// 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,
|
lesson_id: l.lesson_id,
|
||||||
uuid: l.uuid,
|
uuid: l.uuid,
|
||||||
title: l.title,
|
title: l.title,
|
||||||
description: l.description,
|
description: l.description,
|
||||||
order_index: l.order_index ?? 0,
|
order_index: l.order_index ?? 0,
|
||||||
|
duration_seconds: l.duration_seconds ?? 0,
|
||||||
blocks: l.page?.blocks ?? [],
|
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.", {
|
return R.success(res, "Unit lessons retrieved.", {
|
||||||
unit_id: unit.unit_id,
|
unit_id: unit.unit_id,
|
||||||
uuid: unit.uuid,
|
uuid: unit.uuid,
|
||||||
title: unit.title,
|
title: unit.title,
|
||||||
description: unit.description,
|
description: unit.description,
|
||||||
course: unit.course ?? null,
|
duration_seconds: plain.duration_seconds ?? 0,
|
||||||
|
course: plain.courses?.[0] ?? null, // back-compat singular field
|
||||||
|
courses: plain.courses ?? [],
|
||||||
|
quiz,
|
||||||
|
is_completed,
|
||||||
lessons,
|
lessons,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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) => {
|
exports.getLessonByUuid = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { uuid } = req.params;
|
const { uuid } = req.params;
|
||||||
@@ -1104,30 +1197,40 @@ exports.getLessonByUuid = async (req, res) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: Unit,
|
model: Unit,
|
||||||
as: "unit",
|
as: "units",
|
||||||
attributes: ["unit_id", "title", "order_index"],
|
where: notDeleted, required: false,
|
||||||
include: [{ model: Course, as: "course", attributes: ["course_id", "title", "subscription"] }],
|
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) 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({
|
return res.status(403).json({
|
||||||
status: "error",
|
status: "error",
|
||||||
message: "You do not have access to this course.",
|
message: "You do not have access to this lesson.",
|
||||||
course: { title: lesson.unit.course.title, subscription: lesson.unit.course.subscription },
|
course: firstCourse ? { title: firstCourse.title, subscription: firstCourse.subscription } : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const plain = lesson.toJSON();
|
||||||
|
const firstUnit = plain.units?.[0] ?? null;
|
||||||
const data = {
|
const data = {
|
||||||
lesson_id: lesson.lesson_id,
|
lesson_id: plain.lesson_id,
|
||||||
uuid: lesson.uuid,
|
uuid: plain.uuid,
|
||||||
title: lesson.title,
|
title: plain.title,
|
||||||
description: lesson.description,
|
description: plain.description,
|
||||||
blocks: lesson.page?.blocks ?? [],
|
blocks: plain.page?.blocks ?? [],
|
||||||
unit: lesson.unit ?? null,
|
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);
|
return R.success(res, "Lesson retrieved.", data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -138,7 +138,7 @@ const getQuizScores = async (req, res) => {
|
|||||||
const memberIds = [...new Set(memberships.map(m => m.user_id))];
|
const memberIds = [...new Set(memberships.map(m => m.user_id))];
|
||||||
|
|
||||||
const quiz = await UnitQuiz.findByPk(quizId, {
|
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.' });
|
if (!quiz) return res.status(404).json({ success: false, message: 'Quiz not found.' });
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
const { Course, CourseProductCategory } = require("./courses.mdl");
|
const { Course, CourseProductCategory } = require("./courses.mdl");
|
||||||
const Unit = require("./units.mdl");
|
const Unit = require("./units.mdl");
|
||||||
const Lesson = require("./lessons.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 LessonPage = require("./lesson_page.mdl");
|
||||||
const CourseObjective = require("./course_objective.mdl");
|
const CourseObjective = require("./course_objective.mdl");
|
||||||
const LessonObjective = require("./lesson_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' });
|
mdl_Users.hasMany(LessonReadingProgress, { foreignKey: 'user_id', as: 'lessonReadingProgress' });
|
||||||
Lesson.hasMany(LessonReadingProgress, { foreignKey: 'lesson_id', as: 'readingProgress' });
|
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 ────────────────────────────────────────────────────────────────────
|
||||||
Course.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
|
Course.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
|
||||||
Course.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
|
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(CourseObjective, { as: "objectives", foreignKey: "course_id" });
|
||||||
Course.hasMany(CoursePrerequisite, { as: "prerequisites", foreignKey: "course_id" });
|
Course.hasMany(CoursePrerequisite, { as: "prerequisites", foreignKey: "course_id" });
|
||||||
Course.hasOne(CourseAssessment, { as: "assessment", 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' });
|
mdl_Category.belongsToMany(Course, { through: CourseProductCategory, foreignKey: 'category_id', otherKey: 'course_id', as: 'courses' });
|
||||||
|
|
||||||
// ── Unit ──────────────────────────────────────────────────────────────────────
|
// ── Unit ──────────────────────────────────────────────────────────────────────
|
||||||
Unit.belongsTo(Course, { as: "course", foreignKey: "course_id" });
|
|
||||||
Unit.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
|
Unit.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
|
||||||
Unit.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
|
Unit.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
|
||||||
Unit.hasMany(Lesson, { as: "lessons", foreignKey: "unit_id" });
|
|
||||||
Unit.hasOne(UnitQuiz, { as: "quiz", foreignKey: "unit_id" });
|
Unit.hasOne(UnitQuiz, { as: "quiz", foreignKey: "unit_id" });
|
||||||
|
|
||||||
// ── Lesson ────────────────────────────────────────────────────────────────────
|
// ── Lesson ────────────────────────────────────────────────────────────────────
|
||||||
Lesson.belongsTo(Unit, { as: "unit", foreignKey: "unit_id" });
|
|
||||||
Lesson.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
|
Lesson.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
|
||||||
Lesson.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
|
Lesson.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
|
||||||
Lesson.hasOne(LessonPage, { as: "page", foreignKey: "lesson_id" });
|
Lesson.hasOne(LessonPage, { as: "page", foreignKey: "lesson_id" });
|
||||||
@@ -114,6 +130,7 @@ UnitQuiz.hasMany(QuizSession, { as: "sessions", foreignKey: "quiz_id" });
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
Course, CourseProductCategory,
|
Course, CourseProductCategory,
|
||||||
Unit, Lesson, LessonPage,
|
Unit, Lesson, LessonPage,
|
||||||
|
CourseUnit, UnitLesson,
|
||||||
CourseObjective, LessonObjective,
|
CourseObjective, LessonObjective,
|
||||||
CoursePrerequisite, CourseAssessment,
|
CoursePrerequisite, CourseAssessment,
|
||||||
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt,
|
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt,
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ const computedAttributes = [
|
|||||||
literal: `(
|
literal: `(
|
||||||
SELECT CAST(COUNT(*) AS INTEGER)
|
SELECT CAST(COUNT(*) AS INTEGER)
|
||||||
FROM "units"
|
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
|
AND "units"."deletedAt" IS NULL
|
||||||
)`,
|
)`,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@@ -38,10 +39,12 @@ const computedAttributes = [
|
|||||||
type: "number",
|
type: "number",
|
||||||
order: 6,
|
order: 6,
|
||||||
literal: `(
|
literal: `(
|
||||||
SELECT CAST(COUNT(*) AS INTEGER)
|
SELECT CAST(COUNT(DISTINCT "lessons"."lesson_id") AS INTEGER)
|
||||||
FROM "lessons"
|
FROM "lessons"
|
||||||
INNER JOIN "units" ON "lessons"."unit_id" = "units"."unit_id"
|
INNER JOIN "unit_lessons" ON "unit_lessons"."lesson_id" = "lessons"."lesson_id"
|
||||||
WHERE "units"."course_id" = "Course"."course_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
|
AND "lessons"."deletedAt" IS NULL
|
||||||
)`,
|
)`,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ const LessonReadingProgress = sequelize.define('LessonReadingProgress', {
|
|||||||
},
|
},
|
||||||
course_id: {
|
course_id: {
|
||||||
type: DataTypes.BIGINT,
|
type: DataTypes.BIGINT,
|
||||||
allowNull: false,
|
allowNull: true, // NULL when the lesson is read standalone (outside any course)
|
||||||
references: { model: 'courses', key: 'course_id' },
|
references: { model: 'courses', key: 'course_id' },
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
},
|
},
|
||||||
unit_id: {
|
unit_id: {
|
||||||
type: DataTypes.BIGINT,
|
type: DataTypes.BIGINT,
|
||||||
allowNull: false,
|
allowNull: true, // NULL when the lesson is read standalone (outside any unit)
|
||||||
references: { model: 'units', key: 'unit_id' },
|
references: { model: 'units', key: 'unit_id' },
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
const { DataTypes } = require("sequelize");
|
const { DataTypes } = require("sequelize");
|
||||||
const sequelize = require("../../config/db.config");
|
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", {
|
const Lesson = sequelize.define("Lesson", {
|
||||||
lesson_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, hidden: true, order: 0 },
|
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 },
|
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 },
|
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 },
|
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
|
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" },
|
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
|
||||||
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Updated By" },
|
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Updated By" },
|
||||||
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
|
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
|
||||||
@@ -21,8 +21,6 @@ const Lesson = sequelize.define("Lesson", {
|
|||||||
paranoid: true,
|
paranoid: true,
|
||||||
indexes: [
|
indexes: [
|
||||||
{ fields: ["uuid"] },
|
{ fields: ["uuid"] },
|
||||||
{ fields: ["unit_id"] },
|
|
||||||
{ fields: ["order_index"] },
|
|
||||||
{ fields: ["deletedAt"] },
|
{ fields: ["deletedAt"] },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -28,7 +28,7 @@ const UnitReadingProgress = sequelize.define('UnitReadingProgress', {
|
|||||||
},
|
},
|
||||||
course_id: {
|
course_id: {
|
||||||
type: DataTypes.BIGINT,
|
type: DataTypes.BIGINT,
|
||||||
allowNull: false,
|
allowNull: true, // NULL when the unit is read standalone (outside any course)
|
||||||
references: { model: 'courses', key: 'course_id' },
|
references: { model: 'courses', key: 'course_id' },
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
const { DataTypes } = require("sequelize");
|
const { DataTypes } = require("sequelize");
|
||||||
const sequelize = require("../../config/db.config");
|
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", {
|
const Unit = sequelize.define("Unit", {
|
||||||
unit_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, hidden: true, order: 0, filterable: false },
|
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 },
|
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 },
|
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 },
|
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: 2, filterable: false },
|
||||||
duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, hidden: false, order: 3, filterable: false },
|
|
||||||
createdBy: { type: DataTypes.BIGINT, allowNull: true, filterable: true },
|
createdBy: { type: DataTypes.BIGINT, allowNull: true, filterable: true },
|
||||||
updatedBy: { type: DataTypes.BIGINT, allowNull: true, filterable: true },
|
updatedBy: { type: DataTypes.BIGINT, allowNull: true, filterable: true },
|
||||||
deletedBy: { type: DataTypes.BIGINT, allowNull: true, filterable: true },
|
deletedBy: { type: DataTypes.BIGINT, allowNull: true, filterable: true },
|
||||||
@@ -18,8 +18,6 @@ const Unit = sequelize.define("Unit", {
|
|||||||
paranoid: true,
|
paranoid: true,
|
||||||
indexes: [
|
indexes: [
|
||||||
{ fields: ["uuid"] },
|
{ fields: ["uuid"] },
|
||||||
{ fields: ["course_id"] },
|
|
||||||
{ fields: ["order_index"] },
|
|
||||||
{ fields: ["deletedAt"] },
|
{ fields: ["deletedAt"] },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ const usersRoutes = require('./users.routes');
|
|||||||
const groupsRoutes = require('./groups.routes');
|
const groupsRoutes = require('./groups.routes');
|
||||||
const assetsRoutes = require('./assets.routes');
|
const assetsRoutes = require('./assets.routes');
|
||||||
const coursesRoutes = require('./courses.routes');
|
const coursesRoutes = require('./courses.routes');
|
||||||
|
const unitsRoutes = require('./units.routes');
|
||||||
|
const lessonsRoutes = require('./lessons.routes');
|
||||||
const taskRoutes = require('./task.routes');
|
const taskRoutes = require('./task.routes');
|
||||||
const tiersRoutes = require('./tiers.routes');
|
const tiersRoutes = require('./tiers.routes');
|
||||||
const tierPoliciesRoutes = require('./tier_policies.routes');
|
const tierPoliciesRoutes = require('./tier_policies.routes');
|
||||||
@@ -54,6 +56,8 @@ router.use('/users', usersRoutes);
|
|||||||
router.use('/groups', groupsRoutes);
|
router.use('/groups', groupsRoutes);
|
||||||
router.use('/assets', assetsRoutes);
|
router.use('/assets', assetsRoutes);
|
||||||
router.use('/courses', coursesRoutes);
|
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('/task-lists', taskRoutes);
|
||||||
router.use('/tiers/categories', tierCategoriesRoutes);
|
router.use('/tiers/categories', tierCategoriesRoutes);
|
||||||
router.use('/tiers', tiersRoutes);
|
router.use('/tiers', tiersRoutes);
|
||||||
|
|||||||
@@ -96,9 +96,11 @@ router.get("/:courseId/assessment/:assessmentId/sessions", ctrl.getAssessment
|
|||||||
|
|
||||||
router.get("/:courseId/field-values", ctrl.getUnitFieldValues);
|
router.get("/:courseId/field-values", ctrl.getUnitFieldValues);
|
||||||
router.get("/:courseId/units", ctrl.getUnits);
|
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
|
// 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", ctrl.bulkArchiveUnits);
|
||||||
router.delete("/:courseId/units/bulk/permanent", ctrl.bulkPermanentlyDeleteUnits);
|
router.delete("/:courseId/units/bulk/permanent", ctrl.bulkPermanentlyDeleteUnits);
|
||||||
router.get("/:courseId/units/archives", ctrl.getArchivedUnits);
|
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/field-values", ctrl.getLessonFieldValues);
|
||||||
router.get("/:courseId/units/:unitId/lessons", ctrl.getLessons);
|
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
|
// 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", ctrl.bulkArchiveLessons);
|
||||||
router.delete("/:courseId/units/:unitId/lessons/bulk/permanent", ctrl.bulkPermanentlyDeleteLessons);
|
router.delete("/:courseId/units/:unitId/lessons/bulk/permanent", ctrl.bulkPermanentlyDeleteLessons);
|
||||||
router.get("/:courseId/units/:unitId/lessons/archives", ctrl.getArchivedLessons);
|
router.get("/:courseId/units/:unitId/lessons/archives", ctrl.getArchivedLessons);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -28,6 +28,8 @@ const { handleAvatarUpload } = require('../../middleware/avatar_upload.middlewar
|
|||||||
|
|
||||||
const tiersRoutes = require('./tiers.routes');
|
const tiersRoutes = require('./tiers.routes');
|
||||||
const coursesRoutes = require('./courses.routes');
|
const coursesRoutes = require('./courses.routes');
|
||||||
|
const unitsRoutes = require('./units.routes');
|
||||||
|
const lessonsRoutes = require('./lessons.routes');
|
||||||
const coursePurchasesRoutes = require('./course_purchases.routes');
|
const coursePurchasesRoutes = require('./course_purchases.routes');
|
||||||
const mediaRoutes = require("./media.routes");
|
const mediaRoutes = require("./media.routes");
|
||||||
const groupsRoutes = require("./groups.routes");
|
const groupsRoutes = require("./groups.routes");
|
||||||
@@ -58,6 +60,8 @@ router.delete('/sessions/:id', profileCtrl.revokeSession);
|
|||||||
|
|
||||||
router.use('/tiers', tiersRoutes);
|
router.use('/tiers', tiersRoutes);
|
||||||
router.use('/courses', coursesRoutes);
|
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('/course-purchases', coursePurchasesRoutes);
|
||||||
router.use('/groups', groupsRoutes);
|
router.use('/groups', groupsRoutes);
|
||||||
router.use('/advertisements', advertisementRoutes);
|
router.use('/advertisements', advertisementRoutes);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -25,6 +25,8 @@ const sequelize = require('../config/db.config');
|
|||||||
const CourseReadingProgress = require('../models/courses/course_reading_progress.mdl');
|
const CourseReadingProgress = require('../models/courses/course_reading_progress.mdl');
|
||||||
const Lesson = require('../models/courses/lessons.mdl');
|
const Lesson = require('../models/courses/lessons.mdl');
|
||||||
const Unit = require('../models/courses/units.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 CourseAssessment = require('../models/courses/course_assessment.mdl');
|
||||||
const QuizAttempt = require('../models/courses/quiz_attempt.mdl');
|
const QuizAttempt = require('../models/courses/quiz_attempt.mdl');
|
||||||
|
|
||||||
@@ -71,10 +73,18 @@ async function upsertProgress({ userId, courseId, type, referenceId, status }, t
|
|||||||
|
|
||||||
// ─── Derivation helpers ───────────────────────────────────────────────────────
|
// ─── 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) {
|
async function deriveUnitStatus(userId, courseId, unitId, t) {
|
||||||
const lessons = await Lesson.findAll({
|
const links = await UnitLesson.findAll({
|
||||||
where: { unit_id: unitId },
|
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'],
|
attributes: ['uuid'],
|
||||||
transaction: t,
|
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
|
// 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.
|
// AND the course's assessment (if one has been built) has been passed by this user.
|
||||||
async function deriveCourseStatus(userId, courseId, t) {
|
async function deriveCourseStatus(userId, courseId, t) {
|
||||||
const units = await Unit.findAll({
|
const links = await CourseUnit.findAll({
|
||||||
where: { course_id: courseId },
|
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'],
|
attributes: ['uuid'],
|
||||||
transaction: t,
|
transaction: t,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
const sequelize = require('../config/db.config');
|
const sequelize = require('../config/db.config');
|
||||||
const LessonReadingProgress = require('../models/courses/lesson_reading_progress.mdl');
|
const LessonReadingProgress = require('../models/courses/lesson_reading_progress.mdl');
|
||||||
const UnitReadingProgress = require('../models/courses/unit_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');
|
const Lesson = require('../models/courses/lessons.mdl');
|
||||||
|
|
||||||
// ─── Core UPSERTs ────────────────────────────────────────────────────────────
|
// ─── Core UPSERTs ────────────────────────────────────────────────────────────
|
||||||
@@ -32,8 +33,8 @@ async function upsertLessonProgress({ userId, courseId, unitId, lessonId, status
|
|||||||
const [record] = await LessonReadingProgress.upsert(
|
const [record] = await LessonReadingProgress.upsert(
|
||||||
{
|
{
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
course_id: courseId,
|
course_id: courseId ?? null, // NULL when read standalone
|
||||||
unit_id: unitId,
|
unit_id: unitId ?? null, // NULL when read standalone
|
||||||
lesson_id: lessonId,
|
lesson_id: lessonId,
|
||||||
status,
|
status,
|
||||||
completed_at: status === 'completed' ? now : null,
|
completed_at: status === 'completed' ? now : null,
|
||||||
@@ -55,7 +56,7 @@ async function upsertUnitProgress({ userId, courseId, unitId, status }, t) {
|
|||||||
const [record] = await UnitReadingProgress.upsert(
|
const [record] = await UnitReadingProgress.upsert(
|
||||||
{
|
{
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
course_id: courseId,
|
course_id: courseId ?? null, // NULL when read standalone
|
||||||
unit_id: unitId,
|
unit_id: unitId,
|
||||||
status,
|
status,
|
||||||
completed_at: status === 'completed' ? now : null,
|
completed_at: status === 'completed' ? now : null,
|
||||||
@@ -74,13 +75,21 @@ async function upsertUnitProgress({ userId, courseId, unitId, status }, t) {
|
|||||||
|
|
||||||
// ─── Derivation helper ────────────────────────────────────────────────────────
|
// ─── 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) {
|
async function deriveUnitStatus(userId, unitId, t) {
|
||||||
const lessons = await Lesson.findAll({
|
const links = await UnitLesson.findAll({
|
||||||
where: { unit_id: unitId },
|
where: { unit_id: unitId },
|
||||||
attributes: ['lesson_id'],
|
attributes: ['lesson_id'],
|
||||||
transaction: t,
|
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';
|
if (!lessons.length) return 'in_progress';
|
||||||
|
|
||||||
const lessonIds = lessons.map(l => l.lesson_id);
|
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'
|
* @param {string} payload.lessonStatus — 'in_progress' | 'completed'
|
||||||
* @returns {{ lesson, unit }} — status snapshot for each level
|
* @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();
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
// 1. Lesson
|
// 1. Lesson
|
||||||
@@ -122,20 +131,23 @@ async function upsertLessonRead(userId, { courseId, unitId, lessonId, lessonStat
|
|||||||
status: lessonStatus,
|
status: lessonStatus,
|
||||||
}, t);
|
}, t);
|
||||||
|
|
||||||
// 2. Unit — derived from all sibling lessons
|
// 2. Unit — derived from all sibling lessons (skipped for standalone lesson reads)
|
||||||
const unitStatus = await deriveUnitStatus(userId, unitId, t);
|
let unitStatus = null;
|
||||||
|
if (unitId) {
|
||||||
|
unitStatus = await deriveUnitStatus(userId, unitId, t);
|
||||||
await upsertUnitProgress({
|
await upsertUnitProgress({
|
||||||
userId,
|
userId,
|
||||||
courseId,
|
courseId,
|
||||||
unitId,
|
unitId,
|
||||||
status: unitStatus,
|
status: unitStatus,
|
||||||
}, t);
|
}, t);
|
||||||
|
}
|
||||||
|
|
||||||
await t.commit();
|
await t.commit();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lesson: { lesson_id: lessonId, status: lessonStatus },
|
lesson: { lesson_id: lessonId, status: lessonStatus },
|
||||||
unit: { unit_id: unitId, status: unitStatus },
|
unit: unitId ? { unit_id: unitId, status: unitStatus } : null,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await t.rollback();
|
await t.rollback();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
+22
-14
@@ -59,9 +59,10 @@ async function recomputeUnitDuration(unitId) {
|
|||||||
const Unit = require("../models/courses/units.mdl");
|
const Unit = require("../models/courses/units.mdl");
|
||||||
|
|
||||||
const [unitResult] = await Lesson.sequelize.query(`
|
const [unitResult] = await Lesson.sequelize.query(`
|
||||||
SELECT COALESCE(SUM(duration_seconds), 0) AS total
|
SELECT COALESCE(SUM(l.duration_seconds), 0) AS total
|
||||||
FROM lessons
|
FROM lessons l
|
||||||
WHERE unit_id = :unitId AND "deletedAt" IS NULL
|
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 });
|
`, { replacements: { unitId }, type: Lesson.sequelize.QueryTypes.SELECT });
|
||||||
|
|
||||||
await Unit.update({ duration_seconds: unitResult.total }, { where: { unit_id: unitId } });
|
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 { Course } = require("../models/courses/courses.mdl");
|
||||||
|
|
||||||
const [courseResult] = await Unit.sequelize.query(`
|
const [courseResult] = await Unit.sequelize.query(`
|
||||||
SELECT COALESCE(SUM(duration_seconds), 0) AS total
|
SELECT COALESCE(SUM(u.duration_seconds), 0) AS total
|
||||||
FROM units
|
FROM units u
|
||||||
WHERE course_id = :courseId AND "deletedAt" IS NULL
|
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 });
|
`, { replacements: { courseId }, type: Unit.sequelize.QueryTypes.SELECT });
|
||||||
|
|
||||||
await Course.update({ duration_seconds: courseResult.total }, { where: { course_id: courseId } });
|
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:
|
* 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) {
|
async function recomputeDurations(lessonId) {
|
||||||
const Lesson = require("../models/courses/lessons.mdl");
|
const Lesson = require("../models/courses/lessons.mdl");
|
||||||
const Unit = require("../models/courses/units.mdl");
|
|
||||||
const LessonPage = require("../models/courses/lesson_page.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
|
// 1. Lesson duration from blocks
|
||||||
const page = await LessonPage.findOne({ where: { lesson_id: lessonId } });
|
const page = await LessonPage.findOne({ where: { lesson_id: lessonId } });
|
||||||
@@ -104,13 +108,17 @@ async function recomputeDurations(lessonId) {
|
|||||||
const safeLessonSecs = isNaN(lessonSecs) ? 0 : lessonSecs;
|
const safeLessonSecs = isNaN(lessonSecs) ? 0 : lessonSecs;
|
||||||
await Lesson.update({ duration_seconds: safeLessonSecs }, { where: { lesson_id: lessonId } });
|
await Lesson.update({ duration_seconds: safeLessonSecs }, { where: { lesson_id: lessonId } });
|
||||||
|
|
||||||
// 2. Unit duration — sum of its lessons
|
// 2. Every unit that contains this lesson
|
||||||
const lesson = await Lesson.findOne({ where: { lesson_id: lessonId } });
|
const unitLinks = await UnitLesson.findAll({ where: { lesson_id: lessonId }, attributes: ["unit_id"] });
|
||||||
await recomputeUnitDuration(lesson.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
|
// 3. Every course that contains those units
|
||||||
const unit = await Unit.findOne({ where: { unit_id: lesson.unit_id } });
|
if (unitIds.length) {
|
||||||
await recomputeCourseDuration(unit.course_id);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user