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
@@ -275,6 +275,10 @@ exports.updateAdvertisement = async (req, res) => {
} }
}; };
// TODO(ads-7): Once expired (end_date passed), an advertisement should be
// auto-archived (soft-deleted via the same path as archiveAdvertisement below)
// instead of just sitting at derived status "expired" indefinitely. Add a
// cron job — see TODO(ads-7) in new_starr/cron/client.cron.js.
// ─── ARCHIVE (single) ───────────────────────────────────────────────────────── // ─── ARCHIVE (single) ─────────────────────────────────────────────────────────
exports.archiveAdvertisement = async (req, res) => { exports.archiveAdvertisement = async (req, res) => {
+175 -62
View File
@@ -125,7 +125,7 @@ exports.createCourse = async (req, res) => {
try { try {
const { const {
title, description, order_index, title, description, order_index,
course_code, level, subscription, course_code, level, subscription, status,
objectives = [], objectives = [],
category_ids = [], category_ids = [],
achievement_keys = [], achievement_keys = [],
@@ -142,6 +142,7 @@ exports.createCourse = async (req, res) => {
course_code: course_code ?? null, course_code: course_code ?? null,
level: level ?? null, level: level ?? null,
subscription: subscription ?? "free", subscription: subscription ?? "free",
status: status ?? "draft",
duration_seconds: 0, duration_seconds: 0,
badge_color: badge_color ?? "purple", badge_color: badge_color ?? "purple",
badge_asset_id: badge_asset_id ?? null, badge_asset_id: badge_asset_id ?? null,
@@ -193,7 +194,7 @@ exports.createCourseFull = async (req, res) => {
try { try {
const { const {
title, description, order_index, title, description, order_index,
course_code, level, subscription, course_code, level, subscription, status,
objectives = [], objectives = [],
category_ids = [], category_ids = [],
achievement_keys = [], achievement_keys = [],
@@ -224,6 +225,7 @@ exports.createCourseFull = async (req, res) => {
course_code: course_code ?? null, course_code: course_code ?? null,
level: level ?? null, level: level ?? null,
subscription: subscription ?? "free", subscription: subscription ?? "free",
status: status ?? "draft",
duration_seconds: 0, duration_seconds: 0,
badge_color: badge_color ?? "purple", badge_color: badge_color ?? "purple",
badge_asset_id: badge_asset_id ?? null, badge_asset_id: badge_asset_id ?? null,
@@ -341,7 +343,7 @@ exports.updateCourse = async (req, res) => {
const { const {
title, description, order_index, title, description, order_index,
course_code, level, subscription, course_code, level, subscription, status,
objectives, category_ids, objectives, category_ids,
badge_color, badge_asset_id, badge_image_url, badge_color, badge_asset_id, badge_image_url,
updatedBy, updatedBy,
@@ -353,6 +355,7 @@ exports.updateCourse = async (req, res) => {
if (course_code !== undefined) course.course_code = course_code; if (course_code !== undefined) course.course_code = course_code;
if (level !== undefined) course.level = level; if (level !== undefined) course.level = level;
if (subscription !== undefined) course.subscription = subscription; if (subscription !== undefined) course.subscription = subscription;
if (status !== undefined) course.status = status;
if (badge_color !== undefined) course.badge_color = badge_color; if (badge_color !== undefined) course.badge_color = badge_color;
if (badge_asset_id !== undefined) course.badge_asset_id = badge_asset_id; if (badge_asset_id !== undefined) course.badge_asset_id = badge_asset_id;
if (badge_image_url !== undefined) course.badge_image_url = badge_image_url; 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 // POST /:courseId/units/attach { unit_ids: [..] } — attach existing library units
exports.attachUnits = async (req, res) => { exports.attachUnits = async (req, res) => {
const t = await sequelize.transaction(); const t = await sequelize.transaction();
@@ -679,27 +751,34 @@ exports.attachUnits = async (req, res) => {
await t.rollback(); await t.rollback();
return R.error(res, "One or more units were not found.", 404); 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 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 existingSet = new Set(existing.map((r) => String(r.unit_id)));
const toAttach = unit_ids.filter((id) => !existingSet.has(String(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 // A unit already attached to another course gets duplicated instead of
// batch if any candidate is already linked elsewhere, rather than // moved — the original stays put, a clone is attached here.
// silently skipping (the admin should see and deselect it). const otherCourseLinks = toAttach.length
if (toAttach.length) { ? await CourseUnit.findAll({ where: { unit_id: toAttach }, transaction: t })
const otherCourseLinks = await CourseUnit.findAll({ where: { unit_id: toAttach }, transaction: t }); : [];
if (otherCourseLinks.length) { const needsDuplicate = new Set(otherCourseLinks.map((l) => String(l.unit_id)));
await t.rollback();
const blockedSet = new Set(otherCourseLinks.map((l) => String(l.unit_id))); const duplicated = [];
const blockedTitles = units.filter((u) => blockedSet.has(String(u.unit_id))).map((u) => u.title); const finalUnitIds = [];
return R.error(res, `${blockedTitles.join(", ")} ${blockedTitles.length !== 1 ? "are" : "is"} already attached to another course.`, 409); 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); let order = await nextOrderIndex(CourseUnit, { course_id: courseId }, t);
await CourseUnit.bulkCreate( await CourseUnit.bulkCreate(
toAttach.map((unit_id) => ({ finalUnitIds.map((unit_id) => ({
course_id: courseId, course_id: courseId,
unit_id, unit_id,
order_index: order++, order_index: order++,
@@ -710,8 +789,8 @@ exports.attachUnits = async (req, res) => {
await t.commit(); await t.commit();
try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][ATTACH][DURATION]", durErr); } 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 } }); logActivity(req.user?.user_id, "attach_units", { entityType: "course", entityId: Number(courseId), details: { unit_ids: finalUnitIds, duplicated } });
return R.success(res, `${toAttach.length} unit${toAttach.length !== 1 ? "s" : ""} attached.`, { attached: toAttach, skipped: unit_ids.filter((id) => existingSet.has(String(id))) }); 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) { } catch (err) {
await t.rollback(); await t.rollback();
console.error("[UNIT][ATTACH]", err); console.error("[UNIT][ATTACH]", err);
@@ -796,6 +875,7 @@ exports.createUnit = async (req, res) => {
if (!course) return R.error(res, "Course not found.", 404); if (!course) return R.error(res, "Course not found.", 404);
let unit; let unit;
let duplicatedFrom = null;
if (unit_id) { if (unit_id) {
unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, transaction: t }); unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, transaction: t });
if (!unit) { if (!unit) {
@@ -807,11 +887,12 @@ exports.createUnit = async (req, res) => {
await t.rollback(); await t.rollback();
return R.error(res, "Unit is already attached to this course.", 409); 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 }); const otherLink = await CourseUnit.findOne({ where: { unit_id }, transaction: t });
if (otherLink) { if (otherLink) {
await t.rollback(); duplicatedFrom = unit.unit_id;
return R.error(res, "This unit is already attached to another course.", 409); unit = await duplicateUnitForAttach(unit, createdBy ?? req.user?.user_id ?? null, t);
} }
} else { } else {
if (!title) return R.error(res, "Title is required.", 400); if (!title) return R.error(res, "Title is required.", 400);
@@ -833,7 +914,7 @@ exports.createUnit = async (req, res) => {
await t.commit(); await t.commit();
try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][CREATE][DURATION]", durErr); } 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); return R.success(res, unit_id ? "Unit attached." : "Unit created.", { data: { ...unit.toJSON(), order_index } }, 201);
} catch (err) { } catch (err) {
await t.rollback(); await t.rollback();
@@ -2232,30 +2313,41 @@ exports.getCoursesBySubscription = async (req, res) => {
} }
}; };
// One row per (course, unit) attachment; unattached units get a standalone row // One row per Unit (junction revamp: a unit may sit under 0..N courses) —
// with course_title "" so they remain selectable in requirement builders. // `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) => { exports.getUnitsFlat = async (req, res) => {
try { try {
const rows = await sequelize.query(` const rows = await sequelize.query(`
SELECT SELECT u.unit_id, u.uuid, u.title, u.duration_seconds, u.subscription
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
FROM units u 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 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 }); `, { 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) => ({ const data = rows.map((r) => ({
uuid: r.uuid, uuid: r.uuid,
title: r.title, title: r.title,
order_index: Number(r.order_index ?? 0),
duration_seconds: Number(r.duration_seconds ?? 0), duration_seconds: Number(r.duration_seconds ?? 0),
course_title: r.course_title ?? "",
subscription: r.subscription ?? "free", subscription: r.subscription ?? "free",
courses: coursesByUnit.get(r.unit_id) ?? [],
})); }));
return R.success(res, "Units retrieved.", data); return R.success(res, "Units retrieved.", data);
} catch (err) { } catch (err) {
@@ -2264,36 +2356,43 @@ exports.getUnitsFlat = async (req, res) => {
} }
}; };
// One row per (course, unit, lesson) attachment chain; standalone lessons keep // One row per Lesson — a lesson may sit under 0..N units, each possibly under
// empty unit/course labels. // 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) => { exports.getLessonsFlat = async (req, res) => {
try { try {
const rows = await sequelize.query(` const rows = await sequelize.query(`
SELECT SELECT l.lesson_id, l.uuid, l.title, l.duration_seconds
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
FROM lessons l 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 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 }); `, { 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) => ({ const data = rows.map((r) => ({
uuid: r.uuid, uuid: r.uuid,
title: r.title, title: r.title,
order_index: Number(r.order_index ?? 0),
duration_seconds: Number(r.duration_seconds ?? 0), duration_seconds: Number(r.duration_seconds ?? 0),
unit_title: r.unit_title ?? "", courses: coursesByLesson.get(r.lesson_id) ?? [],
unit_order: Number(r.unit_order ?? 0),
course_title: r.course_title ?? "",
subscription: r.subscription ?? "free",
})); }));
return R.success(res, "Lessons retrieved.", data); return R.success(res, "Lessons retrieved.", data);
} catch (err) { } 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 // 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 // unit_quizzes) — `courses[]` is the deduped set of courses the parent unit
// "no content yet" convention as read_*: question_count === 0 is flagged // is attached to, batch-fetched the same way as getUnitsFlat/getLessonsFlat.
// the same way duration_seconds === 0 is for content requirements. // 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) => { exports.getQuizzesFlat = async (req, res) => {
try { try {
const rows = await sequelize.query(` const rows = await sequelize.query(`
SELECT SELECT
q.uuid, q.title, u.title AS unit_title, q.quiz_id, 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 (SELECT CAST(COUNT(*) AS INTEGER) FROM quiz_questions qq
WHERE qq.quiz_id = q.quiz_id AND qq."deletedAt" IS NULL) AS question_count WHERE qq.quiz_id = q.quiz_id AND qq."deletedAt" IS NULL) AS question_count
FROM unit_quizzes q FROM unit_quizzes q
JOIN units u ON u.unit_id = q.unit_id AND u."deletedAt" IS NULL 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 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 }); `, { 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) => ({ const data = rows.map((r) => ({
uuid: r.uuid, uuid: r.uuid,
title: r.title || `${r.unit_title} Quiz`, title: r.title || `${r.unit_title} Quiz`,
unit_title: r.unit_title ?? "", unit_title: r.unit_title ?? "",
course_title: r.course_title ?? "", courses: coursesByQuiz.get(r.quiz_id) ?? [],
subscription: r.subscription ?? "free",
question_count: Number(r.question_count ?? 0), question_count: Number(r.question_count ?? 0),
// duration_seconds doesn't apply to quizzes — ContentPicker's "no // duration_seconds doesn't apply to quizzes — ContentPicker's "no
// content" check keys off duration_seconds === 0, so surface the same // content" check keys off duration_seconds === 0, so surface the same
+25 -16
View File
@@ -56,27 +56,41 @@ async function recomputeParentDurations(lessonId) {
const LESSON_LIST_COMPUTED = [ const LESSON_LIST_COMPUTED = [
{ {
key: "unit_count", key: "course_count",
label: "Used in units", label: "Used in courses",
type: "number", type: "number",
literal: `( literal: `(
SELECT CAST(COUNT(*) AS INTEGER) SELECT CAST(COUNT(DISTINCT c.course_id) AS INTEGER)
FROM unit_lessons ul FROM unit_lessons ul
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL 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 = "Lesson"."lesson_id" WHERE ul.lesson_id = "Lesson"."lesson_id"
)`, )`,
}, },
{ {
key: "course_bound", key: "course_status",
label: "Course Status", label: "Course Status",
type: "boolean", type: "text",
filterable: false,
literal: `( literal: `(
SELECT EXISTS ( CASE
SELECT 1 WHEN NOT EXISTS (
FROM unit_lessons ul SELECT 1 FROM unit_lessons ul
JOIN course_units cu ON cu.unit_id = ul.unit_id JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
WHERE ul.lesson_id = "Lesson"."lesson_id" 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 = "Lesson"."lesson_id"
) THEN 'standalone'
WHEN EXISTS (
SELECT 1 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 = "Lesson"."lesson_id" AND c.status = 'published'
) THEN 'published'
ELSE 'draft'
END
)`, )`,
}, },
]; ];
@@ -114,11 +128,6 @@ exports.getLessonsFlat = async (req, res) => {
WHERE ul.lesson_id = l.lesson_id) AS unit_count WHERE ul.lesson_id = l.lesson_id) AS unit_count
FROM lessons l FROM lessons l
WHERE l."deletedAt" IS NULL WHERE l."deletedAt" IS NULL
AND NOT EXISTS (
SELECT 1 FROM unit_lessons ul2
JOIN course_units cu ON cu.unit_id = ul2.unit_id
WHERE ul2.lesson_id = l.lesson_id
)
ORDER BY l.title ASC ORDER BY l.title ASC
`, { type: sequelize.QueryTypes.SELECT }); `, { type: sequelize.QueryTypes.SELECT });
return R.success(res, "Lessons retrieved.", rows); return R.success(res, "Lessons retrieved.", rows);
+23 -1
View File
@@ -4,6 +4,7 @@
* Description : Admin notification management. * Description : Admin notification management.
* GET /admin/notifications — paginated list, newest first * GET /admin/notifications — paginated list, newest first
* GET /admin/notifications/unseen — unseen count only * GET /admin/notifications/unseen — unseen count only
* GET /admin/notifications/sticky — current sticky announcement, if any
* PATCH /admin/notifications/:id/seen — mark one as seen * PATCH /admin/notifications/:id/seen — mark one as seen
* PATCH /admin/notifications/seen-all — mark all as seen * PATCH /admin/notifications/seen-all — mark all as seen
* *
@@ -48,6 +49,27 @@ async function unseenCount(req, res) {
} }
} }
// ─── GET /admin/notifications/sticky ──────────────────────────────────────────
// Not user-scoped, same as list()/unseenCount() above — one shared sticky
// banner for every admin. Whoever dismisses it first dismisses it for all.
async function stickyAnnouncement(req, res) {
try {
const notification = await AdminNotification.findOne({
where: {
seen: false,
show_in_sticky: true,
type: 'announcement',
},
order: [['createdAt', 'DESC']],
});
return R.success(res, 'Sticky announcement fetched.', { announcement: notification });
} catch (err) {
console.error('[NOTIFICATION] stickyAnnouncement error:', err);
return R.error(res, 'Failed to fetch sticky announcement.');
}
}
// ─── PATCH /admin/notifications/:id/seen ───────────────────────────────────── // ─── PATCH /admin/notifications/:id/seen ─────────────────────────────────────
async function markSeen(req, res) { async function markSeen(req, res) {
try { try {
@@ -77,4 +99,4 @@ async function markAllSeen(req, res) {
} }
} }
module.exports = { list, unseenCount, markSeen, markAllSeen }; module.exports = { list, unseenCount, stickyAnnouncement, markSeen, markAllSeen };
@@ -31,6 +31,7 @@ const notDeleted = { deletedAt: null };
async function applyBroadcastFields(broadcast, body) { async function applyBroadcastFields(broadcast, body) {
if (body.title !== undefined) broadcast.title = body.title; if (body.title !== undefined) broadcast.title = body.title;
if (body.message !== undefined) broadcast.message = body.message; if (body.message !== undefined) broadcast.message = body.message;
if (body.link_url !== undefined) broadcast.link_url = body.link_url?.trim() || null;
if (body.show_in_sticky !== undefined) broadcast.show_in_sticky = !!body.show_in_sticky; if (body.show_in_sticky !== undefined) broadcast.show_in_sticky = !!body.show_in_sticky;
if (body.show_in_notifications !== undefined) broadcast.show_in_notifications = !!body.show_in_notifications; if (body.show_in_notifications !== undefined) broadcast.show_in_notifications = !!body.show_in_notifications;
@@ -159,6 +160,7 @@ exports.createBroadcast = async (req, res) => {
const { const {
title, title,
message, message,
link_url,
target_type, target_type,
target_id, target_id,
createdBy, createdBy,
@@ -186,6 +188,7 @@ exports.createBroadcast = async (req, res) => {
const broadcast = await NotificationBroadcast.build({ const broadcast = await NotificationBroadcast.build({
title, title,
message, message,
link_url: link_url?.trim() || null,
createdBy, createdBy,
status: 'draft', status: 'draft',
target_type, target_type,
@@ -272,6 +275,7 @@ exports.sendBroadcast = async (req, res) => {
message: broadcast.message, message: broadcast.message,
targetType, targetType,
targetId, targetId,
linkUrl: broadcast.link_url,
}); });
if (targetType === 'admin' || targetType === 'both') { if (targetType === 'admin' || targetType === 'both') {
@@ -308,6 +312,7 @@ exports.sendBroadcast = async (req, res) => {
? NOTIFICATION_REGISTRY.broadcast.build({ ? NOTIFICATION_REGISTRY.broadcast.build({
title: broadcast.title, message: broadcast.message, targetType, targetId, title: broadcast.title, message: broadcast.message, targetType, targetId,
groupId: groupByUser[user_id] ?? null, groupId: groupByUser[user_id] ?? null,
linkUrl: broadcast.link_url,
}) })
: baseNotify), : baseNotify),
seen: false, seen: false,
@@ -4,6 +4,9 @@ const mdl_NotificationTemplate = require('../../models/notifications/notificatio
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util'); const logActivity = require('../../utils/logActivity.util');
const slugify = (str) =>
str.toLowerCase().trim().replace(/[^a-z0-9]+/g, '_').replace(/(^_|_$)/g, '');
// ─── GET /admin/notification-templates ───────────────────────────────────────── // ─── GET /admin/notification-templates ─────────────────────────────────────────
exports.getNotificationTemplates = async (req, res) => { exports.getNotificationTemplates = async (req, res) => {
@@ -31,10 +34,71 @@ exports.getNotificationTemplate = async (req, res) => {
} }
}; };
// ─── POST /admin/notification-templates ────────────────────────────────────────
// Only creates custom (is_system: false) rows. System types still can't be
// added here — they need a code call site (services/notificationTemplate
// .service.js's renderNotification()) before a type means anything. Custom
// rows have no call site at all: they're reusable title/message presets an
// admin can load into the Announcements composer (see AddNotificationBroadcast
// .jsx), so `type` only exists to satisfy the unique key — nothing looks it up.
exports.createNotificationTemplate = async (req, res) => {
try {
const { label, title, message } = req.body;
if (!label?.trim()) return R.error(res, 'label is required.', 400);
if (!title?.trim()) return R.error(res, 'title is required.', 400);
if (!message?.trim()) return R.error(res, 'message cannot be empty.', 400);
const base = slugify(label) || 'template';
let type = `custom_${base}`;
let suffix = 1;
while (await mdl_NotificationTemplate.findOne({ where: { type } })) {
suffix += 1;
type = `custom_${base}_${suffix}`;
}
const template = await mdl_NotificationTemplate.create({
type,
notify_type: 'announcement',
scope: 'both',
label: label.trim(),
status: 'sent',
title: title.trim(),
message: message.trim(),
is_system: false,
});
logActivity(req.user?.user_id, 'create_notification_template', { entityType: 'notification_template', entityId: template.notification_template_id, details: { label: template.label } });
return R.success(res, 'Announcement template created.', template, 201);
} catch (err) {
console.error('[ADMIN][CREATE NOTIFICATION TEMPLATE]', err);
return R.error(res, 'Could not create announcement template.', 500);
}
};
// ─── DELETE /admin/notification-templates/:id ──────────────────────────────────
// System templates stay protected — deleting one would break the code call
// site that references its type.
exports.deleteNotificationTemplate = async (req, res) => {
try {
const template = await mdl_NotificationTemplate.findByPk(req.params.id);
if (!template) return R.error(res, 'Announcement template not found.', 404);
if (template.is_system) return R.error(res, 'System templates cannot be deleted.', 400);
await template.destroy();
logActivity(req.user?.user_id, 'delete_notification_template', { entityType: 'notification_template', entityId: template.notification_template_id, details: { label: template.label } });
return R.success(res, 'Announcement template deleted.');
} catch (err) {
console.error('[ADMIN][DELETE NOTIFICATION TEMPLATE]', err);
return R.error(res, 'Could not delete announcement template.', 500);
}
};
// ─── PUT /admin/notification-templates/:id ───────────────────────────────────── // ─── PUT /admin/notification-templates/:id ─────────────────────────────────────
// No create/delete endpoints — every row is is_system by definition (a new
// type needs a code call site before it means anything), so there is nothing
// valid to create or delete through this UI.
exports.updateNotificationTemplate = async (req, res) => { exports.updateNotificationTemplate = async (req, res) => {
try { try {
@@ -46,6 +110,21 @@ exports.updateNotificationTemplate = async (req, res) => {
if (title !== undefined && !title.trim()) return R.error(res, 'title cannot be empty.', 400); if (title !== undefined && !title.trim()) return R.error(res, 'title cannot be empty.', 400);
if (message !== undefined && !message.trim()) return R.error(res, 'message cannot be empty.', 400); if (message !== undefined && !message.trim()) return R.error(res, 'message cannot be empty.', 400);
// Custom templates are just reusable presets — nothing reads them at a
// fixed publish time, so there's no draft/publish workflow: title/message
// save straight to the live columns.
if (!template.is_system) {
await template.update({
label: label ?? template.label,
title: title ?? template.title,
message: message ?? template.message,
});
logActivity(req.user?.user_id, 'update_notification_template', { entityType: 'notification_template', entityId: template.notification_template_id, details: { custom: true } });
return R.success(res, 'Announcement template updated.', template);
}
// "Publish" writes title/message straight to the live columns // "Publish" writes title/message straight to the live columns
// renderNotification() reads and clears any pending draft. A plain save // renderNotification() reads and clears any pending draft. A plain save
// (no publish flag) writes into draft_title/draft_message instead, so // (no publish flag) writes into draft_title/draft_message instead, so
+116 -4
View File
@@ -26,13 +26,14 @@ const { permanentDeleteOne, permanentDeleteMany } = require("../../utils/courses
const { getFieldValues } = require("../../utils/fieldValues.util"); const { getFieldValues } = require("../../utils/fieldValues.util");
const { flattenLessons, nextOrderIndex, reorderJunction } = require("../../utils/courses/hierarchy.util"); const { flattenLessons, nextOrderIndex, reorderJunction } = require("../../utils/courses/hierarchy.util");
const { recomputeUnitDuration, recomputeCourseDuration } = require("../../utils/duration.util"); const { recomputeUnitDuration, recomputeCourseDuration } = require("../../utils/duration.util");
const { syncObjectivesCreate } = require("../../utils/courses/objectives.util");
const logActivity = require("../../utils/logActivity.util"); const logActivity = require("../../utils/logActivity.util");
// ── Models ──────────────────────────────────────────────────────────────────── // ── Models ────────────────────────────────────────────────────────────────────
const { const {
Course, Unit, Lesson, Course, Unit, Lesson, LessonPage,
CourseUnit, UnitLesson, CourseUnit, UnitLesson, LessonObjective,
UnitQuiz, QuizQuestion, QuizOption, UnitQuiz, QuizQuestion, QuizOption,
UnitReadingProgress, LessonReadingProgress, UnitReadingProgress, LessonReadingProgress,
} = require("../../models/courses/courses.associations"); } = require("../../models/courses/courses.associations");
@@ -79,6 +80,27 @@ const UNIT_LIST_COMPUTED = [
WHERE cu.unit_id = "Unit"."unit_id" WHERE cu.unit_id = "Unit"."unit_id"
)`, )`,
}, },
{
key: "course_status",
label: "Course Status",
type: "text",
filterable: false,
literal: `(
CASE
WHEN NOT EXISTS (
SELECT 1 FROM course_units cu
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
WHERE cu.unit_id = "Unit"."unit_id"
) THEN 'standalone'
WHEN EXISTS (
SELECT 1 FROM course_units cu
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
WHERE cu.unit_id = "Unit"."unit_id" AND c.status = 'published'
) THEN 'published'
ELSE 'draft'
END
)`,
},
]; ];
// ══════════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════════
@@ -103,7 +125,7 @@ exports.getUnits = async (req, res) => {
} }
}; };
// Lightweight list for attach pickers: { unit_id, uuid, title, lesson_count, course_count } // Lightweight list for attach pickers: { unit_id, uuid, title, lesson_count, course_count, course_title }
exports.getUnitsFlat = async (req, res) => { exports.getUnitsFlat = async (req, res) => {
try { try {
const rows = await sequelize.query(` const rows = await sequelize.query(`
@@ -114,7 +136,11 @@ exports.getUnitsFlat = async (req, res) => {
WHERE ul.unit_id = u.unit_id) AS lesson_count, WHERE ul.unit_id = u.unit_id) AS lesson_count,
(SELECT CAST(COUNT(*) AS INTEGER) FROM course_units cu (SELECT CAST(COUNT(*) AS INTEGER) FROM course_units cu
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
WHERE cu.unit_id = u.unit_id) AS course_count WHERE cu.unit_id = u.unit_id) AS course_count,
(SELECT 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 = u.unit_id
LIMIT 1) AS course_title
FROM units u FROM units u
WHERE u."deletedAt" IS NULL WHERE u."deletedAt" IS NULL
ORDER BY u.title ASC ORDER BY u.title ASC
@@ -190,6 +216,92 @@ exports.createUnit = async (req, res) => {
} }
}; };
// One consolidated call: creates the unit, its lessons (with objectives + page
// blocks), and attaches each lesson to the unit — all in a single transaction.
// Body: { title, description, subscription,
// lessons: [{ title, description, objectives?: string[], blocks?: [] }],
// createdBy }
const CREATE_UNIT_FULL_LIMITS = { maxLessons: 50, maxObjectives: 20, maxBlocks: 100 };
exports.createUnitFull = async (req, res) => {
const t = await sequelize.transaction();
try {
const { title, description, subscription, lessons = [], createdBy } = req.body;
if (!title) return R.error(res, "Title is required.", 400);
if (!Array.isArray(lessons)) return R.error(res, "Lessons must be a list.", 400);
if (lessons.length > CREATE_UNIT_FULL_LIMITS.maxLessons) {
return R.error(res, `A unit can have at most ${CREATE_UNIT_FULL_LIMITS.maxLessons} lessons.`, 400);
}
for (const lessonInput of lessons) {
if (!lessonInput?.title) return R.error(res, "Each lesson needs a title.", 400);
if (lessonInput.objectives !== undefined && !Array.isArray(lessonInput.objectives)) {
return R.error(res, "Lesson objectives must be a list.", 400);
}
if (lessonInput.objectives?.length > CREATE_UNIT_FULL_LIMITS.maxObjectives) {
return R.error(res, `A lesson can have at most ${CREATE_UNIT_FULL_LIMITS.maxObjectives} objectives.`, 400);
}
if (lessonInput.blocks !== undefined && !Array.isArray(lessonInput.blocks)) {
return R.error(res, "Lesson page blocks must be a list.", 400);
}
if (lessonInput.blocks?.length > CREATE_UNIT_FULL_LIMITS.maxBlocks) {
return R.error(res, `A lesson page can have at most ${CREATE_UNIT_FULL_LIMITS.maxBlocks} blocks.`, 400);
}
}
const by = createdBy ?? req.user?.user_id ?? null;
const unit = await Unit.create({
title,
subscription: subscription || null,
description: description ?? null,
duration_seconds: 0,
createdBy: by,
}, { transaction: t });
for (let i = 0; i < lessons.length; i++) {
const lessonInput = lessons[i];
const 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: lessonInput.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: i,
createdBy: by,
}, { transaction: t });
}
await t.commit();
if (lessons.length) {
try { await recomputeUnitDuration(unit.unit_id); }
catch (durErr) { console.error("[UNIT LIB][CREATE FULL][DURATION]", durErr); }
}
logActivity(req.user?.user_id, "create_unit", { entityType: "unit", entityId: unit.unit_id, details: { title: unit.title, lessons: lessons.length } });
return R.success(res, "Unit created.", { data: unit }, 201);
} catch (err) {
await t.rollback();
console.error("[UNIT LIB][CREATE FULL]", err);
return R.error(res, "Could not create unit.", 500);
}
};
exports.updateUnit = async (req, res) => { exports.updateUnit = async (req, res) => {
try { try {
const { unitId } = req.params; const { unitId } = req.params;
+12 -3
View File
@@ -112,8 +112,9 @@ async function buildUserContext(user_id) {
// Returns true → user may access the course. // Returns true → user may access the course.
// Returns false → user's tier is too low AND no valid individual purchase. // Returns false → user's tier is too low AND no valid individual purchase.
async function canAccessCourse(user_id, course_id) { async function canAccessCourse(user_id, course_id) {
const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription'] }); const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription', 'status'] });
if (!course) return false; if (!course) return false;
if (course.status !== 'published') return false;
const userCtx = await buildUserContext(user_id); const userCtx = await buildUserContext(user_id);
@@ -156,7 +157,15 @@ async function canAccessUnit(user_id, unit_id) {
if (allowed) return true; if (allowed) return true;
} }
const links = await CourseUnit.findAll({ where: { unit_id }, attributes: ['course_id'] }); // Only links to PUBLISHED courses count as a real course dependency — a unit
// whose only link is to a draft/unpublished course behaves as if it had no
// course link at all (falls through to the free/standalone branch below),
// matching the client discovery-list's course_count computation.
const links = await CourseUnit.findAll({
where: { unit_id },
attributes: ['course_id'],
include: [{ model: Course, as: 'course', attributes: [], where: { status: 'published', ...notDeleted }, required: true }],
});
if (!links.length) return !unit?.subscription; if (!links.length) return !unit?.subscription;
for (const link of links) { for (const link of links) {
if (await canAccessCourse(user_id, link.course_id)) return true; if (await canAccessCourse(user_id, link.course_id)) return true;
@@ -256,7 +265,7 @@ exports.getCourses = async (req, res) => {
}; };
const courses = await Course.findAll({ const courses = await Course.findAll({
where: { ...notDeleted }, where: { ...notDeleted, status: 'published' },
attributes: COURSE_LIST_ATTRS, attributes: COURSE_LIST_ATTRS,
include: [ include: [
{ {
+13 -24
View File
@@ -17,10 +17,11 @@
* be able to access at least one attached course. Lessons resolve through * be able to access at least one attached course. Lessons resolve through
* their parent units the same way. * their parent units the same way.
* *
* Discovery rule (getUnits/getLessons only): only course-free content is * Discovery rule (getUnits/getLessons only): ALL non-deleted units/lessons
* listed at all — a unit with any course affiliation, or a lesson with any * are listed, whether or not they're attached to a course — course_count/
* unit that has a course affiliation, is excluded outright rather than * courses[] (published courses only) and is_locked tell the learner whether
* listed-but-locked. This does not affect the single-item endpoints above * a given item is standalone or bound, and if bound, whether they already
* have access. This does not affect the single-item endpoints above
* (:uuid) — those still enforce access normally for direct links, and * (:uuid) — those still enforce access normally for direct links, and
* course-scoped consumption runs through a separate controller entirely. * course-scoped consumption runs through a separate controller entirely.
* *
@@ -61,12 +62,11 @@ function sanitizeQuestions(questions = []) {
// ─── UNIT LIBRARY (learner view) ────────────────────────────────────────────── // ─── UNIT LIBRARY (learner view) ──────────────────────────────────────────────
// Client-side Units/Lessons browsing only ever shows INDEPENDENT content — // Client-side Units/Lessons browsing shows ALL content, bound to a course or
// anything affiliated with a course (directly, or for a lesson, through any // not — course_count/courses[] + is_locked below tell the learner which is
// of its attached units) is excluded from these listings entirely, not just // which. This does not affect course-scoped consumption (which runs through
// flagged locked. This does not affect course-scoped consumption (which runs // ClientCoursesContext/getCourse, a separate path) or direct-link access to
// through ClientCoursesContext/getCourse, a separate path) or direct-link // UnitDetails/LessonDetails, which still enforce access normally.
// access to UnitDetails/LessonDetails, which still enforce access normally.
exports.getUnits = async (req, res) => { exports.getUnits = async (req, res) => {
try { try {
const rows = await sequelize.query(` const rows = await sequelize.query(`
@@ -76,17 +76,12 @@ exports.getUnits = async (req, res) => {
JOIN lessons l ON l.lesson_id = ul.lesson_id AND l."deletedAt" IS NULL JOIN lessons l ON l.lesson_id = ul.lesson_id AND l."deletedAt" IS NULL
WHERE ul.unit_id = u.unit_id) AS lesson_count, WHERE ul.unit_id = u.unit_id) AS lesson_count,
(SELECT CAST(COUNT(*) AS INTEGER) FROM course_units cu (SELECT CAST(COUNT(*) AS INTEGER) FROM course_units cu
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL AND c.status = 'published'
WHERE cu.unit_id = u.unit_id) AS course_count, WHERE cu.unit_id = u.unit_id) AS course_count,
(SELECT quiz_id FROM unit_quizzes q (SELECT quiz_id FROM unit_quizzes q
WHERE q.unit_id = u.unit_id AND q."deletedAt" IS NULL LIMIT 1) AS quiz_id WHERE q.unit_id = u.unit_id AND q."deletedAt" IS NULL LIMIT 1) AS quiz_id
FROM units u FROM units u
WHERE u."deletedAt" IS NULL WHERE u."deletedAt" IS NULL
AND NOT EXISTS (
SELECT 1 FROM course_units cu
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
WHERE cu.unit_id = u.unit_id
)
ORDER BY u.title ASC ORDER BY u.title ASC
`, { type: sequelize.QueryTypes.SELECT }); `, { type: sequelize.QueryTypes.SELECT });
@@ -97,7 +92,7 @@ exports.getUnits = async (req, res) => {
const courseLinkRows = unitIds.length ? await sequelize.query(` const courseLinkRows = unitIds.length ? await sequelize.query(`
SELECT cu.unit_id, c.course_id, c.uuid, c.title, c.subscription SELECT cu.unit_id, c.course_id, c.uuid, c.title, c.subscription
FROM course_units cu FROM course_units cu
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL AND c.status = 'published'
WHERE cu.unit_id IN (:unitIds) WHERE cu.unit_id IN (:unitIds)
`, { replacements: { unitIds }, type: sequelize.QueryTypes.SELECT }) : []; `, { replacements: { unitIds }, type: sequelize.QueryTypes.SELECT }) : [];
@@ -141,12 +136,6 @@ exports.getLessons = async (req, res) => {
WHERE ul.lesson_id = l.lesson_id) AS unit_count WHERE ul.lesson_id = l.lesson_id) AS unit_count
FROM lessons l FROM lessons l
WHERE l."deletedAt" IS NULL WHERE l."deletedAt" IS NULL
AND NOT EXISTS (
SELECT 1 FROM unit_lessons ul
JOIN course_units cu ON cu.unit_id = ul.unit_id
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
WHERE ul.lesson_id = l.lesson_id
)
ORDER BY l.title ASC ORDER BY l.title ASC
`, { type: sequelize.QueryTypes.SELECT }); `, { type: sequelize.QueryTypes.SELECT });
@@ -157,7 +146,7 @@ exports.getLessons = async (req, res) => {
SELECT DISTINCT ul.lesson_id, c.course_id, c.uuid, c.title, c.subscription SELECT DISTINCT ul.lesson_id, c.course_id, c.uuid, c.title, c.subscription
FROM unit_lessons ul FROM unit_lessons ul
JOIN course_units cu ON cu.unit_id = ul.unit_id JOIN course_units cu ON cu.unit_id = ul.unit_id
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL AND c.status = 'published'
WHERE ul.lesson_id IN (:lessonIds) WHERE ul.lesson_id IN (:lessonIds)
`, { replacements: { lessonIds }, type: sequelize.QueryTypes.SELECT }) : []; `, { replacements: { lessonIds }, type: sequelize.QueryTypes.SELECT }) : [];
+4
View File
@@ -24,6 +24,10 @@ const expireUserTiers = require('./jobs/expire_user_tiers.cron');
const taskDueSoon = require('./jobs/task_due_soon.cron'); const taskDueSoon = require('./jobs/task_due_soon.cron');
const { startSettingsBackedJobs } = require('./cronRegistry.util'); const { startSettingsBackedJobs } = require('./cronRegistry.util');
// TODO(ads-7): Add an `expire_advertisements.cron.js` job (same shape as
// expire_user_tiers.cron.js) that auto-archives (soft-deletes) advertisements
// once their end_date has passed, instead of just leaving them at derived
// status "expired" forever. Register it in the `jobs` array below.
// ─── Registry — add future client-side cron jobs here ──────────────────────── // ─── Registry — add future client-side cron jobs here ────────────────────────
const jobs = [ const jobs = [
userNotifications, userNotifications,
+2 -2
View File
@@ -68,12 +68,12 @@ const NOTIFICATION_REGISTRY = {
type: 'announcement', type: 'announcement',
scope: 'both', scope: 'both',
trigger: 'manual', trigger: 'manual',
build({ title, message, targetType = null, targetId = null, groupId = null }) { build({ title, message, targetType = null, targetId = null, groupId = null, linkUrl = null }) {
return { return {
type: 'announcement', type: 'announcement',
title, title,
message, message,
data: { targetType, targetId, groupId }, data: { targetType, targetId, groupId, linkUrl },
}; };
}, },
}, },
@@ -0,0 +1,34 @@
'use strict';
// Courses gain a publish-state lifecycle (draft/published/unpublished),
// separate from the existing soft-delete Archive feature. `status` is added
// as STRING + an explicit CHECK constraint (addColumn doesn't auto-generate
// one on this CockroachDB instance, unlike createTable), matching the
// pattern used in 20260709000002-add-review-fields-to-task-completions.js.
// Existing non-deleted courses are backfilled to 'published' since they're
// already live/consumed by learners; new courses default to 'draft'.
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('courses', 'status', {
type: Sequelize.STRING,
allowNull: false,
defaultValue: 'draft',
after: 'subscription',
});
await queryInterface.sequelize.query(
`ALTER TABLE courses ADD CONSTRAINT check_status
CHECK (status IN ('draft', 'published', 'unpublished'))`
);
await queryInterface.sequelize.query(
`UPDATE courses SET status = 'published' WHERE "deletedAt" IS NULL`
);
},
async down(queryInterface) {
await queryInterface.sequelize.query(
`ALTER TABLE courses DROP CONSTRAINT IF EXISTS check_status`
);
await queryInterface.removeColumn('courses', 'status');
},
};
@@ -0,0 +1,14 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('notification_broadcasts', 'link_url', {
type: Sequelize.STRING(2048),
allowNull: true,
});
},
async down(queryInterface) {
await queryInterface.removeColumn('notification_broadcasts', 'link_url');
},
};
@@ -12,6 +12,12 @@
// in controllers/admin/advertisements.controller.js) — type is never accepted // in controllers/admin/advertisements.controller.js) — type is never accepted
// from the client once a placement is set. // from the client once a placement is set.
// TODO(ads-1): Re-categorize placements — Hero -> Dashboard, Banner -> Tier Plans.
// Remove the "popup" and "sidebar" formats entirely (dashboard.popup,
// course_details.sidebar). Keep in sync with the frontend mirror at
// new_starr_app/src/data/placement.data.js. This also feeds the Step 1
// "Placement" picker in the Add Advertisement wizard (TODO(ads-6)), which
// should only offer Dashboard / Tier Plans / Course Details.
const PLACEMENTS = [ const PLACEMENTS = [
{ key: "dashboard.hero", format: "hero", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Hero (top of page)" }, { key: "dashboard.hero", format: "hero", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Hero (top of page)" },
{ key: "dashboard.popup", format: "popup", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Popup (on load)" }, { key: "dashboard.popup", format: "popup", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Popup (on load)" },
+4 -3
View File
@@ -10,10 +10,11 @@ const Course = sequelize.define("Course", {
order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, hidden: false, order: 7, filterable: false }, order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, hidden: false, order: 7, filterable: false },
level: { type: DataTypes.ENUM("beginner", "intermediate", "advanced"), allowNull: true, hidden: false, order: 3, filterable: true }, level: { type: DataTypes.ENUM("beginner", "intermediate", "advanced"), allowNull: true, hidden: false, order: 3, filterable: true },
subscription: { type: DataTypes.STRING(50), allowNull: false, defaultValue: "free", hidden: false, order: 4, filterable: true }, subscription: { type: DataTypes.STRING(50), allowNull: false, defaultValue: "free", hidden: false, order: 4, filterable: true },
status: { type: DataTypes.ENUM("draft", "published", "unpublished"), allowNull: false, defaultValue: "draft", hidden: false, order: 5, filterable: true },
duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, hidden: false, order: 8, filterable: false }, duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, hidden: false, order: 8, filterable: false },
badge_color: { type: DataTypes.STRING(50), allowNull: true, defaultValue: "purple" }, badge_color: { type: DataTypes.STRING(50), allowNull: true, defaultValue: "purple", hidden: true },
badge_asset_id: { type: DataTypes.BIGINT, allowNull: true }, badge_asset_id: { type: DataTypes.BIGINT, allowNull: true, hidden: true },
badge_image_url: { type: DataTypes.TEXT, allowNull: true }, badge_image_url: { type: DataTypes.TEXT, allowNull: true, hidden: true },
createdBy: { type: DataTypes.BIGINT, allowNull: true }, createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true }, updatedBy: { type: DataTypes.BIGINT, allowNull: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true }, deletedBy: { type: DataTypes.BIGINT, allowNull: true },
@@ -12,6 +12,9 @@ const NotificationBroadcast = sequelize.define("NotificationBroadcast", {
// ─── Content ────────────────────────────────────────────────────────────── // ─── Content ──────────────────────────────────────────────────────────────
title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", order: 1 }, title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", order: 1 },
message: { type: DataTypes.TEXT, allowNull: false, label: "Message", order: 2 }, message: { type: DataTypes.TEXT, allowNull: false, label: "Message", order: 2 },
// When set, the client's "view full content" dialog shows an "Open Link"
// action pointing here. When null, that dialog is plain text info only.
link_url: { type: DataTypes.STRING(2048), allowNull: true, label: "Link URL", order: 2.2 },
// ─── Visibility ────────────────────────────────────────────────────────── // ─── Visibility ──────────────────────────────────────────────────────────
// Determines where a delivered announcement shows up for recipients. // Determines where a delivered announcement shows up for recipients.
@@ -5,8 +5,10 @@ const ctrl = require('../../controllers/admin/notification_templates.controlle
// Auth + requireAdmin applied by admin.routes.js // Auth + requireAdmin applied by admin.routes.js
router.get('/', ctrl.getNotificationTemplates); router.get ('/', ctrl.getNotificationTemplates);
router.get('/:id', ctrl.getNotificationTemplate); router.post ('/', ctrl.createNotificationTemplate);
router.put('/:id', ctrl.updateNotificationTemplate); router.get ('/:id', ctrl.getNotificationTemplate);
router.put ('/:id', ctrl.updateNotificationTemplate);
router.delete('/:id', ctrl.deleteNotificationTemplate);
module.exports = router; module.exports = router;
+2 -1
View File
@@ -15,10 +15,11 @@
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { list, unseenCount, markSeen, markAllSeen } = require('../../controllers/admin/notification.controller'); const { list, unseenCount, stickyAnnouncement, markSeen, markAllSeen } = require('../../controllers/admin/notification.controller');
router.get('/', list); router.get('/', list);
router.get('/unseen', unseenCount); router.get('/unseen', unseenCount);
router.get('/sticky', stickyAnnouncement);
router.patch('/seen-all', markAllSeen); router.patch('/seen-all', markAllSeen);
router.patch('/:id/seen', markSeen); router.patch('/:id/seen', markSeen);
+1
View File
@@ -17,6 +17,7 @@ const questionCtrl = require("../../controllers/admin/courses.controller"); // s
// ── static segments first ───────────────────────────────────────────────────── // ── static segments first ─────────────────────────────────────────────────────
router.get("/", ctrl.getUnits); router.get("/", ctrl.getUnits);
router.post("/", ctrl.createUnit); router.post("/", ctrl.createUnit);
router.post("/full", ctrl.createUnitFull);
router.get("/flat", ctrl.getUnitsFlat); router.get("/flat", ctrl.getUnitsFlat);
router.get("/field-values", ctrl.getUnitFieldValues); router.get("/field-values", ctrl.getUnitFieldValues);
router.delete("/bulk", ctrl.bulkArchiveUnits); router.delete("/bulk", ctrl.bulkArchiveUnits);