From ea3e82e54c25d5b63a7bb55b97202cc4adb1f5f3 Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Sun, 12 Jul 2026 12:39:17 +0800 Subject: [PATCH] added Signed-off-by: Kenneth Obsequio --- _scratch_test_sort.js | 38 +++ _scratch_test_sort2.js | 37 +++ .../admin/advertisements.controller.js | 36 ++- controllers/admin/courses.controller.js | 6 +- controllers/admin/notification.controller.js | 64 +++- .../notificationBroadcasts.controller.js | 187 ++++++++++- .../notification_templates.controller.js | 161 ---------- controllers/admin/task.controller.js | 10 +- .../admin/task_completion.controller.js | 6 +- controllers/auth.controller.js | 23 +- .../client/advertisements.controller.js | 32 ++ controllers/client/courses.controller.js | 37 ++- controllers/client/notification.controller.js | 70 ++++- controllers/client/task.controller.js | 4 +- controllers/client/tiers.controller.js | 17 +- cron/client.cron.js | 48 ++- cron/jobs/expire_advertisements.cron.js | 57 ++++ cron/jobs/expire_user_tiers.cron.js | 5 +- cron/jobs/issue_certificates.cron.js | 8 +- cron/jobs/task_due_soon.cron.js | 6 +- cron/jobs/task_overdue.cron.js | 4 +- cron/jobs/user_notifications.cron.js | 4 +- data/notification_template_enrichers.data.js | 65 ---- data/notifications.data.js | 290 +++++++++++++++++- ...2-recategorize-advertisement-placements.js | 37 +++ ...mode-and-page-builder-to-advertisements.js | 55 ++++ ...60711000004-drop-notification-templates.js | 32 ++ ...d-color-and-link-label-to-notifications.js | 35 +++ ...art-end-date-to-notification-broadcasts.js | 44 +++ ...00007-add-broadcast-id-to-notifications.js | 24 ++ ...711000008-create-sticky-banner-settings.js | 42 +++ models/advertisements/advertisements.mdl.js | 13 + .../advertisements.placements.js | 11 +- models/assets/assets.mdl.js | 10 +- .../notifications/admin_notification.mdl.js | 27 ++ .../notification_broadcast.mdl.js | 14 + .../notification_template.mdl.js | 46 --- .../sticky_banner_setting.mdl.js | 22 ++ models/notifications/user_notification.mdl.js | 27 ++ routes/admin/admin.routes.js | 5 +- routes/admin/notificationBroadcasts.routes.js | 2 + routes/admin/notification_templates.routes.js | 14 - routes/client/advertisements.routes.js | 3 + services/notificationTemplate.service.js | 52 ---- utils/buildQuery.util.js | 12 + utils/fieldValues.util.js | 15 +- utils/notificationVisibility.util.js | 25 ++ 47 files changed, 1301 insertions(+), 481 deletions(-) create mode 100644 _scratch_test_sort.js create mode 100644 _scratch_test_sort2.js delete mode 100644 controllers/admin/notification_templates.controller.js create mode 100644 cron/jobs/expire_advertisements.cron.js delete mode 100644 data/notification_template_enrichers.data.js create mode 100644 database/migrations/20260711000002-recategorize-advertisement-placements.js create mode 100644 database/migrations/20260711000003-add-content-mode-and-page-builder-to-advertisements.js create mode 100644 database/migrations/20260711000004-drop-notification-templates.js create mode 100644 database/migrations/20260711000005-add-color-and-link-label-to-notifications.js create mode 100644 database/migrations/20260711000006-add-start-end-date-to-notification-broadcasts.js create mode 100644 database/migrations/20260711000007-add-broadcast-id-to-notifications.js create mode 100644 database/migrations/20260711000008-create-sticky-banner-settings.js delete mode 100644 models/notifications/notification_template.mdl.js create mode 100644 models/notifications/sticky_banner_setting.mdl.js delete mode 100644 routes/admin/notification_templates.routes.js delete mode 100644 services/notificationTemplate.service.js create mode 100644 utils/notificationVisibility.util.js diff --git a/_scratch_test_sort.js b/_scratch_test_sort.js new file mode 100644 index 0000000..f4bff65 --- /dev/null +++ b/_scratch_test_sort.js @@ -0,0 +1,38 @@ +require('dotenv').config(); +const { Sequelize } = require('sequelize'); +const mdl_Users = require('./models/users/users.mdl'); +const sequelize = require('./config/db.config'); + +const qg = sequelize.getQueryInterface().queryGenerator; + +const order = [[Sequelize.json('personal_info.name.full_name'), 'ASC']]; + +try { + const sql = qg.selectQuery(mdl_Users.getTableName(), { + model: mdl_Users, + attributes: ['user_id'], + order, + limit: 10, + offset: 0, + }, mdl_Users); + console.log('SORT SQL:\n', sql); +} catch (e) { + console.error('SORT ERROR:', e.message); +} + +// filter test +const { Op } = require('sequelize'); +const whereCond = Sequelize.where(Sequelize.json('personal_info.name.full_name'), { [Op.iLike]: '%a%' }); +try { + const sql2 = qg.selectQuery(mdl_Users.getTableName(), { + model: mdl_Users, + attributes: ['user_id'], + where: whereCond, + limit: 10, + offset: 0, + }, mdl_Users); + console.log('FILTER SQL:\n', sql2); +} catch (e) { + console.error('FILTER ERROR:', e.message); +} +process.exit(0); diff --git a/_scratch_test_sort2.js b/_scratch_test_sort2.js new file mode 100644 index 0000000..78a7fc0 --- /dev/null +++ b/_scratch_test_sort2.js @@ -0,0 +1,37 @@ +const { Sequelize } = require('sequelize'); +const mdl_Users = require('./models/users/users.mdl'); +const { mdl_UserGroups, mdl_UserGroupMembers } = require('./models/users/user_groups.mdl'); +const sequelize = require('./config/db.config'); + +(async () => { + try { + await sequelize.authenticate(); + console.log('DB connected OK'); + + // Find any group_id to test with + const anyGroup = await mdl_UserGroups.findOne({ attributes: ['group_id'], paranoid: false }); + console.log('sample group_id:', anyGroup?.group_id); + const group_id = anyGroup?.group_id; + + const order = [[Sequelize.json('personal_info.name.full_name'), 'ASC']]; + + const result = await mdl_Users.findAndCountAll({ + attributes: ['user_id'], + order, + limit: 3, + offset: 0, + include: [{ + model: mdl_UserGroupMembers, + where: { group_id }, + attributes: [], + required: true, + }], + logging: (sql) => console.log('\n[SQL]', sql), + }); + console.log('SORT+JOIN RESULT COUNT:', result.count, 'rows:', result.rows.length); + } catch (e) { + console.error('ERROR:', e.message); + } finally { + await sequelize.close(); + } +})(); diff --git a/controllers/admin/advertisements.controller.js b/controllers/admin/advertisements.controller.js index 8028b4d..7ccc4f5 100644 --- a/controllers/admin/advertisements.controller.js +++ b/controllers/admin/advertisements.controller.js @@ -94,10 +94,21 @@ async function applyAdvertisementFields(advertisement, body) { // status is intentionally NOT settable here — it's derived via deriveStatus() // right before save, based on is_active + start_date/end_date. + if (body.content_mode !== undefined) { + if (!["image", "content"].includes(body.content_mode)) { + const err = new Error(`Invalid content_mode. Must be one of: image, content`); + err.status = 400; + throw err; + } + advertisement.content_mode = body.content_mode; + } + if (body.badge_label !== undefined) advertisement.badge_label = body.badge_label; if (body.headline !== undefined) advertisement.headline = body.headline; if (body.description !== undefined) advertisement.description = body.description; if (body.image_url !== undefined) advertisement.image_url = body.image_url; + if (body.redirect_link !== undefined) advertisement.redirect_link = body.redirect_link || null; + if (body.landing_page !== undefined) advertisement.landing_page = body.landing_page || null; if (body.image_asset_id !== undefined) { if (body.image_asset_id === null) { @@ -136,8 +147,27 @@ async function applyAdvertisementFields(advertisement, body) { // ─── GET ALL ────────────────────────────────────────────────────────────────── +// Keeps the stored `status` column in sync with deriveStatus() before the +// filtered query runs — status is otherwise only recomputed on individual +// row reads, so filtering by status (e.g. "expired") would miss rows whose +// start_date/end_date lapsed since they were last saved. +async function syncDerivedStatuses() { + await sequelize.query(` + UPDATE advertisements + SET status = CASE + WHEN is_active = false THEN 'draft' + WHEN end_date IS NOT NULL AND end_date < NOW() THEN 'expired' + WHEN start_date IS NOT NULL AND start_date > NOW() THEN 'scheduled' + ELSE 'active' + END + WHERE "deletedAt" IS NULL + `); +} + exports.getAdvertisements = async (req, res) => { try { + await syncDerivedStatuses(); + const result = await paginate(Advertisement, req, { excludeAttributes: adminExclude, jsonbSchemas, @@ -275,10 +305,6 @@ 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) => { @@ -382,7 +408,7 @@ exports.getArchivedAdvertisements = async (req, res) => { excludeAttributes: adminExclude, jsonbSchemas, computedAttributes, - context: "list", + context: "archived", auditOptions: { mdl_Users, parentAlias: 'Advertisement' }, findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } }, }); diff --git a/controllers/admin/courses.controller.js b/controllers/admin/courses.controller.js index 0f3721d..ae572d5 100644 --- a/controllers/admin/courses.controller.js +++ b/controllers/admin/courses.controller.js @@ -14,7 +14,7 @@ const { flattenUnits, flattenLessons, nextOrderIndex, reorderJunction } = requir const { getFieldValues } = require("../../utils/fieldValues.util"); const logActivity = require('../../utils/logActivity.util'); const UserNotification = require('../../models/notifications/user_notification.mdl'); -const { renderNotification } = require('../../services/notificationTemplate.service'); +const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); // ── Models ──────────────────────────────────────────────────────────────────── @@ -2174,11 +2174,11 @@ exports.updateAssessment = async (req, res) => { where: { course_id: courseId }, attributes: ['title', 'uuid'], }); - const notify = await renderNotification({ type: 'assessment_updated', data: { + const notify = NOTIFICATION_REGISTRY.assessment_updated.build({ assessmentTitle: assessment.title, courseTitle: course?.title ?? null, courseUuid: course?.uuid ?? null, - } }); + }); const now = new Date(); await UserNotification.bulkCreate( inProgressSessions.map(({ user_id }) => ({ diff --git a/controllers/admin/notification.controller.js b/controllers/admin/notification.controller.js index 9167d22..6ada9c6 100644 --- a/controllers/admin/notification.controller.js +++ b/controllers/admin/notification.controller.js @@ -12,7 +12,44 @@ * Date Created: Jun. 19, 2026 ***********************************************************************************************************************************************************************/ const AdminNotification = require('../../models/notifications/admin_notification.mdl'); +const StickyBannerSetting = require('../../models/notifications/sticky_banner_setting.mdl'); +const mdl_Assets = require('../../models/assets/assets.mdl'); +const mediaToken = require('../../services/mediaToken.service'); const R = require('../../utils/response.util'); +const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util'); + +const STICKY_LIMIT = 3; +const IMAGE_INCLUDE = { + model: mdl_Assets, + as: 'image', + attributes: ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"], + required: false, +}; + +// Mirrors controllers/admin/advertisements.controller.js's attachImageStreamToken. +async function attachImageStreamToken(image, req) { + if (!image || image.storage_provider !== 's3' || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) { + return image; + } + const ip = mediaToken.resolveIp(req); + const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip); + image.stream_token = token; + image.file_url = null; + image.thumbnail_url = null; + delete image.storage_key; + return image; +} + +// One shared banner image for the whole rotating sticky bar (see +// controllers/admin/notificationBroadcasts.controller.js's +// getStickyBannerSetting/updateStickyBannerSetting) — not per-announcement. +async function resolveSharedBannerImage(req) { + const setting = await StickyBannerSetting.findOne({ where: { id: 1 }, include: [IMAGE_INCLUDE] }); + if (!setting?.image) return null; + const image = setting.toJSON().image; + await attachImageStreamToken(image, req); + return image; +} // ─── GET /admin/notifications ───────────────────────────────────────────────── async function list(req, res) { @@ -25,7 +62,7 @@ async function list(req, res) { order: [['createdAt', 'DESC']], limit, offset, - where: { show_in_notifications: true }, + where: { show_in_notifications: true, ...notInFutureOrExpired() }, }); return R.success(res, 'Notifications fetched.', { @@ -41,7 +78,7 @@ async function list(req, res) { // ─── GET /admin/notifications/unseen ───────────────────────────────────────── async function unseenCount(req, res) { try { - const count = await AdminNotification.count({ where: { seen: false, show_in_notifications: true } }); + const count = await AdminNotification.count({ where: { seen: false, show_in_notifications: true, ...notInFutureOrExpired() } }); return R.success(res, 'Unseen count fetched.', { count }); } catch (err) { console.error('[NOTIFICATION] unseenCount error:', err); @@ -54,16 +91,21 @@ async function unseenCount(req, res) { // 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']], - }); + const [notifications, bannerImage] = await Promise.all([ + AdminNotification.findAll({ + where: { + seen: false, + show_in_sticky: true, + type: 'announcement', + ...notInFutureOrExpired(), + }, + order: [['createdAt', 'DESC']], + limit: STICKY_LIMIT, + }), + resolveSharedBannerImage(req), + ]); - return R.success(res, 'Sticky announcement fetched.', { announcement: notification }); + return R.success(res, 'Sticky announcements fetched.', { announcements: notifications, bannerImage }); } catch (err) { console.error('[NOTIFICATION] stickyAnnouncement error:', err); return R.error(res, 'Failed to fetch sticky announcement.'); diff --git a/controllers/admin/notificationBroadcasts.controller.js b/controllers/admin/notificationBroadcasts.controller.js index 8168b24..a36ec0e 100644 --- a/controllers/admin/notificationBroadcasts.controller.js +++ b/controllers/admin/notificationBroadcasts.controller.js @@ -4,16 +4,20 @@ const sequelize = require("../../config/db.config"); const NotificationBroadcast = require("../../models/notifications/notification_broadcast.mdl"); const AdminNotification = require("../../models/notifications/admin_notification.mdl"); const UserNotification = require("../../models/notifications/user_notification.mdl"); +const StickyBannerSetting = require("../../models/notifications/sticky_banner_setting.mdl"); const mdl_Users = require('../../models/users/users.mdl'); +const mdl_Assets = require('../../models/assets/assets.mdl'); const { TaskList } = require('../../models/task/task.mdl'); const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); const { Course } = require('../../models/courses/courses.mdl'); const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); +const mediaToken = require("../../services/mediaToken.service"); const R = require('../../utils/response.util'); const { paginate } = require("../../utils/paginate.util"); const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/notifications/notification_broadcast.attributes"); const logActivity = require('../../utils/logActivity.util'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); +const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util'); const { ALLOWED_TARGET_TYPES, SCOPED_TARGET_TYPES, @@ -28,10 +32,58 @@ const { Op } = require('sequelize'); const notDeleted = { deletedAt: null }; +// Fields needed off the associated Asset to render the shared sticky banner +// preview AND (for S3 assets) mint a stream token — mirrors advertisements.controller.js. +const IMAGE_ATTRIBUTES = ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"]; +const IMAGE_INCLUDE = { model: mdl_Assets, as: "image", attributes: IMAGE_ATTRIBUTES, required: false }; + +// Mirrors controllers/admin/advertisements.controller.js's attachImageStreamToken +// — kept duplicated rather than shared (same rationale used there). +async function attachImageStreamToken(image, req) { + if (!image || image.storage_provider !== "s3" || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) { + return image; + } + const ip = mediaToken.resolveIp(req); + const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip); + image.stream_token = token; + image.file_url = null; + image.thumbnail_url = null; + delete image.storage_key; + return image; +} + +// "Active sticky" = live in the rotating sticky banner right now: sent, +// show_in_sticky, not archived, and within its own start/end window. Caps the +// bar at 3 concurrent slots (see sendBroadcast/updateBroadcast below). +async function countActiveSticky(excludeId = null) { + return NotificationBroadcast.count({ + where: { + status: 'sent', + show_in_sticky: true, + ...notDeleted, + ...notInFutureOrExpired(), + ...(excludeId ? { broadcast_id: { [Op.ne]: excludeId } } : {}), + }, + }); +} + +const MAX_ACTIVE_STICKY = 3; +const ACTIVE_STICKY_CAP_MESSAGE = `Maximum of ${MAX_ACTIVE_STICKY} active sticky announcements right now — this stays in Draft until one ends or is archived.`; + 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.link_label !== undefined) broadcast.link_label = body.link_label?.trim() || null; + if (body.color !== undefined) broadcast.color = body.color || 'indigo'; + + if (body.start_date !== undefined) broadcast.start_date = body.start_date || null; + if (body.end_date !== undefined) broadcast.end_date = body.end_date || null; + if (broadcast.start_date && broadcast.end_date && new Date(broadcast.start_date) > new Date(broadcast.end_date)) { + const err = new Error("Start date must be before end date."); + err.status = 400; + throw err; + } 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; @@ -161,11 +213,15 @@ exports.createBroadcast = async (req, res) => { title, message, link_url, + link_label, + color, target_type, target_id, createdBy, show_in_sticky, show_in_notifications, + start_date, + end_date, } = req.body; if (!title) return R.error(res, "title is required.", 400); @@ -181,6 +237,10 @@ exports.createBroadcast = async (req, res) => { return R.error(res, "At least one of show_in_sticky or show_in_notifications must be enabled.", 400); } + if (start_date && end_date && new Date(start_date) > new Date(end_date)) { + return R.error(res, "Start date must be before end date.", 400); + } + if (SCOPED_TARGET_TYPES.includes(target_type)) await validateTargetId(target_type, target_id); const t = await sequelize.transaction(); @@ -189,6 +249,10 @@ exports.createBroadcast = async (req, res) => { title, message, link_url: link_url?.trim() || null, + link_label: link_label?.trim() || null, + color: color || 'indigo', + start_date: start_date || null, + end_date: end_date || null, createdBy, status: 'draft', target_type, @@ -222,7 +286,7 @@ exports.updateBroadcast = async (req, res) => { const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } }); if (!broadcast) return R.error(res, "Notification broadcast not found.", 404); - if (broadcast.status !== 'draft') return R.error(res, "Only draft broadcasts can be edited.", 400); + const wasActiveSticky = broadcast.status === 'sent' && broadcast.show_in_sticky; const t = await sequelize.transaction(); try { @@ -232,8 +296,54 @@ exports.updateBroadcast = async (req, res) => { err.status = 400; throw err; } + + // Editing a live broadcast to newly flip on show_in_sticky is the same + // "activate a sticky slot" action as sendBroadcast — must respect the + // same 3-slot cap, or it's a trivial bypass. + if (broadcast.status === 'sent' && broadcast.show_in_sticky && !wasActiveSticky) { + if ((await countActiveSticky(broadcast.broadcast_id)) >= MAX_ACTIVE_STICKY) { + const err = new Error(ACTIVE_STICKY_CAP_MESSAGE); + err.status = 409; + throw err; + } + } + broadcast.updatedBy = req.body.updatedBy ?? null; await broadcast.save({ transaction: t }); + + // Already-sent broadcasts have per-recipient rows created at send time + // (see sendBroadcast) — propagate content/display edits into them so + // changes show up immediately for anyone currently seeing it. Target/ + // audience fields are deliberately NOT propagated (see plan notes): + // recipients were already resolved, and task_list's per-user groupId + // deep-link (stored in each row's own `data`) must not be clobbered. + if (broadcast.status === 'sent') { + const propagated = { + title: broadcast.title, + message: broadcast.message, + color: broadcast.color, + show_in_sticky: broadcast.show_in_sticky, + show_in_notifications: broadcast.show_in_notifications, + start_date: broadcast.start_date, + end_date: broadcast.end_date, + linkUrl: broadcast.link_url, + linkLabel: broadcast.link_label, + broadcastId: broadcast.broadcast_id, + }; + + for (const table of ['admin_notifications', 'user_notifications']) { + await sequelize.query( + `UPDATE ${table} + SET title = :title, message = :message, color = :color, + show_in_sticky = :show_in_sticky, show_in_notifications = :show_in_notifications, + start_date = :start_date, end_date = :end_date, + data = data || jsonb_build_object('linkUrl', :linkUrl, 'linkLabel', :linkLabel) + WHERE broadcast_id = :broadcastId`, + { replacements: propagated, transaction: t } + ); + } + } + await t.commit(); logActivity(req.user?.user_id, 'update_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) }); @@ -260,6 +370,10 @@ exports.sendBroadcast = async (req, res) => { if (!broadcast) return R.error(res, "Announcement not found.", 404); if (broadcast.status !== 'draft') return R.error(res, "Only draft broadcasts can be sent.", 400); + if (broadcast.show_in_sticky && (await countActiveSticky(broadcast.broadcast_id)) >= MAX_ACTIVE_STICKY) { + return R.error(res, ACTIVE_STICKY_CAP_MESSAGE, 409); + } + const t = await sequelize.transaction(); try { const now = new Date(); @@ -275,12 +389,13 @@ exports.sendBroadcast = async (req, res) => { message: broadcast.message, targetType, targetId, - linkUrl: broadcast.link_url, + linkUrl: broadcast.link_url, + linkLabel: broadcast.link_label, }); if (targetType === 'admin' || targetType === 'both') { await AdminNotification.create( - { ...baseNotify, seen: false, show_in_sticky: showInSticky, show_in_notifications: showInNotifications }, + { ...baseNotify, seen: false, show_in_sticky: showInSticky, show_in_notifications: showInNotifications, color: broadcast.color, start_date: broadcast.start_date, end_date: broadcast.end_date, broadcast_id: broadcast.broadcast_id }, { transaction: t } ); recipientCount += 1; @@ -312,7 +427,8 @@ 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, + linkUrl: broadcast.link_url, + linkLabel: broadcast.link_label, }) : baseNotify), seen: false, @@ -320,6 +436,10 @@ exports.sendBroadcast = async (req, res) => { updatedAt: now, show_in_sticky: showInSticky, show_in_notifications: showInNotifications, + color: broadcast.color, + start_date: broadcast.start_date, + end_date: broadcast.end_date, + broadcast_id: broadcast.broadcast_id, })), { validate: false, transaction: t } ); @@ -505,3 +625,62 @@ exports.permanentlyDeleteBroadcasts = async (req, res) => { return R.error(res, "Could not permanently delete announcements.", 500); } }; + +// ─── STICKY BANNER (shared, singleton) ──────────────────────────────────────── +// One image for the whole rotating sticky bar (up to 3 concurrent +// announcements share it) — not one per announcement. Set from the +// Announcements list page. + +exports.getStickyBannerSetting = async (req, res) => { + try { + const setting = await StickyBannerSetting.findOne({ where: { id: 1 }, include: [IMAGE_INCLUDE] }); + if (!setting) return R.success(res, "Sticky banner setting retrieved.", { data: null }); + + const json = setting.toJSON(); + if (json.image) await attachImageStreamToken(json.image, req); + + return R.success(res, "Sticky banner setting retrieved.", { data: json }); + } catch (err) { + console.error("[NOTIFICATION BROADCAST][GET STICKY BANNER]", err); + return R.error(res, "Could not retrieve sticky banner setting.", 500); + } +}; + +exports.updateStickyBannerSetting = async (req, res) => { + try { + const { image_asset_id, updatedBy } = req.body; + + let validatedImageAssetId = null; + if (image_asset_id) { + const asset = await mdl_Assets.findOne({ where: { asset_id: image_asset_id, deletedAt: null } }); + if (!asset) return R.error(res, "Selected image asset was not found.", 400); + validatedImageAssetId = asset.asset_id; + } + + // Plain find-then-create/update rather than findOrCreate() — Sequelize's + // postgres findOrCreate() relies on a temp PL/pgSQL function for atomicity + // that CockroachDB doesn't support ("cannot create user-defined functions + // under a temporary schema") — same fix as trustedDevice.service.js. + let setting = await StickyBannerSetting.findOne({ where: { id: 1 } }); + if (setting) { + setting.image_asset_id = validatedImageAssetId; + setting.updatedBy = updatedBy ?? null; + await setting.save(); + } else { + setting = await StickyBannerSetting.create({ id: 1, image_asset_id: validatedImageAssetId, updatedBy: updatedBy ?? null }); + } + + // Reload with the image association so the response carries a fully + // resolved preview (stream token for S3) — same shape as the GET, so the + // frontend never needs to locally guess/merge in an optimistic image. + await setting.reload({ include: [IMAGE_INCLUDE] }); + const json = setting.toJSON(); + if (json.image) await attachImageStreamToken(json.image, req); + + logActivity(req.user?.user_id, 'update_sticky_banner_setting', { entityType: 'sticky_banner_setting', entityId: 1 }); + return R.success(res, "Sticky banner setting updated.", { data: json }); + } catch (err) { + console.error("[NOTIFICATION BROADCAST][UPDATE STICKY BANNER]", err); + return R.error(res, "Could not update sticky banner setting.", 500); + } +}; diff --git a/controllers/admin/notification_templates.controller.js b/controllers/admin/notification_templates.controller.js deleted file mode 100644 index e9ce7c8..0000000 --- a/controllers/admin/notification_templates.controller.js +++ /dev/null @@ -1,161 +0,0 @@ -'use strict'; - -const mdl_NotificationTemplate = require('../../models/notifications/notification_template.mdl'); -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) => { - try { - const templates = await mdl_NotificationTemplate.findAll({ - order: [['notify_type', 'ASC'], ['type', 'ASC']], - }); - return R.success(res, 'Announcement templates retrieved.', templates); - } catch (err) { - console.error('[ADMIN][GET NOTIFICATION TEMPLATES]', err); - return R.error(res, 'Could not retrieve announcement templates.', 500); - } -}; - -// ─── GET /admin/notification-templates/:id ───────────────────────────────────── - -exports.getNotificationTemplate = async (req, res) => { - try { - const template = await mdl_NotificationTemplate.findByPk(req.params.id); - if (!template) return R.error(res, 'Announcement template not found.', 404); - return R.success(res, 'Announcement template retrieved.', template); - } catch (err) { - console.error('[ADMIN][GET NOTIFICATION TEMPLATE]', err); - return R.error(res, 'Could not retrieve announcement template.', 500); - } -}; - -// ─── 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 ───────────────────────────────────── - -exports.updateNotificationTemplate = async (req, res) => { - try { - const template = await mdl_NotificationTemplate.findByPk(req.params.id); - if (!template) return R.error(res, 'Announcement template not found.', 404); - - const { label, title, message, publish } = req.body; - - 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 - // real notifications keep using the last-published content until an - // admin comes back and explicitly publishes again. - const isPublishing = publish === true || publish === 'true'; - const nextTitle = title ?? template.draft_title ?? template.title; - const nextMessage = message ?? template.draft_message ?? template.message; - - await template.update({ - label: label ?? template.label, - ...(isPublishing - ? { - status: 'sent', - title: nextTitle, - message: nextMessage, - draft_title: null, - draft_message: null, - last_sent_at: new Date(), - } - : { - draft_title: nextTitle, - draft_message: nextMessage, - }), - }); - - logActivity(req.user?.user_id, 'update_notification_template', { entityType: 'notification_template', entityId: template.notification_template_id, details: { type: template.type, published: isPublishing } }); - - return R.success(res, 'Announcement template updated.', template); - } catch (err) { - console.error('[ADMIN][UPDATE NOTIFICATION TEMPLATE]', err); - return R.error(res, 'Could not update announcement template.', 500); - } -}; diff --git a/controllers/admin/task.controller.js b/controllers/admin/task.controller.js index 254e574..1c8057a 100644 --- a/controllers/admin/task.controller.js +++ b/controllers/admin/task.controller.js @@ -14,7 +14,7 @@ const { Task, TaskList, TaskRequirement, TaskListGroup, mdl_UserGroups } = requi const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); const mdl_Users = require('../../models/users/users.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl'); -const { renderNotification } = require('../../services/notificationTemplate.service'); +const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const { adminExclude, jsonbSchemas, computedAttributes } = require('../../models/task/task.attributes'); const R = require('../../utils/response.util'); @@ -439,10 +439,10 @@ exports.assignGroups = async (req, res) => { if (userIds.length) { const now = new Date(); - const notify = await renderNotification({ type: 'task_assigned', data: { + const notify = NOTIFICATION_REGISTRY.task_assigned.build({ taskListName: taskList.name, taskCount, - } }); + }); await UserNotification.bulkCreate( userIds.map((user_id) => ({ user_id, ...notify, seen: false, createdAt: now, updatedAt: now })), { validate: false } @@ -763,11 +763,11 @@ exports.updateTask = async (req, res) => { const now = new Date(); // Title/message are identical for every member — render once, // then vary only the per-member groupId in the data payload. - const notify = await renderNotification({ type: 'task_requirements_updated', data: { + const notify = NOTIFICATION_REGISTRY.task_requirements_updated.build({ taskName: full.name, taskListId: task.task_list_id, groupId: null, - } }); + }); await UserNotification.bulkCreate( members.map(({ user_id, group_id }) => ({ user_id, diff --git a/controllers/admin/task_completion.controller.js b/controllers/admin/task_completion.controller.js index 3eaa015..8f24a6d 100644 --- a/controllers/admin/task_completion.controller.js +++ b/controllers/admin/task_completion.controller.js @@ -15,7 +15,7 @@ const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_c const { Task } = require('../../models/task/task.mdl'); const mdl_Users = require('../../models/users/users.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl'); -const { renderNotification } = require('../../services/notificationTemplate.service'); +const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const { checkTaskCompletion, fireTaskCompletedEvent } = require('../client/task.controller'); const { adminExclude } = require('../../models/task/task_completion.attributes'); @@ -190,9 +190,9 @@ exports.reviewSubmission = async (req, res) => { }); try { - const notify = await renderNotification({ type: 'task_submission_reviewed', data: { + const notify = NOTIFICATION_REGISTRY.task_submission_reviewed.build({ taskName: task.name, status, review_note: review_note || null, - } }); + }); await UserNotification.create({ user_id: completion.user_id, ...notify, seen: false }); } catch (notifyErr) { console.error('[ADMIN][REVIEW SUBMISSION][NOTIFY]', notifyErr); diff --git a/controllers/auth.controller.js b/controllers/auth.controller.js index d950c98..ddf34a8 100644 --- a/controllers/auth.controller.js +++ b/controllers/auth.controller.js @@ -45,7 +45,7 @@ const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util') const { onUserRegistered } = require('../services/achievements.service'); const AdminNotification = require('../models/notifications/admin_notification.mdl'); const UserNotification = require('../models/notifications/user_notification.mdl'); -const { renderNotification } = require('../services/notificationTemplate.service'); +const { NOTIFICATION_REGISTRY } = require('../data/notifications.data'); const { sendEmail } = require('../services/email.service'); const buildSessionInfo = require('../utils/session_info.util'); const logActivity = require('../utils/logActivity.util'); @@ -171,19 +171,17 @@ exports.register = async (req, res) => { // Fire-and-forget: notify admins — explicit group or NOGRP fallback if (group) { - renderNotification({ type: 'user_registration', data: { + AdminNotification.create(NOTIFICATION_REGISTRY.user_registration.build({ groupName: group.name, groupCode: group.group_code, userEmail: email, - } }) - .then(notify => AdminNotification.create(notify)) + })) .catch(err => console.error('[AUTH] Failed to emit user_registration notification:', err)); } else if (enrollGroup) { - renderNotification({ type: 'nogrp_user_registered', data: { + AdminNotification.create(NOTIFICATION_REGISTRY.nogrp_user_registered.build({ userEmail: email, regType: 'system', - } }) - .then(notify => AdminNotification.create(notify)) + })) .catch(err => console.error('[AUTH] Failed to emit nogrp_user_registered notification:', err)); } @@ -241,12 +239,12 @@ exports.verifyOTP = async (req, res) => { const notifications = [ { user_id: user.user_id, - ...(await renderNotification({ type: 'welcome', data: { + ...NOTIFICATION_REGISTRY.welcome.build({ groupName: grp?.name ?? null, groupCode: grp?.group_code ?? null, accType: user.acc_type, groupId: membership?.group_id ?? null, - } })), + }), createdAt: now, updatedAt: now, }, @@ -254,7 +252,7 @@ exports.verifyOTP = async (req, res) => { if (grp?.group_code === 'NOGRP') { notifications.push({ user_id: user.user_id, - ...(await renderNotification({ type: 'nogrp_welcome', data: {} })), + ...NOTIFICATION_REGISTRY.nogrp_welcome.build(), createdAt: now, updatedAt: now, }); @@ -475,11 +473,10 @@ exports.googleCallback = async (req, res) => { // Welcome email/achievements/welcome-notification are deferred to // verifyOTP's first-time branch now (this account isn't verified yet — // it still has to complete the same OTP gate as a system registration). - renderNotification({ type: 'nogrp_user_registered', data: { + AdminNotification.create(NOTIFICATION_REGISTRY.nogrp_user_registered.build({ userEmail: payload.email, regType: 'google', - } }) - .then(notify => AdminNotification.create(notify)) + })) .catch(err => console.error('[AUTH] googleCallback: Failed to emit nogrp_user_registered notification:', err)); } catch (err) { await t.rollback(); diff --git a/controllers/client/advertisements.controller.js b/controllers/client/advertisements.controller.js index eba080d..10b52d2 100644 --- a/controllers/client/advertisements.controller.js +++ b/controllers/client/advertisements.controller.js @@ -189,6 +189,38 @@ exports.getActiveAdvertisements = async (req, res) => { } }; +// ─── GET BY UUID ────────────────────────────────────────────────────────────── +// +// Resolves a single live advertisement by uuid for its own landing page — the +// destination CTA/banner clicks resolve to when the ad has no redirect_link +// (see /ads/:uuid on the client). +// +// GET /api/client/advertisements/uuid/:uuid +// +exports.getAdvertisementByUuid = async (req, res) => { + try { + const { uuid } = req.params; + if (!uuid) return R.error(res, "uuid is required.", 400); + + const advertisement = await Advertisement.findOne({ + where: liveWhere({ uuid }), + include: [AD_IMAGE_INCLUDE], + attributes: { exclude: AD_CLIENT_EXCLUDE }, + }); + + if (!advertisement) return R.error(res, "Advertisement not found.", 404); + + const json = advertisement.toJSON(); + json.status = deriveStatus(json); + if (json.image) await attachImageStreamToken(json.image, req); + + return R.success(res, "Advertisement retrieved.", { data: json }); + } catch (err) { + console.error("[CLIENT][ADVERTISEMENT][GET BY UUID]", err); + return R.error(res, "Could not retrieve advertisement.", 500); + } +}; + // ─── TRACK CLICK ────────────────────────────────────────────────────────────── // // POST /api/client/advertisements/:advertisementId/click diff --git a/controllers/client/courses.controller.js b/controllers/client/courses.controller.js index ec3e8c0..b0c003d 100644 --- a/controllers/client/courses.controller.js +++ b/controllers/client/courses.controller.js @@ -40,7 +40,7 @@ const { onCourseCompleted } = require('../../services/achievements.service' const PendingCertificate = require('../../models/courses/pending_certificate.mdl'); const Certificate = require('../../models/courses/certificate.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl'); -const { renderNotification } = require('../../services/notificationTemplate.service'); +const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const notDeleted = { deletedAt: null }; @@ -468,6 +468,10 @@ exports.getUnit = async (req, res) => { const link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }); if (!link) return R.error(res, "Unit not found.", 404); + if (!await canAccessUnit(req.user.user_id, unitId)) { + return R.error(res, "You do not have access to this unit.", 403); + } + const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, attributes: [ @@ -519,6 +523,10 @@ exports.getLesson = async (req, res) => { ]); if (!courseLink || !lessonLink) return R.error(res, "Lesson not found.", 404); + if (!await canAccessLesson(req.user.user_id, lessonId)) { + return R.error(res, "You do not have access to this lesson.", 403); + } + const lesson = await Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted }, attributes: [ @@ -557,6 +565,10 @@ exports.getUnitQuiz = async (req, res) => { const link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }); if (!link) return R.error(res, "Unit not found.", 404); + if (!await canAccessUnit(req.user.user_id, unitId)) { + return R.error(res, "You do not have access to this unit.", 403); + } + const quiz = await UnitQuiz.findOne({ where: { unit_id: unitId, ...notDeleted }, attributes: [ @@ -615,6 +627,10 @@ exports.getCourseAssessment = async (req, res) => { try { const { courseId } = req.params; + if (!await canAccessCourse(req.user.user_id, courseId)) { + return R.error(res, "You do not have access to this course.", 403); + } + const assessment = await CourseAssessment.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: [ @@ -698,6 +714,10 @@ exports.startCourseAssessment = async (req, res) => { const { courseId, assessmentId } = req.params; const user_id = req.user.user_id; + if (!await canAccessCourse(user_id, courseId)) { + return R.error(res, "You do not have access to this course.", 403); + } + const assessment = await CourseAssessment.findOne({ where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted }, attributes: ["assessment_id", "time_limit_minutes", "passing_score", "max_attempts", "cooldown_hours"], @@ -857,6 +877,10 @@ exports.submitUnitQuiz = async (req, res) => { const link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }); if (!link) return R.error(res, "Unit not found.", 404); + if (!await canAccessUnit(user_id, unitId)) { + return R.error(res, "You do not have access to this unit.", 403); + } + const quiz = await UnitQuiz.findOne({ where: { quiz_id: quizId, unit_id: unitId, ...notDeleted }, include: [{ @@ -949,6 +973,10 @@ exports.submitCourseAssessment = async (req, res) => { const { answers = {}, session_id } = req.body; const user_id = req.user.user_id; + if (!await canAccessCourse(user_id, courseId)) { + return R.error(res, "You do not have access to this course.", 403); + } + const assessment = await CourseAssessment.findOne({ where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted }, include: [{ @@ -1038,9 +1066,10 @@ exports.submitCourseAssessment = async (req, res) => { } // Immediate notification: course completed, certificate incoming - renderNotification({ type: 'course_completed', data: { courseTitle: course?.title ?? '', courseUuid: course?.uuid ?? null } }) - .then(notify => UserNotification.create({ user_id, ...notify })) - .catch(err => console.error('[ASSESSMENT] Failed to emit course_completed notification:', err)); + UserNotification.create({ + user_id, + ...NOTIFICATION_REGISTRY.course_completed.build({ courseTitle: course?.title ?? '', courseUuid: course?.uuid ?? null }), + }).catch(err => console.error('[ASSESSMENT] Failed to emit course_completed notification:', err)); } return R.success(res, "Assessment submitted.", { diff --git a/controllers/client/notification.controller.js b/controllers/client/notification.controller.js index a7eaabc..c89bff1 100644 --- a/controllers/client/notification.controller.js +++ b/controllers/client/notification.controller.js @@ -12,7 +12,45 @@ * Date Created: Jun. 19, 2026 ***********************************************************************************************************************************************************************/ const UserNotification = require('../../models/notifications/user_notification.mdl'); +const StickyBannerSetting = require('../../models/notifications/sticky_banner_setting.mdl'); +const mdl_Assets = require('../../models/assets/assets.mdl'); +const mediaToken = require('../../services/mediaToken.service'); const R = require('../../utils/response.util'); +const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util'); + +const STICKY_LIMIT = 3; +const IMAGE_INCLUDE = { + model: mdl_Assets, + as: 'image', + attributes: ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"], + required: false, +}; + +// Mirrors controllers/admin/advertisements.controller.js's attachImageStreamToken +// — kept duplicated rather than shared across the admin/client boundary. +async function attachImageStreamToken(image, req) { + if (!image || image.storage_provider !== 's3' || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) { + return image; + } + const ip = mediaToken.resolveIp(req); + const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip); + image.stream_token = token; + image.file_url = null; + image.thumbnail_url = null; + delete image.storage_key; + return image; +} + +// One shared banner image for the whole rotating sticky bar (see +// controllers/admin/notificationBroadcasts.controller.js's +// getStickyBannerSetting/updateStickyBannerSetting) — not per-announcement. +async function resolveSharedBannerImage(req) { + const setting = await StickyBannerSetting.findOne({ where: { id: 1 }, include: [IMAGE_INCLUDE] }); + if (!setting?.image) return null; + const image = setting.toJSON().image; + await attachImageStreamToken(image, req); + return image; +} // ─── GET /client/notifications ──────────────────────────────────────────────── async function list(req, res) { @@ -23,7 +61,7 @@ async function list(req, res) { const offset = (page - 1) * limit; const { count, rows } = await UserNotification.findAndCountAll({ - where: { user_id: userId, show_in_notifications: true }, + where: { user_id: userId, show_in_notifications: true, ...notInFutureOrExpired() }, order: [['createdAt', 'DESC']], limit, offset, @@ -44,7 +82,7 @@ async function unseenCount(req, res) { if (!req.user) return R.success(res, 'Unseen count fetched.', { count: null }); try { const count = await UserNotification.count({ - where: { user_id: req.user.user_id, seen: false, show_in_notifications: true }, + where: { user_id: req.user.user_id, seen: false, show_in_notifications: true, ...notInFutureOrExpired() }, }); return R.success(res, 'Unseen count fetched.', { count }); } catch (err) { @@ -56,18 +94,24 @@ async function unseenCount(req, res) { // ─── GET /client/notifications/sticky ───────────────────────────────────── async function stickyAnnouncement(req, res) { try { - const notification = await UserNotification.findOne({ - where: { - user_id: req.user.user_id, - seen: false, - show_in_sticky: true, - type: "announcement", - }, - order: [["createdAt", "DESC"]], - }); + const [notifications, bannerImage] = await Promise.all([ + UserNotification.findAll({ + where: { + user_id: req.user.user_id, + seen: false, + show_in_sticky: true, + type: "announcement", + ...notInFutureOrExpired(), + }, + order: [["createdAt", "DESC"]], + limit: STICKY_LIMIT, + }), + resolveSharedBannerImage(req), + ]); - return R.success(res, "Sticky announcement fetched.", { - announcement: notification, + return R.success(res, "Sticky announcements fetched.", { + announcements: notifications, + bannerImage, }); } catch (err) { console.error("[CLIENT NOTIFICATION] stickyAnnouncement error:", err); diff --git a/controllers/client/task.controller.js b/controllers/client/task.controller.js index 0a1cf95..dc7a3f4 100644 --- a/controllers/client/task.controller.js +++ b/controllers/client/task.controller.js @@ -25,7 +25,7 @@ const logActivity = require('../../utils/logActivity.util'); const R = require('../../utils/response.util'); const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service'); const UserNotification = require('../../models/notifications/user_notification.mdl'); -const { renderNotification } = require('../../services/notificationTemplate.service'); +const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const { onTaskCompleted, onTaskListCompleted } = require('../../services/achievements.service'); const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -248,7 +248,7 @@ const fireTaskCompletedEvent = async (userId, taskId) => { if (!task) return; try { - const notify = await renderNotification({ type: 'task_completed', data: { taskName: task.name } }); + const notify = NOTIFICATION_REGISTRY.task_completed.build({ taskName: task.name }); await UserNotification.create({ user_id: userId, ...notify, seen: false }); } catch (notifyErr) { console.error('[TASK][NOTIFY COMPLETED]', notifyErr); diff --git a/controllers/client/tiers.controller.js b/controllers/client/tiers.controller.js index e86957c..3a5b165 100644 --- a/controllers/client/tiers.controller.js +++ b/controllers/client/tiers.controller.js @@ -22,7 +22,7 @@ const { onTierActivated } = require('../../services/achievements.service'); const { Course } = require('../../models/courses/courses.mdl'); const paymentSvc = require('../../services/payment.service'); const R = require('../../utils/response.util'); -const { renderNotification } = require('../../services/notificationTemplate.service'); +const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); require('../../models/tiers/tier.associations'); @@ -49,13 +49,14 @@ exports.getMyTier = async (req, res) => { // ── Inline safety net: expire between cron ticks ────────────────────────── if (tier && tier.expires_at && new Date(tier.expires_at) <= new Date()) { await tier.update({ status: 'expired' }); - renderNotification({ type: 'tier_expired', data: { - tier: tier.tier, - label: tier.plan?.label ?? null, - planId: tier.plan?.plan_id ?? null, - } }) - .then(notify => UserNotification.create({ user_id: req.user.user_id, ...notify })) - .catch(() => {}); + UserNotification.create({ + user_id: req.user.user_id, + ...NOTIFICATION_REGISTRY.tier_expired.build({ + tier: tier.tier, + label: tier.plan?.label ?? null, + planId: tier.plan?.plan_id ?? null, + }), + }).catch(() => {}); return R.success(res, 'Active tier retrieved.', { tier: 'free', status: 'active', category: null, just_expired: true, }); diff --git a/cron/client.cron.js b/cron/client.cron.js index bfe8335..38137bf 100644 --- a/cron/client.cron.js +++ b/cron/client.cron.js @@ -5,40 +5,58 @@ * Same shape as admin.cron.js — each job module exports * { name, schedule, run }, listed in the `jobs` array below. * - * All three are settings-backed (see cronRegistry.util.js) — + * The settings-backed jobs emit notifications, so their * schedule/enabled state lives in cron_notification_settings * and is configurable from /admin/notifications/settings - * without a restart. + * without a restart. expireAdvertisements has no notification + * tied to it, so it stays on a plain hardcoded schedule (same + * reasoning as liftExpiredBans in admin.cron.js). * * Currently registered: - * - userNotifications (cron/jobs/user_notifications.cron.js) - * - issueCertificates (cron/jobs/issue_certificates.cron.js) - * - expireUserTiers (cron/jobs/expire_user_tiers.cron.js) + * - userNotifications (cron/jobs/user_notifications.cron.js) + * - issueCertificates (cron/jobs/issue_certificates.cron.js) + * - expireUserTiers (cron/jobs/expire_user_tiers.cron.js) + * - taskDueSoon (cron/jobs/task_due_soon.cron.js) + * - expireAdvertisements (cron/jobs/expire_advertisements.cron.js) — plain * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 17, 2026 ***********************************************************************************************************************************************************************/ -const userNotifications = require('./jobs/user_notifications.cron'); -const issueCertificates = require('./jobs/issue_certificates.cron'); -const expireUserTiers = require('./jobs/expire_user_tiers.cron'); -const taskDueSoon = require('./jobs/task_due_soon.cron'); +const cron = require('node-cron'); +const userNotifications = require('./jobs/user_notifications.cron'); +const issueCertificates = require('./jobs/issue_certificates.cron'); +const expireUserTiers = require('./jobs/expire_user_tiers.cron'); +const taskDueSoon = require('./jobs/task_due_soon.cron'); +const expireAdvertisements = require('./jobs/expire_advertisements.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 = [ +const settingsBackedJobs = [ userNotifications, issueCertificates, expireUserTiers, taskDueSoon, ]; +// Plain hardcoded-schedule jobs (not tied to any notification setting). +const plainJobs = [ + expireAdvertisements, +]; + // ─── Boot all registered client-side jobs ───────────────────────────────────── async function startClientCronJobs() { - return startSettingsBackedJobs(jobs, 'CLIENT'); + const registered = await startSettingsBackedJobs(settingsBackedJobs, 'CLIENT'); + + for (const job of plainJobs) { + if (!cron.validate(job.schedule)) { + console.error(`[CRON][CLIENT] Invalid schedule for "${job.name}": "${job.schedule}" — skipped.`); + continue; + } + cron.schedule(job.schedule, job.run); + registered.push({ name: job.name, scope: 'CLIENT', schedule: job.schedule }); + } + + return registered; } module.exports = { startClientCronJobs }; diff --git a/cron/jobs/expire_advertisements.cron.js b/cron/jobs/expire_advertisements.cron.js new file mode 100644 index 0000000..0e0776c --- /dev/null +++ b/cron/jobs/expire_advertisements.cron.js @@ -0,0 +1,57 @@ +/*********************************************************************************************************************************************************************** + * File Name : expire_advertisements.cron.js + * Type : Cron Job + * Description : Auto-archives (soft-deletes) advertisements once their + * end_date has passed, so expired ads don't sit indefinitely + * in the active Advertisements list — they fall through to + * the Archived Advertisements table, same path as a manual + * archive action. + * + * Only touches rows with end_date IS NOT NULL so ads with no + * end date (run indefinitely) are never auto-archived. + * + * Schedule : Every minute ("* * * * *"). Registered by cron/client.cron.js. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 11, 2026 + ***********************************************************************************************************************************************************************/ +'use strict'; + +const { Op } = require('sequelize'); +const mdl_Advertisements = require('../../models/advertisements/advertisements.mdl'); + +async function run() { + let expired; + try { + expired = await mdl_Advertisements.findAll({ + where: { + deletedAt: null, + end_date: { [Op.ne]: null, [Op.lt]: new Date() }, + }, + attributes: ['advertisement_id'], + }); + } catch (err) { + console.error('[CRON][EXPIRE ADVERTISEMENTS] Failed to query advertisements:', err); + return; + } + + if (!expired.length) return; + + const ids = expired.map((a) => a.advertisement_id); + + try { + await mdl_Advertisements.update({ status: 'expired' }, { where: { advertisement_id: { [Op.in]: ids } } }); + await mdl_Advertisements.destroy({ where: { advertisement_id: { [Op.in]: ids } } }); + } catch (err) { + console.error('[CRON][EXPIRE ADVERTISEMENTS] Archive failed:', err); + return; + } + + console.log(`[CRON][EXPIRE ADVERTISEMENTS] Auto-archived ${ids.length} expired advertisement(s).`); +} + +module.exports = { + name: 'expireAdvertisements', + schedule: '* * * * *', + run, +}; diff --git a/cron/jobs/expire_user_tiers.cron.js b/cron/jobs/expire_user_tiers.cron.js index 8d40aa2..06230f6 100644 --- a/cron/jobs/expire_user_tiers.cron.js +++ b/cron/jobs/expire_user_tiers.cron.js @@ -28,7 +28,7 @@ const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl'); const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl'); -const { getNotificationTemplate, renderNotificationContent } = require('../../services/notificationTemplate.service'); +const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); require('../../models/tiers/tier.associations'); @@ -74,10 +74,9 @@ async function run() { const settings = await CronNotificationSetting.findOne({ where: { job_name: 'expireUserTiers' } }); if (!settings || settings.enabled) { try { - const template = await getNotificationTemplate('tier_expired'); const notifications = expired.map((t) => ({ user_id: t.user_id, - ...renderNotificationContent(template, { + ...NOTIFICATION_REGISTRY.tier_expired.build({ tier: t.tier, label: t.plan?.label ?? null, planId: t.plan?.plan_id ?? null, diff --git a/cron/jobs/issue_certificates.cron.js b/cron/jobs/issue_certificates.cron.js index 52799a2..684c53d 100644 --- a/cron/jobs/issue_certificates.cron.js +++ b/cron/jobs/issue_certificates.cron.js @@ -32,7 +32,7 @@ const PendingCertificate = require('../../models/courses/pending_certificate.mdl const mdl_Achievements = require('../../models/users/achievements.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl'); const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl'); -const { getNotificationTemplate, renderNotificationContent } = require('../../services/notificationTemplate.service'); +const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const { ensureCertificateRecord } = require('../../services/certificate-record.service'); async function run() { @@ -59,10 +59,6 @@ async function run() { console.log(`[CRON][ISSUE CERTS] Processing ${rows.length} pending certificate(s).`); - // Fetched once outside the loop — content differs per row (courseTitle), - // but there's no need to re-query the template for every row. - const certificateTemplate = notificationsEnabled ? await getNotificationTemplate('certificate_issued') : null; - for (const row of rows) { const { pending_id, user_id, course_uuid, course_title } = row; const achKey = `course_completed_${course_uuid}`; @@ -91,7 +87,7 @@ async function run() { if (notificationsEnabled) { await UserNotification.create({ user_id, - ...renderNotificationContent(certificateTemplate, { + ...NOTIFICATION_REGISTRY.certificate_issued.build({ courseTitle: course_title ?? '', courseUuid: course_uuid, }), diff --git a/cron/jobs/task_due_soon.cron.js b/cron/jobs/task_due_soon.cron.js index d424e55..d830dc7 100644 --- a/cron/jobs/task_due_soon.cron.js +++ b/cron/jobs/task_due_soon.cron.js @@ -25,7 +25,7 @@ const sequelize = require('../../config/db.config'); const { Task } = require('../../models/task/task.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl'); const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl'); -const { renderNotification } = require('../../services/notificationTemplate.service'); +const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const { checkTaskCompletion } = require('../../controllers/client/task.controller'); const WINDOW_START_MS = 23 * 60 * 60 * 1000; @@ -91,9 +91,9 @@ async function run() { } if (!incompleteUserIds.length) continue; - const notify = await renderNotification({ type: 'task_reminder', data: { + const notify = NOTIFICATION_REGISTRY.task_reminder.build({ taskName: task.name, deadline: task.deadline, - } }); + }); await UserNotification.bulkCreate( incompleteUserIds.map((user_id) => ({ user_id, ...notify, seen: false, createdAt: now2, updatedAt: now2 })), { validate: false } diff --git a/cron/jobs/task_overdue.cron.js b/cron/jobs/task_overdue.cron.js index 15cc9ed..7db14a3 100644 --- a/cron/jobs/task_overdue.cron.js +++ b/cron/jobs/task_overdue.cron.js @@ -26,7 +26,7 @@ const { Op } = require('sequelize'); const { Task } = require('../../models/task/task.mdl'); const AdminNotification = require('../../models/notifications/admin_notification.mdl'); const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl'); -const { renderNotification } = require('../../services/notificationTemplate.service'); +const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); // ─── The actual sweep ──────────────────────────────────────────────────────── async function run() { @@ -59,7 +59,7 @@ async function run() { if (settings && !settings.enabled) return; await AdminNotification.create( - await renderNotification({ type: 'task_overdue', data: { count: affectedCount } }) + NOTIFICATION_REGISTRY.task_overdue.build({ count: affectedCount }) ); } catch (err) { console.error('[CRON][TASK OVERDUE] Failed to insert admin notification:', err); diff --git a/cron/jobs/user_notifications.cron.js b/cron/jobs/user_notifications.cron.js index 3505c07..0de856f 100644 --- a/cron/jobs/user_notifications.cron.js +++ b/cron/jobs/user_notifications.cron.js @@ -25,7 +25,7 @@ const sequelize = require('../../config/db.config'); const { Task } = require('../../models/task/task.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl'); const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl'); -const { renderNotification } = require('../../services/notificationTemplate.service'); +const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const WINDOW_MS = 65 * 60 * 1000; // 65-minute lookback @@ -71,7 +71,7 @@ async function run() { const count = recentlyOverdue.length; const now = new Date(); - const notify = await renderNotification({ type: 'user_task_overdue', data: { count, task_list_ids: taskListIds } }); + const notify = NOTIFICATION_REGISTRY.user_task_overdue.build({ count, task_list_ids: taskListIds }); await UserNotification.bulkCreate( affectedUsers.map(({ user_id }) => ({ diff --git a/data/notification_template_enrichers.data.js b/data/notification_template_enrichers.data.js deleted file mode 100644 index ff726c2..0000000 --- a/data/notification_template_enrichers.data.js +++ /dev/null @@ -1,65 +0,0 @@ -/*********************************************************************************************************************************************************************** - * File Name: notification_template_enrichers.data.js - * Type of Program: Data / Registry - * Description: Admin-edited notification templates are plain text — no - * conditionals or expressions allowed. Any type that used to - * branch on data in JS (e.g. task_overdue's singular/plural - * wording) gets that branch precomputed here into flat - * placeholder keys BEFORE substitution, so the stored title/ - * message only ever needs straight {{key}} swaps. - * Mirrors data/email_template_enrichers.data.js. - * Author: Kenneth Obsequio (@lash0000) - * Date Created: Jul. 3, 2026 - ***********************************************************************************************************************************************************************/ -const { fmtDate } = require('../utils/datetime.util'); - -const ENRICHERS = { - task_overdue: (data) => ({ - ...data, - task_word: Number(data.count) === 1 ? 'task was' : 'tasks were', - }), - - user_task_overdue: (data) => ({ - ...data, - task_label: Number(data.count) === 1 ? '1 task has' : `${data.count} tasks have`, - }), - - task_reminder: (data) => ({ - ...data, - deadline: fmtDate(data.deadline), - }), - - task_submission_reviewed: (data) => ({ - ...data, - statusLabel: data.status === 'approved' ? 'approved' : 'rejected', - reviewNoteSuffix: data.review_note ? ` Note: ${data.review_note}` : '', - }), - - welcome: (data) => { - const greeting = data.accType === 'admin' - ? 'Welcome, Administrator!' - : data.accType === 'staff' - ? 'Welcome to the Philproperties team!' - : 'Welcome to Philproperties!'; - return { - ...data, - greeting, - group_suffix: data.groupName ? ` You have been added to ${data.groupName}.` : '', - }; - }, - - assessment_updated: (data) => ({ - ...data, - assessmentTitle: data.assessmentTitle || 'Course Assessment', - courseTitle: data.courseTitle || 'your course', - }), - - tier_expired: (data) => ({ - ...data, - planLabel: data.label ?? data.tier, - }), -}; - -const enrichNotificationData = (type, data = {}) => (ENRICHERS[type] ? ENRICHERS[type](data) : data); - -module.exports = { enrichNotificationData }; diff --git a/data/notifications.data.js b/data/notifications.data.js index ced7e9b..f49ad79 100644 --- a/data/notifications.data.js +++ b/data/notifications.data.js @@ -1,37 +1,185 @@ /*********************************************************************************************************************************************************************** * File Name: notifications.data.js * Type of Program: Data - * Description: Registry of notification types that have no fixed, admin- - * editable wording — content is entirely supplied by the caller - * at trigger time, so there's nothing to template. + * Description: Central registry of all notification types for both admin and + * client (user) notifications. * - * Every other system-triggered notification type (task overdue, - * welcome, tier expired, etc.) has been moved to the - * notification_templates table — admin-editable, {{placeholder}}- - * based, rendered via services/notificationTemplate.service.js's - * renderNotification()/renderNotificationContent(). See - * controllers/admin/notification_templates.controller.js. - * - * Each entry here describes one notification type: + * Each entry describes one notification type: * type {string} — stored in the DB 'type' column * scope {string} — 'admin' | 'user' | 'both' - * trigger {string} — what fires it (event | manual) + * trigger {string} — what fires it (cron | event | manual) * build {function} — takes a data payload, returns the object * ready to pass to AdminNotification.create() * or UserNotification.create() / bulkCreate() * + * To add a new notification type: + * 1. Add an entry in the relevant section below. + * 2. Call NOTIFICATION_REGISTRY..build(data) at the trigger + * site (controller, cron, service). + * No other changes needed. + * * Current types: - * User : achievement, announcement + * Admin : task_overdue, user_registration, nogrp_user_registered + * User : task_requirements_updated, user_task_overdue, task_reminder, achievement, + * course_unlocked, course_completed, certificate_issued, welcome, + * nogrp_welcome, assessment_updated, announcement, tier_expired, + * task_submission_reviewed, task_assigned, task_completed * Both : broadcast (admin-composed, sent via notification_broadcasts CRUD) * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 19, 2026 - * Date Modified: Jul. 3, 2026 — fixed-wording types moved into notification_templates + * Date Modified: Jul. 11, 2026 — reverted from notification_templates (DB-editable) back to hardcoded ***********************************************************************************************************************************************************************/ 'use strict'; +const { fmtDate } = require('../utils/datetime.util'); + const NOTIFICATION_REGISTRY = { + // ───────────────────────────────────────────────────────────────────────── + // ADMIN notifications (scope: 'admin') + // ───────────────────────────────────────────────────────────────────────── + + // ── Task ────────────────────────────────────────────────────────────────── + task_overdue: { + type: 'task_overdue', + scope: 'admin', + trigger: 'cron', + build({ count, task_list_ids = [] }) { + return { + type: 'task_overdue', + title: 'Tasks Overdue', + message: `${count} task${count === 1 ? ' was' : 's were'} automatically marked as overdue.`, + data: { count, task_list_ids }, + }; + }, + }, + + // ── User Registration ───────────────────────────────────────────────────── + user_registration: { + type: 'user_registration', + scope: 'admin', + trigger: 'event', + build({ groupName, groupCode, userEmail }) { + return { + type: 'user_registration', + title: 'New User Registered', + message: `A new user registered in ${groupName}.`, + data: { groupName, groupCode, userEmail }, + }; + }, + }, + + // ── Unaffiliated User Registration ─────────────────────────────────────── + nogrp_user_registered: { + type: 'nogrp_user_registered', + scope: 'admin', + trigger: 'event', + build({ userEmail, regType }) { + return { + type: 'nogrp_user_registered', + title: 'New Unaffiliated User', + message: `A new user (${userEmail}) registered via ${regType} without a group code and was placed in the default group.`, + data: { userEmail, regType }, + }; + }, + }, + + // ───────────────────────────────────────────────────────────────────────── + // USER notifications (scope: 'user') + // ───────────────────────────────────────────────────────────────────────── + + // ── Task ────────────────────────────────────────────────────────────────── + task_requirements_updated: { + type: 'task', + scope: 'user', + trigger: 'event', + build({ taskName, taskListId = null, groupId = null }) { + return { + type: 'task', + title: 'Task Updated', + message: `The requirements for "${taskName}" have been updated by your administrator.`, + data: { taskName, taskListId, groupId }, + }; + }, + }, + + user_task_overdue: { + type: 'task', + scope: 'user', + trigger: 'cron', + build({ count, task_list_ids = [] }) { + const label = count === 1 ? '1 task has' : `${count} tasks have`; + return { + type: 'task', + title: 'Tasks Overdue', + message: `${label} passed their deadline and been marked as overdue.`, + data: { count, task_list_ids }, + }; + }, + }, + + task_reminder: { + type: 'task', + scope: 'user', + trigger: 'cron', + build({ taskName, deadline, taskListId = null, groupId = null }) { + return { + type: 'task', + title: 'Task Deadline Approaching', + message: `"${taskName}" is due on ${fmtDate(deadline)}.`, + data: { taskName, deadline, taskListId, groupId }, + }; + }, + }, + + // ── Task submission review (approve/reject) ───────────────────────────── + task_submission_reviewed: { + type: 'task', + scope: 'user', + trigger: 'event', + build({ taskName, status, review_note = null }) { + const statusLabel = status === 'approved' ? 'approved' : 'rejected'; + const reviewNoteSuffix = review_note ? ` Note: ${review_note}` : ''; + return { + type: 'task', + title: 'Submission Reviewed', + message: `Your submission for "${taskName}" was ${statusLabel}.${reviewNoteSuffix}`, + data: { taskName, status, review_note }, + }; + }, + }, + + // ── Task list assignment (group added to a task list) ─────────────────── + task_assigned: { + type: 'task', + scope: 'user', + trigger: 'event', + build({ taskListName, taskCount }) { + return { + type: 'task', + title: 'New Task Assigned', + message: `You have been assigned "${taskListName}" — ${taskCount} task(s) to complete.`, + data: { taskListName, taskCount }, + }; + }, + }, + + // ── Per-task completion (0→1 transition) ───────────────────────────────── + task_completed: { + type: 'task', + scope: 'user', + trigger: 'event', + build({ taskName }) { + return { + type: 'task', + title: 'Task Completed', + message: `You completed "${taskName}".`, + data: { taskName }, + }; + }, + }, + // ── Achievement — title/message come from the achievement definition itself ─ achievement: { type: 'achievement', @@ -47,7 +195,100 @@ const NOTIFICATION_REGISTRY = { }, }, - // ── Platform — title/body typed fresh by whoever calls this ───────────────── + // ── Course ──────────────────────────────────────────────────────────────── + course_unlocked: { + type: 'course', + scope: 'user', + trigger: 'event', + build({ courseTitle, courseUuid = null }) { + return { + type: 'course', + title: 'New Course Available', + message: `"${courseTitle}" has been added to your learning library.`, + data: { courseTitle, courseUuid }, + }; + }, + }, + + course_completed: { + type: 'course', + scope: 'user', + trigger: 'event', + build({ courseTitle, courseUuid = null }) { + return { + type: 'course', + title: 'Course Completed', + message: `Great job! You've completed "${courseTitle}". Your certificate will be issued within the next hour.`, + data: { courseTitle, courseUuid }, + }; + }, + }, + + certificate_issued: { + type: 'course', + scope: 'user', + trigger: 'cron', + build({ courseTitle, courseUuid }) { + return { + type: 'course', + title: 'Certificate Issued', + message: `Congratulations! Your certificate for "${courseTitle}" is ready.`, + data: { courseTitle, courseUuid }, + }; + }, + }, + + // ── Welcome ─────────────────────────────────────────────────────────────── + welcome: { + type: 'announcement', + scope: 'user', + trigger: 'event', + build({ groupName, groupCode, accType, groupId = null }) { + const greeting = accType === 'admin' + ? 'Welcome, Administrator!' + : accType === 'staff' + ? 'Welcome to the Philproperties team!' + : 'Welcome to Philproperties!'; + return { + type: 'announcement', + title: 'Welcome to Philproperties', + message: groupName ? `${greeting} You have been added to ${groupName}.` : greeting, + data: { groupName, groupCode, accType, groupId }, + }; + }, + }, + + // ── No-Group Welcome ────────────────────────────────────────────────────── + nogrp_welcome: { + type: 'announcement', + scope: 'user', + trigger: 'event', + build() { + return { + type: 'announcement', + title: "You're Not in a Group Yet", + message: 'You are currently in the default group. Contact an administrator to be assigned to your team.', + data: { groupCode: 'NOGRP' }, + }; + }, + }, + + // ── Assessment ──────────────────────────────────────────────────────────── + assessment_updated: { + type: 'assessment', + scope: 'user', + trigger: 'event', + build({ assessmentTitle, courseTitle, courseUuid = null }) { + return { + type: 'assessment', + title: 'Assessment Updated', + message: `The administrator has updated the "${assessmentTitle || 'Course Assessment'}" in "${courseTitle || 'your course'}". Your current session is still valid — continue where you left off.`, + data: { assessmentTitle, courseTitle, courseUuid }, + }; + }, + }, + + // ── Platform ────────────────────────────────────────────────────────────── announcement: { type: 'announcement', scope: 'user', @@ -68,12 +309,27 @@ const NOTIFICATION_REGISTRY = { type: 'announcement', scope: 'both', trigger: 'manual', - build({ title, message, targetType = null, targetId = null, groupId = null, linkUrl = null }) { + build({ title, message, targetType = null, targetId = null, groupId = null, linkUrl = null, linkLabel = null }) { return { type: 'announcement', title, message, - data: { targetType, targetId, groupId, linkUrl }, + data: { targetType, targetId, groupId, linkUrl, linkLabel }, + }; + }, + }, + + // ── Tier ────────────────────────────────────────────────────────────────── + tier_expired: { + type: 'tier_expired', + scope: 'user', + trigger: 'cron', + build({ tier, label, planId = null }) { + return { + type: 'tier_expired', + title: 'Subscription Expired', + message: `Your ${label ?? tier} plan has expired. Renew to keep access.`, + data: { tier, label, planId }, }; }, }, diff --git a/database/migrations/20260711000002-recategorize-advertisement-placements.js b/database/migrations/20260711000002-recategorize-advertisement-placements.js new file mode 100644 index 0000000..26b1f0b --- /dev/null +++ b/database/migrations/20260711000002-recategorize-advertisement-placements.js @@ -0,0 +1,37 @@ +'use strict'; + +// Re-categorizes advertisement placements: Hero stays on Dashboard, Banner +// consolidates onto Tier Plans + Course Details. Popup (dashboard.popup) and +// Sidebar (course_details.sidebar) are retired as formats, and the Courses +// list banner (course_list.banner) is dropped along with them since the new +// registry only offers Dashboard / Tier Plans / Course Details. +// +// Existing rows on a retired placement are archived (soft-deleted) rather +// than deleted outright — they still show up in Archived Advertisements for +// an admin to review/permanently delete if desired. +const RETIRED_PLACEMENTS = ['dashboard.popup', 'course_list.banner', 'course_details.sidebar']; + +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + UPDATE advertisements SET placement = 'tier_plans.banner' WHERE placement = 'plans.banner' + `); + + await queryInterface.sequelize.query(` + UPDATE advertisements + SET "deletedAt" = NOW() + WHERE placement IN (:retired) AND "deletedAt" IS NULL + `, { + replacements: { retired: RETIRED_PLACEMENTS }, + }); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + UPDATE advertisements SET placement = 'plans.banner' WHERE placement = 'tier_plans.banner' + `); + // Retired-placement rows that were auto-archived by `up` are intentionally + // left archived — there's no reliable way to distinguish them from ads an + // admin archived manually in the meantime. + }, +}; diff --git a/database/migrations/20260711000003-add-content-mode-and-page-builder-to-advertisements.js b/database/migrations/20260711000003-add-content-mode-and-page-builder-to-advertisements.js new file mode 100644 index 0000000..11aeb51 --- /dev/null +++ b/database/migrations/20260711000003-add-content-mode-and-page-builder-to-advertisements.js @@ -0,0 +1,55 @@ +'use strict'; + +// Supports the new advertisement creation wizard: +// - content_mode: "image" (image only) vs "content" (badge/headline/ +// description/CTAs alongside the image) — an explicit admin choice, +// decoupled from placement/format. Added as STRING + an explicit CHECK +// constraint (addColumn can't create a native enum type on this +// CockroachDB instance, unlike createTable) — matching the pattern used +// in 20260710000001-add-status-to-courses.js. The Sequelize model still +// declares it as DataTypes.ENUM for app-level validation; the column +// itself is a plain string. +// - redirect_link: where the ad as a whole links to when clicked with no +// CTA of its own (banners/full-image ads have no CTA row). +// - landing_page: when no redirect_link is given, an internally-authored +// landing page (title/description/body/links) the ad's own click-through +// resolves to instead (see GET /api/client/advertisements/uuid/:uuid and +// the /ads/:uuid client route). +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('advertisements', 'content_mode', { + type: Sequelize.STRING, + allowNull: false, + defaultValue: 'image', + }); + await queryInterface.sequelize.query(` + ALTER TABLE advertisements ADD CONSTRAINT check_content_mode + CHECK (content_mode IN ('image', 'content')) + `); + + await queryInterface.addColumn('advertisements', 'redirect_link', { + type: Sequelize.STRING(512), + allowNull: true, + }); + + await queryInterface.addColumn('advertisements', 'landing_page', { + type: Sequelize.JSONB, + allowNull: true, + }); + + // Existing rows already have real content (headline/description/ctas) — + // treat them as "content" mode rather than defaulting to "image". + await queryInterface.sequelize.query(` + UPDATE advertisements + SET content_mode = 'content' + WHERE headline IS NOT NULL AND headline <> '' + `); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('advertisements', 'landing_page'); + await queryInterface.removeColumn('advertisements', 'redirect_link'); + await queryInterface.sequelize.query(`ALTER TABLE advertisements DROP CONSTRAINT IF EXISTS check_content_mode`); + await queryInterface.removeColumn('advertisements', 'content_mode'); + }, +}; diff --git a/database/migrations/20260711000004-drop-notification-templates.js b/database/migrations/20260711000004-drop-notification-templates.js new file mode 100644 index 0000000..aba5453 --- /dev/null +++ b/database/migrations/20260711000004-drop-notification-templates.js @@ -0,0 +1,32 @@ +'use strict'; + +// Reverts the notification_templates feature (20260703000008 + +// 20260709000004) — admin-editable notification wording has been moved back +// to hardcoded build() functions in data/notifications.data.js. + +module.exports = { + async up(queryInterface) { + await queryInterface.dropTable('notification_templates'); + await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_notification_templates_scope";`); + await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_notification_templates_status";`); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.createTable('notification_templates', { + notification_template_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + type: { type: Sequelize.STRING(100), allowNull: false, unique: true }, + notify_type: { type: Sequelize.STRING(64), allowNull: false }, + scope: { type: Sequelize.ENUM('admin', 'user', 'both'), allowNull: false }, + label: { type: Sequelize.STRING(150), allowNull: false }, + status: { type: Sequelize.ENUM('draft', 'sent'), allowNull: false, defaultValue: 'draft' }, + title: { type: Sequelize.STRING(255), allowNull: true }, + message: { type: Sequelize.TEXT, allowNull: true }, + draft_title: { type: Sequelize.STRING(255), allowNull: true }, + draft_message: { type: Sequelize.TEXT, allowNull: true }, + last_sent_at: { type: Sequelize.DATE, allowNull: true }, + is_system: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + }, +}; diff --git a/database/migrations/20260711000005-add-color-and-link-label-to-notifications.js b/database/migrations/20260711000005-add-color-and-link-label-to-notifications.js new file mode 100644 index 0000000..5251dc6 --- /dev/null +++ b/database/migrations/20260711000005-add-color-and-link-label-to-notifications.js @@ -0,0 +1,35 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('notification_broadcasts', 'color', { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'indigo', + }); + + await queryInterface.addColumn('notification_broadcasts', 'link_label', { + type: Sequelize.STRING(60), + allowNull: true, + }); + + await queryInterface.addColumn('admin_notifications', 'color', { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'indigo', + }); + + await queryInterface.addColumn('user_notifications', 'color', { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'indigo', + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('user_notifications', 'color'); + await queryInterface.removeColumn('admin_notifications', 'color'); + await queryInterface.removeColumn('notification_broadcasts', 'link_label'); + await queryInterface.removeColumn('notification_broadcasts', 'color'); + }, +}; diff --git a/database/migrations/20260711000006-add-start-end-date-to-notification-broadcasts.js b/database/migrations/20260711000006-add-start-end-date-to-notification-broadcasts.js new file mode 100644 index 0000000..fbe22ad --- /dev/null +++ b/database/migrations/20260711000006-add-start-end-date-to-notification-broadcasts.js @@ -0,0 +1,44 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('notification_broadcasts', 'start_date', { + type: Sequelize.DATE, + allowNull: true, + }); + + await queryInterface.addColumn('notification_broadcasts', 'end_date', { + type: Sequelize.DATE, + allowNull: true, + }); + + await queryInterface.addColumn('admin_notifications', 'start_date', { + type: Sequelize.DATE, + allowNull: true, + }); + + await queryInterface.addColumn('admin_notifications', 'end_date', { + type: Sequelize.DATE, + allowNull: true, + }); + + await queryInterface.addColumn('user_notifications', 'start_date', { + type: Sequelize.DATE, + allowNull: true, + }); + + await queryInterface.addColumn('user_notifications', 'end_date', { + type: Sequelize.DATE, + allowNull: true, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('user_notifications', 'end_date'); + await queryInterface.removeColumn('user_notifications', 'start_date'); + await queryInterface.removeColumn('admin_notifications', 'end_date'); + await queryInterface.removeColumn('admin_notifications', 'start_date'); + await queryInterface.removeColumn('notification_broadcasts', 'end_date'); + await queryInterface.removeColumn('notification_broadcasts', 'start_date'); + }, +}; diff --git a/database/migrations/20260711000007-add-broadcast-id-to-notifications.js b/database/migrations/20260711000007-add-broadcast-id-to-notifications.js new file mode 100644 index 0000000..7a45bbc --- /dev/null +++ b/database/migrations/20260711000007-add-broadcast-id-to-notifications.js @@ -0,0 +1,24 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('admin_notifications', 'broadcast_id', { + type: Sequelize.BIGINT, + allowNull: true, + references: { model: 'notification_broadcasts', key: 'broadcast_id' }, + onDelete: 'SET NULL', + }); + + await queryInterface.addColumn('user_notifications', 'broadcast_id', { + type: Sequelize.BIGINT, + allowNull: true, + references: { model: 'notification_broadcasts', key: 'broadcast_id' }, + onDelete: 'SET NULL', + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('user_notifications', 'broadcast_id'); + await queryInterface.removeColumn('admin_notifications', 'broadcast_id'); + }, +}; diff --git a/database/migrations/20260711000008-create-sticky-banner-settings.js b/database/migrations/20260711000008-create-sticky-banner-settings.js new file mode 100644 index 0000000..8d80e0b --- /dev/null +++ b/database/migrations/20260711000008-create-sticky-banner-settings.js @@ -0,0 +1,42 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + // Single shared banner image for the rotating sticky announcement bar — + // one image for all (up to 3) concurrently-active announcements, not one + // per announcement. Enforced as a singleton in application code (always + // read/written as id=1), same pragmatic approach as the "no need for a + // dedicated Banner entity" ask — just a one-row setting. + await queryInterface.createTable('sticky_banner_settings', { + id: { + type: Sequelize.SMALLINT, + primaryKey: true, + defaultValue: 1, + }, + image_asset_id: { + type: Sequelize.BIGINT, + allowNull: true, + references: { model: 'assets', key: 'asset_id' }, + onDelete: 'SET NULL', + }, + updatedBy: { + type: Sequelize.BIGINT, + allowNull: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('sticky_banner_settings'); + }, +}; diff --git a/models/advertisements/advertisements.mdl.js b/models/advertisements/advertisements.mdl.js index 5de2038..806c8d0 100644 --- a/models/advertisements/advertisements.mdl.js +++ b/models/advertisements/advertisements.mdl.js @@ -33,6 +33,10 @@ const Advertisement = sequelize.define("Advertisement", { }, // ─── Content ────────────────────────────────────────────────────────────── + // "image" = image only, "content" = badge/headline/description/CTAs alongside + // the image — an explicit admin choice made in the creation wizard, decoupled + // from placement/format. + content_mode: { type: DataTypes.ENUM("image", "content"), allowNull: false, defaultValue: "image", label: "Content Mode", order: 3.5 }, badge_label: { type: DataTypes.STRING(100), label: "Badge Label", order: 4 }, headline: { type: DataTypes.STRING(255), label: "Headline", order: 5 }, description: { type: DataTypes.TEXT, label: "Description", order: 6 }, @@ -45,6 +49,15 @@ const Advertisement = sequelize.define("Advertisement", { // [{ label, link }, ...] — 0-2 entries depending on type ctas: { type: DataTypes.JSONB, allowNull: false, defaultValue: [], label: "CTAs", order: 7 }, + // Where the ad as a whole links to when clicked with no CTA of its own + // (banners/full-image ads have no CTA row) — takes priority over landing_page. + redirect_link: { type: DataTypes.STRING(512), allowNull: true, label: "Redirect Link", order: 7.5 }, + + // Internally-authored landing page { title, description, body, links: [{label, link}] } + // used as the click-through destination when redirect_link is empty — see + // GET /api/client/advertisements/uuid/:uuid and the /ads/:uuid client route. + landing_page: { type: DataTypes.JSONB, allowNull: true, label: "Landing Page", order: 0, hidden: true }, + // ─── Scheduling ─────────────────────────────────────────────────────────── start_date: { type: DataTypes.DATE, label: "Start Date", order: 8 }, end_date: { type: DataTypes.DATE, label: "End Date", order: 9 }, diff --git a/models/advertisements/advertisements.placements.js b/models/advertisements/advertisements.placements.js index 678daa6..f55d18b 100644 --- a/models/advertisements/advertisements.placements.js +++ b/models/advertisements/advertisements.placements.js @@ -12,19 +12,10 @@ // 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)" }, - { key: "course_list.banner", format: "banner", page: "course_list", pageLabel: "Courses", slotLabel: "Banner (above course grid)" }, + { key: "tier_plans.banner", format: "banner", page: "tier_plans", pageLabel: "Tier Plans", slotLabel: "Banner (above plan cards)" }, { key: "course_details.banner", format: "banner", page: "course_details", pageLabel: "Course Details", slotLabel: "Banner (below hero)" }, - { key: "course_details.sidebar", format: "sidebar", page: "course_details", pageLabel: "Course Details", slotLabel: "Sidebar (beside course content)" }, - { key: "plans.banner", format: "banner", page: "plans", pageLabel: "Plans", slotLabel: "Banner (above plan cards)" }, ]; const PLACEMENT_MAP = Object.fromEntries(PLACEMENTS.map((p) => [p.key, p])); diff --git a/models/assets/assets.mdl.js b/models/assets/assets.mdl.js index 9cad504..9e5506f 100644 --- a/models/assets/assets.mdl.js +++ b/models/assets/assets.mdl.js @@ -11,11 +11,11 @@ const Asset = sequelize.define("Asset", { // ─── File info ──────────────────────────────────────────────────────────── original_name: { type: DataTypes.STRING(255), allowNull: false, label: "Original Name", order: 0, hidden: true }, - display_name: { type: DataTypes.STRING(255), allowNull: false, label: "Name", order: 0 }, + display_name: { type: DataTypes.STRING(255), allowNull: false, label: "Name", order: 0, filterable: true }, file_url: { type: DataTypes.STRING(512), allowNull: false, label: "File URL", order: 0, hidden: true }, file_size: { type: DataTypes.BIGINT, allowNull: false, label: "File Size", order: 0, hidden: true }, mime_type: { type: DataTypes.STRING(100), allowNull: false, label: "MIME Type", order: 0, hidden: true }, - extension: { type: DataTypes.STRING(20), label: "File Type", order: 0 }, + extension: { type: DataTypes.STRING(20), label: "File Type", order: 0, filterable: true }, checksum: { type: DataTypes.STRING(64), label: "Checksum", order: 0, hidden: true }, // ─── Classification ─────────────────────────────────────────────────────── @@ -57,9 +57,9 @@ const Asset = sequelize.define("Asset", { }, // ── Audit trails ──────────────────────────────────────────────────────────── - createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, - updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" }, - deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" }, + createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By", filterable: true }, + updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By", filterable: true }, + deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By", filterable: true }, }, { tableName: "assets", timestamps: true, // createdAt, updatedAt diff --git a/models/notifications/admin_notification.mdl.js b/models/notifications/admin_notification.mdl.js index 1d86065..53eff9d 100644 --- a/models/notifications/admin_notification.mdl.js +++ b/models/notifications/admin_notification.mdl.js @@ -42,6 +42,33 @@ const AdminNotification = sequelize.define('AdminNotification', { defaultValue: false, }, + // Named color key (utils/tierColors.js on the frontend) for the sticky banner. + color: { + type: DataTypes.STRING(20), + allowNull: false, + defaultValue: 'indigo', + }, + + // Denormalized copy of the source NotificationBroadcast's visibility + // window (see notificationVisibility.util.js) — null means unbounded. + start_date: { + type: DataTypes.DATE, + allowNull: true, + }, + end_date: { + type: DataTypes.DATE, + allowNull: true, + }, + + // Links back to the source broadcast so editing it after send can + // propagate content changes into already-created rows (see + // notificationBroadcasts.controller.js#updateBroadcast). Null for + // notification types with no broadcast source. + broadcast_id: { + type: DataTypes.BIGINT, + allowNull: true, + }, + data: { type: DataTypes.JSONB, allowNull: true, diff --git a/models/notifications/notification_broadcast.mdl.js b/models/notifications/notification_broadcast.mdl.js index 85c9cef..dada02f 100644 --- a/models/notifications/notification_broadcast.mdl.js +++ b/models/notifications/notification_broadcast.mdl.js @@ -15,6 +15,20 @@ const NotificationBroadcast = sequelize.define("NotificationBroadcast", { // 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 }, + // Custom CTA button text shown right after the title in the sticky banner + // (e.g. "Shop now"). Falls back to "Open Link" when unset. + link_label: { type: DataTypes.STRING(60), allowNull: true, label: "Button Label", order: 2.3 }, + + // Named color key (see utils/tierColors.js on the frontend) driving the + // sticky banner's panel background/border — same registry rewards/tiers use. + color: { type: DataTypes.STRING(20), allowNull: false, defaultValue: 'indigo', label: "Color", order: 2.4 }, + + // Optional visibility window. Null start_date = show immediately once sent; + // null end_date = show indefinitely. Copied onto AdminNotification/ + // UserNotification rows at send time so client reads can filter without + // joining back to this table (see sendBroadcast + notificationVisibility.util.js). + start_date: { type: DataTypes.DATE, allowNull: true, label: "Start Date", order: 2.7 }, + end_date: { type: DataTypes.DATE, allowNull: true, label: "End Date", order: 2.8 }, // ─── Visibility ────────────────────────────────────────────────────────── // Determines where a delivered announcement shows up for recipients. diff --git a/models/notifications/notification_template.mdl.js b/models/notifications/notification_template.mdl.js deleted file mode 100644 index cf49426..0000000 --- a/models/notifications/notification_template.mdl.js +++ /dev/null @@ -1,46 +0,0 @@ -/*********************************************************************************************************************************************************************** - * File Name: notification_template.mdl.js - * Type of Program: Model - * Description: Admin-managed catalog of system-triggered notification wording - * (title + message). Mirrors models/email_templates/email_templates.mdl.js. - * `is_system` rows are the built-in types referenced by `type` from - * services/notificationTemplate.service.js's renderNotification() - * callers (cron jobs, controllers) — protected from deletion by the - * admin controller (no create/delete endpoint at all, since a new - * type needs a code call site before it means anything). - * - * Publish workflow: `title`/`message` are the LIVE content — the - * only columns renderNotification() ever reads. Editing a 'sent' - * template writes to `draft_title`/`draft_message` instead, leaving - * live content untouched until an admin explicitly publishes again - * (see controllers/admin/notification_templates.controller.js). - * - * Author: Kenneth Obsequio (@lash0000) - * Date Created: Jul. 3, 2026 - ***********************************************************************************************************************************************************************/ -const { DataTypes } = require('sequelize'); -const sequelize = require('../../config/db.config'); - -const mdl_NotificationTemplate = sequelize.define('NotificationTemplate', { - notification_template_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'ID', hidden: true, order: 0 }, - type: { type: DataTypes.STRING(100), allowNull: false, unique: true, label: 'Type', hidden: false, order: 1, filterable: true }, - // Written into the delivered admin_notifications/user_notifications row's own - // `type` column (e.g. 'task', 'course', 'announcement') — distinct from the - // lookup key above, since several lookup keys share the same delivered type. - notify_type: { type: DataTypes.STRING(64), allowNull: false, label: 'Notify Type', hidden: false, order: 2, filterable: true }, - scope: { type: DataTypes.ENUM('admin', 'user', 'both'), allowNull: false, label: 'Scope', hidden: false, order: 3, filterable: true }, - label: { type: DataTypes.STRING(150), allowNull: false, label: 'Label', hidden: false, order: 4, filterable: true }, - status: { type: DataTypes.ENUM('draft', 'sent'), allowNull: false, defaultValue: 'draft', label: 'Status', hidden: false, order: 5, filterable: true }, - title: { type: DataTypes.STRING(255), allowNull: true, label: 'Title', hidden: false, order: 6, filterable: false }, - message: { type: DataTypes.TEXT, allowNull: true, label: 'Message', hidden: false, order: 7, filterable: false }, - draft_title: { type: DataTypes.STRING(255), allowNull: true, label: 'Draft Title', hidden: false, order: 8, filterable: false }, - draft_message: { type: DataTypes.TEXT, allowNull: true, label: 'Draft Message', hidden: false, order: 9, filterable: false }, - last_sent_at: { type: DataTypes.DATE, allowNull: true, label: 'Last Published', hidden: false, order: 10, filterable: false }, - is_system: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, label: 'System', hidden: false, order: 11, filterable: true }, -}, { - tableName: 'notification_templates', - timestamps: true, - paranoid: false, -}); - -module.exports = mdl_NotificationTemplate; diff --git a/models/notifications/sticky_banner_setting.mdl.js b/models/notifications/sticky_banner_setting.mdl.js new file mode 100644 index 0000000..1e59df3 --- /dev/null +++ b/models/notifications/sticky_banner_setting.mdl.js @@ -0,0 +1,22 @@ +// models/notifications/sticky_banner_setting.mdl.js +// +// Singleton (always id=1) — one shared banner image shown alongside every +// currently-active sticky announcement, not one image per announcement. Set +// from the Announcements list page (see admin/notificationBroadcasts.controller.js's +// getStickyBannerSetting/updateStickyBannerSetting). +const { DataTypes } = require("sequelize"); +const sequelize = require("../../config/db.config"); +const mdl_Assets = require("../assets/assets.mdl"); + +const StickyBannerSetting = sequelize.define("StickyBannerSetting", { + id: { type: DataTypes.SMALLINT, primaryKey: true, defaultValue: 1 }, + image_asset_id: { type: DataTypes.BIGINT, allowNull: true }, + updatedBy: { type: DataTypes.BIGINT, allowNull: true }, +}, { + tableName: "sticky_banner_settings", + timestamps: true, +}); + +StickyBannerSetting.belongsTo(mdl_Assets, { as: "image", foreignKey: "image_asset_id" }); + +module.exports = StickyBannerSetting; diff --git a/models/notifications/user_notification.mdl.js b/models/notifications/user_notification.mdl.js index 052d84c..424e600 100644 --- a/models/notifications/user_notification.mdl.js +++ b/models/notifications/user_notification.mdl.js @@ -48,6 +48,33 @@ const UserNotification = sequelize.define('UserNotification', { defaultValue: false, }, + // Named color key (utils/tierColors.js on the frontend) for the sticky banner. + color: { + type: DataTypes.STRING(20), + allowNull: false, + defaultValue: 'indigo', + }, + + // Denormalized copy of the source NotificationBroadcast's visibility + // window (see notificationVisibility.util.js) — null means unbounded. + start_date: { + type: DataTypes.DATE, + allowNull: true, + }, + end_date: { + type: DataTypes.DATE, + allowNull: true, + }, + + // Links back to the source broadcast so editing it after send can + // propagate content changes into already-created rows (see + // notificationBroadcasts.controller.js#updateBroadcast). Null for + // notification types with no broadcast source. + broadcast_id: { + type: DataTypes.BIGINT, + allowNull: true, + }, + data: { type: DataTypes.JSONB, allowNull: true, diff --git a/routes/admin/admin.routes.js b/routes/admin/admin.routes.js index cfa13e5..614d3fc 100644 --- a/routes/admin/admin.routes.js +++ b/routes/admin/admin.routes.js @@ -43,7 +43,6 @@ const notificationBroadcastRoutes = require('./notificationBroadcasts.routes'); const notificationSettingsRoutes = require('./notificationSettings.routes'); const mediaRoutes = require('./media.routes'); const achievementsRoutes = require('./achievements.routes'); -const notificationTemplatesRoutes = require('./notification_templates.routes'); const activityCtrl = require('../../controllers/admin/user_activity.controller'); // ── Guards — applied to ALL admin routes ────────────────────────────────────── @@ -68,11 +67,9 @@ router.use('/advertisements', advertisementRoutes); router.use('/notifications', notificationRoutes); router.use('/notification-broadcasts', notificationBroadcastRoutes); router.use('/notification-settings', notificationSettingsRoutes); -router.use('/notification-templates', notificationTemplatesRoutes); -// Announcements (alias routes for notification broadcasts/templates/settings) +// Announcements (alias routes for notification broadcasts/settings) router.use('/announcements', notificationBroadcastRoutes); router.use('/announcement-settings', notificationSettingsRoutes); -router.use('/announcement-templates', notificationTemplatesRoutes); router.use('/media', mediaRoutes); router.use('/achievements', achievementsRoutes); diff --git a/routes/admin/notificationBroadcasts.routes.js b/routes/admin/notificationBroadcasts.routes.js index 7a9a59c..27aa753 100644 --- a/routes/admin/notificationBroadcasts.routes.js +++ b/routes/admin/notificationBroadcasts.routes.js @@ -8,6 +8,8 @@ router.get('/archived', controller.getArchivedBroadcasts); router.delete('/bulk', sensitiveOpsLimiter, controller.archiveBroadcasts); router.patch('/bulk-restore', controller.restoreBroadcasts); router.delete('/bulk/permanent', sensitiveOpsLimiter, controller.permanentlyDeleteBroadcasts); +router.get('/sticky-banner', controller.getStickyBannerSetting); +router.patch('/sticky-banner', sensitiveOpsLimiter, controller.updateStickyBannerSetting); // ─── Collection ─────────────────────────────────────────────────────────────── router.get('/', controller.getBroadcasts); diff --git a/routes/admin/notification_templates.routes.js b/routes/admin/notification_templates.routes.js deleted file mode 100644 index bb845c1..0000000 --- a/routes/admin/notification_templates.routes.js +++ /dev/null @@ -1,14 +0,0 @@ -'use strict'; - -const router = require('express').Router(); -const ctrl = require('../../controllers/admin/notification_templates.controller'); - -// Auth + requireAdmin applied by admin.routes.js - -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/client/advertisements.routes.js b/routes/client/advertisements.routes.js index 4c71405..88624cb 100644 --- a/routes/client/advertisements.routes.js +++ b/routes/client/advertisements.routes.js @@ -11,6 +11,9 @@ router.get('/active-batch', controller.getActiveAdvertisements); // ─── GET /api/client/advertisements/active-list?placement=dashboard.hero ────── router.get('/active-list', controller.getActiveAdvertisementList); +// ─── GET /api/client/advertisements/uuid/:uuid ───────────────────────────────── +router.get('/uuid/:uuid', controller.getAdvertisementByUuid); + // ─── POST /api/client/advertisements/:advertisementId/click ─────────────────── router.post('/:advertisementId/click', controller.trackClick); diff --git a/services/notificationTemplate.service.js b/services/notificationTemplate.service.js deleted file mode 100644 index e9af8fa..0000000 --- a/services/notificationTemplate.service.js +++ /dev/null @@ -1,52 +0,0 @@ -/*********************************************************************************************************************************************************************** - * File Name: notificationTemplate.service.js - * Type of Program: Service - * Description: Renders system-triggered notification title/message from the - * notification_templates table (admin-editable, see - * controllers/admin/notification_templates.controller.js). - * Mirrors the template-loading half of services/email.service.js's - * sendEmail() — same "only live columns count as published" rule, - * same {{placeholder}} substitution via utils/renderTemplate.util.js. - * Author: Kenneth Obsequio (@lash0000) - * Date Created: Jul. 3, 2026 - *********************************************************************************************************************************************************************** - * HOW TO USE: - * const { renderNotification } = require('../services/notificationTemplate.service'); - * const notify = await renderNotification({ type: 'task_overdue', data: { count } }); - * await AdminNotification.create(notify); - * - * // Bulk/loop use — fetch once, render per-row without re-querying the DB: - * const { getNotificationTemplate, renderNotificationContent } = require('../services/notificationTemplate.service'); - * const template = await getNotificationTemplate('tier_expired'); - * const rows = expired.map((t) => ({ user_id: t.user_id, ...renderNotificationContent(template, { tier: t.tier, label: t.plan?.label ?? null }) })); - ***********************************************************************************************************************************************************************/ -const mdl_NotificationTemplate = require('../models/notifications/notification_template.mdl'); -const { enrichNotificationData } = require('../data/notification_template_enrichers.data'); -const { renderTemplate } = require('../utils/renderTemplate.util'); - -const getNotificationTemplate = async (type) => { - const template = await mdl_NotificationTemplate.findOne({ where: { type } }); - if (!template || !template.title || !template.message) { - throw new Error(`Notification template "${type}" has no published version yet`); - } - return template; -}; - -// Pure — no DB access. Use when a template has already been fetched once -// (e.g. reused across a bulk-create loop) to avoid a query per row. -const renderNotificationContent = (template, data = {}) => { - const enriched = enrichNotificationData(template.type, data); - return { - type: template.notify_type, - title: renderTemplate(template.title, enriched), - message: renderTemplate(template.message, enriched), - data, - }; -}; - -const renderNotification = async ({ type, data = {} }) => { - const template = await getNotificationTemplate(type); - return renderNotificationContent(template, data); -}; - -module.exports = { getNotificationTemplate, renderNotificationContent, renderNotification }; diff --git a/utils/buildQuery.util.js b/utils/buildQuery.util.js index 0bf9095..acdbd64 100644 --- a/utils/buildQuery.util.js +++ b/utils/buildQuery.util.js @@ -7,6 +7,13 @@ const ENUM_SORT_ORDER = { reg_type: ["system", "google"], }; +// createdBy/updatedBy/deletedBy filter options come from getFieldValues() +// as { value: user_id, label: full_name } — the filter sheet selects by id, +// so these need an exact match against the bigint column, never the +// substring-on-text-cast path used for everything else (that path compares +// against the raw id and can never match a name-looking value). +const AUDIT_ID_FIELDS = new Set(["createdBy", "updatedBy", "deletedBy"]); + /** * Builds a Sequelize `where` clause from an array of filters. * @@ -22,6 +29,11 @@ function buildWhere(filters = [], allowedFields = new Set()) { const values = Array.isArray(value) ? value : [value]; + if (AUDIT_ID_FIELDS.has(id)) { + where.push({ [id]: { [Op.in]: values } }); + continue; + } + const conditions = values.map((v) => id.startsWith("personal_info.") ? Sequelize.where( diff --git a/utils/fieldValues.util.js b/utils/fieldValues.util.js index bf67b4e..89a1863 100644 --- a/utils/fieldValues.util.js +++ b/utils/fieldValues.util.js @@ -30,25 +30,30 @@ const getFieldValues = (Model, logTag, options = {}) => async (req, res) => { return R.error(res, "Invalid or restricted field.", 400); if (auditByFields.includes(field)) { + // Filtering must match the audit column's real (bigint) id — returning + // just the display name here previously made buildWhere() compare a + // name string against the raw id column via a text cast, which can + // never match. Carry both: `value` (id) is what gets filtered on, + // `label` (full_name) is what the filter sheet displays. const tableName = Model.getTableName(); const [rows] = selfJoin ? await sequelize.query(` - SELECT DISTINCT u2."personal_info"->'name'->>'full_name' AS value + SELECT DISTINCT u1."${field}" AS value, u2."personal_info"->'name'->>'full_name' AS label FROM "${tableName}" u1 JOIN "${tableName}" u2 ON u2.user_id = u1."${field}" WHERE u1."${field}" IS NOT NULL AND u2."personal_info"->'name'->>'full_name' IS NOT NULL - ORDER BY value ASC + ORDER BY label ASC `) : await sequelize.query(` - SELECT DISTINCT u."personal_info"->'name'->>'full_name' AS value + SELECT DISTINCT t."${field}" AS value, u."personal_info"->'name'->>'full_name' AS label FROM "${tableName}" t JOIN users u ON u.user_id = t."${field}" WHERE t."${field}" IS NOT NULL AND u."personal_info"->'name'->>'full_name' IS NOT NULL - ORDER BY value ASC + ORDER BY label ASC `); - return R.success(res, "Field values retrieved.", rows.map((r) => r.value).filter(Boolean)); + return R.success(res, "Field values retrieved.", rows.filter((r) => r.label)); } if (dateFields.includes(field)) { diff --git a/utils/notificationVisibility.util.js b/utils/notificationVisibility.util.js new file mode 100644 index 0000000..ef6123e --- /dev/null +++ b/utils/notificationVisibility.util.js @@ -0,0 +1,25 @@ +/*********************************************************************************************************************************************************************** + * File Name : notificationVisibility.util.js + * Type : Utility + * Description : Shared visibility-window filter for AdminNotification/ + * UserNotification rows carrying a denormalized start_date/ + * end_date (copied from NotificationBroadcast at send time — + * see notificationBroadcasts.controller.js#sendBroadcast). + * Non-announcement notifications have both columns null, so + * this filter is a no-op for them. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 11, 2026 + ***********************************************************************************************************************************************************************/ +const { Op } = require('sequelize'); + +function notInFutureOrExpired(now = new Date()) { + return { + [Op.and]: [ + { [Op.or]: [{ start_date: null }, { start_date: { [Op.lte]: now } }] }, + { [Op.or]: [{ end_date: null }, { end_date: { [Op.gte]: now } }] }, + ], + }; +} + +module.exports = { notInFutureOrExpired };