mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
units,lesson as standalone
This commit is contained in:
@@ -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
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user