mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
2814 lines
113 KiB
JavaScript
2814 lines
113 KiB
JavaScript
"use strict";
|
||
|
||
const { Op, Sequelize } = require("sequelize");
|
||
const sequelize = require("../../config/db.config");
|
||
const R = require("../../utils/response.util");
|
||
const { paginate } = require("../../utils/paginate.util");
|
||
const { recomputeDurations, recomputeUnitDuration, recomputeCourseDuration, formatDuration } = require("../../utils/duration.util");
|
||
const { syncObjectivesCreate, syncObjectivesUpdate } = require("../../utils/courses/objectives.util");
|
||
const { resolvePrerequisiteTitles } = require("../../utils/courses/resolvePrerequisiteTitles.util");
|
||
const { syncJunction } = require("../../utils/courses/junction.util");
|
||
const { archiveOne, archiveMany } = require("../../utils/courses/archive.util");
|
||
const { restoreOne, restoreMany } = require("../../utils/courses/restore.util");
|
||
const { permanentDeleteOne, permanentDeleteMany } = require("../../utils/courses/permanentDelete.util");
|
||
const { flattenUnits, flattenLessons, nextOrderIndex, reorderJunction } = require("../../utils/courses/hierarchy.util");
|
||
const { getFieldValues } = require("../../utils/fieldValues.util");
|
||
const logActivity = require('../../utils/logActivity.util');
|
||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||
|
||
// ── Models ────────────────────────────────────────────────────────────────────
|
||
|
||
const {
|
||
Course, CourseProductCategory: CourseProductCat,
|
||
Unit, Lesson, LessonPage,
|
||
CourseUnit, UnitLesson,
|
||
CourseObjective, LessonObjective,
|
||
CoursePrerequisite, CourseRole, CourseAssessment,
|
||
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,
|
||
} = require("../../models/courses/courses.attributes");
|
||
|
||
const notDeleted = { deletedAt: null };
|
||
const onlyDeleted = { deletedAt: { [Op.not]: null } };
|
||
const auditByFields = ["createdBy", "updatedBy", "deletedBy"];
|
||
const adminExclude = [];
|
||
|
||
// ── Junction guards ───────────────────────────────────────────────────────────
|
||
// A unit "belongs to" a course when a course_units row links them; same for
|
||
// lessons via unit_lessons. Nested routes validate membership through these.
|
||
|
||
async function getCourseUnitLink(courseId, unitId, transaction) {
|
||
return CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId }, transaction });
|
||
}
|
||
|
||
async function getUnitLessonLink(unitId, lessonId, transaction) {
|
||
return UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId }, transaction });
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// COURSE
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
exports.getCourses = async (req, res) => {
|
||
try {
|
||
const result = await paginate(Course, req, {
|
||
excludeAttributes: courseExclude,
|
||
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 } },
|
||
});
|
||
return R.success(res, "Courses retrieved.", result);
|
||
} catch (err) {
|
||
console.error("[COURSE][GET ALL]", err);
|
||
return R.error(res, "Could not retrieve courses.", 500);
|
||
}
|
||
};
|
||
|
||
exports.getCourse = async (req, res) => {
|
||
try {
|
||
const { courseId } = req.params;
|
||
|
||
const course = await Course.findOne({
|
||
where: { course_id: courseId, ...notDeleted },
|
||
include: [
|
||
{
|
||
model: Unit, as: "units",
|
||
where: notDeleted, required: false,
|
||
through: { attributes: ["order_index"] },
|
||
include: [
|
||
{ model: Lesson, as: "lessons", where: notDeleted, required: false, through: { attributes: ["order_index"] } },
|
||
{ model: UnitQuiz, as: "quiz", required: false },
|
||
],
|
||
},
|
||
{ model: CourseObjective, as: "objectives", required: false },
|
||
{ model: CoursePrerequisite, as: "prerequisites", required: false },
|
||
{ model: CourseRole, as: "roles", required: false },
|
||
{ model: CourseAssessment, as: "assessment", required: false },
|
||
],
|
||
order: [
|
||
[{ model: CourseObjective, as: "objectives" }, "order_index", "ASC"],
|
||
[{ model: CourseRole, as: "roles" }, "order_index", "ASC"],
|
||
],
|
||
});
|
||
|
||
if (!course) return R.error(res, "Course not found.", 404);
|
||
|
||
const plain = course.toJSON();
|
||
plain.units = flattenUnits(plain.units); // junction order_index → flat field, sorted
|
||
plain.prerequisites = await resolvePrerequisiteTitles(plain.prerequisites, { Course, Unit, Lesson });
|
||
return R.success(res, "Course retrieved.", { data: plain });
|
||
} catch (err) {
|
||
console.error("[COURSE][GET ONE]", err);
|
||
return R.error(res, "Could not retrieve course.", 500);
|
||
}
|
||
};
|
||
|
||
exports.createCourse = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const {
|
||
title, description, order_index,
|
||
course_code, level, subscription, status,
|
||
objectives = [],
|
||
roles = [],
|
||
category_ids = [],
|
||
achievement_keys = [],
|
||
badge_color, badge_asset_id, badge_image_url,
|
||
createdBy,
|
||
} = req.body;
|
||
|
||
if (!title) return R.error(res, "Title is required.", 400);
|
||
|
||
const course = await Course.create({
|
||
title,
|
||
description: description ?? null,
|
||
order_index: order_index ?? 0,
|
||
course_code: course_code ?? null,
|
||
level: level ?? null,
|
||
subscription: subscription ?? "free",
|
||
status: status ?? "draft",
|
||
duration_seconds: 0,
|
||
badge_color: badge_color ?? "purple",
|
||
badge_asset_id: badge_asset_id ?? null,
|
||
badge_image_url: badge_image_url ?? null,
|
||
createdBy: createdBy ?? null,
|
||
}, { transaction: t });
|
||
|
||
await syncObjectivesCreate(CourseObjective, "course_id", course.course_id, objectives, t);
|
||
await syncObjectivesCreate(CourseRole, "course_id", course.course_id, roles, t);
|
||
await syncJunction(CourseProductCat, course.course_id, category_ids, "category_id", t);
|
||
|
||
if (achievement_keys.length) {
|
||
await CourseAchievement.bulkCreate(
|
||
achievement_keys.slice(0, 1).map((key, i) => ({ course_id: course.course_id, achievement_key: key, order_index: i })),
|
||
{ transaction: t },
|
||
);
|
||
}
|
||
|
||
await t.commit();
|
||
logActivity(req.user?.user_id, 'create_course', { entityType: 'course', entityId: course.course_id, details: { title: course.title } });
|
||
return R.success(res, "Course created.", { data: course }, 201);
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[COURSE][CREATE]", err);
|
||
return R.error(res, "Could not create course.", 500);
|
||
}
|
||
};
|
||
|
||
// POST /full — single-shot Add Course wizard submit. Creates the course,
|
||
// its objectives/badge/achievement, and the whole units/lessons roadmap
|
||
// (attaching existing library items and/or creating new ones) in one
|
||
// transaction, so the wizard fires exactly one write instead of one per
|
||
// step/click.
|
||
//
|
||
// Body:
|
||
// { title, description, order_index, course_code, level, subscription,
|
||
// objectives: [string], category_ids: [id],
|
||
// badge_color, badge_asset_id, badge_image_url, achievement_keys: [key],
|
||
// units: [{
|
||
// unit_id?, // attach existing library unit
|
||
// title, description, // create new unit when unit_id is absent
|
||
// lessons: [{
|
||
// lesson_id?, // attach existing library lesson
|
||
// title, description, objectives, // create new lesson when lesson_id is absent
|
||
// }],
|
||
// }],
|
||
// createdBy }
|
||
exports.createCourseFull = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const {
|
||
title, description, order_index,
|
||
course_code, level, subscription, status,
|
||
objectives = [],
|
||
roles = [],
|
||
prerequisites = [],
|
||
category_ids = [],
|
||
achievement_keys = [],
|
||
badge_color, badge_asset_id, badge_image_url,
|
||
units = [],
|
||
createdBy,
|
||
} = req.body;
|
||
|
||
if (!title) return R.error(res, "Title is required.", 400);
|
||
|
||
for (const unit of units) {
|
||
if (!unit.unit_id && !unit.title) {
|
||
await t.rollback();
|
||
return R.error(res, "Each new unit needs a title.", 400);
|
||
}
|
||
for (const lesson of unit.lessons ?? []) {
|
||
if (!lesson.lesson_id && !lesson.title) {
|
||
await t.rollback();
|
||
return R.error(res, "Each new lesson needs a title.", 400);
|
||
}
|
||
}
|
||
}
|
||
|
||
const validRefTypes = ["course", "unit", "lesson"];
|
||
for (const p of prerequisites) {
|
||
if (!validRefTypes.includes(p.ref_type)) {
|
||
await t.rollback();
|
||
return R.error(res, `Invalid ref_type: ${p.ref_type}`, 400);
|
||
}
|
||
if (p.ref_id === undefined || p.ref_id === null || p.ref_id === "") {
|
||
await t.rollback();
|
||
return R.error(res, "Each prerequisite needs an item selected.", 400);
|
||
}
|
||
}
|
||
|
||
const course = await Course.create({
|
||
title,
|
||
description: description ?? null,
|
||
order_index: order_index ?? 0,
|
||
course_code: course_code ?? null,
|
||
level: level ?? null,
|
||
subscription: subscription ?? "free",
|
||
status: status ?? "draft",
|
||
duration_seconds: 0,
|
||
badge_color: badge_color ?? "purple",
|
||
badge_asset_id: badge_asset_id ?? null,
|
||
badge_image_url: badge_image_url ?? null,
|
||
createdBy: createdBy ?? null,
|
||
}, { transaction: t });
|
||
|
||
await syncObjectivesCreate(CourseObjective, "course_id", course.course_id, objectives, t);
|
||
await syncObjectivesCreate(CourseRole, "course_id", course.course_id, roles, t);
|
||
await syncJunction(CourseProductCat, course.course_id, category_ids, "category_id", t);
|
||
|
||
if (prerequisites.length) {
|
||
await CoursePrerequisite.bulkCreate(
|
||
prerequisites.map((p, i) => ({
|
||
course_id: course.course_id,
|
||
ref_type: p.ref_type,
|
||
ref_id: p.ref_id,
|
||
order_index: i,
|
||
})),
|
||
{ transaction: t },
|
||
);
|
||
}
|
||
|
||
if (achievement_keys.length) {
|
||
await CourseAchievement.bulkCreate(
|
||
achievement_keys.slice(0, 1).map((key, i) => ({ course_id: course.course_id, achievement_key: key, order_index: i })),
|
||
{ transaction: t },
|
||
);
|
||
}
|
||
|
||
const by = createdBy ?? req.user?.user_id ?? null;
|
||
const touchedUnitIds = [];
|
||
|
||
for (let i = 0; i < units.length; i++) {
|
||
const unitInput = units[i];
|
||
let unit;
|
||
|
||
if (unitInput.unit_id) {
|
||
unit = await Unit.findOne({ where: { unit_id: unitInput.unit_id, ...notDeleted }, transaction: t });
|
||
if (!unit) {
|
||
await t.rollback();
|
||
return R.error(res, "One or more units were not found.", 404);
|
||
}
|
||
const otherLink = await CourseUnit.findOne({ where: { unit_id: unitInput.unit_id }, transaction: t });
|
||
if (otherLink) {
|
||
await t.rollback();
|
||
return R.error(res, `"${unit.title}" is already attached to another course.`, 409);
|
||
}
|
||
} else {
|
||
unit = await Unit.create({
|
||
title: unitInput.title,
|
||
description: unitInput.description ?? null,
|
||
duration_seconds: 0,
|
||
createdBy: by,
|
||
}, { transaction: t });
|
||
}
|
||
|
||
await CourseUnit.create({
|
||
course_id: course.course_id,
|
||
unit_id: unit.unit_id,
|
||
order_index: i,
|
||
createdBy: by,
|
||
}, { transaction: t });
|
||
|
||
const lessons = unitInput.lessons ?? [];
|
||
if (lessons.length) touchedUnitIds.push(unit.unit_id);
|
||
let lessonOrder = unitInput.unit_id ? await nextOrderIndex(UnitLesson, { unit_id: unit.unit_id }, t) : 0;
|
||
|
||
for (const lessonInput of lessons) {
|
||
let lesson;
|
||
|
||
if (lessonInput.lesson_id) {
|
||
lesson = await Lesson.findOne({ where: { lesson_id: lessonInput.lesson_id, ...notDeleted }, transaction: t });
|
||
if (!lesson) {
|
||
await t.rollback();
|
||
return R.error(res, "One or more lessons were not found.", 404);
|
||
}
|
||
const already = await getUnitLessonLink(unit.unit_id, lessonInput.lesson_id, t);
|
||
if (already) continue; // already attached to this unit, nothing to do
|
||
} else {
|
||
lesson = await Lesson.create({
|
||
title: lessonInput.title,
|
||
description: lessonInput.description ?? null,
|
||
duration_seconds: 0,
|
||
createdBy: by,
|
||
}, { transaction: t });
|
||
|
||
await LessonPage.create({
|
||
lesson_id: lesson.lesson_id,
|
||
blocks: [],
|
||
createdBy: by,
|
||
}, { transaction: t });
|
||
|
||
await syncObjectivesCreate(LessonObjective, "lesson_id", lesson.lesson_id, lessonInput.objectives ?? [], t);
|
||
}
|
||
|
||
await UnitLesson.create({
|
||
unit_id: unit.unit_id,
|
||
lesson_id: lesson.lesson_id,
|
||
order_index: lessonOrder++,
|
||
createdBy: by,
|
||
}, { transaction: t });
|
||
}
|
||
}
|
||
|
||
await t.commit();
|
||
|
||
try {
|
||
for (const unitId of touchedUnitIds) await recomputeUnitDuration(unitId);
|
||
await recomputeCourseDuration(course.course_id);
|
||
} catch (durErr) { console.error("[COURSE][CREATE FULL][DURATION]", durErr); }
|
||
|
||
logActivity(req.user?.user_id, 'create_course', { entityType: 'course', entityId: course.course_id, details: { title: course.title, units: units.length } });
|
||
return R.success(res, "Course created.", { data: course }, 201);
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[COURSE][CREATE FULL]", err);
|
||
return R.error(res, "Could not create course.", 500);
|
||
}
|
||
};
|
||
|
||
exports.updateCourse = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
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 {
|
||
title, description, order_index,
|
||
course_code, level, subscription, status,
|
||
objectives, roles, category_ids,
|
||
badge_color, badge_asset_id, badge_image_url,
|
||
updatedBy,
|
||
} = req.body;
|
||
|
||
if (title !== undefined) course.title = title;
|
||
if (description !== undefined) course.description = description;
|
||
if (order_index !== undefined) course.order_index = order_index;
|
||
if (course_code !== undefined) course.course_code = course_code;
|
||
if (level !== undefined) course.level = level;
|
||
if (subscription !== undefined) course.subscription = subscription;
|
||
if (status !== undefined) course.status = status;
|
||
if (badge_color !== undefined) course.badge_color = badge_color;
|
||
if (badge_asset_id !== undefined) course.badge_asset_id = badge_asset_id;
|
||
if (badge_image_url !== undefined) course.badge_image_url = badge_image_url;
|
||
course.updatedBy = updatedBy ?? null;
|
||
await course.save({ transaction: t });
|
||
|
||
if (objectives !== undefined) await syncObjectivesUpdate(CourseObjective, "course_id", courseId, objectives, t);
|
||
if (roles !== undefined) await syncObjectivesUpdate(CourseRole, "course_id", courseId, roles, t, "role_id");
|
||
if (category_ids !== undefined) await syncJunction(CourseProductCat, courseId, category_ids, "category_id", t);
|
||
|
||
await t.commit();
|
||
logActivity(req.user?.user_id, 'update_course', { entityType: 'course', entityId: Number(courseId), details: { title: course.title } });
|
||
return R.success(res, "Course updated.", { data: course });
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[COURSE][UPDATE]", err);
|
||
return R.error(res, "Could not update course.", 500);
|
||
}
|
||
};
|
||
|
||
exports.archiveCourse = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { courseId } = req.params;
|
||
const record = await archiveOne(Course, { course_id: courseId, ...notDeleted }, req.user.user_id, t);
|
||
if (!record) return R.error(res, "Course not found.", 404);
|
||
await t.commit();
|
||
logActivity(req.user.user_id, 'archive_course', { entityType: 'course', entityId: Number(courseId) });
|
||
return R.success(res, "Course archived.");
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[COURSE][ARCHIVE]", err);
|
||
return R.error(res, "Could not archive course.", 500);
|
||
}
|
||
};
|
||
|
||
exports.bulkArchiveCourses = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { ids = [] } = req.body;
|
||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||
|
||
const count = await archiveMany(Course, "course_id", ids, req.user.user_id, t);
|
||
await t.commit();
|
||
logActivity(req.user.user_id, 'bulk_archive_courses', { entityType: 'course', details: { ids, count } });
|
||
return R.success(res, `${count} course${count !== 1 ? "s" : ""} archived.`);
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[COURSE][BULK ARCHIVE]", err);
|
||
return R.error(res, "Could not archive courses.", 500);
|
||
}
|
||
};
|
||
|
||
exports.getArchivedCourses = async (req, res) => {
|
||
try {
|
||
const result = await paginate(Course, req, {
|
||
excludeAttributes: courseExclude,
|
||
computedAttributes: courseComputed,
|
||
auditOptions: { mdl_Users, parentAlias: "Course" },
|
||
context: "archived",
|
||
findOptions: { where: { ...onlyDeleted }, paranoid: false },
|
||
});
|
||
return R.success(res, "Archived courses retrieved.", result);
|
||
} catch (err) {
|
||
console.error("[COURSE][GET ARCHIVES]", err);
|
||
return R.error(res, "Could not retrieve archived courses.", 500);
|
||
}
|
||
};
|
||
|
||
exports.getArchivedCourse = async (req, res) => {
|
||
try {
|
||
const { courseId } = req.params;
|
||
const course = await Course.findOne({
|
||
where: { course_id: courseId, ...onlyDeleted },
|
||
paranoid: false,
|
||
});
|
||
if (!course) return R.error(res, "Archived course not found.", 404);
|
||
return R.success(res, "Archived course retrieved.", { data: course.toJSON() });
|
||
} catch (err) {
|
||
console.error("[COURSE][GET ARCHIVE ONE]", err);
|
||
return R.error(res, "Could not retrieve archived course.", 500);
|
||
}
|
||
};
|
||
|
||
exports.restoreCourse = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { courseId } = req.params;
|
||
const record = await restoreOne(Course, { course_id: courseId }, req.user.user_id, t);
|
||
if (!record) return R.error(res, "Archived course not found.", 404);
|
||
await t.commit();
|
||
logActivity(req.user.user_id, 'restore_course', { entityType: 'course', entityId: Number(courseId) });
|
||
return R.success(res, "Course restored.", { data: record });
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[COURSE][RESTORE]", err);
|
||
return R.error(res, "Could not restore course.", 500);
|
||
}
|
||
};
|
||
|
||
exports.bulkRestoreCourses = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { ids = [] } = req.body;
|
||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||
|
||
const count = await restoreMany(Course, "course_id", ids, req.user.user_id, t);
|
||
await t.commit();
|
||
logActivity(req.user.user_id, 'bulk_restore_courses', { entityType: 'course', details: { ids, count } });
|
||
return R.success(res, `${count} course${count !== 1 ? "s" : ""} restored.`);
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[COURSE][BULK RESTORE]", err);
|
||
return R.error(res, "Could not restore courses.", 500);
|
||
}
|
||
};
|
||
|
||
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);
|
||
}
|
||
};
|
||
|
||
exports.permanentlyDeleteCourse = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { courseId } = req.params;
|
||
const record = await permanentDeleteOne(Course, { course_id: courseId }, t);
|
||
if (record === null) return R.error(res, "Course not found.", 404);
|
||
if (record === false) return R.error(res, "Course must be archived before it can be permanently deleted.", 400);
|
||
await CourseUnit.destroy({ where: { course_id: courseId }, transaction: t }); // detach all units — they survive in the library
|
||
await t.commit();
|
||
logActivity(req.user.user_id, 'permanently_delete_course', { entityType: 'course', entityId: Number(courseId) });
|
||
return R.success(res, "Course permanently deleted.");
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[COURSE][PERMANENT DELETE]", err);
|
||
return R.error(res, "Could not permanently delete course.", 500);
|
||
}
|
||
};
|
||
|
||
exports.bulkPermanentlyDeleteCourses = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { ids = [] } = req.body;
|
||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||
|
||
const count = await permanentDeleteMany(Course, "course_id", ids, t);
|
||
await CourseUnit.destroy({ where: { course_id: ids }, transaction: t });
|
||
await t.commit();
|
||
logActivity(req.user.user_id, 'bulk_permanently_delete_courses', { entityType: 'course', details: { ids, count } });
|
||
return R.success(res, `${count} course${count !== 1 ? "s" : ""} permanently deleted.`);
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[COURSE][BULK PERMANENT DELETE]", err);
|
||
return R.error(res, "Could not permanently delete courses.", 500);
|
||
}
|
||
};
|
||
|
||
exports.getCoursePermanentDeleteImpact = async (req, res) => {
|
||
try {
|
||
const { courseId } = req.params;
|
||
|
||
const links = await CourseUnit.findAll({ where: { course_id: courseId }, attributes: ["unit_id"] });
|
||
const unitIds = links.map((l) => l.unit_id);
|
||
const unitCount = unitIds.length;
|
||
const lessonCount = unitIds.length
|
||
? await UnitLesson.count({ where: { unit_id: unitIds }, distinct: true, col: "lesson_id" })
|
||
: 0;
|
||
|
||
// Deleting a course only removes attachments — units/lessons survive in the library
|
||
return R.success(res, "Impact retrieved.", { unitCount, lessonCount });
|
||
} catch (err) {
|
||
console.error("[COURSE][PERMANENT DELETE IMPACT]", err);
|
||
return R.error(res, "Could not retrieve impact.", 500);
|
||
}
|
||
};
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// COURSE PREREQUISITES
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
exports.getPrerequisites = async (req, res) => {
|
||
try {
|
||
const { courseId } = req.params;
|
||
const prereqs = await CoursePrerequisite.findAll({
|
||
where: { course_id: courseId },
|
||
order: [["order_index", "ASC"]],
|
||
});
|
||
const data = await resolvePrerequisiteTitles(prereqs.map((p) => p.toJSON()), { Course, Unit, Lesson });
|
||
return R.success(res, "Prerequisites retrieved.", { data });
|
||
} catch (err) {
|
||
console.error("[PREREQ][GET ALL]", err);
|
||
return R.error(res, "Could not retrieve prerequisites.", 500);
|
||
}
|
||
};
|
||
|
||
exports.syncPrerequisites = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { courseId } = req.params;
|
||
const { prerequisites = [] } = req.body;
|
||
|
||
const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted } });
|
||
if (!course) return R.error(res, "Course not found.", 404);
|
||
|
||
await CoursePrerequisite.destroy({ where: { course_id: courseId }, transaction: t });
|
||
|
||
if (prerequisites.length) {
|
||
const validTypes = ["course", "unit", "lesson"];
|
||
for (const p of prerequisites) {
|
||
if (!validTypes.includes(p.ref_type)) {
|
||
await t.rollback();
|
||
return R.error(res, `Invalid ref_type: ${p.ref_type}`, 400);
|
||
}
|
||
if (p.ref_id === undefined || p.ref_id === null || p.ref_id === "") {
|
||
await t.rollback();
|
||
return R.error(res, "Each prerequisite needs an item selected.", 400);
|
||
}
|
||
}
|
||
await CoursePrerequisite.bulkCreate(
|
||
prerequisites.map((p, i) => ({
|
||
course_id: courseId,
|
||
ref_type: p.ref_type,
|
||
ref_id: p.ref_id,
|
||
order_index: i,
|
||
})),
|
||
{ transaction: t }
|
||
);
|
||
}
|
||
|
||
await t.commit();
|
||
logActivity(req.user?.user_id, 'sync_prerequisites', { entityType: 'course', entityId: Number(courseId), details: { count: prerequisites.length } });
|
||
return R.success(res, "Prerequisites updated.");
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[PREREQ][SYNC]", err);
|
||
return R.error(res, "Could not update prerequisites.", 500);
|
||
}
|
||
};
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// UNIT (course-scoped — membership through course_units)
|
||
//
|
||
// Units are standalone now. Under a course these endpoints manage the
|
||
// attachment (course_units row): "delete" detaches, ordering lives on the
|
||
// junction row, and existing units can be attached without being re-created.
|
||
// Entity-level archive/permanent-delete lives in /admin/units (the library).
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
// Literal fragments that resolve a unit's order within THIS course.
|
||
const courseOrderLiteral = (courseId) =>
|
||
`(SELECT cu.order_index FROM course_units cu WHERE cu.course_id = ${Number(courseId)} AND cu.unit_id = "Unit"."unit_id" LIMIT 1)`;
|
||
const courseMembershipLiteral = (courseId) =>
|
||
`(SELECT cu.unit_id FROM course_units cu WHERE cu.course_id = ${Number(courseId)})`;
|
||
|
||
exports.getUnits = async (req, res) => {
|
||
try {
|
||
const { courseId } = req.params;
|
||
if (!Number.isFinite(Number(courseId))) return R.error(res, "Course not found.", 404);
|
||
|
||
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, {
|
||
excludeAttributes: adminExclude,
|
||
auditOptions: { mdl_Users, parentAlias: "Unit" },
|
||
context: "list",
|
||
computedAttributes: [
|
||
{
|
||
key: "order_index",
|
||
label: "Order",
|
||
type: "number",
|
||
literal: courseOrderLiteral(courseId),
|
||
},
|
||
{
|
||
key: "quiz_id",
|
||
label: "Quiz ID",
|
||
type: "text",
|
||
literal: `(SELECT quiz_id FROM unit_quizzes WHERE unit_id = "Unit"."unit_id" AND "deletedAt" IS NULL LIMIT 1)`,
|
||
},
|
||
],
|
||
findOptions: {
|
||
where: {
|
||
unit_id: { [Op.in]: Sequelize.literal(courseMembershipLiteral(courseId)) },
|
||
...notDeleted,
|
||
},
|
||
order: [[Sequelize.literal(courseOrderLiteral(courseId)), "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);
|
||
}
|
||
};
|
||
|
||
// A unit already attached elsewhere can't also be attached here (it would
|
||
// alias the same content into two courses). Instead of moving it, clone it:
|
||
// a new Unit row, sharing the same lessons via unit_lessons (edits to a
|
||
// lesson still show up everywhere it's attached), and a deep copy of the
|
||
// quiz (unit_quizzes.unit_id is 1:1, so the quiz can't be shared).
|
||
async function duplicateUnitForAttach(sourceUnit, userId, t) {
|
||
const clone = await Unit.create({
|
||
title: `${sourceUnit.title} (Copy)`,
|
||
subscription: sourceUnit.subscription,
|
||
description: sourceUnit.description,
|
||
duration_seconds: sourceUnit.duration_seconds,
|
||
createdBy: userId,
|
||
}, { transaction: t });
|
||
|
||
const lessonLinks = await UnitLesson.findAll({ where: { unit_id: sourceUnit.unit_id }, transaction: t });
|
||
if (lessonLinks.length) {
|
||
await UnitLesson.bulkCreate(
|
||
lessonLinks.map((l) => ({
|
||
unit_id: clone.unit_id,
|
||
lesson_id: l.lesson_id,
|
||
order_index: l.order_index,
|
||
createdBy: userId,
|
||
})),
|
||
{ transaction: t }
|
||
);
|
||
}
|
||
|
||
const sourceQuiz = await UnitQuiz.findOne({ where: { unit_id: sourceUnit.unit_id }, transaction: t });
|
||
if (sourceQuiz) {
|
||
const quizClone = await UnitQuiz.create({
|
||
unit_id: clone.unit_id,
|
||
title: sourceQuiz.title,
|
||
is_required: sourceQuiz.is_required,
|
||
passing_score: sourceQuiz.passing_score,
|
||
max_questions: sourceQuiz.max_questions,
|
||
shuffle_questions: sourceQuiz.shuffle_questions,
|
||
createdBy: userId,
|
||
}, { transaction: t });
|
||
|
||
const questions = await QuizQuestion.findAll({ where: { quiz_id: sourceQuiz.quiz_id }, transaction: t });
|
||
for (const q of questions) {
|
||
const questionClone = await QuizQuestion.create({
|
||
quiz_id: quizClone.quiz_id,
|
||
type: q.type,
|
||
question: q.question,
|
||
explanation: q.explanation,
|
||
order_index: q.order_index,
|
||
points: q.points,
|
||
createdBy: userId,
|
||
}, { transaction: t });
|
||
|
||
const options = await QuizOption.findAll({ where: { question_id: q.question_id }, transaction: t });
|
||
if (options.length) {
|
||
await QuizOption.bulkCreate(
|
||
options.map((o) => ({
|
||
question_id: questionClone.question_id,
|
||
text: o.text,
|
||
is_correct: o.is_correct,
|
||
order_index: o.order_index,
|
||
})),
|
||
{ transaction: t }
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
return clone;
|
||
}
|
||
|
||
// POST /:courseId/units/attach { unit_ids: [..] } — attach existing library units
|
||
exports.attachUnits = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { courseId } = req.params;
|
||
const { unit_ids = [] } = req.body;
|
||
if (!unit_ids.length) return R.error(res, "unit_ids is required.", 400);
|
||
|
||
const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted }, transaction: t });
|
||
if (!course) return R.error(res, "Course not found.", 404);
|
||
|
||
const units = await Unit.findAll({ where: { unit_id: unit_ids, ...notDeleted }, transaction: t });
|
||
if (units.length !== unit_ids.length) {
|
||
await t.rollback();
|
||
return R.error(res, "One or more units were not found.", 404);
|
||
}
|
||
const unitById = new Map(units.map((u) => [String(u.unit_id), u]));
|
||
|
||
const existing = await CourseUnit.findAll({ where: { course_id: courseId, unit_id: unit_ids }, transaction: t });
|
||
const existingSet = new Set(existing.map((r) => String(r.unit_id)));
|
||
const toAttach = unit_ids.filter((id) => !existingSet.has(String(id)));
|
||
|
||
// A unit already attached to another course gets duplicated instead of
|
||
// moved — the original stays put, a clone is attached here.
|
||
const otherCourseLinks = toAttach.length
|
||
? await CourseUnit.findAll({ where: { unit_id: toAttach }, transaction: t })
|
||
: [];
|
||
const needsDuplicate = new Set(otherCourseLinks.map((l) => String(l.unit_id)));
|
||
|
||
const duplicated = [];
|
||
const finalUnitIds = [];
|
||
for (const unit_id of toAttach) {
|
||
if (needsDuplicate.has(String(unit_id))) {
|
||
const clone = await duplicateUnitForAttach(unitById.get(String(unit_id)), req.user?.user_id ?? null, t);
|
||
duplicated.push({ from: unit_id, to: clone.unit_id, title: clone.title });
|
||
finalUnitIds.push(clone.unit_id);
|
||
} else {
|
||
finalUnitIds.push(unit_id);
|
||
}
|
||
}
|
||
|
||
let order = await nextOrderIndex(CourseUnit, { course_id: courseId }, t);
|
||
await CourseUnit.bulkCreate(
|
||
finalUnitIds.map((unit_id) => ({
|
||
course_id: courseId,
|
||
unit_id,
|
||
order_index: order++,
|
||
createdBy: req.user?.user_id ?? null,
|
||
})),
|
||
{ transaction: t }
|
||
);
|
||
|
||
await t.commit();
|
||
try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][ATTACH][DURATION]", durErr); }
|
||
logActivity(req.user?.user_id, "attach_units", { entityType: "course", entityId: Number(courseId), details: { unit_ids: finalUnitIds, duplicated } });
|
||
return R.success(res, `${finalUnitIds.length} unit${finalUnitIds.length !== 1 ? "s" : ""} attached.`, { attached: finalUnitIds, duplicated, skipped: unit_ids.filter((id) => existingSet.has(String(id))) });
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[UNIT][ATTACH]", err);
|
||
return R.error(res, "Could not attach units.", 500);
|
||
}
|
||
};
|
||
|
||
// PUT /:courseId/units/order { unit_ids: [orderedIds] }
|
||
exports.reorderUnits = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { courseId } = req.params;
|
||
const { unit_ids = [] } = req.body;
|
||
if (!unit_ids.length) return R.error(res, "unit_ids is required.", 400);
|
||
|
||
const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted }, transaction: t });
|
||
if (!course) return R.error(res, "Course not found.", 404);
|
||
|
||
await reorderJunction(CourseUnit, "course_id", courseId, "unit_id", unit_ids, t);
|
||
await t.commit();
|
||
logActivity(req.user?.user_id, "reorder_units", { entityType: "course", entityId: Number(courseId), details: { unit_ids } });
|
||
return R.success(res, "Unit order updated.");
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[UNIT][REORDER]", err);
|
||
return R.error(res, "Could not reorder units.", 500);
|
||
}
|
||
};
|
||
|
||
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;
|
||
|
||
const link = await getCourseUnitLink(courseId, unitId);
|
||
if (!link) return R.error(res, "Unit not found.", 404);
|
||
|
||
const unit = await Unit.findOne({
|
||
where: { unit_id: unitId, ...notDeleted },
|
||
include: [
|
||
{ model: Lesson, as: "lessons", where: notDeleted, required: false, through: { attributes: ["order_index"] } },
|
||
{ model: UnitQuiz, as: "quiz", required: false },
|
||
],
|
||
});
|
||
|
||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||
|
||
const plain = unit.toJSON();
|
||
plain.order_index = link.order_index;
|
||
plain.lessons = flattenLessons(plain.lessons);
|
||
return R.success(res, "Unit retrieved.", { data: plain });
|
||
} catch (err) {
|
||
console.error("[UNIT][GET ONE]", err);
|
||
return R.error(res, "Could not retrieve unit.", 500);
|
||
}
|
||
};
|
||
|
||
// POST /:courseId/units — body { unit_id } attaches an existing library unit;
|
||
// otherwise creates a new standalone unit AND attaches it (back-compat shape).
|
||
exports.createUnit = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { courseId } = req.params;
|
||
const { unit_id, title, description, order, createdBy } = req.body;
|
||
|
||
const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted }, transaction: t });
|
||
if (!course) return R.error(res, "Course not found.", 404);
|
||
|
||
let unit;
|
||
let duplicatedFrom = null;
|
||
if (unit_id) {
|
||
unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, transaction: t });
|
||
if (!unit) {
|
||
await t.rollback();
|
||
return R.error(res, "Unit not found.", 404);
|
||
}
|
||
const already = await getCourseUnitLink(courseId, unit_id, t);
|
||
if (already) {
|
||
await t.rollback();
|
||
return R.error(res, "Unit is already attached to this course.", 409);
|
||
}
|
||
// A unit already attached to another course gets duplicated instead of
|
||
// moved — the original stays where it is.
|
||
const otherLink = await CourseUnit.findOne({ where: { unit_id }, transaction: t });
|
||
if (otherLink) {
|
||
duplicatedFrom = unit.unit_id;
|
||
unit = await duplicateUnitForAttach(unit, createdBy ?? req.user?.user_id ?? null, t);
|
||
}
|
||
} else {
|
||
if (!title) return R.error(res, "Title is required.", 400);
|
||
unit = await Unit.create({
|
||
title,
|
||
description: description ?? null,
|
||
duration_seconds: 0,
|
||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||
}, { transaction: t });
|
||
}
|
||
|
||
const order_index = order ?? await nextOrderIndex(CourseUnit, { course_id: courseId }, t);
|
||
await CourseUnit.create({
|
||
course_id: courseId,
|
||
unit_id: unit.unit_id,
|
||
order_index,
|
||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||
}, { transaction: t });
|
||
|
||
await t.commit();
|
||
try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][CREATE][DURATION]", durErr); }
|
||
logActivity(req.user?.user_id, 'create_unit', { entityType: 'unit', entityId: unit.unit_id, details: { title: unit.title, course_id: Number(courseId), attached_existing: !!unit_id, duplicated_from_unit_id: duplicatedFrom } });
|
||
return R.success(res, unit_id ? "Unit attached." : "Unit created.", { data: { ...unit.toJSON(), order_index } }, 201);
|
||
} catch (err) {
|
||
await t.rollback();
|
||
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 link = await getCourseUnitLink(courseId, unitId);
|
||
if (!link) return R.error(res, "Unit not found.", 404);
|
||
|
||
const unit = await Unit.findOne({ where: { unit_id: unitId, ...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;
|
||
unit.updatedBy = updatedBy ?? req.user?.user_id ?? null;
|
||
await unit.save();
|
||
|
||
if (order !== undefined) {
|
||
await link.update({ order_index: order, updatedBy: updatedBy ?? req.user?.user_id ?? null });
|
||
}
|
||
|
||
logActivity(req.user?.user_id, 'update_unit', { entityType: 'unit', entityId: Number(unitId) });
|
||
return R.success(res, "Unit updated.", { data: { ...unit.toJSON(), order_index: order !== undefined ? order : link.order_index } });
|
||
} catch (err) {
|
||
console.error("[UNIT][UPDATE]", err);
|
||
return R.error(res, "Could not update unit.", 500);
|
||
}
|
||
};
|
||
|
||
// DELETE /:courseId/units/:unitId — detaches the unit from this course.
|
||
// The unit itself survives in the library (archive it from /admin/units).
|
||
exports.archiveUnit = async (req, res) => {
|
||
try {
|
||
const { courseId, unitId } = req.params;
|
||
|
||
const removed = await CourseUnit.destroy({ where: { course_id: courseId, unit_id: unitId } });
|
||
if (!removed) return R.error(res, "Unit is not attached to this course.", 404);
|
||
|
||
try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][DETACH][DURATION]", durErr); }
|
||
logActivity(req.user.user_id, 'detach_unit', { entityType: 'unit', entityId: Number(unitId), details: { course_id: Number(courseId) } });
|
||
return R.success(res, "Unit removed from course.");
|
||
} catch (err) {
|
||
console.error("[UNIT][DETACH]", err);
|
||
return R.error(res, "Could not remove unit from course.", 500);
|
||
}
|
||
};
|
||
|
||
exports.bulkArchiveUnits = async (req, res) => {
|
||
try {
|
||
const { courseId } = req.params;
|
||
const { ids = [] } = req.body;
|
||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||
|
||
const count = await CourseUnit.destroy({ where: { course_id: courseId, unit_id: ids } });
|
||
|
||
try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][BULK DETACH][DURATION]", durErr); }
|
||
logActivity(req.user.user_id, 'bulk_detach_units', { entityType: 'unit', details: { ids, count, course_id: Number(courseId) } });
|
||
return R.success(res, `${count} unit${count !== 1 ? "s" : ""} removed from course.`);
|
||
} catch (err) {
|
||
console.error("[UNIT][BULK DETACH]", err);
|
||
return R.error(res, "Could not remove units from course.", 500);
|
||
}
|
||
};
|
||
|
||
exports.getArchivedUnits = async (req, res) => {
|
||
try {
|
||
const { courseId } = req.params;
|
||
if (!Number.isFinite(Number(courseId))) return R.error(res, "Course not found.", 404);
|
||
|
||
const course = await Course.findOne({ where: { course_id: courseId }, paranoid: false });
|
||
if (!course) return R.error(res, "Course not found.", 404);
|
||
|
||
// Archived units that are still attached to this course
|
||
const result = await paginate(Unit, req, {
|
||
excludeAttributes: adminExclude,
|
||
auditOptions: { mdl_Users, parentAlias: "Unit" },
|
||
context: "archived",
|
||
findOptions: {
|
||
where: {
|
||
unit_id: { [Op.in]: Sequelize.literal(courseMembershipLiteral(courseId)) },
|
||
...onlyDeleted,
|
||
},
|
||
paranoid: false,
|
||
order: [[Sequelize.literal(courseOrderLiteral(courseId)), "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 link = await getCourseUnitLink(courseId, unitId);
|
||
if (!link) return R.error(res, "Archived unit not found.", 404);
|
||
|
||
const unit = await Unit.findOne({
|
||
where: { unit_id: unitId, ...onlyDeleted },
|
||
paranoid: false,
|
||
});
|
||
if (!unit) return R.error(res, "Archived unit not found.", 404);
|
||
return R.success(res, "Archived unit retrieved.", { data: { ...unit.toJSON(), order_index: link.order_index } });
|
||
} 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 link = await getCourseUnitLink(courseId, unitId, t);
|
||
if (!link) return R.error(res, "Archived unit not found.", 404);
|
||
|
||
const record = await restoreOne(Unit, { unit_id: unitId, ...onlyDeleted }, req.user.user_id, t);
|
||
if (!record) return R.error(res, "Archived unit not found.", 404);
|
||
await t.commit();
|
||
try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][RESTORE][DURATION]", durErr); }
|
||
logActivity(req.user.user_id, 'restore_unit', { entityType: 'unit', entityId: Number(unitId) });
|
||
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 = [] } = req.body;
|
||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||
|
||
const links = await CourseUnit.findAll({ where: { course_id: courseId, unit_id: ids }, transaction: t });
|
||
const linkedIds = links.map((l) => l.unit_id);
|
||
|
||
const units = await Unit.findAll({ where: { unit_id: linkedIds, ...onlyDeleted }, paranoid: false, transaction: t });
|
||
const validIds = units.map((u) => u.unit_id);
|
||
|
||
const count = await restoreMany(Unit, "unit_id", validIds, req.user.user_id, t);
|
||
await t.commit();
|
||
try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][BULK RESTORE][DURATION]", durErr); }
|
||
logActivity(req.user.user_id, 'bulk_restore_units', { entityType: 'unit', details: { ids: validIds, count } });
|
||
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);
|
||
}
|
||
};
|
||
|
||
exports.permanentlyDeleteUnit = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { courseId, unitId } = req.params;
|
||
|
||
const link = await getCourseUnitLink(courseId, unitId, t);
|
||
if (!link) return R.error(res, "Unit not found.", 404);
|
||
|
||
const record = await permanentDeleteOne(Unit, { unit_id: unitId }, t);
|
||
if (record === null) return R.error(res, "Unit not found.", 404);
|
||
if (record === false) return R.error(res, "Unit must be archived before it can be permanently deleted.", 400);
|
||
|
||
await CourseUnit.destroy({ where: { unit_id: unitId }, transaction: t });
|
||
await UnitLesson.destroy({ where: { unit_id: unitId }, transaction: t });
|
||
|
||
await t.commit();
|
||
try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][PERMANENT DELETE][DURATION]", durErr); }
|
||
logActivity(req.user.user_id, 'permanently_delete_unit', { entityType: 'unit', entityId: Number(unitId) });
|
||
return R.success(res, "Unit permanently deleted.");
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[UNIT][PERMANENT DELETE]", err);
|
||
return R.error(res, "Could not permanently delete unit.", 500);
|
||
}
|
||
};
|
||
|
||
exports.bulkPermanentlyDeleteUnits = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { courseId } = req.params;
|
||
const { ids = [] } = req.body;
|
||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||
|
||
const links = await CourseUnit.findAll({ where: { course_id: courseId, unit_id: ids }, transaction: t });
|
||
const validIds = links.map((l) => l.unit_id);
|
||
|
||
const count = await permanentDeleteMany(Unit, "unit_id", validIds, t);
|
||
await CourseUnit.destroy({ where: { unit_id: validIds }, transaction: t });
|
||
await UnitLesson.destroy({ where: { unit_id: validIds }, transaction: t });
|
||
|
||
await t.commit();
|
||
try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][BULK PERMANENT DELETE][DURATION]", durErr); }
|
||
logActivity(req.user.user_id, 'bulk_permanently_delete_units', { entityType: 'unit', details: { ids: validIds, count } });
|
||
return R.success(res, `${count} unit${count !== 1 ? "s" : ""} permanently deleted.`);
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[UNIT][BULK PERMANENT DELETE]", err);
|
||
return R.error(res, "Could not permanently delete units.", 500);
|
||
}
|
||
};
|
||
|
||
exports.getUnitPermanentDeleteImpact = async (req, res) => {
|
||
try {
|
||
const { unitId } = req.params;
|
||
|
||
const [lessonCount, courseCount] = await Promise.all([
|
||
UnitLesson.count({ where: { unit_id: unitId } }),
|
||
CourseUnit.count({ where: { unit_id: unitId } }),
|
||
]);
|
||
|
||
return R.success(res, "Impact retrieved.", { lessonCount, courseCount });
|
||
} catch (err) {
|
||
console.error("[UNIT][PERMANENT DELETE IMPACT]", err);
|
||
return R.error(res, "Could not retrieve impact.", 500);
|
||
}
|
||
};
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// LESSON (unit-scoped — membership through unit_lessons)
|
||
//
|
||
// Lessons are standalone now. Under a unit these endpoints manage the
|
||
// attachment (unit_lessons row): "delete" detaches, ordering lives on the
|
||
// junction row, and existing lessons can be attached without being re-created.
|
||
// Entity-level archive/permanent-delete lives in /admin/lessons (the library).
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
const unitOrderLiteral = (unitId) =>
|
||
`(SELECT ul.order_index FROM unit_lessons ul WHERE ul.unit_id = ${Number(unitId)} AND ul.lesson_id = "Lesson"."lesson_id" LIMIT 1)`;
|
||
const unitMembershipLiteral = (unitId) =>
|
||
`(SELECT ul.lesson_id FROM unit_lessons ul WHERE ul.unit_id = ${Number(unitId)})`;
|
||
|
||
exports.getLessons = async (req, res) => {
|
||
try {
|
||
const { courseId, unitId } = req.params;
|
||
if (!Number.isFinite(Number(unitId))) return R.error(res, "Unit not found.", 404);
|
||
|
||
const link = await getCourseUnitLink(courseId, unitId);
|
||
if (!link) return R.error(res, "Unit not found.", 404);
|
||
|
||
const result = await paginate(Lesson, req, {
|
||
excludeAttributes: adminExclude,
|
||
auditOptions: { mdl_Users, parentAlias: "Lesson" },
|
||
context: "list",
|
||
computedAttributes: [
|
||
{
|
||
key: "order_index",
|
||
label: "Order",
|
||
type: "number",
|
||
literal: unitOrderLiteral(unitId),
|
||
},
|
||
],
|
||
findOptions: {
|
||
where: {
|
||
lesson_id: { [Op.in]: Sequelize.literal(unitMembershipLiteral(unitId)) },
|
||
...notDeleted,
|
||
},
|
||
order: [[Sequelize.literal(unitOrderLiteral(unitId)), "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 courseLink = await getCourseUnitLink(courseId, unitId);
|
||
if (!courseLink) return R.error(res, "Unit not found.", 404);
|
||
|
||
const lessonLink = await getUnitLessonLink(unitId, lessonId);
|
||
if (!lessonLink) return R.error(res, "Lesson not found.", 404);
|
||
|
||
const lesson = await Lesson.findOne({
|
||
where: { lesson_id: lessonId, ...notDeleted },
|
||
include: [
|
||
{ model: LessonPage, as: "page", required: false },
|
||
{ model: LessonObjective, as: "objectives", required: false, order: [["order_index", "ASC"]] },
|
||
],
|
||
});
|
||
|
||
if (!lesson) return R.error(res, "Lesson not found.", 404);
|
||
return R.success(res, "Lesson retrieved.", { data: { ...lesson.toJSON(), order_index: lessonLink.order_index } });
|
||
} catch (err) {
|
||
console.error("[LESSON][GET ONE]", err);
|
||
return R.error(res, "Could not retrieve lesson.", 500);
|
||
}
|
||
};
|
||
|
||
// POST /:courseId/units/:unitId/lessons — body { lesson_id } attaches an existing
|
||
// library lesson; otherwise creates a new standalone lesson AND attaches it.
|
||
exports.createLesson = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { courseId, unitId } = req.params;
|
||
const { lesson_id, title, description, order, objectives = [], createdBy } = req.body;
|
||
|
||
const courseLink = await getCourseUnitLink(courseId, unitId, t);
|
||
if (!courseLink) return R.error(res, "Unit not found.", 404);
|
||
|
||
let lesson;
|
||
if (lesson_id) {
|
||
lesson = await Lesson.findOne({ where: { lesson_id, ...notDeleted }, transaction: t });
|
||
if (!lesson) {
|
||
await t.rollback();
|
||
return R.error(res, "Lesson not found.", 404);
|
||
}
|
||
const already = await getUnitLessonLink(unitId, lesson_id, t);
|
||
if (already) {
|
||
await t.rollback();
|
||
return R.error(res, "Lesson is already attached to this unit.", 409);
|
||
}
|
||
} else {
|
||
if (!title) return R.error(res, "Title is required.", 400);
|
||
lesson = await Lesson.create({
|
||
title,
|
||
description: description ?? null,
|
||
duration_seconds: 0,
|
||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||
}, { transaction: t });
|
||
|
||
await LessonPage.create({
|
||
lesson_id: lesson.lesson_id,
|
||
blocks: [],
|
||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||
}, { transaction: t });
|
||
|
||
await syncObjectivesCreate(LessonObjective, "lesson_id", lesson.lesson_id, objectives, t);
|
||
}
|
||
|
||
const order_index = order ?? await nextOrderIndex(UnitLesson, { unit_id: unitId }, t);
|
||
await UnitLesson.create({
|
||
unit_id: unitId,
|
||
lesson_id: lesson.lesson_id,
|
||
order_index,
|
||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||
}, { transaction: t });
|
||
|
||
await t.commit();
|
||
try {
|
||
await recomputeUnitDuration(unitId);
|
||
await recomputeCourseDuration(courseId);
|
||
} catch (durErr) { console.error("[LESSON][CREATE][DURATION]", durErr); }
|
||
logActivity(req.user?.user_id, 'create_lesson', { entityType: 'lesson', entityId: lesson.lesson_id, details: { title: lesson.title, unit_id: Number(unitId), attached_existing: !!lesson_id } });
|
||
return R.success(res, lesson_id ? "Lesson attached." : "Lesson created.", { data: { ...lesson.toJSON(), order_index } }, 201);
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[LESSON][CREATE]", err);
|
||
return R.error(res, "Could not create lesson.", 500);
|
||
}
|
||
};
|
||
|
||
// PUT /:courseId/units/:unitId/lessons/order { lesson_ids: [orderedIds] }
|
||
exports.reorderLessons = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { courseId, unitId } = req.params;
|
||
const { lesson_ids = [] } = req.body;
|
||
if (!lesson_ids.length) return R.error(res, "lesson_ids is required.", 400);
|
||
|
||
const courseLink = await getCourseUnitLink(courseId, unitId, t);
|
||
if (!courseLink) return R.error(res, "Unit not found.", 404);
|
||
|
||
await reorderJunction(UnitLesson, "unit_id", unitId, "lesson_id", lesson_ids, t);
|
||
await t.commit();
|
||
logActivity(req.user?.user_id, 'reorder_lessons', { entityType: 'unit', entityId: Number(unitId), details: { lesson_ids } });
|
||
return R.success(res, "Lesson order updated.");
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[LESSON][REORDER]", err);
|
||
return R.error(res, "Could not reorder lessons.", 500);
|
||
}
|
||
};
|
||
|
||
exports.updateLesson = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { courseId, unitId, lessonId } = req.params;
|
||
|
||
const courseLink = await getCourseUnitLink(courseId, unitId, t);
|
||
if (!courseLink) return R.error(res, "Unit not found.", 404);
|
||
|
||
const lessonLink = await getUnitLessonLink(unitId, lessonId, t);
|
||
if (!lessonLink) return R.error(res, "Lesson not found.", 404);
|
||
|
||
const lesson = await Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted }, transaction: t });
|
||
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;
|
||
lesson.updatedBy = updatedBy ?? req.user?.user_id ?? null;
|
||
await lesson.save({ transaction: t });
|
||
|
||
if (order !== undefined) {
|
||
await lessonLink.update({ order_index: order, updatedBy: updatedBy ?? req.user?.user_id ?? null }, { 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"]] }],
|
||
});
|
||
|
||
logActivity(req.user?.user_id, 'update_lesson', { entityType: 'lesson', entityId: Number(lessonId) });
|
||
return R.success(res, "Lesson updated.", { data: { ...updated.toJSON(), order_index: order !== undefined ? order : lessonLink.order_index } });
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[LESSON][UPDATE]", err);
|
||
return R.error(res, "Could not update lesson.", 500);
|
||
}
|
||
};
|
||
|
||
// DELETE /:courseId/units/:unitId/lessons/:lessonId — detaches the lesson from
|
||
// this unit. The lesson survives in the library (archive it from /admin/lessons).
|
||
exports.archiveLesson = async (req, res) => {
|
||
try {
|
||
const { courseId, unitId, lessonId } = req.params;
|
||
|
||
const courseLink = await getCourseUnitLink(courseId, unitId);
|
||
if (!courseLink) return R.error(res, "Unit not found.", 404);
|
||
|
||
const removed = await UnitLesson.destroy({ where: { unit_id: unitId, lesson_id: lessonId } });
|
||
if (!removed) return R.error(res, "Lesson is not attached to this unit.", 404);
|
||
|
||
try {
|
||
await recomputeUnitDuration(unitId);
|
||
await recomputeCourseDuration(courseId);
|
||
} catch (durErr) { console.error("[LESSON][DETACH][DURATION]", durErr); }
|
||
logActivity(req.user.user_id, 'detach_lesson', { entityType: 'lesson', entityId: Number(lessonId), details: { unit_id: Number(unitId) } });
|
||
return R.success(res, "Lesson removed from unit.");
|
||
} catch (err) {
|
||
console.error("[LESSON][DETACH]", err);
|
||
return R.error(res, "Could not remove lesson from unit.", 500);
|
||
}
|
||
};
|
||
|
||
exports.bulkArchiveLessons = async (req, res) => {
|
||
try {
|
||
const { courseId, unitId } = req.params;
|
||
const { ids = [] } = req.body;
|
||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||
|
||
const courseLink = await getCourseUnitLink(courseId, unitId);
|
||
if (!courseLink) return R.error(res, "Unit not found.", 404);
|
||
|
||
const count = await UnitLesson.destroy({ where: { unit_id: unitId, lesson_id: ids } });
|
||
|
||
try {
|
||
await recomputeUnitDuration(unitId);
|
||
await recomputeCourseDuration(courseId);
|
||
} catch (durErr) { console.error("[LESSON][BULK DETACH][DURATION]", durErr); }
|
||
logActivity(req.user.user_id, 'bulk_detach_lessons', { entityType: 'lesson', details: { ids, count, unit_id: Number(unitId) } });
|
||
return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} removed from unit.`);
|
||
} catch (err) {
|
||
console.error("[LESSON][BULK DETACH]", err);
|
||
return R.error(res, "Could not remove lessons from unit.", 500);
|
||
}
|
||
};
|
||
|
||
exports.getArchivedLessons = async (req, res) => {
|
||
try {
|
||
const { courseId, unitId } = req.params;
|
||
if (!Number.isFinite(Number(unitId))) return R.error(res, "Unit not found.", 404);
|
||
|
||
const link = await getCourseUnitLink(courseId, unitId);
|
||
if (!link) return R.error(res, "Unit not found.", 404);
|
||
|
||
// Archived lessons that are still attached to this unit
|
||
const result = await paginate(Lesson, req, {
|
||
excludeAttributes: adminExclude,
|
||
auditOptions: { mdl_Users, parentAlias: "Lesson" },
|
||
context: "archived",
|
||
findOptions: {
|
||
where: {
|
||
lesson_id: { [Op.in]: Sequelize.literal(unitMembershipLiteral(unitId)) },
|
||
...onlyDeleted,
|
||
},
|
||
paranoid: false,
|
||
order: [[Sequelize.literal(unitOrderLiteral(unitId)), "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 { unitId, lessonId } = req.params;
|
||
|
||
const lessonLink = await getUnitLessonLink(unitId, lessonId);
|
||
if (!lessonLink) return R.error(res, "Archived lesson not found.", 404);
|
||
|
||
const lesson = await Lesson.findOne({
|
||
where: { lesson_id: lessonId, ...onlyDeleted },
|
||
paranoid: false,
|
||
});
|
||
if (!lesson) return R.error(res, "Archived lesson not found.", 404);
|
||
return R.success(res, "Archived lesson retrieved.", { data: { ...lesson.toJSON(), order_index: lessonLink.order_index } });
|
||
} 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 lessonLink = await getUnitLessonLink(unitId, lessonId, t);
|
||
if (!lessonLink) return R.error(res, "Archived lesson not found.", 404);
|
||
|
||
const record = await restoreOne(Lesson, { lesson_id: lessonId, ...onlyDeleted }, req.user.user_id, t);
|
||
if (!record) return R.error(res, "Archived lesson not found.", 404);
|
||
await t.commit();
|
||
try {
|
||
await recomputeUnitDuration(unitId);
|
||
await recomputeCourseDuration(courseId);
|
||
} catch (durErr) { console.error("[LESSON][RESTORE][DURATION]", durErr); }
|
||
logActivity(req.user.user_id, 'restore_lesson', { entityType: 'lesson', entityId: Number(lessonId) });
|
||
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 = [] } = req.body;
|
||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||
|
||
const links = await UnitLesson.findAll({ where: { unit_id: unitId, lesson_id: ids }, transaction: t });
|
||
const linkedIds = links.map((l) => l.lesson_id);
|
||
|
||
const lessons = await Lesson.findAll({ where: { lesson_id: linkedIds, ...onlyDeleted }, paranoid: false, transaction: t });
|
||
const validIds = lessons.map((l) => l.lesson_id);
|
||
|
||
const count = await restoreMany(Lesson, "lesson_id", validIds, req.user.user_id, t);
|
||
await t.commit();
|
||
try {
|
||
await recomputeUnitDuration(unitId);
|
||
await recomputeCourseDuration(courseId);
|
||
} catch (durErr) { console.error("[LESSON][BULK RESTORE][DURATION]", durErr); }
|
||
logActivity(req.user.user_id, 'bulk_restore_lessons', { entityType: 'lesson', details: { ids: validIds, count } });
|
||
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);
|
||
}
|
||
};
|
||
|
||
exports.permanentlyDeleteLesson = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { courseId, unitId, lessonId } = req.params;
|
||
|
||
const lessonLink = await getUnitLessonLink(unitId, lessonId, t);
|
||
if (!lessonLink) return R.error(res, "Lesson not found.", 404);
|
||
|
||
const record = await permanentDeleteOne(Lesson, { lesson_id: lessonId }, t);
|
||
if (record === null) return R.error(res, "Lesson not found.", 404);
|
||
if (record === false) return R.error(res, "Lesson must be archived before it can be permanently deleted.", 400);
|
||
|
||
await UnitLesson.destroy({ where: { lesson_id: lessonId }, transaction: t });
|
||
|
||
await t.commit();
|
||
try {
|
||
await recomputeUnitDuration(unitId);
|
||
await recomputeCourseDuration(courseId);
|
||
} catch (durErr) { console.error("[LESSON][PERMANENT DELETE][DURATION]", durErr); }
|
||
logActivity(req.user.user_id, 'permanently_delete_lesson', { entityType: 'lesson', entityId: Number(lessonId) });
|
||
return R.success(res, "Lesson permanently deleted.");
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[LESSON][PERMANENT DELETE]", err);
|
||
return R.error(res, "Could not permanently delete lesson.", 500);
|
||
}
|
||
};
|
||
|
||
exports.bulkPermanentlyDeleteLessons = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { courseId, unitId } = req.params;
|
||
const { ids = [] } = req.body;
|
||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||
|
||
const links = await UnitLesson.findAll({ where: { unit_id: unitId, lesson_id: ids }, transaction: t });
|
||
const validIds = links.map((l) => l.lesson_id);
|
||
|
||
const count = await permanentDeleteMany(Lesson, "lesson_id", validIds, t);
|
||
await UnitLesson.destroy({ where: { lesson_id: validIds }, transaction: t });
|
||
|
||
await t.commit();
|
||
try {
|
||
await recomputeUnitDuration(unitId);
|
||
await recomputeCourseDuration(courseId);
|
||
} catch (durErr) { console.error("[LESSON][BULK PERMANENT DELETE][DURATION]", durErr); }
|
||
logActivity(req.user.user_id, 'bulk_permanently_delete_lessons', { entityType: 'lesson', details: { ids: validIds, count } });
|
||
return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} permanently deleted.`);
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[LESSON][BULK PERMANENT DELETE]", err);
|
||
return R.error(res, "Could not permanently delete 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);
|
||
}
|
||
|
||
logActivity(req.user?.user_id, 'upsert_lesson_page', { entityType: 'lesson', entityId: Number(lessonId) });
|
||
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);
|
||
}
|
||
};
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// UNIT QUIZ
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
exports.getQuiz = async (req, res) => {
|
||
try {
|
||
const { courseId, unitId } = req.params;
|
||
|
||
const link = await getCourseUnitLink(courseId, unitId);
|
||
if (!link) 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, max_questions, shuffle_questions, createdBy } = req.body;
|
||
|
||
const link = await getCourseUnitLink(courseId, unitId);
|
||
if (!link) 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,
|
||
max_questions: max_questions ?? null,
|
||
shuffle_questions: shuffle_questions ?? false,
|
||
createdBy: createdBy ?? null,
|
||
});
|
||
|
||
logActivity(req.user?.user_id, 'create_quiz', { entityType: 'quiz', entityId: quiz.quiz_id });
|
||
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, max_questions, shuffle_questions, 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;
|
||
if (max_questions !== undefined) quiz.max_questions = max_questions;
|
||
if (shuffle_questions !== undefined) quiz.shuffle_questions = shuffle_questions;
|
||
|
||
quiz.updatedBy = updatedBy ?? null;
|
||
|
||
await quiz.save();
|
||
logActivity(req.user?.user_id, 'update_quiz', { entityType: 'quiz', entityId: Number(quizId) });
|
||
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();
|
||
logActivity(req.user?.user_id, 'archive_quiz', { entityType: 'quiz', entityId: Number(quizId) });
|
||
return R.success(res, "Quiz archived.");
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[QUIZ][ARCHIVE]", err);
|
||
return R.error(res, "Could not archive quiz.", 500);
|
||
}
|
||
};
|
||
|
||
exports.getArchivedQuiz = async (req, res) => {
|
||
try {
|
||
const { courseId, unitId } = req.params;
|
||
|
||
const link = await getCourseUnitLink(courseId, unitId);
|
||
if (!link) return R.error(res, "Unit not found.", 404);
|
||
|
||
const quiz = await UnitQuiz.findOne({
|
||
where: { unit_id: unitId, ...onlyDeleted },
|
||
paranoid: false,
|
||
});
|
||
if (!quiz) return R.error(res, "Archived quiz not found.", 404);
|
||
return R.success(res, "Archived quiz retrieved.", { data: quiz.toJSON() });
|
||
} catch (err) {
|
||
console.error("[QUIZ][GET ARCHIVE]", err);
|
||
return R.error(res, "Could not retrieve archived quiz.", 500);
|
||
}
|
||
};
|
||
|
||
exports.restoreQuiz = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { unitId, quizId } = req.params;
|
||
const record = await restoreOne(UnitQuiz, { quiz_id: quizId, unit_id: unitId, ...onlyDeleted }, req.body.restoredBy, t);
|
||
if (!record) return R.error(res, "Archived quiz not found.", 404);
|
||
await t.commit();
|
||
logActivity(req.user?.user_id, 'restore_quiz', { entityType: 'quiz', entityId: Number(quizId) });
|
||
return R.success(res, "Quiz restored.", { data: record });
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[QUIZ][RESTORE]", err);
|
||
return R.error(res, "Could not restore quiz.", 500);
|
||
}
|
||
};
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// QUIZ QUESTIONS (shared by unit quiz + course assessment)
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
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;
|
||
}
|
||
|
||
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"]] }],
|
||
});
|
||
|
||
logActivity(req.user?.user_id, 'create_question', { entityType: 'question', entityId: created.question_id });
|
||
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"]] }],
|
||
});
|
||
|
||
logActivity(req.user?.user_id, 'update_question', { entityType: 'question', entityId: Number(questionId) });
|
||
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();
|
||
logActivity(req.user?.user_id, 'archive_question', { entityType: 'question', entityId: Number(questionId) });
|
||
return R.success(res, "Question archived.");
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[QUESTION][ARCHIVE]", err);
|
||
return R.error(res, "Could not archive question.", 500);
|
||
}
|
||
};
|
||
|
||
exports.getArchivedQuestion = async (req, res) => {
|
||
try {
|
||
const { questionId } = req.params;
|
||
const question = await QuizQuestion.findOne({
|
||
where: { question_id: questionId, ...onlyDeleted },
|
||
paranoid: false,
|
||
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
|
||
});
|
||
if (!question) return R.error(res, "Archived question not found.", 404);
|
||
return R.success(res, "Archived question retrieved.", { data: question.toJSON() });
|
||
} catch (err) {
|
||
console.error("[QUESTION][GET ARCHIVE ONE]", err);
|
||
return R.error(res, "Could not retrieve archived question.", 500);
|
||
}
|
||
};
|
||
|
||
exports.restoreQuestion = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { questionId } = req.params;
|
||
const record = await restoreOne(QuizQuestion, { question_id: questionId, ...onlyDeleted }, req.body.restoredBy, t);
|
||
if (!record) return R.error(res, "Archived question not found.", 404);
|
||
await t.commit();
|
||
logActivity(req.user?.user_id, 'restore_question', { entityType: 'question', entityId: Number(questionId) });
|
||
return R.success(res, "Question restored.", { data: record });
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[QUESTION][RESTORE]", err);
|
||
return R.error(res, "Could not restore question.", 500);
|
||
}
|
||
};
|
||
|
||
exports.bulkArchiveQuestions = 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 { ids = [], deletedBy } = req.body;
|
||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||
|
||
// Verify all IDs belong to this parent
|
||
const questions = await QuizQuestion.findAll({
|
||
where: { question_id: ids, [parent.parentField]: parent.parentId, ...notDeleted },
|
||
});
|
||
const validIds = questions.map((q) => q.question_id);
|
||
|
||
const count = await archiveMany(QuizQuestion, "question_id", validIds, deletedBy, t);
|
||
await t.commit();
|
||
logActivity(req.user?.user_id, 'bulk_archive_questions', { entityType: 'question', details: { ids: validIds, count } });
|
||
return R.success(res, `${count} question${count !== 1 ? "s" : ""} archived.`);
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[QUESTION][BULK ARCHIVE]", err);
|
||
return R.error(res, "Could not archive questions.", 500);
|
||
}
|
||
};
|
||
|
||
exports.bulkRestoreQuestions = 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 { ids = [], restoredBy } = req.body;
|
||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||
|
||
const questions = await QuizQuestion.findAll({
|
||
where: { question_id: ids, [parent.parentField]: parent.parentId, ...onlyDeleted },
|
||
paranoid: false,
|
||
});
|
||
const validIds = questions.map((q) => q.question_id);
|
||
|
||
const count = await restoreMany(QuizQuestion, "question_id", validIds, restoredBy, t);
|
||
await t.commit();
|
||
logActivity(req.user?.user_id, 'bulk_restore_questions', { entityType: 'question', details: { ids: validIds, count } });
|
||
return R.success(res, `${count} question${count !== 1 ? "s" : ""} restored.`);
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[QUESTION][BULK RESTORE]", err);
|
||
return R.error(res, "Could not restore questions.", 500);
|
||
}
|
||
};
|
||
|
||
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, confirmFullReplace } = 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));
|
||
|
||
// Safety guard: a sync where NONE of the currently-existing questions are
|
||
// referenced by id would archive the entire pool in one call — indistinguishable
|
||
// from a client sending a stale/incomplete array (exactly how real questions were
|
||
// nearly lost while diagnosing this endpoint). Partial edits (dropping a question
|
||
// or two out of many) are unaffected — only a full wipe requires explicit confirmation.
|
||
if (toArchive.length > 0 && toArchive.length === existingIds.length && !confirmFullReplace) {
|
||
await t.rollback();
|
||
return R.error(
|
||
res,
|
||
`This would archive all ${toArchive.length} existing question(s) and can't be recovered from here. Resend with confirmFullReplace: true if this is intentional.`,
|
||
409
|
||
);
|
||
}
|
||
|
||
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
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
exports.getAssessment = async (req, res) => {
|
||
try {
|
||
const { courseId } = req.params;
|
||
|
||
const assessment = await CourseAssessment.findOne({
|
||
where: { course_id: courseId, ...notDeleted },
|
||
include: [{
|
||
model: QuizQuestion, as: "questions",
|
||
where: notDeleted, required: false,
|
||
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
|
||
}],
|
||
});
|
||
|
||
if (!assessment) return R.error(res, "Assessment not found.", 404);
|
||
return R.success(res, "Assessment retrieved.", { data: assessment });
|
||
} catch (err) {
|
||
console.error("[ASSESSMENT][GET]", err);
|
||
return R.error(res, "Could not retrieve assessment.", 500);
|
||
}
|
||
};
|
||
|
||
exports.createAssessment = async (req, res) => {
|
||
try {
|
||
const { courseId } = req.params;
|
||
const { title, is_required, passing_score, time_limit_minutes, max_questions, max_attempts, cooldown_hours, shuffle_questions, createdBy } = req.body;
|
||
|
||
const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted } });
|
||
if (!course) return R.error(res, "Course not found.", 404);
|
||
|
||
const existing = await CourseAssessment.findOne({ where: { course_id: courseId, ...notDeleted } });
|
||
if (existing) return R.error(res, "Assessment already exists for this course.", 409);
|
||
|
||
const assessment = await CourseAssessment.create({
|
||
course_id: courseId,
|
||
title: title ?? null,
|
||
is_required: is_required ?? false,
|
||
passing_score: passing_score ?? 70,
|
||
time_limit_minutes: time_limit_minutes ?? null,
|
||
max_questions: max_questions ?? null,
|
||
max_attempts: max_attempts ?? 3,
|
||
cooldown_hours: cooldown_hours ?? 24,
|
||
shuffle_questions: shuffle_questions ?? false,
|
||
createdBy: createdBy ?? null,
|
||
});
|
||
|
||
logActivity(req.user?.user_id, 'create_assessment', { entityType: 'assessment', entityId: assessment.assessment_id });
|
||
return R.success(res, "Assessment created.", { data: assessment }, 201);
|
||
} catch (err) {
|
||
console.error("[ASSESSMENT][CREATE]", err);
|
||
return R.error(res, "Could not create assessment.", 500);
|
||
}
|
||
};
|
||
|
||
exports.updateAssessment = async (req, res) => {
|
||
try {
|
||
const { courseId, assessmentId } = req.params;
|
||
const { title, is_required, passing_score, time_limit_minutes, max_questions, max_attempts, cooldown_hours, shuffle_questions, updatedBy } = req.body;
|
||
|
||
const assessment = await CourseAssessment.findOne({
|
||
where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted },
|
||
});
|
||
if (!assessment) return R.error(res, "Assessment not found.", 404);
|
||
|
||
if (title !== undefined) assessment.title = title;
|
||
if (is_required !== undefined) assessment.is_required = is_required;
|
||
if (passing_score !== undefined) assessment.passing_score = passing_score;
|
||
if (time_limit_minutes !== undefined) assessment.time_limit_minutes = time_limit_minutes;
|
||
if (max_questions !== undefined) assessment.max_questions = max_questions;
|
||
if (max_attempts !== undefined) assessment.max_attempts = max_attempts;
|
||
if (cooldown_hours !== undefined) assessment.cooldown_hours = cooldown_hours;
|
||
if (shuffle_questions !== undefined) assessment.shuffle_questions = shuffle_questions;
|
||
|
||
assessment.updatedBy = updatedBy ?? null;
|
||
|
||
await assessment.save();
|
||
logActivity(req.user?.user_id, 'update_assessment', { entityType: 'assessment', entityId: Number(assessmentId) });
|
||
|
||
// Update in-progress sessions + notify affected students
|
||
try {
|
||
const inProgressSessions = await AssessmentSession.findAll({
|
||
where: { assessment_id: assessmentId, status: 'in_progress' },
|
||
attributes: ['session_id', 'user_id', 'started_at'],
|
||
});
|
||
if (inProgressSessions.length > 0) {
|
||
// Update expires_at based on new time limit — but never shorten a student's
|
||
// remaining time. If the new limit would expire sooner than the current one,
|
||
// leave that session untouched.
|
||
const newTimeLimitMs = (assessment.time_limit_minutes ?? 0) * 60_000;
|
||
await Promise.all(
|
||
inProgressSessions.map(s => {
|
||
if (newTimeLimitMs === 0) {
|
||
// Removing the time limit entirely → always an improvement
|
||
return s.update({ expires_at: null });
|
||
}
|
||
const candidate = new Date(new Date(s.started_at).getTime() + newTimeLimitMs);
|
||
// Only update if the new expiry is later than what they already have
|
||
if (s.expires_at && candidate <= new Date(s.expires_at)) return Promise.resolve();
|
||
return s.update({ expires_at: candidate });
|
||
})
|
||
);
|
||
|
||
const course = await Course.findOne({
|
||
where: { course_id: courseId },
|
||
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(
|
||
inProgressSessions.map(({ user_id }) => ({
|
||
user_id,
|
||
...notify,
|
||
seen: false,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
})),
|
||
{ validate: false }
|
||
);
|
||
}
|
||
} catch (notifyErr) {
|
||
// Non-fatal — log but don't fail the update response
|
||
console.error('[ASSESSMENT][UPDATE][NOTIFY]', notifyErr);
|
||
}
|
||
|
||
return R.success(res, "Assessment updated.", { data: assessment });
|
||
} catch (err) {
|
||
console.error("[ASSESSMENT][UPDATE]", err);
|
||
return R.error(res, "Could not update assessment.", 500);
|
||
}
|
||
};
|
||
|
||
exports.deleteAssessment = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { courseId, assessmentId } = req.params;
|
||
const record = await archiveOne(CourseAssessment, { assessment_id: assessmentId, course_id: courseId, ...notDeleted }, req.body.deletedBy, t);
|
||
if (!record) return R.error(res, "Assessment not found.", 404);
|
||
await t.commit();
|
||
logActivity(req.user?.user_id, 'archive_assessment', { entityType: 'assessment', entityId: Number(assessmentId) });
|
||
return R.success(res, "Assessment archived.");
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[ASSESSMENT][ARCHIVE]", err);
|
||
return R.error(res, "Could not archive assessment.", 500);
|
||
}
|
||
};
|
||
|
||
exports.getArchivedAssessment = 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 assessment = await CourseAssessment.findOne({
|
||
where: { course_id: courseId, ...onlyDeleted },
|
||
paranoid: false,
|
||
});
|
||
if (!assessment) return R.error(res, "Archived assessment not found.", 404);
|
||
return R.success(res, "Archived assessment retrieved.", { data: assessment.toJSON() });
|
||
} catch (err) {
|
||
console.error("[ASSESSMENT][GET ARCHIVE]", err);
|
||
return R.error(res, "Could not retrieve archived assessment.", 500);
|
||
}
|
||
};
|
||
|
||
exports.restoreAssessment = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { courseId, assessmentId } = req.params;
|
||
const record = await restoreOne(CourseAssessment, { assessment_id: assessmentId, course_id: courseId, ...onlyDeleted }, req.body.restoredBy, t);
|
||
if (!record) return R.error(res, "Archived assessment not found.", 404);
|
||
await t.commit();
|
||
logActivity(req.user?.user_id, 'restore_assessment', { entityType: 'assessment', entityId: Number(assessmentId) });
|
||
return R.success(res, "Assessment restored.", { data: record });
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[ASSESSMENT][RESTORE]", err);
|
||
return R.error(res, "Could not restore assessment.", 500);
|
||
}
|
||
};
|
||
|
||
exports.getCourseFieldValues = getFieldValues(Course, "COURSE");
|
||
exports.getUnitFieldValues = getFieldValues(Unit, "UNIT");
|
||
exports.getLessonFieldValues = getFieldValues(Lesson, "LESSON");
|
||
|
||
// ── Flat lists for requirement builder dropdowns ───────────────────────────────
|
||
// Returns lightweight id+title arrays (no pagination) used when building task requirements.
|
||
|
||
exports.getCoursesFlat = async (req, res) => {
|
||
try {
|
||
const data = await Course.findAll({
|
||
where: notDeleted,
|
||
attributes: ["course_id", "uuid", "title", "subscription", "duration_seconds"],
|
||
order: [["title", "ASC"]],
|
||
});
|
||
return R.success(res, "Courses retrieved.", data);
|
||
} catch (err) {
|
||
console.error("[COURSE][GET FLAT]", err);
|
||
return R.error(res, "Could not retrieve courses.", 500);
|
||
}
|
||
};
|
||
|
||
exports.getCoursesBySubscription = async (req, res) => {
|
||
try {
|
||
const { slug } = req.query;
|
||
if (!slug) return R.error(res, 'slug query param is required.', 400);
|
||
|
||
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);
|
||
return R.error(res, 'Could not retrieve courses.', 500);
|
||
}
|
||
};
|
||
|
||
// One row per Unit (junction revamp: a unit may sit under 0..N courses) —
|
||
// `courses[]` is batch-fetched separately so requirement builders can show
|
||
// exactly which course(s) a unit is bound to, or mark it "Standalone" when
|
||
// empty, instead of the old one-row-per-(course,unit)-attachment duplicates.
|
||
exports.getUnitsFlat = async (req, res) => {
|
||
try {
|
||
const rows = await sequelize.query(`
|
||
SELECT u.unit_id, u.uuid, u.title, u.duration_seconds, u.subscription
|
||
FROM units u
|
||
WHERE u."deletedAt" IS NULL
|
||
ORDER BY u.title ASC
|
||
`, { type: sequelize.QueryTypes.SELECT });
|
||
|
||
const unitIds = rows.map((r) => r.unit_id);
|
||
const courseLinkRows = unitIds.length ? await sequelize.query(`
|
||
SELECT cu.unit_id, c.uuid, c.title
|
||
FROM course_units cu
|
||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||
WHERE cu.unit_id IN (:unitIds)
|
||
ORDER BY c.title ASC
|
||
`, { replacements: { unitIds }, type: sequelize.QueryTypes.SELECT }) : [];
|
||
|
||
const coursesByUnit = new Map();
|
||
for (const row of courseLinkRows) {
|
||
const list = coursesByUnit.get(row.unit_id) ?? [];
|
||
list.push({ uuid: row.uuid, title: row.title });
|
||
coursesByUnit.set(row.unit_id, list);
|
||
}
|
||
|
||
const data = rows.map((r) => ({
|
||
unit_id: r.unit_id,
|
||
uuid: r.uuid,
|
||
title: r.title,
|
||
duration_seconds: Number(r.duration_seconds ?? 0),
|
||
subscription: r.subscription ?? "free",
|
||
courses: coursesByUnit.get(r.unit_id) ?? [],
|
||
}));
|
||
return R.success(res, "Units retrieved.", data);
|
||
} catch (err) {
|
||
console.error("[UNIT][GET FLAT]", err);
|
||
return R.error(res, "Could not retrieve units.", 500);
|
||
}
|
||
};
|
||
|
||
// One row per Lesson — a lesson may sit under 0..N units, each possibly under
|
||
// several courses, so `courses[]` is the deduped set of every course reachable
|
||
// through any attached unit (mirrors client getLessons' batching). Lessons
|
||
// have no tier field of their own; "Standalone" (empty courses[]) is the only
|
||
// binding signal that applies here.
|
||
exports.getLessonsFlat = async (req, res) => {
|
||
try {
|
||
const rows = await sequelize.query(`
|
||
SELECT l.lesson_id, l.uuid, l.title, l.duration_seconds
|
||
FROM lessons l
|
||
WHERE l."deletedAt" IS NULL
|
||
ORDER BY l.title ASC
|
||
`, { type: sequelize.QueryTypes.SELECT });
|
||
|
||
const lessonIds = rows.map((r) => r.lesson_id);
|
||
const courseLinkRows = lessonIds.length ? await sequelize.query(`
|
||
SELECT DISTINCT ul.lesson_id, c.uuid, c.title
|
||
FROM unit_lessons ul
|
||
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
|
||
JOIN course_units cu ON cu.unit_id = u.unit_id
|
||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||
WHERE ul.lesson_id IN (:lessonIds)
|
||
ORDER BY c.title ASC
|
||
`, { replacements: { lessonIds }, type: sequelize.QueryTypes.SELECT }) : [];
|
||
|
||
const coursesByLesson = new Map();
|
||
for (const row of courseLinkRows) {
|
||
const list = coursesByLesson.get(row.lesson_id) ?? [];
|
||
list.push({ uuid: row.uuid, title: row.title });
|
||
coursesByLesson.set(row.lesson_id, list);
|
||
}
|
||
|
||
const data = rows.map((r) => ({
|
||
lesson_id: r.lesson_id,
|
||
uuid: r.uuid,
|
||
title: r.title,
|
||
duration_seconds: Number(r.duration_seconds ?? 0),
|
||
courses: coursesByLesson.get(r.lesson_id) ?? [],
|
||
}));
|
||
return R.success(res, "Lessons retrieved.", data);
|
||
} catch (err) {
|
||
console.error("[LESSON][GET FLAT]", err);
|
||
return R.error(res, "Could not retrieve lessons.", 500);
|
||
}
|
||
};
|
||
|
||
// One row per unit quiz (a quiz is always unit-scoped, unit_id unique on
|
||
// unit_quizzes) — `courses[]` is the deduped set of courses the parent unit
|
||
// is attached to, batch-fetched the same way as getUnitsFlat/getLessonsFlat.
|
||
// Used by the pass_quiz task requirement picker — same "no content yet"
|
||
// convention as read_*: question_count === 0 is flagged the same way
|
||
// duration_seconds === 0 is for content requirements.
|
||
exports.getQuizzesFlat = async (req, res) => {
|
||
try {
|
||
const rows = await sequelize.query(`
|
||
SELECT
|
||
q.quiz_id, q.uuid, q.title, u.title AS unit_title,
|
||
(SELECT CAST(COUNT(*) AS INTEGER) FROM quiz_questions qq
|
||
WHERE qq.quiz_id = q.quiz_id AND qq."deletedAt" IS NULL) AS question_count
|
||
FROM unit_quizzes q
|
||
JOIN units u ON u.unit_id = q.unit_id AND u."deletedAt" IS NULL
|
||
WHERE q."deletedAt" IS NULL
|
||
ORDER BY u.title ASC, q.title ASC
|
||
`, { type: sequelize.QueryTypes.SELECT });
|
||
|
||
const quizIds = rows.map((r) => r.quiz_id);
|
||
const courseLinkRows = quizIds.length ? await sequelize.query(`
|
||
SELECT q.quiz_id, c.uuid, c.title
|
||
FROM unit_quizzes q
|
||
JOIN course_units cu ON cu.unit_id = q.unit_id
|
||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||
WHERE q.quiz_id IN (:quizIds)
|
||
ORDER BY c.title ASC
|
||
`, { replacements: { quizIds }, type: sequelize.QueryTypes.SELECT }) : [];
|
||
|
||
const coursesByQuiz = new Map();
|
||
for (const row of courseLinkRows) {
|
||
const list = coursesByQuiz.get(row.quiz_id) ?? [];
|
||
list.push({ uuid: row.uuid, title: row.title });
|
||
coursesByQuiz.set(row.quiz_id, list);
|
||
}
|
||
|
||
const data = rows.map((r) => ({
|
||
uuid: r.uuid,
|
||
title: r.title || `${r.unit_title} Quiz`,
|
||
unit_title: r.unit_title ?? "",
|
||
courses: coursesByQuiz.get(r.quiz_id) ?? [],
|
||
question_count: Number(r.question_count ?? 0),
|
||
// duration_seconds doesn't apply to quizzes — ContentPicker's "no
|
||
// content" check keys off duration_seconds === 0, so surface the same
|
||
// signal under that name rather than adding a second code path.
|
||
duration_seconds: Number(r.question_count ?? 0),
|
||
}));
|
||
return R.success(res, "Quizzes retrieved.", data);
|
||
} catch (err) {
|
||
console.error("[QUIZ][GET FLAT]", err);
|
||
return R.error(res, "Could not retrieve quizzes.", 500);
|
||
}
|
||
};
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// COURSE INSTRUCTORS
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
exports.getInstructors = async (req, res) => {
|
||
try {
|
||
const { courseId } = req.params;
|
||
|
||
const instructors = await CourseInstructor.findAll({
|
||
where: { course_id: courseId },
|
||
order: [["order_index", "ASC"]],
|
||
include: [{
|
||
model: mdl_Users,
|
||
as: "user",
|
||
attributes: ["user_id", "email", "acc_type", "personal_info"],
|
||
required: false,
|
||
}],
|
||
});
|
||
|
||
return R.success(res, "Instructors retrieved.", { data: instructors });
|
||
} catch (err) {
|
||
console.error("[INSTRUCTOR][GET]", err);
|
||
return R.error(res, "Could not retrieve instructors.", 500);
|
||
}
|
||
};
|
||
|
||
exports.syncInstructors = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { courseId } = req.params;
|
||
const { instructors = [] } = req.body;
|
||
const actor_id = req.user?.user_id ?? null;
|
||
|
||
const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted } });
|
||
if (!course) return R.error(res, "Course not found.", 404);
|
||
|
||
// Validate any linked user_ids are staff or admin
|
||
const linkedIds = instructors.map(i => i.user_id).filter(Boolean);
|
||
if (linkedIds.length) {
|
||
const validUsers = await mdl_Users.findAll({
|
||
where: { user_id: linkedIds, acc_type: ["staff", "admin"], deletedAt: null },
|
||
attributes: ["user_id"],
|
||
});
|
||
const validSet = new Set(validUsers.map(u => String(u.user_id)));
|
||
const invalid = linkedIds.find(id => !validSet.has(String(id)));
|
||
if (invalid) {
|
||
await t.rollback();
|
||
return R.error(res, `User ${invalid} is not a staff or admin account.`, 422);
|
||
}
|
||
}
|
||
|
||
await CourseInstructor.destroy({ where: { course_id: courseId }, transaction: t });
|
||
|
||
if (instructors.length) {
|
||
await CourseInstructor.bulkCreate(
|
||
instructors.map((inst, i) => ({
|
||
course_id: courseId,
|
||
user_id: inst.user_id ?? null,
|
||
display_name: inst.display_name,
|
||
order_index: inst.order_index ?? i,
|
||
created_by: actor_id,
|
||
})),
|
||
{ transaction: t }
|
||
);
|
||
}
|
||
|
||
await t.commit();
|
||
logActivity(actor_id, 'sync_instructors', { entityType: 'course', entityId: Number(courseId), details: { count: instructors.length } });
|
||
return R.success(res, "Instructors updated.");
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[INSTRUCTOR][SYNC]", err);
|
||
return R.error(res, "Could not update instructors.", 500);
|
||
}
|
||
};
|
||
|
||
// ─── COMPLETIONS HELPERS ──────────────────────────────────────────────────────
|
||
|
||
function extractUserInfo(user) {
|
||
if (!user) return { full_name: null, email: null, avatar_url: null, deleted: false };
|
||
return {
|
||
full_name: user.personal_info?.name?.full_name ?? null,
|
||
email: user.email ?? null,
|
||
avatar_url: user.personal_info?.avatar?.url ?? null,
|
||
deleted: !!user.deletedAt,
|
||
};
|
||
}
|
||
|
||
function groupByUser(attempts) {
|
||
const map = new Map();
|
||
for (const a of attempts) {
|
||
const uid = String(a.user_id);
|
||
if (!map.has(uid)) {
|
||
const { full_name, email, avatar_url, deleted } = extractUserInfo(a.user);
|
||
map.set(uid, {
|
||
user_id: a.user_id,
|
||
full_name,
|
||
email,
|
||
avatar_url,
|
||
deleted,
|
||
attempt_count: 0,
|
||
best_score: 0,
|
||
passed: false,
|
||
latest_at: null,
|
||
attempts: [],
|
||
});
|
||
}
|
||
const row = map.get(uid);
|
||
row.attempt_count += 1;
|
||
if (a.score > row.best_score) row.best_score = a.score;
|
||
if (a.passed) row.passed = true;
|
||
if (!row.latest_at || new Date(a.createdAt) > new Date(row.latest_at)) row.latest_at = a.createdAt;
|
||
row.attempts.push({ attempt_id: a.attempt_id, attempt_number: a.attempt_number, score: a.score, earned_points: a.earned_points, total_points: a.total_points, passed: a.passed, createdAt: a.createdAt });
|
||
}
|
||
return [...map.values()].sort((a, b) => new Date(b.latest_at) - new Date(a.latest_at));
|
||
}
|
||
|
||
function buildSummary(attempts) {
|
||
const takers = new Set(attempts.map((a) => String(a.user_id))).size;
|
||
const passed = attempts.filter((a) => a.passed).length;
|
||
const failed = attempts.length - passed;
|
||
|
||
/*
|
||
* ┌─────────────────────────────────────────────────────────────────┐
|
||
* │ AVG SCORE formula │
|
||
* │ │
|
||
* │ avg = Σ( max(score) per user ) / unique_user_count │
|
||
* │ │
|
||
* │ Each student contributes exactly once — their personal best. │
|
||
* │ A student who retries 3× before passing counts once at peak │
|
||
* │ performance, not three times. Mirrors the "Best Score" column │
|
||
* │ shown per student in the completions table. │
|
||
* │ │
|
||
* │ NOTE: CockroachDB returns INT8 columns as strings via the pg │
|
||
* │ driver. Coerce with Number() before any arithmetic. │
|
||
* └─────────────────────────────────────────────────────────────────┘
|
||
*/
|
||
const bestByUser = new Map();
|
||
for (const a of attempts) {
|
||
const uid = String(a.user_id);
|
||
const s = Number(a.score);
|
||
if (!bestByUser.has(uid) || s > bestByUser.get(uid)) bestByUser.set(uid, s);
|
||
}
|
||
const bestScores = [...bestByUser.values()];
|
||
const avg = bestScores.length
|
||
? Math.round(bestScores.reduce((sum, s) => sum + s, 0) / bestScores.length)
|
||
: 0;
|
||
|
||
return {
|
||
total_takers: takers,
|
||
passed_count: passed,
|
||
failed_count: failed,
|
||
pass_rate: takers ? Math.round((new Set(attempts.filter((a) => a.passed).map((a) => String(a.user_id))).size / takers) * 100) : 0,
|
||
avg_score: avg,
|
||
total_attempts: attempts.length,
|
||
};
|
||
}
|
||
|
||
// ─── QUIZ COMPLETIONS ─────────────────────────────────────────────────────────
|
||
|
||
exports.getQuizCompletions = async (req, res) => {
|
||
try {
|
||
const { quizId } = req.params;
|
||
|
||
const attempts = await QuizAttempt.findAll({
|
||
where: { quiz_id: quizId },
|
||
attributes: ["attempt_id", "user_id", "attempt_number", "score", "earned_points", "total_points", "passed", "createdAt"],
|
||
include: [{ model: mdl_Users, as: "user", attributes: ["user_id", "email", "personal_info", "deletedAt"], paranoid: false }],
|
||
order: [["createdAt", "DESC"]],
|
||
});
|
||
|
||
const plain = attempts.map((a) => a.toJSON());
|
||
return R.success(res, "Quiz completions retrieved.", {
|
||
summary: buildSummary(plain),
|
||
completions: groupByUser(plain),
|
||
});
|
||
} catch (err) {
|
||
console.error("[ADMIN][QUIZ][COMPLETIONS]", err);
|
||
return R.error(res, "Could not retrieve quiz completions.", 500);
|
||
}
|
||
};
|
||
|
||
// ─── ASSESSMENT COMPLETIONS ───────────────────────────────────────────────────
|
||
|
||
exports.getAssessmentCompletions = async (req, res) => {
|
||
try {
|
||
const { assessmentId } = req.params;
|
||
|
||
const attempts = await QuizAttempt.findAll({
|
||
where: { assessment_id: assessmentId },
|
||
attributes: ["attempt_id", "user_id", "attempt_number", "score", "earned_points", "total_points", "passed", "createdAt"],
|
||
include: [{ model: mdl_Users, as: "user", attributes: ["user_id", "email", "personal_info", "deletedAt"], paranoid: false }],
|
||
order: [["createdAt", "DESC"]],
|
||
});
|
||
|
||
const plain = attempts.map((a) => a.toJSON());
|
||
return R.success(res, "Assessment completions retrieved.", {
|
||
summary: buildSummary(plain),
|
||
completions: groupByUser(plain),
|
||
});
|
||
} catch (err) {
|
||
console.error("[ADMIN][ASSESSMENT][COMPLETIONS]", err);
|
||
return R.error(res, "Could not retrieve assessment completions.", 500);
|
||
}
|
||
};
|
||
|
||
// ─── ASSESSMENT SESSIONS ──────────────────────────────────────────────────────
|
||
|
||
exports.getAssessmentSessions = async (req, res) => {
|
||
try {
|
||
const { assessmentId } = req.params;
|
||
|
||
const sessions = await AssessmentSession.findAll({
|
||
where: { assessment_id: assessmentId },
|
||
attributes: ["session_id", "user_id", "status", "started_at", "expires_at", "attempt_id", "createdAt", "updatedAt"],
|
||
include: [{ model: mdl_Users, as: "user", attributes: ["user_id", "email", "personal_info", "deletedAt"], paranoid: false }],
|
||
order: [["createdAt", "DESC"]],
|
||
});
|
||
|
||
const rows = sessions.map((s) => {
|
||
const j = s.toJSON();
|
||
const { full_name, email, avatar_url, deleted } = extractUserInfo(j.user);
|
||
const time_spent_seconds = j.status !== 'in_progress' && j.started_at
|
||
? Math.round((new Date(j.updatedAt) - new Date(j.started_at)) / 1000)
|
||
: null;
|
||
return {
|
||
session_id: j.session_id,
|
||
user_id: j.user_id,
|
||
full_name,
|
||
email,
|
||
avatar_url,
|
||
deleted,
|
||
status: j.status,
|
||
started_at: j.started_at,
|
||
expires_at: j.expires_at,
|
||
time_spent_seconds,
|
||
attempt_id: j.attempt_id,
|
||
};
|
||
});
|
||
|
||
const summary = {
|
||
total_sessions: rows.length,
|
||
in_progress_count: rows.filter((r) => r.status === 'in_progress').length,
|
||
completed_count: rows.filter((r) => r.status === 'completed').length,
|
||
expired_count: rows.filter((r) => r.status === 'expired').length,
|
||
};
|
||
|
||
return R.success(res, "Assessment sessions retrieved.", { summary, sessions: rows });
|
||
} catch (err) {
|
||
console.error("[ADMIN][ASSESSMENT][SESSIONS]", err);
|
||
return R.error(res, "Could not retrieve assessment sessions.", 500);
|
||
}
|
||
};
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// COURSE ACHIEVEMENTS
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
exports.getCourseAchievements = async (req, res) => {
|
||
try {
|
||
const { courseId } = req.params;
|
||
const rows = await CourseAchievement.findAll({
|
||
where: { course_id: courseId },
|
||
order: [["order_index", "ASC"]],
|
||
});
|
||
return R.success(res, "Course achievements retrieved.", { data: rows });
|
||
} catch (err) {
|
||
console.error("[COURSE][ACHIEVEMENTS][GET]", err);
|
||
return R.error(res, "Could not retrieve course achievements.", 500);
|
||
}
|
||
};
|
||
|
||
exports.syncCourseAchievements = async (req, res) => {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
const { courseId } = req.params;
|
||
const { achievement_keys = [] } = req.body;
|
||
|
||
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 });
|
||
|
||
if (achievement_keys.length) {
|
||
await CourseAchievement.bulkCreate(
|
||
achievement_keys.map((key, i) => ({ course_id: courseId, achievement_key: key, order_index: i })),
|
||
{ transaction: t },
|
||
);
|
||
}
|
||
|
||
await t.commit();
|
||
const rows = await CourseAchievement.findAll({
|
||
where: { course_id: courseId },
|
||
order: [["order_index", "ASC"]],
|
||
});
|
||
return R.success(res, "Course achievements updated.", { data: rows });
|
||
} catch (err) {
|
||
await t.rollback();
|
||
console.error("[COURSE][ACHIEVEMENTS][SYNC]", err);
|
||
return R.error(res, "Could not update course achievements.", 500);
|
||
}
|
||
};
|