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 { 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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -1,22 +1,24 @@
|
|||||||
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 },
|
||||||
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
|
|
||||||
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Updated By" },
|
duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0 }, // computed from blocks on save
|
||||||
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
|
|
||||||
|
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,
|
timestamps: true,
|
||||||
paranoid: true,
|
paranoid: true,
|
||||||
indexes: [
|
indexes: [
|
||||||
{ fields: ["uuid"] },
|
{ fields: ["uuid"] },
|
||||||
{ fields: ["unit_id"] },
|
{ 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;
|
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 { 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 },
|
||||||
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
|
order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Order", hidden: false, order: 3 },
|
||||||
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Updated By" },
|
|
||||||
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
|
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,
|
timestamps: true,
|
||||||
paranoid: true,
|
paranoid: true,
|
||||||
indexes: [
|
indexes: [
|
||||||
{ fields: ["uuid"] },
|
{ fields: ["uuid"] },
|
||||||
{ fields: ["course_id"] },
|
{ 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;
|
module.exports = Unit;
|
||||||
@@ -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;
|
||||||
+129
-24
@@ -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);
|
|
||||||
|
|
||||||
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 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) => {
|
||||||
|
|||||||
@@ -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,
|
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user