mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
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"
|
||||||
)
|
) 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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 }) : [];
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)" },
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ 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.post ('/', ctrl.createNotificationTemplate);
|
||||||
router.get ('/:id', ctrl.getNotificationTemplate);
|
router.get ('/:id', ctrl.getNotificationTemplate);
|
||||||
router.put ('/:id', ctrl.updateNotificationTemplate);
|
router.put ('/:id', ctrl.updateNotificationTemplate);
|
||||||
|
router.delete('/:id', ctrl.deleteNotificationTemplate);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user