mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
Adjusted
This commit is contained in:
+1048
-125
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -1,32 +1,48 @@
|
||||
const { DataTypes } = require("sequelize");
|
||||
const sequelize = require("../../config/db.config");
|
||||
const mdl_Users = require("../users/users.mdl");
|
||||
|
||||
const Course = sequelize.define("Course", {
|
||||
|
||||
course_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
|
||||
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true },
|
||||
|
||||
title: { type: DataTypes.STRING(255), allowNull: false, label: "Title" },
|
||||
description: { type: DataTypes.TEXT, allowNull: true, label: "Description" },
|
||||
order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Order" },
|
||||
|
||||
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
|
||||
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Updated By" },
|
||||
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
|
||||
|
||||
course_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, hidden: true, order: 0, filterable: true },
|
||||
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, hidden: true, order: 0, filterable: 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 },
|
||||
course_code: { type: DataTypes.STRING(50), allowNull: true, unique: true, hidden: false, order: 1, filterable: true },
|
||||
order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, hidden: false, order: 7, filterable: false },
|
||||
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 },
|
||||
duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, hidden: false, order: 8, filterable: false },
|
||||
createdBy: { type: DataTypes.BIGINT, allowNull: true },
|
||||
updatedBy: { type: DataTypes.BIGINT, allowNull: true },
|
||||
deletedBy: { type: DataTypes.BIGINT, allowNull: true },
|
||||
}, {
|
||||
tableName: "courses",
|
||||
timestamps: true,
|
||||
paranoid: true,
|
||||
indexes: [
|
||||
{ fields: ["uuid"] },
|
||||
{ fields: ["order_index"] },
|
||||
{ fields: ["deletedAt"] },
|
||||
],
|
||||
});
|
||||
|
||||
Course.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
|
||||
Course.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
|
||||
// ── Junction Tables ───────────────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -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 sequelize = require("../../config/db.config");
|
||||
const mdl_Users = require("../users/users.mdl");
|
||||
const Lesson = require("./lessons.mdl");
|
||||
|
||||
const LessonPage = sequelize.define("LessonPage", {
|
||||
page_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
|
||||
@@ -15,9 +13,4 @@ const LessonPage = sequelize.define("LessonPage", {
|
||||
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;
|
||||
@@ -1,22 +1,24 @@
|
||||
const { DataTypes } = require("sequelize");
|
||||
const sequelize = require("../../config/db.config");
|
||||
const mdl_Users = require("../users/users.mdl");
|
||||
const Unit = require("./units.mdl");
|
||||
const sequelize = require("../../config/db.config");
|
||||
|
||||
const Lesson = sequelize.define("Lesson", {
|
||||
lesson_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
|
||||
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true },
|
||||
unit_id: { type: DataTypes.BIGINT, allowNull: false },
|
||||
title: { type: DataTypes.STRING(255), allowNull: false, label: "Title" },
|
||||
description: { type: DataTypes.TEXT, allowNull: true, label: "Description" },
|
||||
order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Order" },
|
||||
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
|
||||
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Updated By" },
|
||||
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
|
||||
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 },
|
||||
unit_id: { type: DataTypes.BIGINT, allowNull: false, hidden: true, order: 0 },
|
||||
|
||||
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 },
|
||||
|
||||
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" },
|
||||
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Updated By" },
|
||||
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
|
||||
}, {
|
||||
tableName: "lessons",
|
||||
tableName: "lessons",
|
||||
timestamps: true,
|
||||
paranoid: true,
|
||||
paranoid: true,
|
||||
indexes: [
|
||||
{ fields: ["uuid"] },
|
||||
{ fields: ["unit_id"] },
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+16
-19
@@ -1,22 +1,24 @@
|
||||
const { DataTypes } = require("sequelize");
|
||||
const sequelize = require("../../config/db.config");
|
||||
const mdl_Users = require("../users/users.mdl");
|
||||
const Course = require("./courses.mdl");
|
||||
const sequelize = require("../../config/db.config");
|
||||
|
||||
const Unit = sequelize.define("Unit", {
|
||||
unit_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
|
||||
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true },
|
||||
course_id: { type: DataTypes.BIGINT, allowNull: false },
|
||||
title: { type: DataTypes.STRING(255), allowNull: false, label: "Title" },
|
||||
description: { type: DataTypes.TEXT, allowNull: true, label: "Description" },
|
||||
order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Order" },
|
||||
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
|
||||
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Updated By" },
|
||||
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
|
||||
unit_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 },
|
||||
course_id: { type: DataTypes.BIGINT, allowNull: false, hidden: true, order: 0 },
|
||||
|
||||
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 },
|
||||
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" },
|
||||
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Updated By" },
|
||||
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
|
||||
}, {
|
||||
tableName: "units",
|
||||
tableName: "units",
|
||||
timestamps: true,
|
||||
paranoid: true,
|
||||
paranoid: true,
|
||||
indexes: [
|
||||
{ fields: ["uuid"] },
|
||||
{ fields: ["course_id"] },
|
||||
@@ -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;
|
||||
@@ -25,6 +25,7 @@ const dashboardRoutes = require('./dashboard.routes');
|
||||
const usersRoutes = require('./users.routes');
|
||||
const groupsRoutes = require('./groups.routes');
|
||||
const assetsRoutes = require('./assets.routes');
|
||||
const coursesRoutes = require('./courses.routes')
|
||||
|
||||
// ── Guards — applied to ALL admin routes ──────────────────────────────────────
|
||||
router.use(authenticate, requireAdmin(), adminLimiter);
|
||||
@@ -34,5 +35,6 @@ router.use('/dashboard', dashboardRoutes);
|
||||
router.use('/users', usersRoutes);
|
||||
router.use('/groups', groupsRoutes);
|
||||
router.use('/assets', assetsRoutes);
|
||||
router.use('/courses', coursesRoutes);
|
||||
|
||||
module.exports = router;
|
||||
+129
-24
@@ -1,30 +1,135 @@
|
||||
const router = require("express").Router();
|
||||
const controller = require("../../controllers/admin/courses.controller");
|
||||
const { sensitiveOpsLimiter } = require("../../middleware/rateLimiter.middleware");
|
||||
const ctrl = require("../../controllers/admin/courses.controller");
|
||||
|
||||
// ─── Courses ──────────────────────────────────────────────────────────────────
|
||||
router.get("/", controller.getCourses);
|
||||
router.post("/", sensitiveOpsLimiter, controller.createCourse);
|
||||
router.get("/:courseId", controller.getCourse);
|
||||
router.patch("/:courseId", sensitiveOpsLimiter, controller.updateCourse);
|
||||
router.delete("/:courseId", sensitiveOpsLimiter, controller.deleteCourse);
|
||||
const { authenticate } = require("../../middleware/auth.middleware");
|
||||
const { requireAdmin } = require("../../middleware/rbac.middleware");
|
||||
const { adminLimiter } = require("../../middleware/rateLimiter.middleware");
|
||||
|
||||
// ─── Units ────────────────────────────────────────────────────────────────────
|
||||
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);
|
||||
router.use(authenticate, requireAdmin(), adminLimiter);
|
||||
|
||||
// ─── Lessons ──────────────────────────────────────────────────────────────────
|
||||
router.get("/:courseId/units/:unitId/lessons", controller.getLessons);
|
||||
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);
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// COURSES
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ─── Lesson Page ──────────────────────────────────────────────────────────────
|
||||
router.get("/:courseId/units/:unitId/lessons/:lessonId/page", controller.getLessonPage);
|
||||
router.put("/:courseId/units/:unitId/lessons/:lessonId/page", sensitiveOpsLimiter, controller.upsertLessonPage);
|
||||
router.get("/", ctrl.getCourses);
|
||||
router.post("/", ctrl.createCourse);
|
||||
|
||||
module.exports = router;
|
||||
// ── 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;
|
||||
@@ -33,7 +33,6 @@ const authRoutes = require('./routes/auth.routes');
|
||||
const clientRoutes = require('./routes/client/client.routes');
|
||||
const staffRoutes = require('./routes/staff/staff.routes');
|
||||
const adminRoutes = require('./routes/admin/admin.routes');
|
||||
const courseRoutes = require('./routes/admin/courses.routes');
|
||||
|
||||
// ── Models (ensure associations are loaded) ────────────────────────────────────
|
||||
require('./models/users/users.mdl');
|
||||
@@ -97,7 +96,6 @@ app.use('/api/auth', authRoutes);
|
||||
app.use('/api/client', clientRoutes);
|
||||
app.use('/api/staff', staffRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/admin/courses', courseRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (req, res) => {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -72,6 +72,7 @@ function flattenJsonb(jsonbSchema = {}, prefix = "", exclude = []) {
|
||||
field: path,
|
||||
order: value?.order ?? Infinity, // ← carry order from schema
|
||||
hidden: value?.hidden ?? false, // ← carry hidden flag
|
||||
filterable: value.filterable ?? true,
|
||||
options: {},
|
||||
});
|
||||
}
|
||||
@@ -141,14 +142,15 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa
|
||||
name: defaultTimestampLabels[field] ?? def.label ?? toLabel(field),
|
||||
type,
|
||||
field,
|
||||
order: def.order ?? Infinity, // ← carry order from model
|
||||
hidden: def.hidden ?? false, // ← carry hidden flag
|
||||
order: def.order ?? Infinity,
|
||||
hidden: def.hidden ?? false,
|
||||
filterable: def.filterable ?? true,
|
||||
options: resolveOptions(def.type)
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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 (!rawAttrs[field]) continue; // skip if field doesn't exist on model
|
||||
|
||||
@@ -161,6 +163,7 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa
|
||||
order,
|
||||
hidden: isArchived ? hiddenOnArchived : hiddenOnList,
|
||||
options: resolveOptions(rawAttrs[field]?.type),
|
||||
filterable: filterable ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -151,12 +151,13 @@ async function paginate(model, req, {
|
||||
const totalPages = Math.ceil(count / limit);
|
||||
|
||||
// ← 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,
|
||||
type: type ?? 'text',
|
||||
field: key,
|
||||
order: ord ?? Infinity,
|
||||
options: {},
|
||||
filterable: filterable
|
||||
}));
|
||||
|
||||
// ← Merge, sort, THEN strip order
|
||||
|
||||
Reference in New Issue
Block a user