Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-11 12:12:29 +08:00
parent e1ffdab190
commit 82ea9c77c4
19 changed files with 530 additions and 122 deletions
+175 -62
View File
@@ -125,7 +125,7 @@ exports.createCourse = async (req, res) => {
try {
const {
title, description, order_index,
course_code, level, subscription,
course_code, level, subscription, status,
objectives = [],
category_ids = [],
achievement_keys = [],
@@ -142,6 +142,7 @@ exports.createCourse = async (req, res) => {
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,
@@ -193,7 +194,7 @@ exports.createCourseFull = async (req, res) => {
try {
const {
title, description, order_index,
course_code, level, subscription,
course_code, level, subscription, status,
objectives = [],
category_ids = [],
achievement_keys = [],
@@ -224,6 +225,7 @@ exports.createCourseFull = async (req, res) => {
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,
@@ -341,7 +343,7 @@ exports.updateCourse = async (req, res) => {
const {
title, description, order_index,
course_code, level, subscription,
course_code, level, subscription, status,
objectives, category_ids,
badge_color, badge_asset_id, badge_image_url,
updatedBy,
@@ -353,6 +355,7 @@ exports.updateCourse = async (req, res) => {
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;
@@ -663,6 +666,75 @@ exports.getUnits = async (req, res) => {
}
};
// 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();
@@ -679,27 +751,34 @@ exports.attachUnits = async (req, res) => {
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 may only belong to one course at a time — reject the whole
// batch if any candidate is already linked elsewhere, rather than
// silently skipping (the admin should see and deselect it).
if (toAttach.length) {
const otherCourseLinks = await CourseUnit.findAll({ where: { unit_id: toAttach }, transaction: t });
if (otherCourseLinks.length) {
await t.rollback();
const blockedSet = new Set(otherCourseLinks.map((l) => String(l.unit_id)));
const blockedTitles = units.filter((u) => blockedSet.has(String(u.unit_id))).map((u) => u.title);
return R.error(res, `${blockedTitles.join(", ")} ${blockedTitles.length !== 1 ? "are" : "is"} already attached to another course.`, 409);
// 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(
toAttach.map((unit_id) => ({
finalUnitIds.map((unit_id) => ({
course_id: courseId,
unit_id,
order_index: order++,
@@ -710,8 +789,8 @@ exports.attachUnits = async (req, res) => {
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: toAttach } });
return R.success(res, `${toAttach.length} unit${toAttach.length !== 1 ? "s" : ""} attached.`, { attached: toAttach, skipped: unit_ids.filter((id) => existingSet.has(String(id))) });
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);
@@ -796,6 +875,7 @@ exports.createUnit = async (req, res) => {
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) {
@@ -807,11 +887,12 @@ exports.createUnit = async (req, res) => {
await t.rollback();
return R.error(res, "Unit is already attached to this course.", 409);
}
// A unit may only belong to one course at a time.
// 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) {
await t.rollback();
return R.error(res, "This unit is already attached to another course.", 409);
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);
@@ -833,7 +914,7 @@ exports.createUnit = async (req, res) => {
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 } });
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();
@@ -2232,30 +2313,41 @@ exports.getCoursesBySubscription = async (req, res) => {
}
};
// One row per (course, unit) attachment; unattached units get a standalone row
// with course_title "" so they remain selectable in requirement builders.
// 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.uuid, u.title, u.duration_seconds,
COALESCE(cu.order_index, 0) AS order_index,
COALESCE(c.title, '') AS course_title,
COALESCE(c.subscription, 'free') AS subscription
SELECT u.unit_id, u.uuid, u.title, u.duration_seconds, u.subscription
FROM units u
LEFT JOIN course_units cu ON cu.unit_id = u.unit_id
LEFT JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
WHERE u."deletedAt" IS NULL
ORDER BY course_title ASC, order_index ASC, u.title ASC
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) => ({
uuid: r.uuid,
title: r.title,
order_index: Number(r.order_index ?? 0),
duration_seconds: Number(r.duration_seconds ?? 0),
course_title: r.course_title ?? "",
subscription: r.subscription ?? "free",
courses: coursesByUnit.get(r.unit_id) ?? [],
}));
return R.success(res, "Units retrieved.", data);
} catch (err) {
@@ -2264,36 +2356,43 @@ exports.getUnitsFlat = async (req, res) => {
}
};
// One row per (course, unit, lesson) attachment chain; standalone lessons keep
// empty unit/course labels.
// 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.uuid, l.title, l.duration_seconds,
COALESCE(ul.order_index, 0) AS order_index,
COALESCE(u.title, '') AS unit_title,
COALESCE(cu.order_index, 0) AS unit_order,
COALESCE(c.title, '') AS course_title,
COALESCE(c.subscription, 'free') AS subscription
SELECT l.lesson_id, l.uuid, l.title, l.duration_seconds
FROM lessons l
LEFT JOIN unit_lessons ul ON ul.lesson_id = l.lesson_id
LEFT JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
LEFT JOIN course_units cu ON cu.unit_id = u.unit_id
LEFT JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
WHERE l."deletedAt" IS NULL
ORDER BY course_title ASC, unit_order ASC, order_index ASC, l.title ASC
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) => ({
uuid: r.uuid,
title: r.title,
order_index: Number(r.order_index ?? 0),
duration_seconds: Number(r.duration_seconds ?? 0),
unit_title: r.unit_title ?? "",
unit_order: Number(r.unit_order ?? 0),
course_title: r.course_title ?? "",
subscription: r.subscription ?? "free",
courses: coursesByLesson.get(r.lesson_id) ?? [],
}));
return R.success(res, "Lessons retrieved.", data);
} catch (err) {
@@ -2303,32 +2402,46 @@ exports.getLessonsFlat = async (req, res) => {
};
// One row per unit quiz (a quiz is always unit-scoped, unit_id unique on
// unit_quizzes). 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.
// 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.uuid, q.title, u.title AS unit_title,
COALESCE(c.title, '') AS course_title,
COALESCE(c.subscription, 'free') AS subscription,
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
LEFT JOIN course_units cu ON cu.unit_id = u.unit_id
LEFT JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
WHERE q."deletedAt" IS NULL
ORDER BY course_title ASC, u.title ASC, q.title ASC
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 ?? "",
course_title: r.course_title ?? "",
subscription: r.subscription ?? "free",
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