mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
510 lines
18 KiB
JavaScript
510 lines
18 KiB
JavaScript
"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);
|
|
}
|
|
}; |