units,lesson as standalone

This commit is contained in:
2026-07-10 11:44:46 +08:00
parent 86fba50b95
commit e1ffdab190
46 changed files with 1463 additions and 197 deletions
+222
View File
@@ -169,6 +169,169 @@ exports.createCourse = async (req, res) => {
}
};
// 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,
objectives = [],
category_ids = [],
achievement_keys = [],
badge_color, badge_asset_id, badge_image_url,
units = [],
createdBy,
} = req.body;
if (!title) return R.error(res, "Title is required.", 400);
for (const unit of units) {
if (!unit.unit_id && !unit.title) {
await t.rollback();
return R.error(res, "Each new unit needs a title.", 400);
}
for (const lesson of unit.lessons ?? []) {
if (!lesson.lesson_id && !lesson.title) {
await t.rollback();
return R.error(res, "Each new lesson needs a title.", 400);
}
}
}
const course = await Course.create({
title,
description: description ?? null,
order_index: order_index ?? 0,
course_code: course_code ?? null,
level: level ?? null,
subscription: subscription ?? "free",
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 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 },
);
}
const by = createdBy ?? req.user?.user_id ?? null;
const touchedUnitIds = [];
for (let i = 0; i < units.length; i++) {
const unitInput = units[i];
let unit;
if (unitInput.unit_id) {
unit = await Unit.findOne({ where: { unit_id: unitInput.unit_id, ...notDeleted }, transaction: t });
if (!unit) {
await t.rollback();
return R.error(res, "One or more units were not found.", 404);
}
const otherLink = await CourseUnit.findOne({ where: { unit_id: unitInput.unit_id }, transaction: t });
if (otherLink) {
await t.rollback();
return R.error(res, `"${unit.title}" is already attached to another course.`, 409);
}
} else {
unit = await Unit.create({
title: unitInput.title,
description: unitInput.description ?? null,
duration_seconds: 0,
createdBy: by,
}, { transaction: t });
}
await CourseUnit.create({
course_id: course.course_id,
unit_id: unit.unit_id,
order_index: i,
createdBy: by,
}, { transaction: t });
const lessons = unitInput.lessons ?? [];
if (lessons.length) touchedUnitIds.push(unit.unit_id);
let lessonOrder = unitInput.unit_id ? await nextOrderIndex(UnitLesson, { unit_id: unit.unit_id }, t) : 0;
for (const lessonInput of lessons) {
let lesson;
if (lessonInput.lesson_id) {
lesson = await Lesson.findOne({ where: { lesson_id: lessonInput.lesson_id, ...notDeleted }, transaction: t });
if (!lesson) {
await t.rollback();
return R.error(res, "One or more lessons were not found.", 404);
}
const already = await getUnitLessonLink(unit.unit_id, lessonInput.lesson_id, t);
if (already) continue; // already attached to this unit, nothing to do
} else {
lesson = await Lesson.create({
title: lessonInput.title,
description: lessonInput.description ?? null,
duration_seconds: 0,
createdBy: by,
}, { transaction: t });
await LessonPage.create({
lesson_id: lesson.lesson_id,
blocks: [],
createdBy: by,
}, { transaction: t });
await syncObjectivesCreate(LessonObjective, "lesson_id", lesson.lesson_id, lessonInput.objectives ?? [], t);
}
await UnitLesson.create({
unit_id: unit.unit_id,
lesson_id: lesson.lesson_id,
order_index: lessonOrder++,
createdBy: by,
}, { transaction: t });
}
}
await t.commit();
try {
for (const unitId of touchedUnitIds) await recomputeUnitDuration(unitId);
await recomputeCourseDuration(course.course_id);
} catch (durErr) { console.error("[COURSE][CREATE FULL][DURATION]", durErr); }
logActivity(req.user?.user_id, 'create_course', { entityType: 'course', entityId: course.course_id, details: { title: course.title, units: units.length } });
return R.success(res, "Course created.", { data: course }, 201);
} catch (err) {
await t.rollback();
console.error("[COURSE][CREATE FULL]", err);
return R.error(res, "Could not create course.", 500);
}
};
exports.updateCourse = async (req, res) => {
const t = await sequelize.transaction();
try {
@@ -521,6 +684,19 @@ exports.attachUnits = async (req, res) => {
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);
}
}
let order = await nextOrderIndex(CourseUnit, { course_id: courseId }, t);
await CourseUnit.bulkCreate(
toAttach.map((unit_id) => ({
@@ -631,6 +807,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.
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);
}
} else {
if (!title) return R.error(res, "Title is required.", 400);
unit = await Unit.create({
@@ -2119,6 +2301,46 @@ exports.getLessonsFlat = async (req, res) => {
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). 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,
(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
`, { type: sequelize.QueryTypes.SELECT });
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",
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
// ══════════════════════════════════════════════════════════════════════════════