Files
starr-philproperties/controllers/admin/courses.controller.js
T
2026-08-05 04:32:43 +08:00

2864 lines
116 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 { resolveAvatarUrl } = require("../../utils/resolveAvatar.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');
const CompletionRequirement = require('../../models/courses/completion_requirement.mdl');
const { VALID_ENTITY_TYPES } = require('../../utils/courses/completion_requirements.registry');
// ── 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 {
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 {
// Default to catalog order (same field the client-facing course list sorts
// by) so Move Up/Down is reflected on refetch; an explicit column sort from
// the table header still takes priority.
if (!req.query.sort) req.query.sort = JSON.stringify([{ id: "order_index", desc: false }]);
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 ?? await nextOrderIndex(Course, {}, t),
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);
}
// Requirements only apply to units being created fresh here — units
// attached by unit_id already have their own configured set.
if (!unit.unit_id && unit.requirements?.length) {
const seenTypes = new Set();
for (const r of unit.requirements) {
const allowedEntityTypes = VALID_ENTITY_TYPES[r.type];
if (!allowedEntityTypes || !allowedEntityTypes.includes("unit")) {
await t.rollback();
return R.error(res, `"${r.type}" cannot be configured on a unit.`, 400);
}
if (seenTypes.has(r.type)) {
await t.rollback();
return R.error(res, `Duplicate "${r.type}" requirement — only one per unit is allowed.`, 400);
}
seenTypes.add(r.type);
}
}
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 ?? await nextOrderIndex(Course, {}, t),
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 });
if (unitInput.requirements?.length) {
const rows = unitInput.requirements.map((r, ri) => ({
entity_type: "unit",
entity_id: unit.unit_id,
type: r.type,
order: r.order ?? ri,
min_percent: r.type === "watch_percent" ? (r.min_percent ?? 100) : null,
button_label: r.type === "manual_complete" ? (r.button_label || null) : null,
is_required: r.is_required ?? true,
createdBy: by,
updatedBy: by,
}));
await CompletionRequirement.bulkCreate(rows, { 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.reorderCourses = async (req, res) => {
const t = await sequelize.transaction();
try {
const { course_ids = [] } = req.body;
if (!course_ids.length) return R.error(res, "course_ids is required.", 400);
await Promise.all(course_ids.map((id, i) =>
Course.update({ order_index: i }, { where: { course_id: id, ...notDeleted }, transaction: t })
));
await t.commit();
logActivity(req.user?.user_id, "reorder_courses", { entityType: "course", details: { course_ids } });
return R.success(res, "Course order updated.");
} catch (err) {
await t.rollback();
console.error("[COURSE][REORDER]", err);
return R.error(res, "Could not reorder courses.", 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.
// course_id is a 19-digit snowflake-style BIGINT — well past
// Number.MAX_SAFE_INTEGER, so Number(courseId) silently rounds to the wrong
// id and these subqueries match nothing. BigInt preserves full precision
// (and throws on non-numeric input, same effect as the old NaN would've had
// via the caller's try/catch).
const courseOrderLiteral = (courseId) =>
`(SELECT cu.order_index FROM course_units cu WHERE cu.course_id = ${BigInt(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 = ${BigInt(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).
// ══════════════════════════════════════════════════════════════════════════════
// Same BigInt fix as courseOrderLiteral/courseMembershipLiteral above —
// unit_id is a 19-digit snowflake BIGINT, past Number.MAX_SAFE_INTEGER.
const unitOrderLiteral = (unitId) =>
`(SELECT ul.order_index FROM unit_lessons ul WHERE ul.unit_id = ${BigInt(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 = ${BigInt(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'],
order: [['title', 'ASC']],
});
// A course may belong to any number of other plans (Tier Plans v2, silent
// duplication across bundles is intentional) — no conflict to report here.
const data = rows.map((c) => c.toJSON());
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 ──────────────────────────────────────────────────────
async function extractUserInfo(user) {
if (!user) return { full_name: null, email: null, avatar_url: null, deleted: false };
const avatar = await resolveAvatarUrl(user.personal_info?.avatar);
return {
full_name: user.personal_info?.name?.full_name ?? null,
email: user.email ?? null,
avatar_url: avatar?.url ?? null,
deleted: !!user.deletedAt,
};
}
async 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 } = await 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: await 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: await 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 = await Promise.all(sessions.map(async (s) => {
const j = s.toJSON();
const { full_name, email, avatar_url, deleted } = await 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);
}
};