new commits

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-03 16:20:58 +08:00
parent 1372f4e975
commit 1d12f04967
93 changed files with 3849 additions and 1063 deletions
+182 -21
View File
@@ -24,10 +24,13 @@ const {
UnitQuiz, QuizQuestion, QuizOption,
QuizAttempt, AssessmentSession,
CourseInstructor, CourseAchievement,
UnitReadingProgress, LessonReadingProgress,
} = require("../../models/courses/courses.associations");
const mdl_Users = require("../../models/users/users.mdl");
const { mdl_PlanCourses, mdl_TierPlans } = require("../../models/tiers/tier.associations");
const {
excludeAttributes: courseExclude,
computedAttributes: courseComputed,
@@ -46,7 +49,15 @@ exports.getCourses = async (req, res) => {
try {
const result = await paginate(Course, req, {
excludeAttributes: courseExclude,
computedAttributes: courseComputed,
computedAttributes: [
...courseComputed,
{
key: "assessment_id",
label: "Assessment ID",
type: "text",
literal: `(SELECT assessment_id FROM course_assessments WHERE course_id = "Course"."course_id" AND "deletedAt" IS NULL LIMIT 1)`,
},
],
auditOptions: { mdl_Users, parentAlias: "Course" },
context: "list",
findOptions: { where: { ...notDeleted } },
@@ -126,7 +137,7 @@ exports.createCourse = async (req, res) => {
if (achievement_keys.length) {
await CourseAchievement.bulkCreate(
achievement_keys.slice(0, 3).map((key, i) => ({ course_id: course.course_id, achievement_key: key, order_index: i })),
achievement_keys.slice(0, 1).map((key, i) => ({ course_id: course.course_id, achievement_key: key, order_index: i })),
{ transaction: t },
);
}
@@ -278,6 +289,30 @@ exports.bulkRestoreCourses = async (req, res) => {
}
};
exports.getCourseArchiveImpact = async (req, res) => {
try {
const { courseId } = req.params;
const [activeCount, totalCount] = await Promise.all([
UnitReadingProgress.count({
where: { course_id: courseId, status: "in_progress" },
distinct: true,
col: "user_id",
}),
UnitReadingProgress.count({
where: { course_id: courseId },
distinct: true,
col: "user_id",
}),
]);
return R.success(res, "Impact retrieved.", { activeCount, totalCount });
} catch (err) {
console.error("[COURSE][ARCHIVE IMPACT]", err);
return R.error(res, "Could not retrieve impact.", 500);
}
};
// ══════════════════════════════════════════════════════════════════════════════
// COURSE PREREQUISITES
// ══════════════════════════════════════════════════════════════════════════════
@@ -364,6 +399,22 @@ exports.getUnits = async (req, res) => {
}
};
exports.getUnitArchiveImpact = async (req, res) => {
try {
const { unitId } = req.params;
const [completionCount, progressCount] = await Promise.all([
UnitReadingProgress.count({ where: { unit_id: unitId, status: "completed" } }),
LessonReadingProgress.count({ where: { unit_id: unitId }, distinct: true, col: "user_id" }),
]);
return R.success(res, "Impact retrieved.", { completionCount, progressCount });
} catch (err) {
console.error("[UNIT][ARCHIVE IMPACT]", err);
return R.error(res, "Could not retrieve impact.", 500);
}
};
exports.getUnit = async (req, res) => {
try {
const { courseId, unitId } = req.params;
@@ -1234,6 +1285,87 @@ exports.bulkRestoreQuestions = async (req, res) => {
}
};
exports.bulkSyncQuestions = 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 { questions = [], updatedBy } = req.body;
const existing = await QuizQuestion.findAll({
where: { [parent.parentField]: parent.parentId, ...notDeleted },
});
const existingIds = existing.map((q) => q.question_id);
const incomingIds = questions.filter((q) => q.question_id).map((q) => q.question_id);
const toArchive = existingIds.filter((id) => !incomingIds.includes(id));
if (toArchive.length) {
await QuizQuestion.update(
{ deletedAt: new Date(), deletedBy: updatedBy ?? null },
{ where: { question_id: toArchive }, transaction: t }
);
}
const result = [];
for (let i = 0; i < questions.length; i++) {
const { question_id, type, question, explanation, points, options = [] } = questions[i];
if (question_id && existingIds.includes(question_id)) {
const q = existing.find((e) => e.question_id === question_id);
q.type = type ?? q.type;
q.question = question ?? q.question;
q.explanation = explanation ?? null;
q.order_index = i;
q.points = points ?? q.points;
q.updatedBy = updatedBy ?? null;
await q.save({ transaction: t });
await QuizOption.destroy({ where: { question_id }, transaction: t });
if (options.length) {
await QuizOption.bulkCreate(
options.map((o, oi) => ({ question_id, text: o.text, is_correct: o.is_correct ?? false, order_index: oi })),
{ transaction: t }
);
}
result.push(question_id);
} else {
const q = await QuizQuestion.create({
[parent.parentField]: parent.parentId,
type, question,
explanation: explanation ?? null,
order_index: i,
points: points ?? 1,
createdBy: updatedBy ?? null,
}, { transaction: t });
if (options.length) {
await QuizOption.bulkCreate(
options.map((o, oi) => ({ question_id: q.question_id, text: o.text, is_correct: o.is_correct ?? false, order_index: oi })),
{ transaction: t }
);
}
result.push(q.question_id);
}
}
await t.commit();
const synced = await QuizQuestion.findAll({
where: { question_id: result },
order: [["order_index", "ASC"]],
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
});
logActivity(req.user?.user_id, 'bulk_sync_questions', { entityType: 'question', details: { parentField: parent.parentField, parentId: parent.parentId, count: synced.length } });
return R.success(res, "Questions synced.", { data: synced });
} catch (err) {
await t.rollback();
console.error("[QUESTION][BULK SYNC]", err);
return R.error(res, "Could not sync questions.", 500);
}
};
// ══════════════════════════════════════════════════════════════════════════════
// COURSE ASSESSMENT
// ══════════════════════════════════════════════════════════════════════════════
@@ -1341,11 +1473,12 @@ exports.updateAssessment = async (req, res) => {
const course = await Course.findOne({
where: { course_id: courseId },
attributes: ['title'],
attributes: ['title', 'uuid'],
});
const notify = NOTIFICATION_REGISTRY.assessment_updated.build({
assessmentTitle: assessment.title,
courseTitle: course?.title ?? null,
courseUuid: course?.uuid ?? null,
});
const now = new Date();
await UserNotification.bulkCreate(
@@ -1433,7 +1566,7 @@ exports.getCoursesFlat = async (req, res) => {
try {
const data = await Course.findAll({
where: notDeleted,
attributes: ["uuid", "title"],
attributes: ["uuid", "title", "subscription", "duration_seconds"],
order: [["title", "ASC"]],
});
return R.success(res, "Courses retrieved.", data);
@@ -1448,11 +1581,32 @@ exports.getCoursesBySubscription = async (req, res) => {
const { slug } = req.query;
if (!slug) return R.error(res, 'slug query param is required.', 400);
const data = await Course.findAll({
const rows = await Course.findAll({
where: { ...notDeleted, subscription: slug },
attributes: ['course_id', 'title', 'description', 'subscription'],
include: [{
model: mdl_PlanCourses,
as: 'planCourse',
required: false,
attributes: ['plan_id'],
include: [{
model: mdl_TierPlans,
as: 'plan',
attributes: ['plan_id', 'label'],
}],
}],
order: [['title', 'ASC']],
});
// Flatten so the frontend can just check `assigned_plan` — a course belongs
// to at most one plan (UNIQUE constraint on plan_courses.course_id).
const data = rows.map((c) => {
const plain = c.toJSON();
const assigned_plan = plain.planCourse?.plan ?? null;
delete plain.planCourse;
return { ...plain, assigned_plan };
});
return R.success(res, 'Courses retrieved.', data);
} catch (err) {
console.error('[COURSE][BY SUBSCRIPTION]', err);
@@ -1464,11 +1618,12 @@ exports.getUnitsFlat = async (req, res) => {
try {
const rows = await Unit.findAll({
where: notDeleted,
attributes: ["uuid", "title", "order_index"],
attributes: ["uuid", "title", "order_index", "duration_seconds"],
include: [{
model: Course,
as: "course",
attributes: ["title"],
attributes: ["title", "subscription"],
paranoid: false,
}],
order: [
[{ model: Course, as: "course" }, "title", "ASC"],
@@ -1476,10 +1631,12 @@ exports.getUnitsFlat = async (req, res) => {
],
});
const data = rows.map((u) => ({
uuid: u.uuid,
title: u.title,
order_index: u.order_index ?? 0,
course_title: u.course?.title ?? "",
uuid: u.uuid,
title: u.title,
order_index: u.order_index ?? 0,
duration_seconds: u.duration_seconds ?? 0,
course_title: u.course?.title ?? "",
subscription: u.course?.subscription ?? "free",
}));
return R.success(res, "Units retrieved.", data);
} catch (err) {
@@ -1492,15 +1649,17 @@ exports.getLessonsFlat = async (req, res) => {
try {
const rows = await Lesson.findAll({
where: notDeleted,
attributes: ["uuid", "title", "order_index"],
attributes: ["uuid", "title", "order_index", "duration_seconds"],
include: [{
model: Unit,
as: "unit",
attributes: ["title", "order_index"],
paranoid: false,
include: [{
model: Course,
as: "course",
attributes: ["title"],
attributes: ["title", "subscription"],
paranoid: false,
}],
}],
order: [
@@ -1510,12 +1669,14 @@ exports.getLessonsFlat = async (req, res) => {
],
});
const data = rows.map((l) => ({
uuid: l.uuid,
title: l.title,
order_index: l.order_index ?? 0,
unit_title: l.unit?.title ?? "",
unit_order: l.unit?.order_index ?? 0,
course_title: l.unit?.course?.title ?? "",
uuid: l.uuid,
title: l.title,
order_index: l.order_index ?? 0,
duration_seconds: l.duration_seconds ?? 0,
unit_title: l.unit?.title ?? "",
unit_order: l.unit?.order_index ?? 0,
course_title: l.unit?.course?.title ?? "",
subscription: l.unit?.course?.subscription ?? "free",
}));
return R.success(res, "Lessons retrieved.", data);
} catch (err) {
@@ -1801,8 +1962,8 @@ exports.syncCourseAchievements = async (req, res) => {
const { courseId } = req.params;
const { achievement_keys = [] } = req.body;
if (achievement_keys.length > 3)
return R.error(res, "Maximum 3 achievements allowed per course.", 400);
if (achievement_keys.length > 1)
return R.error(res, "Maximum 1 achievement allowed per course.", 400);
await CourseAchievement.destroy({ where: { course_id: courseId }, transaction: t });