mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user