This commit is contained in:
rgrgogu
2026-05-20 13:22:29 +08:00
parent 6907e9bb2d
commit 6a5145f553
26 changed files with 2615 additions and 221 deletions
File diff suppressed because it is too large Load Diff
+510
View File
@@ -0,0 +1,510 @@
"use strict";
const { Op } = require("sequelize");
const sequelize = require("../../config/db.config");
const R = require("../../utils/response.util");
const { paginate } = require("../../utils/paginate.util");
const { recomputeDurations } = require("../../utils/duration.util");
const { syncObjectivesCreate, syncObjectivesUpdate } = require("../../utils/courses/objectives.util");
const { archiveOne, archiveMany } = require("../../utils/courses/archive.util");
const { restoreOne, restoreMany } = require("../../utils/courses/restore.util");
// ── Models ────────────────────────────────────────────────────────────────────
const {
Unit, Lesson, LessonPage,
LessonObjective,
CourseAssessment, UnitQuiz,
QuizQuestion, QuizOption,
} = require("../../models/courses/courses.associations");
const mdl_Users = require("../../models/users/users.mdl");
const notDeleted = { deletedAt: null };
const onlyDeleted = { deletedAt: { [Op.not]: null } };
// ── Helpers ───────────────────────────────────────────────────────────────────
async function resolveQuestionParent(params) {
const { quizId, assessmentId } = params;
if (quizId) {
const rec = await UnitQuiz.findOne({ where: { quiz_id: quizId, ...notDeleted } });
return { parentField: "quiz_id", parentId: quizId, parentRecord: rec };
}
if (assessmentId) {
const rec = await CourseAssessment.findOne({ where: { assessment_id: assessmentId, ...notDeleted } });
return { parentField: "assessment_id", parentId: assessmentId, parentRecord: rec };
}
return null;
}
// ══════════════════════════════════════════════════════════════════════════════
// LESSON
// ══════════════════════════════════════════════════════════════════════════════
exports.getLessons = async (req, res) => {
try {
const { courseId, unitId } = req.params;
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
const result = await paginate(Lesson, req, {
auditOptions: { mdl_Users, parentAlias: "Lesson" },
context: "list",
findOptions: {
where: { unit_id: unitId, ...notDeleted },
order: [["order_index", "ASC"]],
},
});
return R.success(res, "Lessons retrieved.", result);
} catch (err) {
console.error("[LESSON][GET ALL]", err);
return R.error(res, "Could not retrieve lessons.", 500);
}
};
exports.getLesson = async (req, res) => {
try {
const { courseId, unitId, lessonId } = req.params;
const lesson = await Lesson.findOne({
where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted },
include: [
{ model: LessonPage, as: "page", required: false },
{ model: LessonObjective, as: "objectives", required: false, order: [["order_index", "ASC"]] },
{ model: Unit, as: "unit", where: { course_id: courseId, ...notDeleted } },
],
});
if (!lesson) return R.error(res, "Lesson not found.", 404);
return R.success(res, "Lesson retrieved.", { data: lesson.toJSON() });
} catch (err) {
console.error("[LESSON][GET ONE]", err);
return R.error(res, "Could not retrieve lesson.", 500);
}
};
exports.createLesson = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId, unitId } = req.params;
const { title, description, order, objectives = [], createdBy } = req.body;
if (!title) return R.error(res, "Title is required.", 400);
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
const lesson = await Lesson.create({
unit_id: unitId,
title,
description: description ?? null,
order_index: order ?? 0,
duration_seconds: 0,
createdBy: createdBy ?? null,
}, { transaction: t });
await LessonPage.create({
lesson_id: lesson.lesson_id,
blocks: [],
createdBy: createdBy ?? null,
}, { transaction: t });
await syncObjectivesCreate(LessonObjective, "lesson_id", lesson.lesson_id, objectives, t);
await t.commit();
return R.success(res, "Lesson created.", { data: lesson }, 201);
} catch (err) {
await t.rollback();
console.error("[LESSON][CREATE]", err);
return R.error(res, "Could not create lesson.", 500);
}
};
exports.updateLesson = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId, unitId, lessonId } = req.params;
const lesson = await Lesson.findOne({
where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted },
include: [{ model: Unit, as: "unit", where: { course_id: courseId, ...notDeleted } }],
});
if (!lesson) return R.error(res, "Lesson not found.", 404);
const { title, description, order, objectives, updatedBy } = req.body;
if (title !== undefined) lesson.title = title;
if (description !== undefined) lesson.description = description;
if (order !== undefined) lesson.order_index = order;
lesson.updatedBy = updatedBy ?? null;
await lesson.save({ transaction: t });
if (objectives !== undefined) {
await syncObjectivesUpdate(LessonObjective, "lesson_id", lessonId, objectives, t);
}
await t.commit();
const updated = await Lesson.findOne({
where: { lesson_id: lessonId },
include: [{ model: LessonObjective, as: "objectives", order: [["order_index", "ASC"]] }],
});
return R.success(res, "Lesson updated.", { data: updated });
} catch (err) {
await t.rollback();
console.error("[LESSON][UPDATE]", err);
return R.error(res, "Could not update lesson.", 500);
}
};
exports.deleteLesson = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId, unitId, lessonId } = req.params;
const lesson = await Lesson.findOne({
where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted },
include: [{ model: Unit, as: "unit", where: { course_id: courseId, ...notDeleted } }],
});
if (!lesson) return R.error(res, "Lesson not found.", 404);
await lesson.update({ deletedBy: req.body.deletedBy ?? null }, { transaction: t });
await lesson.destroy({ transaction: t });
await t.commit();
return R.success(res, "Lesson archived.");
} catch (err) {
await t.rollback();
console.error("[LESSON][DELETE]", err);
return R.error(res, "Could not archive lesson.", 500);
}
};
exports.bulkArchiveLessons = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId, unitId } = req.params;
const { ids = [], deletedBy } = req.body;
if (!ids.length) return R.error(res, "No IDs provided.", 400);
const lessons = await Lesson.findAll({
where: { lesson_id: ids, unit_id: unitId, ...notDeleted },
include: [{ model: Unit, as: "unit", where: { course_id: courseId, ...notDeleted } }],
});
const validIds = lessons.map((l) => l.lesson_id);
const count = await archiveMany(Lesson, "lesson_id", validIds, deletedBy, t);
await t.commit();
return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} archived.`);
} catch (err) {
await t.rollback();
console.error("[LESSON][BULK ARCHIVE]", err);
return R.error(res, "Could not archive lessons.", 500);
}
};
exports.getArchivedLessons = async (req, res) => {
try {
const { courseId, unitId } = req.params;
const unit = await Unit.findOne({
where: { unit_id: unitId, course_id: courseId },
paranoid: false,
});
if (!unit) return R.error(res, "Unit not found.", 404);
const result = await paginate(Lesson, req, {
auditOptions: { mdl_Users, parentAlias: "Lesson" },
context: "list",
findOptions: {
where: { unit_id: unitId, ...onlyDeleted },
paranoid: false,
order: [["order_index", "ASC"]],
},
});
return R.success(res, "Archived lessons retrieved.", result);
} catch (err) {
console.error("[LESSON][GET ARCHIVES]", err);
return R.error(res, "Could not retrieve archived lessons.", 500);
}
};
exports.getArchivedLesson = async (req, res) => {
try {
const { courseId, unitId, lessonId } = req.params;
const unit = await Unit.findOne({
where: { unit_id: unitId, course_id: courseId },
paranoid: false,
});
if (!unit) return R.error(res, "Unit not found.", 404);
const lesson = await Lesson.findOne({
where: { lesson_id: lessonId, unit_id: unitId, ...onlyDeleted },
paranoid: false,
});
if (!lesson) return R.error(res, "Archived lesson not found.", 404);
return R.success(res, "Archived lesson retrieved.", { data: lesson.toJSON() });
} catch (err) {
console.error("[LESSON][GET ARCHIVE ONE]", err);
return R.error(res, "Could not retrieve archived lesson.", 500);
}
};
exports.restoreLesson = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId, unitId, lessonId } = req.params;
const unit = await Unit.findOne({
where: { unit_id: unitId, course_id: courseId },
paranoid: false,
});
if (!unit) return R.error(res, "Unit not found.", 404);
const record = await restoreOne(
Lesson,
{ lesson_id: lessonId, unit_id: unitId, ...onlyDeleted },
req.body.restoredBy,
t
);
if (!record) return R.error(res, "Archived lesson not found.", 404);
await t.commit();
return R.success(res, "Lesson restored.", { data: record });
} catch (err) {
await t.rollback();
console.error("[LESSON][RESTORE]", err);
return R.error(res, "Could not restore lesson.", 500);
}
};
exports.bulkRestoreLessons = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId, unitId } = req.params;
const { ids = [], restoredBy } = req.body;
if (!ids.length) return R.error(res, "No IDs provided.", 400);
const unit = await Unit.findOne({
where: { unit_id: unitId, course_id: courseId },
paranoid: false,
});
if (!unit) return R.error(res, "Unit not found.", 404);
const lessons = await Lesson.findAll({
where: { lesson_id: ids, unit_id: unitId, ...onlyDeleted },
paranoid: false,
});
const validIds = lessons.map((l) => l.lesson_id);
const count = await restoreMany(Lesson, "lesson_id", validIds, restoredBy, t);
await t.commit();
return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} restored.`);
} catch (err) {
await t.rollback();
console.error("[LESSON][BULK RESTORE]", err);
return R.error(res, "Could not restore lessons.", 500);
}
};
// ══════════════════════════════════════════════════════════════════════════════
// LESSON PAGE
// ══════════════════════════════════════════════════════════════════════════════
exports.getLessonPage = async (req, res) => {
try {
const { lessonId } = req.params;
const page = await LessonPage.findOne({ where: { lesson_id: lessonId } });
if (!page) return R.error(res, "Lesson page not found.", 404);
return R.success(res, "Lesson page retrieved.", { data: page });
} catch (err) {
console.error("[LESSON PAGE][GET]", err);
return R.error(res, "Could not retrieve lesson page.", 500);
}
};
exports.upsertLessonPage = async (req, res) => {
try {
const { lessonId } = req.params;
const { blocks } = req.body;
if (!Array.isArray(blocks)) return R.error(res, "blocks must be an array.", 400);
const lesson = await Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted } });
if (!lesson) return R.error(res, "Lesson not found.", 404);
const [page, created] = await LessonPage.upsert({
lesson_id: lessonId,
blocks,
updatedBy: req.body.updatedBy ?? null,
createdBy: req.body.updatedBy ?? null,
}, { returning: true });
try {
await recomputeDurations(lessonId);
} catch (durErr) {
console.error("[LESSON PAGE][DURATION]", durErr);
}
return R.success(
res,
created ? "Lesson page created." : "Lesson page updated.",
{ data: page },
created ? 201 : 200,
);
} catch (err) {
console.error("[LESSON PAGE][UPSERT]", err);
return R.error(res, "Could not save lesson page.", 500);
}
};
// ══════════════════════════════════════════════════════════════════════════════
// QUIZ QUESTIONS
// ══════════════════════════════════════════════════════════════════════════════
exports.getQuestions = async (req, res) => {
try {
const parent = await resolveQuestionParent(req.params);
if (!parent?.parentRecord) return R.error(res, "Parent not found.", 404);
const questions = await QuizQuestion.findAll({
where: { [parent.parentField]: parent.parentId, ...notDeleted },
order: [["order_index", "ASC"]],
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
});
return R.success(res, "Questions retrieved.", { data: questions });
} catch (err) {
console.error("[QUESTION][GET ALL]", err);
return R.error(res, "Could not retrieve questions.", 500);
}
};
exports.createQuestion = async (req, res) => {
const t = await sequelize.transaction();
try {
const parent = await resolveQuestionParent(req.params);
if (!parent?.parentRecord) return R.error(res, "Parent not found.", 404);
const { type, question, explanation, order_index, points, options = [], createdBy } = req.body;
const VALID_TYPES = ["true_false", "multiple_choice", "multi_select"];
if (!VALID_TYPES.includes(type)) return R.error(res, `Invalid type. Must be one of: ${VALID_TYPES.join(", ")}`, 400);
if (!question) return R.error(res, "question text is required.", 400);
let finalOptions = options;
if (type === "true_false" && !options.length) {
finalOptions = [
{ text: "True", is_correct: true, order_index: 0 },
{ text: "False", is_correct: false, order_index: 1 },
];
}
const q = await QuizQuestion.create({
[parent.parentField]: parent.parentId,
type,
question,
explanation: explanation ?? null,
order_index: order_index ?? 0,
points: points ?? 1,
createdBy: createdBy ?? null,
}, { transaction: t });
if (finalOptions.length) {
await QuizOption.bulkCreate(
finalOptions.map((o, i) => ({
question_id: q.question_id,
text: o.text,
is_correct: o.is_correct ?? false,
order_index: o.order_index ?? i,
})),
{ transaction: t }
);
}
await t.commit();
const created = await QuizQuestion.findOne({
where: { question_id: q.question_id },
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
});
return R.success(res, "Question created.", { data: created }, 201);
} catch (err) {
await t.rollback();
console.error("[QUESTION][CREATE]", err);
return R.error(res, "Could not create question.", 500);
}
};
exports.updateQuestion = async (req, res) => {
const t = await sequelize.transaction();
try {
const { questionId } = req.params;
const q = await QuizQuestion.findOne({ where: { question_id: questionId, ...notDeleted } });
if (!q) return R.error(res, "Question not found.", 404);
const { type, question, explanation, order_index, points, options, updatedBy } = req.body;
if (type !== undefined) q.type = type;
if (question !== undefined) q.question = question;
if (explanation !== undefined) q.explanation = explanation;
if (order_index !== undefined) q.order_index = order_index;
if (points !== undefined) q.points = points;
q.updatedBy = updatedBy ?? null;
await q.save({ transaction: t });
if (options !== undefined) {
await QuizOption.destroy({ where: { question_id: questionId }, transaction: t });
if (options.length) {
await QuizOption.bulkCreate(
options.map((o, i) => ({
question_id: questionId,
text: o.text,
is_correct: o.is_correct ?? false,
order_index: o.order_index ?? i,
})),
{ transaction: t }
);
}
}
await t.commit();
const updated = await QuizQuestion.findOne({
where: { question_id: questionId },
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
});
return R.success(res, "Question updated.", { data: updated });
} catch (err) {
await t.rollback();
console.error("[QUESTION][UPDATE]", err);
return R.error(res, "Could not update question.", 500);
}
};
exports.deleteQuestion = async (req, res) => {
const t = await sequelize.transaction();
try {
const { questionId } = req.params;
const record = await archiveOne(
QuizQuestion,
{ question_id: questionId, ...notDeleted },
req.body.deletedBy,
t
);
if (!record) return R.error(res, "Question not found.", 404);
await t.commit();
return R.success(res, "Question archived.");
} catch (err) {
await t.rollback();
console.error("[QUESTION][DELETE]", err);
return R.error(res, "Could not archive question.", 500);
}
};
+335
View File
@@ -0,0 +1,335 @@
"use strict";
const { Op } = require("sequelize");
const sequelize = require("../../config/db.config");
const R = require("../../utils/response.util");
const { paginate } = require("../../utils/paginate.util");
const { archiveOne, archiveMany } = require("../../utils/courses/archive.util");
const { restoreOne, restoreMany } = require("../../utils/courses/restore.util");
// ── Models ────────────────────────────────────────────────────────────────────
const {
Course, Unit, Lesson,
UnitQuiz, QuizQuestion, QuizOption,
} = require("../../models/courses/courses.associations");
const mdl_Users = require("../../models/users/users.mdl");
const notDeleted = { deletedAt: null };
const onlyDeleted = { deletedAt: { [Op.not]: null } };
// ══════════════════════════════════════════════════════════════════════════════
// UNIT
// ══════════════════════════════════════════════════════════════════════════════
exports.getUnits = async (req, res) => {
try {
const { courseId } = req.params;
const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted } });
if (!course) return R.error(res, "Course not found.", 404);
const result = await paginate(Unit, req, {
auditOptions: { mdl_Users, parentAlias: "Unit" },
context: "list",
findOptions: {
where: { course_id: courseId, ...notDeleted },
order: [["order_index", "ASC"]],
},
});
return R.success(res, "Units retrieved.", result);
} catch (err) {
console.error("[UNIT][GET ALL]", err);
return R.error(res, "Could not retrieve units.", 500);
}
};
exports.getUnit = async (req, res) => {
try {
const { courseId, unitId } = req.params;
const unit = await Unit.findOne({
where: { unit_id: unitId, course_id: courseId, ...notDeleted },
include: [
{ model: Lesson, as: "lessons", where: notDeleted, required: false },
{ model: UnitQuiz, as: "quiz", required: false },
],
order: [[{ model: Lesson, as: "lessons" }, "order_index", "ASC"]],
});
if (!unit) return R.error(res, "Unit not found.", 404);
return R.success(res, "Unit retrieved.", { data: unit.toJSON() });
} catch (err) {
console.error("[UNIT][GET ONE]", err);
return R.error(res, "Could not retrieve unit.", 500);
}
};
exports.createUnit = async (req, res) => {
try {
const { courseId } = req.params;
const { title, description, order, createdBy } = req.body;
if (!title) return R.error(res, "Title is required.", 400);
const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted } });
if (!course) return R.error(res, "Course not found.", 404);
const unit = await Unit.create({
course_id: courseId,
title,
description: description ?? null,
order_index: order ?? 0,
duration_seconds: 0,
createdBy: createdBy ?? null,
});
return R.success(res, "Unit created.", { data: unit }, 201);
} catch (err) {
console.error("[UNIT][CREATE]", err);
return R.error(res, "Could not create unit.", 500);
}
};
exports.updateUnit = async (req, res) => {
try {
const { courseId, unitId } = req.params;
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
const { title, description, order, updatedBy } = req.body;
if (title !== undefined) unit.title = title;
if (description !== undefined) unit.description = description;
if (order !== undefined) unit.order_index = order;
unit.updatedBy = updatedBy ?? null;
await unit.save();
return R.success(res, "Unit updated.", { data: unit });
} catch (err) {
console.error("[UNIT][UPDATE]", err);
return R.error(res, "Could not update unit.", 500);
}
};
exports.deleteUnit = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId, unitId } = req.params;
const record = await archiveOne(
Unit,
{ unit_id: unitId, course_id: courseId, ...notDeleted },
req.body.deletedBy,
t
);
if (!record) return R.error(res, "Unit not found.", 404);
await t.commit();
return R.success(res, "Unit archived.");
} catch (err) {
await t.rollback();
console.error("[UNIT][DELETE]", err);
return R.error(res, "Could not archive unit.", 500);
}
};
exports.bulkArchiveUnits = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId } = req.params;
const { ids = [], deletedBy } = req.body;
if (!ids.length) return R.error(res, "No IDs provided.", 400);
const units = await Unit.findAll({
where: { unit_id: ids, course_id: courseId, ...notDeleted },
});
const validIds = units.map((u) => u.unit_id);
const count = await archiveMany(Unit, "unit_id", validIds, deletedBy, t);
await t.commit();
return R.success(res, `${count} unit${count !== 1 ? "s" : ""} archived.`);
} catch (err) {
await t.rollback();
console.error("[UNIT][BULK ARCHIVE]", err);
return R.error(res, "Could not archive units.", 500);
}
};
exports.getArchivedUnits = async (req, res) => {
try {
const { courseId } = req.params;
const course = await Course.findOne({ where: { course_id: courseId }, paranoid: false });
if (!course) return R.error(res, "Course not found.", 404);
const result = await paginate(Unit, req, {
auditOptions: { mdl_Users, parentAlias: "Unit" },
context: "list",
findOptions: {
where: { course_id: courseId, ...onlyDeleted },
paranoid: false,
order: [["order_index", "ASC"]],
},
});
return R.success(res, "Archived units retrieved.", result);
} catch (err) {
console.error("[UNIT][GET ARCHIVES]", err);
return R.error(res, "Could not retrieve archived units.", 500);
}
};
exports.getArchivedUnit = async (req, res) => {
try {
const { courseId, unitId } = req.params;
const unit = await Unit.findOne({
where: { unit_id: unitId, course_id: courseId, ...onlyDeleted },
paranoid: false,
});
if (!unit) return R.error(res, "Archived unit not found.", 404);
return R.success(res, "Archived unit retrieved.", { data: unit.toJSON() });
} catch (err) {
console.error("[UNIT][GET ARCHIVE ONE]", err);
return R.error(res, "Could not retrieve archived unit.", 500);
}
};
exports.restoreUnit = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId, unitId } = req.params;
const record = await restoreOne(
Unit,
{ unit_id: unitId, course_id: courseId, ...onlyDeleted },
req.body.restoredBy,
t
);
if (!record) return R.error(res, "Archived unit not found.", 404);
await t.commit();
return R.success(res, "Unit restored.", { data: record });
} catch (err) {
await t.rollback();
console.error("[UNIT][RESTORE]", err);
return R.error(res, "Could not restore unit.", 500);
}
};
exports.bulkRestoreUnits = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId } = req.params;
const { ids = [], restoredBy } = req.body;
if (!ids.length) return R.error(res, "No IDs provided.", 400);
const units = await Unit.findAll({
where: { unit_id: ids, course_id: courseId, ...onlyDeleted },
paranoid: false,
});
const validIds = units.map((u) => u.unit_id);
const count = await restoreMany(Unit, "unit_id", validIds, restoredBy, t);
await t.commit();
return R.success(res, `${count} unit${count !== 1 ? "s" : ""} restored.`);
} catch (err) {
await t.rollback();
console.error("[UNIT][BULK RESTORE]", err);
return R.error(res, "Could not restore units.", 500);
}
};
// ══════════════════════════════════════════════════════════════════════════════
// UNIT QUIZ
// ══════════════════════════════════════════════════════════════════════════════
exports.getQuiz = async (req, res) => {
try {
const { courseId, unitId } = req.params;
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
const quiz = await UnitQuiz.findOne({
where: { unit_id: unitId, ...notDeleted },
include: [{
model: QuizQuestion, as: "questions",
where: notDeleted, required: false,
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
}],
});
if (!quiz) return R.error(res, "Quiz not found.", 404);
return R.success(res, "Quiz retrieved.", { data: quiz });
} catch (err) {
console.error("[QUIZ][GET]", err);
return R.error(res, "Could not retrieve quiz.", 500);
}
};
exports.createQuiz = async (req, res) => {
try {
const { courseId, unitId } = req.params;
const { title, is_required, passing_score, createdBy } = req.body;
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
const existing = await UnitQuiz.findOne({ where: { unit_id: unitId, ...notDeleted } });
if (existing) return R.error(res, "Quiz already exists for this unit.", 409);
const quiz = await UnitQuiz.create({
unit_id: unitId,
title: title ?? null,
is_required: is_required ?? false,
passing_score: passing_score ?? 70,
createdBy: createdBy ?? null,
});
return R.success(res, "Quiz created.", { data: quiz }, 201);
} catch (err) {
console.error("[QUIZ][CREATE]", err);
return R.error(res, "Could not create quiz.", 500);
}
};
exports.updateQuiz = async (req, res) => {
try {
const { courseId, unitId, quizId } = req.params;
const { title, is_required, passing_score, updatedBy } = req.body;
const quiz = await UnitQuiz.findOne({ where: { quiz_id: quizId, unit_id: unitId, ...notDeleted } });
if (!quiz) return R.error(res, "Quiz not found.", 404);
if (title !== undefined) quiz.title = title;
if (is_required !== undefined) quiz.is_required = is_required;
if (passing_score !== undefined) quiz.passing_score = passing_score;
quiz.updatedBy = updatedBy ?? null;
await quiz.save();
return R.success(res, "Quiz updated.", { data: quiz });
} catch (err) {
console.error("[QUIZ][UPDATE]", err);
return R.error(res, "Could not update quiz.", 500);
}
};
exports.deleteQuiz = async (req, res) => {
const t = await sequelize.transaction();
try {
const { unitId, quizId } = req.params;
const record = await archiveOne(
UnitQuiz,
{ quiz_id: quizId, unit_id: unitId, ...notDeleted },
req.body.deletedBy,
t
);
if (!record) return R.error(res, "Quiz not found.", 404);
await t.commit();
return R.success(res, "Quiz archived.");
} catch (err) {
await t.rollback();
console.error("[QUIZ][DELETE]", err);
return R.error(res, "Could not archive quiz.", 500);
}
};
+22
View File
@@ -0,0 +1,22 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const CourseAssessment = sequelize.define("CourseAssessment", {
assessment_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, unique: true },
course_id: { type: DataTypes.BIGINT, allowNull: false, unique: true }, // one per course
title: { type: DataTypes.STRING(255), allowNull: true },
is_required: { type: DataTypes.BOOLEAN, defaultValue: false },
passing_score: { type: DataTypes.INTEGER, defaultValue: 70 },
time_limit_minutes: { type: DataTypes.INTEGER, allowNull: true }, // null = no limit
max_questions: { type: DataTypes.INTEGER, allowNull: true }, // null = show all
createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true },
}, {
tableName: "course_assessments",
timestamps: true,
paranoid: true,
});
module.exports = CourseAssessment
+15
View File
@@ -0,0 +1,15 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const CourseObjective = sequelize.define("CourseObjective", {
objective_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: DataTypes.BIGINT, allowNull: false },
text: { type: DataTypes.TEXT, allowNull: false },
order_index: { type: DataTypes.INTEGER, defaultValue: 0 },
}, {
tableName: "course_objectives",
timestamps: true,
paranoid: true,
});
module.exports = CourseObjective
+15
View File
@@ -0,0 +1,15 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const CoursePrerequisite = sequelize.define("CoursePrerequisite", {
prereq_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: DataTypes.BIGINT, allowNull: false }, // the course that HAS this prereq
ref_type: { type: DataTypes.ENUM("course", "unit", "lesson"), allowNull: false },
ref_id: { type: DataTypes.BIGINT, allowNull: false }, // FK to courses/units/lessons
order_index:{ type: DataTypes.INTEGER, defaultValue: 0 },
}, {
tableName: "course_prerequisites",
timestamps: true,
});
module.exports = CoursePrerequisite
+67
View File
@@ -0,0 +1,67 @@
// models/courses/associations.js
const { Course, CourseProduct, CourseRole, CourseProductCategory } = require("./courses.mdl");
const Unit = require("./units.mdl");
const Lesson = require("./lessons.mdl");
const LessonPage = require("./lesson_page.mdl");
const CourseObjective = require("./course_objective.mdl");
const LessonObjective = require("./lesson_objective.mdl");
const CoursePrerequisite = require("./course_prerequisite.mdl");
const CourseAssessment = require("./course_assessment.mdl");
const UnitQuiz = require("./unit_quiz.mdl");
const QuizQuestion = require("./quiz_question.mdl");
const QuizOption = require("./quiz_option.mdl");
const mdl_Users = require("../users/users.mdl");
// ── Course ────────────────────────────────────────────────────────────────────
Course.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
Course.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
Course.hasMany(Unit, { as: "units", foreignKey: "course_id" });
Course.hasMany(CourseObjective, { as: "objectives", foreignKey: "course_id" });
Course.hasMany(CoursePrerequisite, { as: "prerequisites", foreignKey: "course_id" });
Course.hasOne(CourseAssessment, { as: "assessment", foreignKey: "course_id" });
Course.hasMany(CourseProduct, { as: "products", foreignKey: "course_id" });
Course.hasMany(CourseRole, { as: "roles", foreignKey: "course_id" });
Course.hasMany(CourseProductCategory, { as: "categories", foreignKey: "course_id" });
// ── Unit ──────────────────────────────────────────────────────────────────────
Unit.belongsTo(Course, { as: "course", foreignKey: "course_id" });
Unit.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
Unit.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
Unit.hasMany(Lesson, { as: "lessons", foreignKey: "unit_id" });
Unit.hasOne(UnitQuiz, { as: "quiz", foreignKey: "unit_id" });
// ── Lesson ────────────────────────────────────────────────────────────────────
Lesson.belongsTo(Unit, { as: "unit", foreignKey: "unit_id" });
Lesson.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
Lesson.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
Lesson.hasOne(LessonPage, { as: "page", foreignKey: "lesson_id" });
Lesson.hasMany(LessonObjective, { as: "objectives", foreignKey: "lesson_id" });
// ── UnitQuiz ──────────────────────────────────────────────────────────────────
UnitQuiz.belongsTo(Unit, { as: "unit", foreignKey: "unit_id" });
UnitQuiz.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
UnitQuiz.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
UnitQuiz.hasMany(QuizQuestion, { as: "questions", foreignKey: "quiz_id" });
// ── CourseAssessment ──────────────────────────────────────────────────────────
CourseAssessment.belongsTo(Course, { as: "course", foreignKey: "course_id" });
CourseAssessment.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
CourseAssessment.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
CourseAssessment.hasMany(QuizQuestion, { as: "questions", foreignKey: "assessment_id" });
// ── QuizQuestion ──────────────────────────────────────────────────────────────
QuizQuestion.belongsTo(UnitQuiz, { as: "quiz", foreignKey: "quiz_id" });
QuizQuestion.belongsTo(CourseAssessment, { as: "assessment", foreignKey: "assessment_id" });
QuizQuestion.hasMany(QuizOption, { as: "options", foreignKey: "question_id" });
// ── QuizOption ────────────────────────────────────────────────────────────────
QuizOption.belongsTo(QuizQuestion, { as: "question", foreignKey: "question_id" });
module.exports = {
Course, CourseProduct, CourseRole, CourseProductCategory,
Unit, Lesson, LessonPage,
CourseObjective, LessonObjective,
CoursePrerequisite, CourseAssessment,
UnitQuiz, QuizQuestion, QuizOption,
};
+51
View File
@@ -0,0 +1,51 @@
const excludeAttributes = [
];
const jsonbSchemas = {
// Add here
};
// Different exclude sets per role
const adminExclude = [
...excludeAttributes,
// admins can see audit fields, so nothing extra excluded
];
const userExclude = [
...excludeAttributes,
// regular users cannot see audit trails
"created_by", "updated_by", "deleted_by",
"deleted_at",
];
const computedAttributes = [
{
key: "unitCount",
label: "Units",
type: "number",
order: 5,
literal: `(
SELECT CAST(COUNT(*) AS INTEGER)
FROM "units"
WHERE "units"."course_id" = "Course"."course_id"
AND "units"."deletedAt" IS NULL
)`,
filterable: false,
},
{
key: "lessonCount",
label: "Lessons",
type: "number",
order: 6,
literal: `(
SELECT CAST(COUNT(*) AS INTEGER)
FROM "lessons"
INNER JOIN "units" ON "lessons"."unit_id" = "units"."unit_id"
WHERE "units"."course_id" = "Course"."course_id"
AND "lessons"."deletedAt" IS NULL
)`,
filterable: false,
},
];
module.exports = { excludeAttributes, adminExclude, userExclude, jsonbSchemas, computedAttributes };
+37 -21
View File
@@ -1,32 +1,48 @@
const { DataTypes } = require("sequelize"); const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config"); const sequelize = require("../../config/db.config");
const mdl_Users = require("../users/users.mdl");
const Course = sequelize.define("Course", { const Course = sequelize.define("Course", {
course_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, hidden: true, order: 0, filterable: true },
course_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, hidden: true, order: 0, filterable: true },
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true }, title: { type: DataTypes.STRING(255), allowNull: false, hidden: false, order: 2, filterable: true },
description: { type: DataTypes.TEXT, allowNull: true, hidden: true, order: 0, filterable: true },
title: { type: DataTypes.STRING(255), allowNull: false, label: "Title" }, course_code: { type: DataTypes.STRING(50), allowNull: true, unique: true, hidden: false, order: 1, filterable: true },
description: { type: DataTypes.TEXT, allowNull: true, label: "Description" }, order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, hidden: false, order: 7, filterable: false },
order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Order" }, level: { type: DataTypes.ENUM("beginner", "intermediate", "advanced"), allowNull: true, hidden: false, order: 3, filterable: true },
subscription: { type: DataTypes.ENUM("free", "premium"), allowNull: false, defaultValue: "free", hidden: false, order: 4, filterable: true },
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, hidden: false, order: 8, filterable: false },
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Updated By" }, createdBy: { type: DataTypes.BIGINT, allowNull: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" }, updatedBy: { type: DataTypes.BIGINT, allowNull: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true },
}, { }, {
tableName: "courses", tableName: "courses",
timestamps: true, timestamps: true,
paranoid: true, paranoid: true,
indexes: [
{ fields: ["uuid"] },
{ fields: ["order_index"] },
{ fields: ["deletedAt"] },
],
}); });
Course.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" }); // ── Junction Tables ───────────────────────────────────────────────────────────
Course.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
module.exports = Course; const CourseProduct = sequelize.define("CourseProduct", {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: DataTypes.BIGINT, allowNull: false },
product_id: { type: DataTypes.BIGINT, allowNull: false },
}, { tableName: "course_products", timestamps: true });
const CourseRole = sequelize.define("CourseRole", {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: DataTypes.BIGINT, allowNull: false },
role_id: { type: DataTypes.BIGINT, allowNull: false },
}, { tableName: "course_roles", timestamps: true });
const CourseProductCategory = sequelize.define("CourseProductCategory", {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: DataTypes.BIGINT, allowNull: false },
category_id: { type: DataTypes.BIGINT, allowNull: false },
}, { tableName: "course_product_categories", timestamps: true });
module.exports = {
Course,
CourseProduct,
CourseRole,
CourseProductCategory,
};
+15
View File
@@ -0,0 +1,15 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const LessonObjective = sequelize.define("LessonObjective", {
objective_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
lesson_id: { type: DataTypes.BIGINT, allowNull: false },
text: { type: DataTypes.TEXT, allowNull: false },
order_index: { type: DataTypes.INTEGER, defaultValue: 0 },
}, {
tableName: "lesson_objectives",
timestamps: true,
paranoid: true,
});
module.exports = LessonObjective
@@ -1,7 +1,5 @@
const { DataTypes } = require("sequelize"); const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config"); const sequelize = require("../../config/db.config");
const mdl_Users = require("../users/users.mdl");
const Lesson = require("./lessons.mdl");
const LessonPage = sequelize.define("LessonPage", { const LessonPage = sequelize.define("LessonPage", {
page_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, page_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
@@ -15,9 +13,4 @@ const LessonPage = sequelize.define("LessonPage", {
paranoid: false, paranoid: false,
}); });
LessonPage.belongsTo(Lesson, { as: "lesson", foreignKey: "lesson_id" });
LessonPage.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
LessonPage.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
Lesson.hasOne(LessonPage, { as: "page", foreignKey: "lesson_id" });
module.exports = LessonPage; module.exports = LessonPage;
+10 -13
View File
@@ -1,15 +1,17 @@
const { DataTypes } = require("sequelize"); const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config"); const sequelize = require("../../config/db.config");
const mdl_Users = require("../users/users.mdl");
const Unit = require("./units.mdl");
const Lesson = sequelize.define("Lesson", { const Lesson = sequelize.define("Lesson", {
lesson_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, lesson_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, hidden: true, order: 0 },
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true }, uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, hidden: true, order: 0 },
unit_id: { type: DataTypes.BIGINT, allowNull: false }, unit_id: { type: DataTypes.BIGINT, allowNull: false, hidden: true, order: 0 },
title: { type: DataTypes.STRING(255), allowNull: false, label: "Title" },
description: { type: DataTypes.TEXT, allowNull: true, label: "Description" }, title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", hidden: false, order: 1 },
order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Order" }, description: { type: DataTypes.TEXT, allowNull: true, label: "Description", hidden: false, order: 2 },
duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0 }, // computed from blocks on save
order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Order", hidden: false, order: 3 },
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, 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" },
@@ -25,9 +27,4 @@ const Lesson = sequelize.define("Lesson", {
], ],
}); });
Lesson.belongsTo(Unit, { as: "unit", foreignKey: "unit_id" });
Lesson.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
Lesson.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
Unit.hasMany(Lesson, { as: "lessons", foreignKey: "unit_id" });
module.exports = Lesson; module.exports = Lesson;
+15
View File
@@ -0,0 +1,15 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const QuizOption = sequelize.define("QuizOption", {
option_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
question_id: { type: DataTypes.BIGINT, allowNull: false },
text: { type: DataTypes.TEXT, allowNull: false },
is_correct: { type: DataTypes.BOOLEAN, defaultValue: false },
order_index: { type: DataTypes.INTEGER, defaultValue: 0 },
}, {
tableName: "quiz_options",
timestamps: true,
});
module.exports = QuizOption
+28
View File
@@ -0,0 +1,28 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const QuizQuestion = sequelize.define("QuizQuestion", {
question_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, unique: true },
// polymorphic: belongs to either unit_quiz OR course_assessment
quiz_id: { type: DataTypes.BIGINT, allowNull: true }, // → unit_quizzes
assessment_id: { type: DataTypes.BIGINT, allowNull: true }, // → course_assessments
type: {
type: DataTypes.ENUM("true_false", "multiple_choice", "multi_select"),
allowNull: false,
},
question: { type: DataTypes.TEXT, allowNull: false },
explanation: { type: DataTypes.TEXT, allowNull: true }, // shown after answer
order_index: { type: DataTypes.INTEGER, defaultValue: 0 },
points: { type: DataTypes.INTEGER, defaultValue: 1 },
createdBy: { type: DataTypes.BIGINT, allowNull: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true },
}, {
tableName: "quiz_questions",
timestamps: true,
paranoid: true,
});
module.exports = QuizQuestion
+21
View File
@@ -0,0 +1,21 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const UnitQuiz = sequelize.define("UnitQuiz", {
quiz_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, unique: true },
unit_id: { type: DataTypes.BIGINT, allowNull: false, unique: true }, // one quiz per unit
title: { type: DataTypes.STRING(255), allowNull: true },
is_required: { type: DataTypes.BOOLEAN, defaultValue: false },
passing_score: { type: DataTypes.INTEGER, defaultValue: 70 }, // percentage
max_questions: { type: DataTypes.INTEGER, allowNull: true }, // null = show all
createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true },
}, {
tableName: "unit_quizzes",
timestamps: true,
paranoid: true,
});
module.exports = UnitQuiz
+10 -13
View File
@@ -1,15 +1,17 @@
const { DataTypes } = require("sequelize"); const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config"); const sequelize = require("../../config/db.config");
const mdl_Users = require("../users/users.mdl");
const Course = require("./courses.mdl");
const Unit = sequelize.define("Unit", { const Unit = sequelize.define("Unit", {
unit_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, unit_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, hidden: true, order: 0 },
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true }, uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, hidden: true, order: 0 },
course_id: { type: DataTypes.BIGINT, allowNull: false }, course_id: { type: DataTypes.BIGINT, allowNull: false, hidden: true, order: 0 },
title: { type: DataTypes.STRING(255), allowNull: false, label: "Title" },
description: { type: DataTypes.TEXT, allowNull: true, label: "Description" }, title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", hidden: false, order: 1 },
order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Order" }, description: { type: DataTypes.TEXT, allowNull: true, label: "Description", hidden: false, order: 2 },
order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Order", hidden: false, order: 3 },
duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0 },
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" },
@@ -25,9 +27,4 @@ const Unit = sequelize.define("Unit", {
], ],
}); });
Unit.belongsTo(Course, { as: "course", foreignKey: "course_id" });
Unit.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
Unit.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
Course.hasMany(Unit, { as: "units", foreignKey: "course_id" });
module.exports = Unit; module.exports = Unit;
+2
View File
@@ -25,6 +25,7 @@ const dashboardRoutes = require('./dashboard.routes');
const usersRoutes = require('./users.routes'); 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')
// ── Guards — applied to ALL admin routes ────────────────────────────────────── // ── Guards — applied to ALL admin routes ──────────────────────────────────────
router.use(authenticate, requireAdmin(), adminLimiter); router.use(authenticate, requireAdmin(), adminLimiter);
@@ -34,5 +35,6 @@ router.use('/dashboard', dashboardRoutes);
router.use('/users', usersRoutes); router.use('/users', usersRoutes);
router.use('/groups', groupsRoutes); router.use('/groups', groupsRoutes);
router.use('/assets', assetsRoutes); router.use('/assets', assetsRoutes);
router.use('/courses', coursesRoutes);
module.exports = router; module.exports = router;
+128 -23
View File
@@ -1,30 +1,135 @@
const router = require("express").Router(); const router = require("express").Router();
const controller = require("../../controllers/admin/courses.controller"); const ctrl = require("../../controllers/admin/courses.controller");
const { sensitiveOpsLimiter } = require("../../middleware/rateLimiter.middleware");
// ─── Courses ────────────────────────────────────────────────────────────────── const { authenticate } = require("../../middleware/auth.middleware");
router.get("/", controller.getCourses); const { requireAdmin } = require("../../middleware/rbac.middleware");
router.post("/", sensitiveOpsLimiter, controller.createCourse); const { adminLimiter } = require("../../middleware/rateLimiter.middleware");
router.get("/:courseId", controller.getCourse);
router.patch("/:courseId", sensitiveOpsLimiter, controller.updateCourse);
router.delete("/:courseId", sensitiveOpsLimiter, controller.deleteCourse);
// ─── Units ──────────────────────────────────────────────────────────────────── router.use(authenticate, requireAdmin(), adminLimiter);
router.get("/:courseId/units", controller.getUnits);
router.post("/:courseId/units", sensitiveOpsLimiter, controller.createUnit);
router.get("/:courseId/units/:unitId", controller.getUnit);
router.patch("/:courseId/units/:unitId", sensitiveOpsLimiter, controller.updateUnit);
router.delete("/:courseId/units/:unitId", sensitiveOpsLimiter, controller.deleteUnit);
// ─── Lessons ────────────────────────────────────────────────────────────────── // ══════════════════════════════════════════════════════════════════════════════
router.get("/:courseId/units/:unitId/lessons", controller.getLessons); // COURSES
router.post("/:courseId/units/:unitId/lessons", sensitiveOpsLimiter, controller.createLesson); // ══════════════════════════════════════════════════════════════════════════════
router.get("/:courseId/units/:unitId/lessons/:lessonId", controller.getLesson);
router.patch("/:courseId/units/:unitId/lessons/:lessonId", sensitiveOpsLimiter, controller.updateLesson);
router.delete("/:courseId/units/:unitId/lessons/:lessonId", sensitiveOpsLimiter, controller.deleteLesson);
// ─── Lesson Page ────────────────────────────────────────────────────────────── router.get("/", ctrl.getCourses);
router.get("/:courseId/units/:unitId/lessons/:lessonId/page", controller.getLessonPage); router.post("/", ctrl.createCourse);
router.put("/:courseId/units/:unitId/lessons/:lessonId/page", sensitiveOpsLimiter, controller.upsertLessonPage);
// ── static segments first ────────────────────────────────────────────────────
router.get("/field-values", ctrl.getCourseFieldValues);
router.delete("/bulk", ctrl.bulkArchiveCourses);
router.get("/archives", ctrl.getArchivedCourses);
router.patch("/restore/bulk", ctrl.bulkRestoreCourses);
// ── then :courseId ────────────────────────────────────────────────────────────
router.get("/archives/:courseId", ctrl.getArchivedCourse);
router.patch("/:courseId/restore", ctrl.restoreCourse);
router.get("/:courseId", ctrl.getCourse);
router.put("/:courseId", ctrl.updateCourse);
router.delete("/:courseId", ctrl.archiveCourse);
// ══════════════════════════════════════════════════════════════════════════════
// PREREQUISITES
// ══════════════════════════════════════════════════════════════════════════════
router.get("/:courseId/prerequisites", ctrl.getPrerequisites);
router.put("/:courseId/prerequisites", ctrl.syncPrerequisites);
// ══════════════════════════════════════════════════════════════════════════════
// COURSE ASSESSMENT
// ══════════════════════════════════════════════════════════════════════════════
router.get("/:courseId/assessment", ctrl.getAssessment);
router.post("/:courseId/assessment", ctrl.createAssessment);
// ── static segments first ────────────────────────────────────────────────────
router.get("/:courseId/assessment/archives", ctrl.getArchivedAssessment);
// ── then :assessmentId ────────────────────────────────────────────────────────
router.put("/:courseId/assessment/:assessmentId", ctrl.updateAssessment);
router.delete("/:courseId/assessment/:assessmentId", ctrl.deleteAssessment);
router.patch("/:courseId/assessment/:assessmentId/restore", ctrl.restoreAssessment);
// ── Assessment Questions ──────────────────────────────────────────────────────
router.get("/:courseId/assessment/:assessmentId/questions", ctrl.getQuestions);
router.post("/:courseId/assessment/:assessmentId/questions", ctrl.createQuestion);
// static before :questionId
router.delete("/:courseId/assessment/:assessmentId/questions/bulk", ctrl.bulkArchiveQuestions);
router.patch("/:courseId/assessment/:assessmentId/questions/restore/bulk", ctrl.bulkRestoreQuestions);
router.get("/:courseId/assessment/:assessmentId/questions/archives/:questionId", ctrl.getArchivedQuestion);
router.put("/:courseId/assessment/:assessmentId/questions/:questionId", ctrl.updateQuestion);
router.delete("/:courseId/assessment/:assessmentId/questions/:questionId", ctrl.deleteQuestion);
router.patch("/:courseId/assessment/:assessmentId/questions/:questionId/restore", ctrl.restoreQuestion);
// ══════════════════════════════════════════════════════════════════════════════
// UNITS
// ══════════════════════════════════════════════════════════════════════════════
router.get("/:courseId/units", ctrl.getUnits);
router.post("/:courseId/units", ctrl.createUnit);
// static before :unitId
router.delete("/:courseId/units/bulk", ctrl.bulkArchiveUnits);
router.get("/:courseId/units/archives", ctrl.getArchivedUnits);
router.patch("/:courseId/units/restore/bulk", ctrl.bulkRestoreUnits);
router.get("/:courseId/units/archives/:unitId", ctrl.getArchivedUnit);
router.patch("/:courseId/units/:unitId/restore", ctrl.restoreUnit);
router.get("/:courseId/units/:unitId", ctrl.getUnit);
router.put("/:courseId/units/:unitId", ctrl.updateUnit);
router.delete("/:courseId/units/:unitId", ctrl.deleteUnit);
// ══════════════════════════════════════════════════════════════════════════════
// UNIT QUIZ
// ══════════════════════════════════════════════════════════════════════════════
router.get("/:courseId/units/:unitId/quiz", ctrl.getQuiz);
router.post("/:courseId/units/:unitId/quiz", ctrl.createQuiz);
// static before :quizId
router.get("/:courseId/units/:unitId/quiz/archives", ctrl.getArchivedQuiz);
router.put("/:courseId/units/:unitId/quiz/:quizId", ctrl.updateQuiz);
router.delete("/:courseId/units/:unitId/quiz/:quizId", ctrl.deleteQuiz);
router.patch("/:courseId/units/:unitId/quiz/:quizId/restore", ctrl.restoreQuiz);
// ── Quiz Questions ────────────────────────────────────────────────────────────
router.get("/:courseId/units/:unitId/quiz/:quizId/questions", ctrl.getQuestions);
router.post("/:courseId/units/:unitId/quiz/:quizId/questions", ctrl.createQuestion);
// static before :questionId
router.delete("/:courseId/units/:unitId/quiz/:quizId/questions/bulk", ctrl.bulkArchiveQuestions);
router.patch("/:courseId/units/:unitId/quiz/:quizId/questions/restore/bulk", ctrl.bulkRestoreQuestions);
router.get("/:courseId/units/:unitId/quiz/:quizId/questions/archives/:questionId", ctrl.getArchivedQuestion);
router.put("/:courseId/units/:unitId/quiz/:quizId/questions/:questionId", ctrl.updateQuestion);
router.delete("/:courseId/units/:unitId/quiz/:quizId/questions/:questionId", ctrl.deleteQuestion);
router.patch("/:courseId/units/:unitId/quiz/:quizId/questions/:questionId/restore", ctrl.restoreQuestion);
// ══════════════════════════════════════════════════════════════════════════════
// LESSONS
// ══════════════════════════════════════════════════════════════════════════════
router.get("/:courseId/units/:unitId/lessons", ctrl.getLessons);
router.post("/:courseId/units/:unitId/lessons", ctrl.createLesson);
// static before :lessonId
router.delete("/:courseId/units/:unitId/lessons/bulk", ctrl.bulkArchiveLessons);
router.get("/:courseId/units/:unitId/lessons/archives", ctrl.getArchivedLessons);
router.patch("/:courseId/units/:unitId/lessons/restore/bulk", ctrl.bulkRestoreLessons);
router.get("/:courseId/units/:unitId/lessons/archives/:lessonId", ctrl.getArchivedLesson);
router.patch("/:courseId/units/:unitId/lessons/:lessonId/restore", ctrl.restoreLesson);
router.get("/:courseId/units/:unitId/lessons/:lessonId", ctrl.getLesson);
router.put("/:courseId/units/:unitId/lessons/:lessonId", ctrl.updateLesson);
router.delete("/:courseId/units/:unitId/lessons/:lessonId", ctrl.deleteLesson);
// ══════════════════════════════════════════════════════════════════════════════
// LESSON PAGE
// ══════════════════════════════════════════════════════════════════════════════
router.get("/:courseId/units/:unitId/lessons/:lessonId/page", ctrl.getLessonPage);
router.put("/:courseId/units/:unitId/lessons/:lessonId/page", ctrl.upsertLessonPage);
module.exports = router; module.exports = router;
-2
View File
@@ -33,7 +33,6 @@ const authRoutes = require('./routes/auth.routes');
const clientRoutes = require('./routes/client/client.routes'); const clientRoutes = require('./routes/client/client.routes');
const staffRoutes = require('./routes/staff/staff.routes'); const staffRoutes = require('./routes/staff/staff.routes');
const adminRoutes = require('./routes/admin/admin.routes'); const adminRoutes = require('./routes/admin/admin.routes');
const courseRoutes = require('./routes/admin/courses.routes');
// ── Models (ensure associations are loaded) ──────────────────────────────────── // ── Models (ensure associations are loaded) ────────────────────────────────────
require('./models/users/users.mdl'); require('./models/users/users.mdl');
@@ -97,7 +96,6 @@ app.use('/api/auth', authRoutes);
app.use('/api/client', clientRoutes); app.use('/api/client', clientRoutes);
app.use('/api/staff', staffRoutes); app.use('/api/staff', staffRoutes);
app.use('/api/admin', adminRoutes); app.use('/api/admin', adminRoutes);
app.use('/api/admin/courses', courseRoutes);
// Health check // Health check
app.get('/api/health', (req, res) => { app.get('/api/health', (req, res) => {
+31
View File
@@ -0,0 +1,31 @@
"use strict";
/**
* Single archive — soft delete one record
*/
async function archiveOne(Model, where, deletedBy, transaction) {
const record = await Model.findOne({ where });
if (!record) return null;
await record.update({ deletedBy: deletedBy ?? null }, { transaction });
await record.destroy({ transaction });
return record;
}
/**
* Bulk archive — soft delete multiple records by primary key
*/
async function archiveMany(Model, pkField, ids, deletedBy, transaction) {
if (!ids?.length) return 0;
const records = await Model.findAll({ where: { [pkField]: ids, deletedAt: null } });
if (!records.length) return 0;
for (const record of records) {
await record.update({ deletedBy: deletedBy ?? null }, { transaction });
await record.destroy({ transaction });
}
return records.length;
}
module.exports = { archiveOne, archiveMany };
+16
View File
@@ -0,0 +1,16 @@
"use strict";
/**
* Replace all junction rows for a course in one shot (delete + bulk create).
*/
async function syncJunction(Model, courseId, ids, fkField, transaction) {
await Model.destroy({ where: { course_id: courseId }, transaction });
if (ids?.length) {
await Model.bulkCreate(
ids.map((id) => ({ course_id: courseId, [fkField]: id })),
{ transaction }
);
}
}
module.exports = { syncJunction };
+62
View File
@@ -0,0 +1,62 @@
"use strict";
/**
* For CREATE — plain text array, no IDs needed
* objectives: ["text1"] or [{ text: "text1" }]
*/
async function syncObjectivesCreate(Model, parentField, parentId, objectives, transaction) {
if (!objectives?.length) return;
await Model.bulkCreate(
objectives.map((o, i) => ({
[parentField]: parentId,
text: typeof o === "string" ? o : o.text,
order_index: i,
})),
{ transaction }
);
}
/**
* For UPDATE — upsert by objective_id, hard delete removed ones
* objectives: [{ objective_id: "123", text: "text1" }, { text: "new" }]
*/
async function syncObjectivesUpdate(Model, parentField, parentId, objectives, transaction) {
if (!objectives?.length) {
await Model.destroy({ where: { [parentField]: parentId }, force: true, transaction });
return;
}
const existing = await Model.findAll({ where: { [parentField]: parentId }, transaction });
const existingMap = new Map(existing.map((o) => [String(o.objective_id), o]));
const incomingIds = new Set(
objectives.filter((o) => o.objective_id).map((o) => String(o.objective_id))
);
// Hard delete removed
const toDelete = existing.filter((o) => !incomingIds.has(String(o.objective_id)));
if (toDelete.length) {
await Model.destroy({
where: { objective_id: toDelete.map((o) => o.objective_id) },
force: true,
transaction,
});
}
// Update existing or create new
for (let i = 0; i < objectives.length; i++) {
const item = objectives[i];
const record = item.objective_id ? existingMap.get(String(item.objective_id)) : null;
if (record) {
await record.update({ text: item.text, order_index: i }, { transaction });
} else {
await Model.create(
{ [parentField]: parentId, text: item.text, order_index: i },
{ transaction }
);
}
}
}
module.exports = { syncObjectivesCreate, syncObjectivesUpdate };
+44
View File
@@ -0,0 +1,44 @@
"use strict";
const { Op } = require("sequelize");
const onlyDeleted = { deletedAt: { [Op.not]: null } };
/**
* Soft-restore a single record by clearing deletedAt / deletedBy.
* Returns the record on success, null if not found.
*
* @param {Model} Model - Sequelize model
* @param {object} where - Where clause (must include onlyDeleted or equivalent)
* @param {*} restoredBy - User ID stamped onto updatedBy
* @param {object} t - Sequelize transaction
*/
async function restoreOne(Model, where, restoredBy, t) {
const record = await Model.findOne({ where, paranoid: false });
if (!record) return null;
await record.restore();
await record.update({ updatedBy: restoredBy, deletedBy: null })
return record;
}
/**
* Soft-restore many records by their primary key column.
* Returns the count of restored records.
*
* @param {Model} Model - Sequelize model
* @param {string} pkColumn - Primary key column name (e.g. "course_id")
* @param {Array} ids - Array of primary key values to restore
* @param {*} restoredBy - User ID stamped onto updatedBy
* @param {object} t - Sequelize transaction
*/
async function restoreMany(Model, pkColumn, ids, restoredBy, t) {
const [count] = await Model.update(
{ deletedAt: null, deletedBy: null, updatedBy: restoredBy ?? null },
{ where: { [pkColumn]: ids, ...onlyDeleted }, transaction: t, paranoid: false }
);
return count;
}
module.exports = { restoreOne, restoreMany };
+112
View File
@@ -0,0 +1,112 @@
// utils/duration.util.js
const AVG_WORDS_PER_MINUTE = 200;
function stripHtml(html) {
// Simple regex strip — no extra dependency needed
return (html ?? "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
/**
* Estimate seconds for a single lesson block
* Block shape: { type: 'text'|'image'|'video', content, word_count, video_duration_seconds }
*/
function estimateBlockDuration(block) {
const videoDuration = Number(block.video_duration_seconds) || 0;
// Derive word count from HTML content on the fly
const getWordCount = (html) => {
const text = stripHtml(html);
return text ? text.split(" ").filter(Boolean).length : 0;
};
const readingSecs = (html) =>
Math.ceil((getWordCount(html) / AVG_WORDS_PER_MINUTE) * 60);
switch (block.type) {
case "text":
return readingSecs(block.content?.body);
case "image":
return 60;
case "video":
return videoDuration;
case "text-image":
case "text_image":
return readingSecs(block.content?.body) + 60;
case "text-video":
case "text_video":
return readingSecs(block.content?.body) + videoDuration;
default:
return 0;
}
}
/**
* Recompute and persist duration_seconds up the chain:
* blocks → lesson → unit → course
*/
async function recomputeDurations(lessonId) {
const Lesson = require("../models/courses/lessons.mdl");
const Unit = require("../models/courses/units.mdl");
const { Course } = require("../models/courses/courses.mdl");
const LessonPage = require("../models/courses/lesson_page.mdl");
// 1. Lesson duration from blocks
const page = await LessonPage.findOne({ where: { lesson_id: lessonId } });
const lessonSecs = (page?.blocks ?? []).reduce(
(sum, block) => sum + estimateBlockDuration(block), 0
);
// Guard against NaN before DB write
const safeLessonSecs = isNaN(lessonSecs) ? 0 : lessonSecs;
await Lesson.update({ duration_seconds: safeLessonSecs }, { where: { lesson_id: lessonId } });
// 2. Unit duration — sum of its lessons
const lesson = await Lesson.findOne({ where: { lesson_id: lessonId } });
const [unitResult] = await Lesson.sequelize.query(`
SELECT COALESCE(SUM(duration_seconds), 0) AS total
FROM lessons
WHERE unit_id = :unitId AND "deletedAt" IS NULL
`, { replacements: { unitId: lesson.unit_id }, type: Lesson.sequelize.QueryTypes.SELECT });
await Unit.update(
{ duration_seconds: unitResult.total },
{ where: { unit_id: lesson.unit_id } }
);
// 3. Course duration — sum of its units
const unit = await Unit.findOne({ where: { unit_id: lesson.unit_id } });
const [courseResult] = await Lesson.sequelize.query(`
SELECT COALESCE(SUM(duration_seconds), 0) AS total
FROM units
WHERE course_id = :courseId AND "deletedAt" IS NULL
`, { replacements: { courseId: unit.course_id }, type: Lesson.sequelize.QueryTypes.SELECT });
await Course.update(
{ duration_seconds: courseResult.total },
{ where: { course_id: unit.course_id } }
);
}
/**
* Format seconds → human readable: "1 hr 10 mins", "45 mins", "30 secs"
*/
function formatDuration(seconds) {
if (!seconds) return "0 mins";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const parts = [];
if (h) parts.push(`${h} hr`);
if (m) parts.push(`${m} min${m !== 1 ? "s" : ""}`);
if (!h && !m && s) parts.push(`${s} sec${s !== 1 ? "s" : ""}`);
return parts.join(" ");
}
module.exports = { estimateBlockDuration, recomputeDurations, formatDuration };
+6 -3
View File
@@ -72,6 +72,7 @@ function flattenJsonb(jsonbSchema = {}, prefix = "", exclude = []) {
field: path, field: path,
order: value?.order ?? Infinity, // ← carry order from schema order: value?.order ?? Infinity, // ← carry order from schema
hidden: value?.hidden ?? false, // ← carry hidden flag hidden: value?.hidden ?? false, // ← carry hidden flag
filterable: value.filterable ?? true,
options: {}, options: {},
}); });
} }
@@ -141,14 +142,15 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa
name: defaultTimestampLabels[field] ?? def.label ?? toLabel(field), name: defaultTimestampLabels[field] ?? def.label ?? toLabel(field),
type, type,
field, field,
order: def.order ?? Infinity, // ← carry order from model order: def.order ?? Infinity,
hidden: def.hidden ?? false, // ← carry hidden flag hidden: def.hidden ?? false,
filterable: def.filterable ?? true,
options: resolveOptions(def.type) options: resolveOptions(def.type)
}); });
} }
// ── Audit fields in correct sequence ──────────────────────────────────────── // ── Audit fields in correct sequence ────────────────────────────────────────
for (const { field, type, order, hiddenOnList, hiddenOnArchived } of auditSequence) { for (const { field, type, order, hiddenOnList, hiddenOnArchived, filterable } of auditSequence) {
if (exclude.includes(field)) continue; if (exclude.includes(field)) continue;
if (!rawAttrs[field]) continue; // skip if field doesn't exist on model if (!rawAttrs[field]) continue; // skip if field doesn't exist on model
@@ -161,6 +163,7 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa
order, order,
hidden: isArchived ? hiddenOnArchived : hiddenOnList, hidden: isArchived ? hiddenOnArchived : hiddenOnList,
options: resolveOptions(rawAttrs[field]?.type), options: resolveOptions(rawAttrs[field]?.type),
filterable: filterable ?? true,
}); });
} }
+2 -1
View File
@@ -151,12 +151,13 @@ async function paginate(model, req, {
const totalPages = Math.ceil(count / limit); const totalPages = Math.ceil(count / limit);
// ← Append computed metadata // ← Append computed metadata
const computedMeta = computedAttributes.map(({ key, label, type, order: ord }) => ({ const computedMeta = computedAttributes.map(({ key, label, type, order: ord, filterable }) => ({
name: label ?? key, name: label ?? key,
type: type ?? 'text', type: type ?? 'text',
field: key, field: key,
order: ord ?? Infinity, order: ord ?? Infinity,
options: {}, options: {},
filterable: filterable
})); }));
// ← Merge, sort, THEN strip order // ← Merge, sort, THEN strip order