From 82ea9c77c471b0905a499fabef611e54c916d6c1 Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Sat, 11 Jul 2026 12:12:29 +0800 Subject: [PATCH] merged Signed-off-by: Kenneth Obsequio --- .../admin/advertisements.controller.js | 4 + controllers/admin/courses.controller.js | 237 +++++++++++++----- controllers/admin/lessons.controller.js | 41 +-- controllers/admin/notification.controller.js | 24 +- .../notificationBroadcasts.controller.js | 5 + .../notification_templates.controller.js | 85 ++++++- controllers/admin/units.controller.js | 120 ++++++++- controllers/client/courses.controller.js | 15 +- controllers/client/units.controller.js | 37 +-- cron/client.cron.js | 4 + data/notifications.data.js | 4 +- .../20260710000001-add-status-to-courses.js | 34 +++ ...add-link-url-to-notification-broadcasts.js | 14 ++ .../advertisements.placements.js | 6 + models/courses/courses.mdl.js | 7 +- .../notification_broadcast.mdl.js | 3 + routes/admin/notification_templates.routes.js | 8 +- routes/admin/notifications.routes.js | 3 +- routes/admin/units.routes.js | 1 + 19 files changed, 530 insertions(+), 122 deletions(-) create mode 100644 database/migrations/20260710000001-add-status-to-courses.js create mode 100644 database/migrations/20260711000001-add-link-url-to-notification-broadcasts.js diff --git a/controllers/admin/advertisements.controller.js b/controllers/admin/advertisements.controller.js index 8f94471..8028b4d 100644 --- a/controllers/admin/advertisements.controller.js +++ b/controllers/admin/advertisements.controller.js @@ -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) ───────────────────────────────────────────────────────── exports.archiveAdvertisement = async (req, res) => { diff --git a/controllers/admin/courses.controller.js b/controllers/admin/courses.controller.js index 71e15bd..0f3721d 100644 --- a/controllers/admin/courses.controller.js +++ b/controllers/admin/courses.controller.js @@ -125,7 +125,7 @@ exports.createCourse = async (req, res) => { try { const { title, description, order_index, - course_code, level, subscription, + course_code, level, subscription, status, objectives = [], category_ids = [], achievement_keys = [], @@ -142,6 +142,7 @@ exports.createCourse = async (req, res) => { course_code: course_code ?? null, level: level ?? null, subscription: subscription ?? "free", + status: status ?? "draft", duration_seconds: 0, badge_color: badge_color ?? "purple", badge_asset_id: badge_asset_id ?? null, @@ -193,7 +194,7 @@ exports.createCourseFull = async (req, res) => { try { const { title, description, order_index, - course_code, level, subscription, + course_code, level, subscription, status, objectives = [], category_ids = [], achievement_keys = [], @@ -224,6 +225,7 @@ exports.createCourseFull = async (req, res) => { course_code: course_code ?? null, level: level ?? null, subscription: subscription ?? "free", + status: status ?? "draft", duration_seconds: 0, badge_color: badge_color ?? "purple", badge_asset_id: badge_asset_id ?? null, @@ -341,7 +343,7 @@ exports.updateCourse = async (req, res) => { const { title, description, order_index, - course_code, level, subscription, + course_code, level, subscription, status, objectives, category_ids, badge_color, badge_asset_id, badge_image_url, updatedBy, @@ -353,6 +355,7 @@ exports.updateCourse = async (req, res) => { if (course_code !== undefined) course.course_code = course_code; if (level !== undefined) course.level = level; if (subscription !== undefined) course.subscription = subscription; + if (status !== undefined) course.status = status; if (badge_color !== undefined) course.badge_color = badge_color; if (badge_asset_id !== undefined) course.badge_asset_id = badge_asset_id; if (badge_image_url !== undefined) course.badge_image_url = badge_image_url; @@ -663,6 +666,75 @@ exports.getUnits = async (req, res) => { } }; +// A unit already attached elsewhere can't also be attached here (it would +// alias the same content into two courses). Instead of moving it, clone it: +// a new Unit row, sharing the same lessons via unit_lessons (edits to a +// lesson still show up everywhere it's attached), and a deep copy of the +// quiz (unit_quizzes.unit_id is 1:1, so the quiz can't be shared). +async function duplicateUnitForAttach(sourceUnit, userId, t) { + const clone = await Unit.create({ + title: `${sourceUnit.title} (Copy)`, + subscription: sourceUnit.subscription, + description: sourceUnit.description, + duration_seconds: sourceUnit.duration_seconds, + createdBy: userId, + }, { transaction: t }); + + const lessonLinks = await UnitLesson.findAll({ where: { unit_id: sourceUnit.unit_id }, transaction: t }); + if (lessonLinks.length) { + await UnitLesson.bulkCreate( + lessonLinks.map((l) => ({ + unit_id: clone.unit_id, + lesson_id: l.lesson_id, + order_index: l.order_index, + createdBy: userId, + })), + { transaction: t } + ); + } + + const sourceQuiz = await UnitQuiz.findOne({ where: { unit_id: sourceUnit.unit_id }, transaction: t }); + if (sourceQuiz) { + const quizClone = await UnitQuiz.create({ + unit_id: clone.unit_id, + title: sourceQuiz.title, + is_required: sourceQuiz.is_required, + passing_score: sourceQuiz.passing_score, + max_questions: sourceQuiz.max_questions, + shuffle_questions: sourceQuiz.shuffle_questions, + createdBy: userId, + }, { transaction: t }); + + const questions = await QuizQuestion.findAll({ where: { quiz_id: sourceQuiz.quiz_id }, transaction: t }); + for (const q of questions) { + const questionClone = await QuizQuestion.create({ + quiz_id: quizClone.quiz_id, + type: q.type, + question: q.question, + explanation: q.explanation, + order_index: q.order_index, + points: q.points, + createdBy: userId, + }, { transaction: t }); + + const options = await QuizOption.findAll({ where: { question_id: q.question_id }, transaction: t }); + if (options.length) { + await QuizOption.bulkCreate( + options.map((o) => ({ + question_id: questionClone.question_id, + text: o.text, + is_correct: o.is_correct, + order_index: o.order_index, + })), + { transaction: t } + ); + } + } + } + + return clone; +} + // POST /:courseId/units/attach { unit_ids: [..] } — attach existing library units exports.attachUnits = async (req, res) => { const t = await sequelize.transaction(); @@ -679,27 +751,34 @@ exports.attachUnits = async (req, res) => { await t.rollback(); return R.error(res, "One or more units were not found.", 404); } + const unitById = new Map(units.map((u) => [String(u.unit_id), u])); const existing = await CourseUnit.findAll({ where: { course_id: courseId, unit_id: unit_ids }, transaction: t }); const existingSet = new Set(existing.map((r) => String(r.unit_id))); const toAttach = unit_ids.filter((id) => !existingSet.has(String(id))); - // A unit may only belong to one course at a time — reject the whole - // batch if any candidate is already linked elsewhere, rather than - // silently skipping (the admin should see and deselect it). - if (toAttach.length) { - const otherCourseLinks = await CourseUnit.findAll({ where: { unit_id: toAttach }, transaction: t }); - if (otherCourseLinks.length) { - await t.rollback(); - const blockedSet = new Set(otherCourseLinks.map((l) => String(l.unit_id))); - const blockedTitles = units.filter((u) => blockedSet.has(String(u.unit_id))).map((u) => u.title); - return R.error(res, `${blockedTitles.join(", ")} ${blockedTitles.length !== 1 ? "are" : "is"} already attached to another course.`, 409); + // A unit already attached to another course gets duplicated instead of + // moved — the original stays put, a clone is attached here. + const otherCourseLinks = toAttach.length + ? await CourseUnit.findAll({ where: { unit_id: toAttach }, transaction: t }) + : []; + const needsDuplicate = new Set(otherCourseLinks.map((l) => String(l.unit_id))); + + const duplicated = []; + const finalUnitIds = []; + for (const unit_id of toAttach) { + if (needsDuplicate.has(String(unit_id))) { + const clone = await duplicateUnitForAttach(unitById.get(String(unit_id)), req.user?.user_id ?? null, t); + duplicated.push({ from: unit_id, to: clone.unit_id, title: clone.title }); + finalUnitIds.push(clone.unit_id); + } else { + finalUnitIds.push(unit_id); } } let order = await nextOrderIndex(CourseUnit, { course_id: courseId }, t); await CourseUnit.bulkCreate( - toAttach.map((unit_id) => ({ + finalUnitIds.map((unit_id) => ({ course_id: courseId, unit_id, order_index: order++, @@ -710,8 +789,8 @@ exports.attachUnits = async (req, res) => { await t.commit(); try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][ATTACH][DURATION]", durErr); } - logActivity(req.user?.user_id, "attach_units", { entityType: "course", entityId: Number(courseId), details: { unit_ids: toAttach } }); - return R.success(res, `${toAttach.length} unit${toAttach.length !== 1 ? "s" : ""} attached.`, { attached: toAttach, skipped: unit_ids.filter((id) => existingSet.has(String(id))) }); + logActivity(req.user?.user_id, "attach_units", { entityType: "course", entityId: Number(courseId), details: { unit_ids: finalUnitIds, duplicated } }); + return R.success(res, `${finalUnitIds.length} unit${finalUnitIds.length !== 1 ? "s" : ""} attached.`, { attached: finalUnitIds, duplicated, skipped: unit_ids.filter((id) => existingSet.has(String(id))) }); } catch (err) { await t.rollback(); console.error("[UNIT][ATTACH]", err); @@ -796,6 +875,7 @@ exports.createUnit = async (req, res) => { if (!course) return R.error(res, "Course not found.", 404); let unit; + let duplicatedFrom = null; if (unit_id) { unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, transaction: t }); if (!unit) { @@ -807,11 +887,12 @@ exports.createUnit = async (req, res) => { await t.rollback(); return R.error(res, "Unit is already attached to this course.", 409); } - // A unit may only belong to one course at a time. + // A unit already attached to another course gets duplicated instead of + // moved — the original stays where it is. const otherLink = await CourseUnit.findOne({ where: { unit_id }, transaction: t }); if (otherLink) { - await t.rollback(); - return R.error(res, "This unit is already attached to another course.", 409); + duplicatedFrom = unit.unit_id; + unit = await duplicateUnitForAttach(unit, createdBy ?? req.user?.user_id ?? null, t); } } else { if (!title) return R.error(res, "Title is required.", 400); @@ -833,7 +914,7 @@ exports.createUnit = async (req, res) => { await t.commit(); try { await recomputeCourseDuration(courseId); } catch (durErr) { console.error("[UNIT][CREATE][DURATION]", durErr); } - logActivity(req.user?.user_id, 'create_unit', { entityType: 'unit', entityId: unit.unit_id, details: { title: unit.title, course_id: Number(courseId), attached_existing: !!unit_id } }); + logActivity(req.user?.user_id, 'create_unit', { entityType: 'unit', entityId: unit.unit_id, details: { title: unit.title, course_id: Number(courseId), attached_existing: !!unit_id, duplicated_from_unit_id: duplicatedFrom } }); return R.success(res, unit_id ? "Unit attached." : "Unit created.", { data: { ...unit.toJSON(), order_index } }, 201); } catch (err) { await t.rollback(); @@ -2232,30 +2313,41 @@ exports.getCoursesBySubscription = async (req, res) => { } }; -// One row per (course, unit) attachment; unattached units get a standalone row -// with course_title "" so they remain selectable in requirement builders. +// One row per Unit (junction revamp: a unit may sit under 0..N courses) — +// `courses[]` is batch-fetched separately so requirement builders can show +// exactly which course(s) a unit is bound to, or mark it "Standalone" when +// empty, instead of the old one-row-per-(course,unit)-attachment duplicates. exports.getUnitsFlat = async (req, res) => { try { const rows = await sequelize.query(` - SELECT - u.uuid, u.title, u.duration_seconds, - COALESCE(cu.order_index, 0) AS order_index, - COALESCE(c.title, '') AS course_title, - COALESCE(c.subscription, 'free') AS subscription + SELECT u.unit_id, u.uuid, u.title, u.duration_seconds, u.subscription FROM units u - LEFT JOIN course_units cu ON cu.unit_id = u.unit_id - LEFT JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL WHERE u."deletedAt" IS NULL - ORDER BY course_title ASC, order_index ASC, u.title ASC + ORDER BY u.title ASC `, { type: sequelize.QueryTypes.SELECT }); + const unitIds = rows.map((r) => r.unit_id); + const courseLinkRows = unitIds.length ? await sequelize.query(` + SELECT cu.unit_id, c.uuid, c.title + FROM course_units cu + JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL + WHERE cu.unit_id IN (:unitIds) + ORDER BY c.title ASC + `, { replacements: { unitIds }, type: sequelize.QueryTypes.SELECT }) : []; + + const coursesByUnit = new Map(); + for (const row of courseLinkRows) { + const list = coursesByUnit.get(row.unit_id) ?? []; + list.push({ uuid: row.uuid, title: row.title }); + coursesByUnit.set(row.unit_id, list); + } + const data = rows.map((r) => ({ uuid: r.uuid, title: r.title, - order_index: Number(r.order_index ?? 0), duration_seconds: Number(r.duration_seconds ?? 0), - course_title: r.course_title ?? "", subscription: r.subscription ?? "free", + courses: coursesByUnit.get(r.unit_id) ?? [], })); return R.success(res, "Units retrieved.", data); } catch (err) { @@ -2264,36 +2356,43 @@ exports.getUnitsFlat = async (req, res) => { } }; -// One row per (course, unit, lesson) attachment chain; standalone lessons keep -// empty unit/course labels. +// One row per Lesson — a lesson may sit under 0..N units, each possibly under +// several courses, so `courses[]` is the deduped set of every course reachable +// through any attached unit (mirrors client getLessons' batching). Lessons +// have no tier field of their own; "Standalone" (empty courses[]) is the only +// binding signal that applies here. exports.getLessonsFlat = async (req, res) => { try { const rows = await sequelize.query(` - SELECT - l.uuid, l.title, l.duration_seconds, - COALESCE(ul.order_index, 0) AS order_index, - COALESCE(u.title, '') AS unit_title, - COALESCE(cu.order_index, 0) AS unit_order, - COALESCE(c.title, '') AS course_title, - COALESCE(c.subscription, 'free') AS subscription + SELECT l.lesson_id, l.uuid, l.title, l.duration_seconds FROM lessons l - LEFT JOIN unit_lessons ul ON ul.lesson_id = l.lesson_id - LEFT JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL - LEFT JOIN course_units cu ON cu.unit_id = u.unit_id - LEFT JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL WHERE l."deletedAt" IS NULL - ORDER BY course_title ASC, unit_order ASC, order_index ASC, l.title ASC + ORDER BY l.title ASC `, { type: sequelize.QueryTypes.SELECT }); + const lessonIds = rows.map((r) => r.lesson_id); + const courseLinkRows = lessonIds.length ? await sequelize.query(` + SELECT DISTINCT ul.lesson_id, c.uuid, c.title + FROM unit_lessons ul + JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL + JOIN course_units cu ON cu.unit_id = u.unit_id + JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL + WHERE ul.lesson_id IN (:lessonIds) + ORDER BY c.title ASC + `, { replacements: { lessonIds }, type: sequelize.QueryTypes.SELECT }) : []; + + const coursesByLesson = new Map(); + for (const row of courseLinkRows) { + const list = coursesByLesson.get(row.lesson_id) ?? []; + list.push({ uuid: row.uuid, title: row.title }); + coursesByLesson.set(row.lesson_id, list); + } + const data = rows.map((r) => ({ uuid: r.uuid, title: r.title, - order_index: Number(r.order_index ?? 0), duration_seconds: Number(r.duration_seconds ?? 0), - unit_title: r.unit_title ?? "", - unit_order: Number(r.unit_order ?? 0), - course_title: r.course_title ?? "", - subscription: r.subscription ?? "free", + courses: coursesByLesson.get(r.lesson_id) ?? [], })); return R.success(res, "Lessons retrieved.", data); } catch (err) { @@ -2303,32 +2402,46 @@ exports.getLessonsFlat = async (req, res) => { }; // One row per unit quiz (a quiz is always unit-scoped, unit_id unique on -// unit_quizzes). Used by the pass_quiz task requirement picker — same -// "no content yet" convention as read_*: question_count === 0 is flagged -// the same way duration_seconds === 0 is for content requirements. +// unit_quizzes) — `courses[]` is the deduped set of courses the parent unit +// is attached to, batch-fetched the same way as getUnitsFlat/getLessonsFlat. +// Used by the pass_quiz task requirement picker — same "no content yet" +// convention as read_*: question_count === 0 is flagged the same way +// duration_seconds === 0 is for content requirements. exports.getQuizzesFlat = async (req, res) => { try { const rows = await sequelize.query(` SELECT - q.uuid, q.title, u.title AS unit_title, - COALESCE(c.title, '') AS course_title, - COALESCE(c.subscription, 'free') AS subscription, + q.quiz_id, q.uuid, q.title, u.title AS unit_title, (SELECT CAST(COUNT(*) AS INTEGER) FROM quiz_questions qq WHERE qq.quiz_id = q.quiz_id AND qq."deletedAt" IS NULL) AS question_count FROM unit_quizzes q JOIN units u ON u.unit_id = q.unit_id AND u."deletedAt" IS NULL - LEFT JOIN course_units cu ON cu.unit_id = u.unit_id - LEFT JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL WHERE q."deletedAt" IS NULL - ORDER BY course_title ASC, u.title ASC, q.title ASC + ORDER BY u.title ASC, q.title ASC `, { type: sequelize.QueryTypes.SELECT }); + const quizIds = rows.map((r) => r.quiz_id); + const courseLinkRows = quizIds.length ? await sequelize.query(` + SELECT q.quiz_id, c.uuid, c.title + FROM unit_quizzes q + JOIN course_units cu ON cu.unit_id = q.unit_id + JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL + WHERE q.quiz_id IN (:quizIds) + ORDER BY c.title ASC + `, { replacements: { quizIds }, type: sequelize.QueryTypes.SELECT }) : []; + + const coursesByQuiz = new Map(); + for (const row of courseLinkRows) { + const list = coursesByQuiz.get(row.quiz_id) ?? []; + list.push({ uuid: row.uuid, title: row.title }); + coursesByQuiz.set(row.quiz_id, list); + } + const data = rows.map((r) => ({ uuid: r.uuid, title: r.title || `${r.unit_title} Quiz`, unit_title: r.unit_title ?? "", - course_title: r.course_title ?? "", - subscription: r.subscription ?? "free", + courses: coursesByQuiz.get(r.quiz_id) ?? [], question_count: Number(r.question_count ?? 0), // duration_seconds doesn't apply to quizzes — ContentPicker's "no // content" check keys off duration_seconds === 0, so surface the same diff --git a/controllers/admin/lessons.controller.js b/controllers/admin/lessons.controller.js index d7f4678..5eb6d16 100644 --- a/controllers/admin/lessons.controller.js +++ b/controllers/admin/lessons.controller.js @@ -56,27 +56,41 @@ async function recomputeParentDurations(lessonId) { const LESSON_LIST_COMPUTED = [ { - key: "unit_count", - label: "Used in units", + key: "course_count", + label: "Used in courses", type: "number", literal: `( - SELECT CAST(COUNT(*) AS INTEGER) + SELECT CAST(COUNT(DISTINCT c.course_id) AS INTEGER) 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" )`, }, { - key: "course_bound", + key: "course_status", label: "Course Status", - type: "boolean", + type: "text", + filterable: false, literal: `( - SELECT EXISTS ( - SELECT 1 - FROM unit_lessons ul - JOIN course_units cu ON cu.unit_id = ul.unit_id - WHERE ul.lesson_id = "Lesson"."lesson_id" - ) + CASE + WHEN NOT 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" + ) 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 FROM lessons l 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 `, { type: sequelize.QueryTypes.SELECT }); return R.success(res, "Lessons retrieved.", rows); diff --git a/controllers/admin/notification.controller.js b/controllers/admin/notification.controller.js index f986402..9167d22 100644 --- a/controllers/admin/notification.controller.js +++ b/controllers/admin/notification.controller.js @@ -4,6 +4,7 @@ * Description : Admin notification management. * GET /admin/notifications — paginated list, newest first * 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/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 ───────────────────────────────────── async function markSeen(req, res) { try { @@ -77,4 +99,4 @@ async function markAllSeen(req, res) { } } -module.exports = { list, unseenCount, markSeen, markAllSeen }; +module.exports = { list, unseenCount, stickyAnnouncement, markSeen, markAllSeen }; diff --git a/controllers/admin/notificationBroadcasts.controller.js b/controllers/admin/notificationBroadcasts.controller.js index 8055bd1..8168b24 100644 --- a/controllers/admin/notificationBroadcasts.controller.js +++ b/controllers/admin/notificationBroadcasts.controller.js @@ -31,6 +31,7 @@ const notDeleted = { deletedAt: null }; async function applyBroadcastFields(broadcast, body) { if (body.title !== undefined) broadcast.title = body.title; 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_notifications !== undefined) broadcast.show_in_notifications = !!body.show_in_notifications; @@ -159,6 +160,7 @@ exports.createBroadcast = async (req, res) => { const { title, message, + link_url, target_type, target_id, createdBy, @@ -186,6 +188,7 @@ exports.createBroadcast = async (req, res) => { const broadcast = await NotificationBroadcast.build({ title, message, + link_url: link_url?.trim() || null, createdBy, status: 'draft', target_type, @@ -272,6 +275,7 @@ exports.sendBroadcast = async (req, res) => { message: broadcast.message, targetType, targetId, + linkUrl: broadcast.link_url, }); if (targetType === 'admin' || targetType === 'both') { @@ -308,6 +312,7 @@ exports.sendBroadcast = async (req, res) => { ? NOTIFICATION_REGISTRY.broadcast.build({ title: broadcast.title, message: broadcast.message, targetType, targetId, groupId: groupByUser[user_id] ?? null, + linkUrl: broadcast.link_url, }) : baseNotify), seen: false, diff --git a/controllers/admin/notification_templates.controller.js b/controllers/admin/notification_templates.controller.js index 3d07ea1..e9ce7c8 100644 --- a/controllers/admin/notification_templates.controller.js +++ b/controllers/admin/notification_templates.controller.js @@ -4,6 +4,9 @@ const mdl_NotificationTemplate = require('../../models/notifications/notificatio const R = require('../../utils/response.util'); const logActivity = require('../../utils/logActivity.util'); +const slugify = (str) => + str.toLowerCase().trim().replace(/[^a-z0-9]+/g, '_').replace(/(^_|_$)/g, ''); + // ─── GET /admin/notification-templates ───────────────────────────────────────── 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 ───────────────────────────────────── -// 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) => { 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 (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 // renderNotification() reads and clears any pending draft. A plain save // (no publish flag) writes into draft_title/draft_message instead, so diff --git a/controllers/admin/units.controller.js b/controllers/admin/units.controller.js index a6d3ecf..6ef61a9 100644 --- a/controllers/admin/units.controller.js +++ b/controllers/admin/units.controller.js @@ -26,13 +26,14 @@ const { permanentDeleteOne, permanentDeleteMany } = require("../../utils/courses const { getFieldValues } = require("../../utils/fieldValues.util"); const { flattenLessons, nextOrderIndex, reorderJunction } = require("../../utils/courses/hierarchy.util"); const { recomputeUnitDuration, recomputeCourseDuration } = require("../../utils/duration.util"); +const { syncObjectivesCreate } = require("../../utils/courses/objectives.util"); const logActivity = require("../../utils/logActivity.util"); // ── Models ──────────────────────────────────────────────────────────────────── const { - Course, Unit, Lesson, - CourseUnit, UnitLesson, + Course, Unit, Lesson, LessonPage, + CourseUnit, UnitLesson, LessonObjective, UnitQuiz, QuizQuestion, QuizOption, UnitReadingProgress, LessonReadingProgress, } = require("../../models/courses/courses.associations"); @@ -79,6 +80,27 @@ const UNIT_LIST_COMPUTED = [ 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) => { try { const rows = await sequelize.query(` @@ -114,7 +136,11 @@ exports.getUnitsFlat = async (req, res) => { WHERE ul.unit_id = u.unit_id) AS lesson_count, (SELECT CAST(COUNT(*) AS INTEGER) 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) 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 WHERE u."deletedAt" IS NULL 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) => { try { const { unitId } = req.params; diff --git a/controllers/client/courses.controller.js b/controllers/client/courses.controller.js index 10fa735..ec3e8c0 100644 --- a/controllers/client/courses.controller.js +++ b/controllers/client/courses.controller.js @@ -112,8 +112,9 @@ async function buildUserContext(user_id) { // Returns true → user may access the course. // Returns false → user's tier is too low AND no valid individual purchase. 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.status !== 'published') return false; const userCtx = await buildUserContext(user_id); @@ -156,7 +157,15 @@ async function canAccessUnit(user_id, unit_id) { 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; for (const link of links) { if (await canAccessCourse(user_id, link.course_id)) return true; @@ -256,7 +265,7 @@ exports.getCourses = async (req, res) => { }; const courses = await Course.findAll({ - where: { ...notDeleted }, + where: { ...notDeleted, status: 'published' }, attributes: COURSE_LIST_ATTRS, include: [ { diff --git a/controllers/client/units.controller.js b/controllers/client/units.controller.js index d85dd9b..f680a67 100644 --- a/controllers/client/units.controller.js +++ b/controllers/client/units.controller.js @@ -17,10 +17,11 @@ * be able to access at least one attached course. Lessons resolve through * their parent units the same way. * - * Discovery rule (getUnits/getLessons only): only course-free content is - * listed at all — a unit with any course affiliation, or a lesson with any - * unit that has a course affiliation, is excluded outright rather than - * listed-but-locked. This does not affect the single-item endpoints above + * Discovery rule (getUnits/getLessons only): ALL non-deleted units/lessons + * are listed, whether or not they're attached to a course — course_count/ + * courses[] (published courses only) and is_locked tell the learner whether + * 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 * course-scoped consumption runs through a separate controller entirely. * @@ -61,12 +62,11 @@ function sanitizeQuestions(questions = []) { // ─── UNIT LIBRARY (learner view) ────────────────────────────────────────────── -// Client-side Units/Lessons browsing only ever shows INDEPENDENT content — -// anything affiliated with a course (directly, or for a lesson, through any -// of its attached units) is excluded from these listings entirely, not just -// flagged locked. This does not affect course-scoped consumption (which runs -// through ClientCoursesContext/getCourse, a separate path) or direct-link -// access to UnitDetails/LessonDetails, which still enforce access normally. +// Client-side Units/Lessons browsing shows ALL content, bound to a course or +// not — course_count/courses[] + is_locked below tell the learner which is +// which. This does not affect course-scoped consumption (which runs through +// ClientCoursesContext/getCourse, a separate path) or direct-link access to +// UnitDetails/LessonDetails, which still enforce access normally. exports.getUnits = async (req, res) => { try { 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 WHERE ul.unit_id = u.unit_id) AS lesson_count, (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, (SELECT quiz_id FROM unit_quizzes q WHERE q.unit_id = u.unit_id AND q."deletedAt" IS NULL LIMIT 1) AS quiz_id FROM units u 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 `, { type: sequelize.QueryTypes.SELECT }); @@ -97,7 +92,7 @@ exports.getUnits = async (req, res) => { const courseLinkRows = unitIds.length ? await sequelize.query(` SELECT cu.unit_id, c.course_id, c.uuid, c.title, c.subscription 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) `, { 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 FROM lessons l 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 `, { 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 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 + 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) `, { replacements: { lessonIds }, type: sequelize.QueryTypes.SELECT }) : []; diff --git a/cron/client.cron.js b/cron/client.cron.js index 797eef7..bfe8335 100644 --- a/cron/client.cron.js +++ b/cron/client.cron.js @@ -24,6 +24,10 @@ const expireUserTiers = require('./jobs/expire_user_tiers.cron'); const taskDueSoon = require('./jobs/task_due_soon.cron'); 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 ──────────────────────── const jobs = [ userNotifications, diff --git a/data/notifications.data.js b/data/notifications.data.js index 0bd9789..ced7e9b 100644 --- a/data/notifications.data.js +++ b/data/notifications.data.js @@ -68,12 +68,12 @@ const NOTIFICATION_REGISTRY = { type: 'announcement', scope: 'both', trigger: 'manual', - build({ title, message, targetType = null, targetId = null, groupId = null }) { + build({ title, message, targetType = null, targetId = null, groupId = null, linkUrl = null }) { return { type: 'announcement', title, message, - data: { targetType, targetId, groupId }, + data: { targetType, targetId, groupId, linkUrl }, }; }, }, diff --git a/database/migrations/20260710000001-add-status-to-courses.js b/database/migrations/20260710000001-add-status-to-courses.js new file mode 100644 index 0000000..040bcd1 --- /dev/null +++ b/database/migrations/20260710000001-add-status-to-courses.js @@ -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'); + }, +}; diff --git a/database/migrations/20260711000001-add-link-url-to-notification-broadcasts.js b/database/migrations/20260711000001-add-link-url-to-notification-broadcasts.js new file mode 100644 index 0000000..96eb68a --- /dev/null +++ b/database/migrations/20260711000001-add-link-url-to-notification-broadcasts.js @@ -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'); + }, +}; diff --git a/models/advertisements/advertisements.placements.js b/models/advertisements/advertisements.placements.js index 3b0432e..678daa6 100644 --- a/models/advertisements/advertisements.placements.js +++ b/models/advertisements/advertisements.placements.js @@ -12,6 +12,12 @@ // in controllers/admin/advertisements.controller.js) — type is never accepted // 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 = [ { 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)" }, diff --git a/models/courses/courses.mdl.js b/models/courses/courses.mdl.js index 77e5d44..a882950 100644 --- a/models/courses/courses.mdl.js +++ b/models/courses/courses.mdl.js @@ -10,10 +10,11 @@ const Course = sequelize.define("Course", { 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 }, 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 }, - badge_color: { type: DataTypes.STRING(50), allowNull: true, defaultValue: "purple" }, - badge_asset_id: { type: DataTypes.BIGINT, allowNull: true }, - badge_image_url: { type: DataTypes.TEXT, allowNull: true }, + badge_color: { type: DataTypes.STRING(50), allowNull: true, defaultValue: "purple", hidden: true }, + badge_asset_id: { type: DataTypes.BIGINT, allowNull: true, hidden: true }, + badge_image_url: { type: DataTypes.TEXT, allowNull: true, hidden: true }, createdBy: { type: DataTypes.BIGINT, allowNull: true }, updatedBy: { type: DataTypes.BIGINT, allowNull: true }, deletedBy: { type: DataTypes.BIGINT, allowNull: true }, diff --git a/models/notifications/notification_broadcast.mdl.js b/models/notifications/notification_broadcast.mdl.js index a9d4b52..85c9cef 100644 --- a/models/notifications/notification_broadcast.mdl.js +++ b/models/notifications/notification_broadcast.mdl.js @@ -12,6 +12,9 @@ const NotificationBroadcast = sequelize.define("NotificationBroadcast", { // ─── Content ────────────────────────────────────────────────────────────── title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", order: 1 }, 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 ────────────────────────────────────────────────────────── // Determines where a delivered announcement shows up for recipients. diff --git a/routes/admin/notification_templates.routes.js b/routes/admin/notification_templates.routes.js index 2a71096..bb845c1 100644 --- a/routes/admin/notification_templates.routes.js +++ b/routes/admin/notification_templates.routes.js @@ -5,8 +5,10 @@ const ctrl = require('../../controllers/admin/notification_templates.controlle // Auth + requireAdmin applied by admin.routes.js -router.get('/', ctrl.getNotificationTemplates); -router.get('/:id', ctrl.getNotificationTemplate); -router.put('/:id', ctrl.updateNotificationTemplate); +router.get ('/', ctrl.getNotificationTemplates); +router.post ('/', ctrl.createNotificationTemplate); +router.get ('/:id', ctrl.getNotificationTemplate); +router.put ('/:id', ctrl.updateNotificationTemplate); +router.delete('/:id', ctrl.deleteNotificationTemplate); module.exports = router; diff --git a/routes/admin/notifications.routes.js b/routes/admin/notifications.routes.js index 2d2b019..08095d4 100644 --- a/routes/admin/notifications.routes.js +++ b/routes/admin/notifications.routes.js @@ -15,10 +15,11 @@ ***********************************************************************************************************************************************************************/ const express = require('express'); 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('/unseen', unseenCount); +router.get('/sticky', stickyAnnouncement); router.patch('/seen-all', markAllSeen); router.patch('/:id/seen', markSeen); diff --git a/routes/admin/units.routes.js b/routes/admin/units.routes.js index a91b01c..62f80cc 100644 --- a/routes/admin/units.routes.js +++ b/routes/admin/units.routes.js @@ -17,6 +17,7 @@ const questionCtrl = require("../../controllers/admin/courses.controller"); // s // ── static segments first ───────────────────────────────────────────────────── router.get("/", ctrl.getUnits); router.post("/", ctrl.createUnit); +router.post("/full", ctrl.createUnitFull); router.get("/flat", ctrl.getUnitsFlat); router.get("/field-values", ctrl.getUnitFieldValues); router.delete("/bulk", ctrl.bulkArchiveUnits);