Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-12 12:39:17 +08:00
parent 82ea9c77c4
commit ea3e82e54c
47 changed files with 1301 additions and 481 deletions
+38
View File
@@ -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);
+37
View File
@@ -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();
}
})();
+31 -5
View File
@@ -94,10 +94,21 @@ async function applyAdvertisementFields(advertisement, body) {
// status is intentionally NOT settable here — it's derived via deriveStatus() // status is intentionally NOT settable here — it's derived via deriveStatus()
// right before save, based on is_active + start_date/end_date. // 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.badge_label !== undefined) advertisement.badge_label = body.badge_label;
if (body.headline !== undefined) advertisement.headline = body.headline; if (body.headline !== undefined) advertisement.headline = body.headline;
if (body.description !== undefined) advertisement.description = body.description; if (body.description !== undefined) advertisement.description = body.description;
if (body.image_url !== undefined) advertisement.image_url = body.image_url; 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 !== undefined) {
if (body.image_asset_id === null) { if (body.image_asset_id === null) {
@@ -136,8 +147,27 @@ async function applyAdvertisementFields(advertisement, body) {
// ─── GET ALL ────────────────────────────────────────────────────────────────── // ─── 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) => { exports.getAdvertisements = async (req, res) => {
try { try {
await syncDerivedStatuses();
const result = await paginate(Advertisement, req, { const result = await paginate(Advertisement, req, {
excludeAttributes: adminExclude, excludeAttributes: adminExclude,
jsonbSchemas, 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) ───────────────────────────────────────────────────────── // ─── ARCHIVE (single) ─────────────────────────────────────────────────────────
exports.archiveAdvertisement = async (req, res) => { exports.archiveAdvertisement = async (req, res) => {
@@ -382,7 +408,7 @@ exports.getArchivedAdvertisements = async (req, res) => {
excludeAttributes: adminExclude, excludeAttributes: adminExclude,
jsonbSchemas, jsonbSchemas,
computedAttributes, computedAttributes,
context: "list", context: "archived",
auditOptions: { mdl_Users, parentAlias: 'Advertisement' }, auditOptions: { mdl_Users, parentAlias: 'Advertisement' },
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } }, findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
}); });
+3 -3
View File
@@ -14,7 +14,7 @@ const { flattenUnits, flattenLessons, nextOrderIndex, reorderJunction } = requir
const { getFieldValues } = require("../../utils/fieldValues.util"); const { getFieldValues } = require("../../utils/fieldValues.util");
const logActivity = require('../../utils/logActivity.util'); const logActivity = require('../../utils/logActivity.util');
const UserNotification = require('../../models/notifications/user_notification.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl');
const { renderNotification } = require('../../services/notificationTemplate.service'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
// ── Models ──────────────────────────────────────────────────────────────────── // ── Models ────────────────────────────────────────────────────────────────────
@@ -2174,11 +2174,11 @@ exports.updateAssessment = async (req, res) => {
where: { course_id: courseId }, where: { course_id: courseId },
attributes: ['title', 'uuid'], attributes: ['title', 'uuid'],
}); });
const notify = await renderNotification({ type: 'assessment_updated', data: { const notify = NOTIFICATION_REGISTRY.assessment_updated.build({
assessmentTitle: assessment.title, assessmentTitle: assessment.title,
courseTitle: course?.title ?? null, courseTitle: course?.title ?? null,
courseUuid: course?.uuid ?? null, courseUuid: course?.uuid ?? null,
} }); });
const now = new Date(); const now = new Date();
await UserNotification.bulkCreate( await UserNotification.bulkCreate(
inProgressSessions.map(({ user_id }) => ({ inProgressSessions.map(({ user_id }) => ({
+53 -11
View File
@@ -12,7 +12,44 @@
* Date Created: Jun. 19, 2026 * Date Created: Jun. 19, 2026
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const AdminNotification = require('../../models/notifications/admin_notification.mdl'); 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 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 ───────────────────────────────────────────────── // ─── GET /admin/notifications ─────────────────────────────────────────────────
async function list(req, res) { async function list(req, res) {
@@ -25,7 +62,7 @@ async function list(req, res) {
order: [['createdAt', 'DESC']], order: [['createdAt', 'DESC']],
limit, limit,
offset, offset,
where: { show_in_notifications: true }, where: { show_in_notifications: true, ...notInFutureOrExpired() },
}); });
return R.success(res, 'Notifications fetched.', { return R.success(res, 'Notifications fetched.', {
@@ -41,7 +78,7 @@ async function list(req, res) {
// ─── GET /admin/notifications/unseen ───────────────────────────────────────── // ─── GET /admin/notifications/unseen ─────────────────────────────────────────
async function unseenCount(req, res) { async function unseenCount(req, res) {
try { 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 }); return R.success(res, 'Unseen count fetched.', { count });
} catch (err) { } catch (err) {
console.error('[NOTIFICATION] unseenCount error:', 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. // banner for every admin. Whoever dismisses it first dismisses it for all.
async function stickyAnnouncement(req, res) { async function stickyAnnouncement(req, res) {
try { try {
const notification = await AdminNotification.findOne({ const [notifications, bannerImage] = await Promise.all([
where: { AdminNotification.findAll({
seen: false, where: {
show_in_sticky: true, seen: false,
type: 'announcement', show_in_sticky: true,
}, type: 'announcement',
order: [['createdAt', 'DESC']], ...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) { } catch (err) {
console.error('[NOTIFICATION] stickyAnnouncement error:', err); console.error('[NOTIFICATION] stickyAnnouncement error:', err);
return R.error(res, 'Failed to fetch sticky announcement.'); return R.error(res, 'Failed to fetch sticky announcement.');
@@ -4,16 +4,20 @@ const sequelize = require("../../config/db.config");
const NotificationBroadcast = require("../../models/notifications/notification_broadcast.mdl"); const NotificationBroadcast = require("../../models/notifications/notification_broadcast.mdl");
const AdminNotification = require("../../models/notifications/admin_notification.mdl"); const AdminNotification = require("../../models/notifications/admin_notification.mdl");
const UserNotification = require("../../models/notifications/user_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_Users = require('../../models/users/users.mdl');
const mdl_Assets = require('../../models/assets/assets.mdl');
const { TaskList } = require('../../models/task/task.mdl'); const { TaskList } = require('../../models/task/task.mdl');
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const { Course } = require('../../models/courses/courses.mdl'); const { Course } = require('../../models/courses/courses.mdl');
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const mediaToken = require("../../services/mediaToken.service");
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { paginate } = require("../../utils/paginate.util"); const { paginate } = require("../../utils/paginate.util");
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/notifications/notification_broadcast.attributes"); const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/notifications/notification_broadcast.attributes");
const logActivity = require('../../utils/logActivity.util'); const logActivity = require('../../utils/logActivity.util');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util');
const { const {
ALLOWED_TARGET_TYPES, ALLOWED_TARGET_TYPES,
SCOPED_TARGET_TYPES, SCOPED_TARGET_TYPES,
@@ -28,10 +32,58 @@ const { Op } = require('sequelize');
const notDeleted = { deletedAt: null }; 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) { async function applyBroadcastFields(broadcast, body) {
if (body.title !== undefined) broadcast.title = body.title; if (body.title !== undefined) broadcast.title = body.title;
if (body.message !== undefined) broadcast.message = body.message; if (body.message !== undefined) broadcast.message = body.message;
if (body.link_url !== undefined) broadcast.link_url = body.link_url?.trim() || null; if (body.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_sticky !== undefined) broadcast.show_in_sticky = !!body.show_in_sticky;
if (body.show_in_notifications !== undefined) broadcast.show_in_notifications = !!body.show_in_notifications; if (body.show_in_notifications !== undefined) broadcast.show_in_notifications = !!body.show_in_notifications;
@@ -161,11 +213,15 @@ exports.createBroadcast = async (req, res) => {
title, title,
message, message,
link_url, link_url,
link_label,
color,
target_type, target_type,
target_id, target_id,
createdBy, createdBy,
show_in_sticky, show_in_sticky,
show_in_notifications, show_in_notifications,
start_date,
end_date,
} = req.body; } = req.body;
if (!title) return R.error(res, "title is required.", 400); 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); 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); if (SCOPED_TARGET_TYPES.includes(target_type)) await validateTargetId(target_type, target_id);
const t = await sequelize.transaction(); const t = await sequelize.transaction();
@@ -189,6 +249,10 @@ exports.createBroadcast = async (req, res) => {
title, title,
message, message,
link_url: link_url?.trim() || null, 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, createdBy,
status: 'draft', status: 'draft',
target_type, target_type,
@@ -222,7 +286,7 @@ exports.updateBroadcast = async (req, res) => {
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } }); const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } });
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404); 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(); const t = await sequelize.transaction();
try { try {
@@ -232,8 +296,54 @@ exports.updateBroadcast = async (req, res) => {
err.status = 400; err.status = 400;
throw err; 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; broadcast.updatedBy = req.body.updatedBy ?? null;
await broadcast.save({ transaction: t }); 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(); await t.commit();
logActivity(req.user?.user_id, 'update_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) }); 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) 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.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(); const t = await sequelize.transaction();
try { try {
const now = new Date(); const now = new Date();
@@ -275,12 +389,13 @@ exports.sendBroadcast = async (req, res) => {
message: broadcast.message, message: broadcast.message,
targetType, targetType,
targetId, targetId,
linkUrl: broadcast.link_url, linkUrl: broadcast.link_url,
linkLabel: broadcast.link_label,
}); });
if (targetType === 'admin' || targetType === 'both') { if (targetType === 'admin' || targetType === 'both') {
await AdminNotification.create( 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 } { transaction: t }
); );
recipientCount += 1; recipientCount += 1;
@@ -312,7 +427,8 @@ exports.sendBroadcast = async (req, res) => {
? NOTIFICATION_REGISTRY.broadcast.build({ ? NOTIFICATION_REGISTRY.broadcast.build({
title: broadcast.title, message: broadcast.message, targetType, targetId, title: broadcast.title, message: broadcast.message, targetType, targetId,
groupId: groupByUser[user_id] ?? null, groupId: groupByUser[user_id] ?? null,
linkUrl: broadcast.link_url, linkUrl: broadcast.link_url,
linkLabel: broadcast.link_label,
}) })
: baseNotify), : baseNotify),
seen: false, seen: false,
@@ -320,6 +436,10 @@ exports.sendBroadcast = async (req, res) => {
updatedAt: now, updatedAt: now,
show_in_sticky: showInSticky, show_in_sticky: showInSticky,
show_in_notifications: showInNotifications, 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 } { validate: false, transaction: t }
); );
@@ -505,3 +625,62 @@ exports.permanentlyDeleteBroadcasts = async (req, res) => {
return R.error(res, "Could not permanently delete announcements.", 500); 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);
}
};
@@ -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);
}
};
+5 -5
View File
@@ -14,7 +14,7 @@ const { Task, TaskList, TaskRequirement, TaskListGroup, mdl_UserGroups } = requi
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const mdl_Users = require('../../models/users/users.mdl'); const mdl_Users = require('../../models/users/users.mdl');
const UserNotification = require('../../models/notifications/user_notification.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 { adminExclude, jsonbSchemas, computedAttributes } = require('../../models/task/task.attributes');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
@@ -439,10 +439,10 @@ exports.assignGroups = async (req, res) => {
if (userIds.length) { if (userIds.length) {
const now = new Date(); const now = new Date();
const notify = await renderNotification({ type: 'task_assigned', data: { const notify = NOTIFICATION_REGISTRY.task_assigned.build({
taskListName: taskList.name, taskListName: taskList.name,
taskCount, taskCount,
} }); });
await UserNotification.bulkCreate( await UserNotification.bulkCreate(
userIds.map((user_id) => ({ user_id, ...notify, seen: false, createdAt: now, updatedAt: now })), userIds.map((user_id) => ({ user_id, ...notify, seen: false, createdAt: now, updatedAt: now })),
{ validate: false } { validate: false }
@@ -763,11 +763,11 @@ exports.updateTask = async (req, res) => {
const now = new Date(); const now = new Date();
// Title/message are identical for every member — render once, // Title/message are identical for every member — render once,
// then vary only the per-member groupId in the data payload. // 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, taskName: full.name,
taskListId: task.task_list_id, taskListId: task.task_list_id,
groupId: null, groupId: null,
} }); });
await UserNotification.bulkCreate( await UserNotification.bulkCreate(
members.map(({ user_id, group_id }) => ({ members.map(({ user_id, group_id }) => ({
user_id, user_id,
@@ -15,7 +15,7 @@ const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_c
const { Task } = require('../../models/task/task.mdl'); const { Task } = require('../../models/task/task.mdl');
const mdl_Users = require('../../models/users/users.mdl'); const mdl_Users = require('../../models/users/users.mdl');
const UserNotification = require('../../models/notifications/user_notification.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 { checkTaskCompletion, fireTaskCompletedEvent } = require('../client/task.controller');
const { adminExclude } = require('../../models/task/task_completion.attributes'); const { adminExclude } = require('../../models/task/task_completion.attributes');
@@ -190,9 +190,9 @@ exports.reviewSubmission = async (req, res) => {
}); });
try { 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, taskName: task.name, status, review_note: review_note || null,
} }); });
await UserNotification.create({ user_id: completion.user_id, ...notify, seen: false }); await UserNotification.create({ user_id: completion.user_id, ...notify, seen: false });
} catch (notifyErr) { } catch (notifyErr) {
console.error('[ADMIN][REVIEW SUBMISSION][NOTIFY]', notifyErr); console.error('[ADMIN][REVIEW SUBMISSION][NOTIFY]', notifyErr);
+10 -13
View File
@@ -45,7 +45,7 @@ const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util')
const { onUserRegistered } = require('../services/achievements.service'); const { onUserRegistered } = require('../services/achievements.service');
const AdminNotification = require('../models/notifications/admin_notification.mdl'); const AdminNotification = require('../models/notifications/admin_notification.mdl');
const UserNotification = require('../models/notifications/user_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 { sendEmail } = require('../services/email.service');
const buildSessionInfo = require('../utils/session_info.util'); const buildSessionInfo = require('../utils/session_info.util');
const logActivity = require('../utils/logActivity.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 // Fire-and-forget: notify admins — explicit group or NOGRP fallback
if (group) { if (group) {
renderNotification({ type: 'user_registration', data: { AdminNotification.create(NOTIFICATION_REGISTRY.user_registration.build({
groupName: group.name, groupName: group.name,
groupCode: group.group_code, groupCode: group.group_code,
userEmail: email, userEmail: email,
} }) }))
.then(notify => AdminNotification.create(notify))
.catch(err => console.error('[AUTH] Failed to emit user_registration notification:', err)); .catch(err => console.error('[AUTH] Failed to emit user_registration notification:', err));
} else if (enrollGroup) { } else if (enrollGroup) {
renderNotification({ type: 'nogrp_user_registered', data: { AdminNotification.create(NOTIFICATION_REGISTRY.nogrp_user_registered.build({
userEmail: email, userEmail: email,
regType: 'system', regType: 'system',
} }) }))
.then(notify => AdminNotification.create(notify))
.catch(err => console.error('[AUTH] Failed to emit nogrp_user_registered notification:', err)); .catch(err => console.error('[AUTH] Failed to emit nogrp_user_registered notification:', err));
} }
@@ -241,12 +239,12 @@ exports.verifyOTP = async (req, res) => {
const notifications = [ const notifications = [
{ {
user_id: user.user_id, user_id: user.user_id,
...(await renderNotification({ type: 'welcome', data: { ...NOTIFICATION_REGISTRY.welcome.build({
groupName: grp?.name ?? null, groupName: grp?.name ?? null,
groupCode: grp?.group_code ?? null, groupCode: grp?.group_code ?? null,
accType: user.acc_type, accType: user.acc_type,
groupId: membership?.group_id ?? null, groupId: membership?.group_id ?? null,
} })), }),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}, },
@@ -254,7 +252,7 @@ exports.verifyOTP = async (req, res) => {
if (grp?.group_code === 'NOGRP') { if (grp?.group_code === 'NOGRP') {
notifications.push({ notifications.push({
user_id: user.user_id, user_id: user.user_id,
...(await renderNotification({ type: 'nogrp_welcome', data: {} })), ...NOTIFICATION_REGISTRY.nogrp_welcome.build(),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
@@ -475,11 +473,10 @@ exports.googleCallback = async (req, res) => {
// Welcome email/achievements/welcome-notification are deferred to // Welcome email/achievements/welcome-notification are deferred to
// verifyOTP's first-time branch now (this account isn't verified yet — // 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). // 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, userEmail: payload.email,
regType: 'google', regType: 'google',
} }) }))
.then(notify => AdminNotification.create(notify))
.catch(err => console.error('[AUTH] googleCallback: Failed to emit nogrp_user_registered notification:', err)); .catch(err => console.error('[AUTH] googleCallback: Failed to emit nogrp_user_registered notification:', err));
} catch (err) { } catch (err) {
await t.rollback(); await t.rollback();
@@ -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 ────────────────────────────────────────────────────────────── // ─── TRACK CLICK ──────────────────────────────────────────────────────────────
// //
// POST /api/client/advertisements/:advertisementId/click // POST /api/client/advertisements/:advertisementId/click
+33 -4
View File
@@ -40,7 +40,7 @@ const { onCourseCompleted } = require('../../services/achievements.service'
const PendingCertificate = require('../../models/courses/pending_certificate.mdl'); const PendingCertificate = require('../../models/courses/pending_certificate.mdl');
const Certificate = require('../../models/courses/certificate.mdl'); const Certificate = require('../../models/courses/certificate.mdl');
const UserNotification = require('../../models/notifications/user_notification.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 }; 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 } }); const link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } });
if (!link) return R.error(res, "Unit not found.", 404); 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({ const unit = await Unit.findOne({
where: { unit_id: unitId, ...notDeleted }, where: { unit_id: unitId, ...notDeleted },
attributes: [ attributes: [
@@ -519,6 +523,10 @@ exports.getLesson = async (req, res) => {
]); ]);
if (!courseLink || !lessonLink) return R.error(res, "Lesson not found.", 404); 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({ const lesson = await Lesson.findOne({
where: { lesson_id: lessonId, ...notDeleted }, where: { lesson_id: lessonId, ...notDeleted },
attributes: [ attributes: [
@@ -557,6 +565,10 @@ exports.getUnitQuiz = async (req, res) => {
const link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }); const link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } });
if (!link) return R.error(res, "Unit not found.", 404); 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({ const quiz = await UnitQuiz.findOne({
where: { unit_id: unitId, ...notDeleted }, where: { unit_id: unitId, ...notDeleted },
attributes: [ attributes: [
@@ -615,6 +627,10 @@ exports.getCourseAssessment = async (req, res) => {
try { try {
const { courseId } = req.params; 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({ const assessment = await CourseAssessment.findOne({
where: { course_id: courseId, ...notDeleted }, where: { course_id: courseId, ...notDeleted },
attributes: [ attributes: [
@@ -698,6 +714,10 @@ exports.startCourseAssessment = async (req, res) => {
const { courseId, assessmentId } = req.params; const { courseId, assessmentId } = req.params;
const user_id = req.user.user_id; 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({ const assessment = await CourseAssessment.findOne({
where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted }, where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted },
attributes: ["assessment_id", "time_limit_minutes", "passing_score", "max_attempts", "cooldown_hours"], 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 } }); const link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } });
if (!link) return R.error(res, "Unit not found.", 404); 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({ const quiz = await UnitQuiz.findOne({
where: { quiz_id: quizId, unit_id: unitId, ...notDeleted }, where: { quiz_id: quizId, unit_id: unitId, ...notDeleted },
include: [{ include: [{
@@ -949,6 +973,10 @@ exports.submitCourseAssessment = async (req, res) => {
const { answers = {}, session_id } = req.body; const { answers = {}, session_id } = req.body;
const user_id = req.user.user_id; 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({ const assessment = await CourseAssessment.findOne({
where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted }, where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted },
include: [{ include: [{
@@ -1038,9 +1066,10 @@ exports.submitCourseAssessment = async (req, res) => {
} }
// Immediate notification: course completed, certificate incoming // Immediate notification: course completed, certificate incoming
renderNotification({ type: 'course_completed', data: { courseTitle: course?.title ?? '', courseUuid: course?.uuid ?? null } }) UserNotification.create({
.then(notify => UserNotification.create({ user_id, ...notify })) user_id,
.catch(err => console.error('[ASSESSMENT] Failed to emit course_completed notification:', err)); ...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.", { return R.success(res, "Assessment submitted.", {
+57 -13
View File
@@ -12,7 +12,45 @@
* Date Created: Jun. 19, 2026 * Date Created: Jun. 19, 2026
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const UserNotification = require('../../models/notifications/user_notification.mdl'); 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 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 ──────────────────────────────────────────────── // ─── GET /client/notifications ────────────────────────────────────────────────
async function list(req, res) { async function list(req, res) {
@@ -23,7 +61,7 @@ async function list(req, res) {
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const { count, rows } = await UserNotification.findAndCountAll({ 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']], order: [['createdAt', 'DESC']],
limit, limit,
offset, offset,
@@ -44,7 +82,7 @@ async function unseenCount(req, res) {
if (!req.user) return R.success(res, 'Unseen count fetched.', { count: null }); if (!req.user) return R.success(res, 'Unseen count fetched.', { count: null });
try { try {
const count = await UserNotification.count({ 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 }); return R.success(res, 'Unseen count fetched.', { count });
} catch (err) { } catch (err) {
@@ -56,18 +94,24 @@ async function unseenCount(req, res) {
// ─── GET /client/notifications/sticky ───────────────────────────────────── // ─── GET /client/notifications/sticky ─────────────────────────────────────
async function stickyAnnouncement(req, res) { async function stickyAnnouncement(req, res) {
try { try {
const notification = await UserNotification.findOne({ const [notifications, bannerImage] = await Promise.all([
where: { UserNotification.findAll({
user_id: req.user.user_id, where: {
seen: false, user_id: req.user.user_id,
show_in_sticky: true, seen: false,
type: "announcement", show_in_sticky: true,
}, type: "announcement",
order: [["createdAt", "DESC"]], ...notInFutureOrExpired(),
}); },
order: [["createdAt", "DESC"]],
limit: STICKY_LIMIT,
}),
resolveSharedBannerImage(req),
]);
return R.success(res, "Sticky announcement fetched.", { return R.success(res, "Sticky announcements fetched.", {
announcement: notification, announcements: notifications,
bannerImage,
}); });
} catch (err) { } catch (err) {
console.error("[CLIENT NOTIFICATION] stickyAnnouncement error:", err); console.error("[CLIENT NOTIFICATION] stickyAnnouncement error:", err);
+2 -2
View File
@@ -25,7 +25,7 @@ const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service'); const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service');
const UserNotification = require('../../models/notifications/user_notification.mdl'); 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 { 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; 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; if (!task) return;
try { 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 }); await UserNotification.create({ user_id: userId, ...notify, seen: false });
} catch (notifyErr) { } catch (notifyErr) {
console.error('[TASK][NOTIFY COMPLETED]', notifyErr); console.error('[TASK][NOTIFY COMPLETED]', notifyErr);
+9 -8
View File
@@ -22,7 +22,7 @@ const { onTierActivated } = require('../../services/achievements.service');
const { Course } = require('../../models/courses/courses.mdl'); const { Course } = require('../../models/courses/courses.mdl');
const paymentSvc = require('../../services/payment.service'); const paymentSvc = require('../../services/payment.service');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { renderNotification } = require('../../services/notificationTemplate.service'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
require('../../models/tiers/tier.associations'); require('../../models/tiers/tier.associations');
@@ -49,13 +49,14 @@ exports.getMyTier = async (req, res) => {
// ── Inline safety net: expire between cron ticks ────────────────────────── // ── Inline safety net: expire between cron ticks ──────────────────────────
if (tier && tier.expires_at && new Date(tier.expires_at) <= new Date()) { if (tier && tier.expires_at && new Date(tier.expires_at) <= new Date()) {
await tier.update({ status: 'expired' }); await tier.update({ status: 'expired' });
renderNotification({ type: 'tier_expired', data: { UserNotification.create({
tier: tier.tier, user_id: req.user.user_id,
label: tier.plan?.label ?? null, ...NOTIFICATION_REGISTRY.tier_expired.build({
planId: tier.plan?.plan_id ?? null, tier: tier.tier,
} }) label: tier.plan?.label ?? null,
.then(notify => UserNotification.create({ user_id: req.user.user_id, ...notify })) planId: tier.plan?.plan_id ?? null,
.catch(() => {}); }),
}).catch(() => {});
return R.success(res, 'Active tier retrieved.', { return R.success(res, 'Active tier retrieved.', {
tier: 'free', status: 'active', category: null, just_expired: true, tier: 'free', status: 'active', category: null, just_expired: true,
}); });
+33 -15
View File
@@ -5,40 +5,58 @@
* Same shape as admin.cron.js — each job module exports * Same shape as admin.cron.js — each job module exports
* { name, schedule, run }, listed in the `jobs` array below. * { 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 * schedule/enabled state lives in cron_notification_settings
* and is configurable from /admin/notifications/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: * Currently registered:
* - userNotifications (cron/jobs/user_notifications.cron.js) * - userNotifications (cron/jobs/user_notifications.cron.js)
* - issueCertificates (cron/jobs/issue_certificates.cron.js) * - issueCertificates (cron/jobs/issue_certificates.cron.js)
* - expireUserTiers (cron/jobs/expire_user_tiers.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) * Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 17, 2026 * Date Created: Jun. 17, 2026
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const userNotifications = require('./jobs/user_notifications.cron'); const cron = require('node-cron');
const issueCertificates = require('./jobs/issue_certificates.cron'); const userNotifications = require('./jobs/user_notifications.cron');
const expireUserTiers = require('./jobs/expire_user_tiers.cron'); const issueCertificates = require('./jobs/issue_certificates.cron');
const taskDueSoon = require('./jobs/task_due_soon.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'); const { startSettingsBackedJobs } = require('./cronRegistry.util');
// TODO(ads-7): Add an `expire_advertisements.cron.js` job (same shape as
// expire_user_tiers.cron.js) that auto-archives (soft-deletes) advertisements
// once their end_date has passed, instead of just leaving them at derived
// status "expired" forever. Register it in the `jobs` array below.
// ─── Registry — add future client-side cron jobs here ──────────────────────── // ─── Registry — add future client-side cron jobs here ────────────────────────
const jobs = [ const settingsBackedJobs = [
userNotifications, userNotifications,
issueCertificates, issueCertificates,
expireUserTiers, expireUserTiers,
taskDueSoon, taskDueSoon,
]; ];
// Plain hardcoded-schedule jobs (not tied to any notification setting).
const plainJobs = [
expireAdvertisements,
];
// ─── Boot all registered client-side jobs ───────────────────────────────────── // ─── Boot all registered client-side jobs ─────────────────────────────────────
async function startClientCronJobs() { 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 }; module.exports = { startClientCronJobs };
+57
View File
@@ -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,
};
+2 -3
View File
@@ -28,7 +28,7 @@ const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl');
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.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'); require('../../models/tiers/tier.associations');
@@ -74,10 +74,9 @@ async function run() {
const settings = await CronNotificationSetting.findOne({ where: { job_name: 'expireUserTiers' } }); const settings = await CronNotificationSetting.findOne({ where: { job_name: 'expireUserTiers' } });
if (!settings || settings.enabled) { if (!settings || settings.enabled) {
try { try {
const template = await getNotificationTemplate('tier_expired');
const notifications = expired.map((t) => ({ const notifications = expired.map((t) => ({
user_id: t.user_id, user_id: t.user_id,
...renderNotificationContent(template, { ...NOTIFICATION_REGISTRY.tier_expired.build({
tier: t.tier, tier: t.tier,
label: t.plan?.label ?? null, label: t.plan?.label ?? null,
planId: t.plan?.plan_id ?? null, planId: t.plan?.plan_id ?? null,
+2 -6
View File
@@ -32,7 +32,7 @@ const PendingCertificate = require('../../models/courses/pending_certificate.mdl
const mdl_Achievements = require('../../models/users/achievements.mdl'); const mdl_Achievements = require('../../models/users/achievements.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl');
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.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'); const { ensureCertificateRecord } = require('../../services/certificate-record.service');
async function run() { async function run() {
@@ -59,10 +59,6 @@ async function run() {
console.log(`[CRON][ISSUE CERTS] Processing ${rows.length} pending certificate(s).`); 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) { for (const row of rows) {
const { pending_id, user_id, course_uuid, course_title } = row; const { pending_id, user_id, course_uuid, course_title } = row;
const achKey = `course_completed_${course_uuid}`; const achKey = `course_completed_${course_uuid}`;
@@ -91,7 +87,7 @@ async function run() {
if (notificationsEnabled) { if (notificationsEnabled) {
await UserNotification.create({ await UserNotification.create({
user_id, user_id,
...renderNotificationContent(certificateTemplate, { ...NOTIFICATION_REGISTRY.certificate_issued.build({
courseTitle: course_title ?? '', courseTitle: course_title ?? '',
courseUuid: course_uuid, courseUuid: course_uuid,
}), }),
+3 -3
View File
@@ -25,7 +25,7 @@ const sequelize = require('../../config/db.config');
const { Task } = require('../../models/task/task.mdl'); const { Task } = require('../../models/task/task.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl');
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.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 { checkTaskCompletion } = require('../../controllers/client/task.controller');
const WINDOW_START_MS = 23 * 60 * 60 * 1000; const WINDOW_START_MS = 23 * 60 * 60 * 1000;
@@ -91,9 +91,9 @@ async function run() {
} }
if (!incompleteUserIds.length) continue; 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, taskName: task.name, deadline: task.deadline,
} }); });
await UserNotification.bulkCreate( await UserNotification.bulkCreate(
incompleteUserIds.map((user_id) => ({ user_id, ...notify, seen: false, createdAt: now2, updatedAt: now2 })), incompleteUserIds.map((user_id) => ({ user_id, ...notify, seen: false, createdAt: now2, updatedAt: now2 })),
{ validate: false } { validate: false }
+2 -2
View File
@@ -26,7 +26,7 @@ const { Op } = require('sequelize');
const { Task } = require('../../models/task/task.mdl'); const { Task } = require('../../models/task/task.mdl');
const AdminNotification = require('../../models/notifications/admin_notification.mdl'); const AdminNotification = require('../../models/notifications/admin_notification.mdl');
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.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 ──────────────────────────────────────────────────────── // ─── The actual sweep ────────────────────────────────────────────────────────
async function run() { async function run() {
@@ -59,7 +59,7 @@ async function run() {
if (settings && !settings.enabled) return; if (settings && !settings.enabled) return;
await AdminNotification.create( await AdminNotification.create(
await renderNotification({ type: 'task_overdue', data: { count: affectedCount } }) NOTIFICATION_REGISTRY.task_overdue.build({ count: affectedCount })
); );
} catch (err) { } catch (err) {
console.error('[CRON][TASK OVERDUE] Failed to insert admin notification:', err); console.error('[CRON][TASK OVERDUE] Failed to insert admin notification:', err);
+2 -2
View File
@@ -25,7 +25,7 @@ const sequelize = require('../../config/db.config');
const { Task } = require('../../models/task/task.mdl'); const { Task } = require('../../models/task/task.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl');
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.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 const WINDOW_MS = 65 * 60 * 1000; // 65-minute lookback
@@ -71,7 +71,7 @@ async function run() {
const count = recentlyOverdue.length; const count = recentlyOverdue.length;
const now = new Date(); 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( await UserNotification.bulkCreate(
affectedUsers.map(({ user_id }) => ({ affectedUsers.map(({ user_id }) => ({
@@ -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 };
+273 -17
View File
@@ -1,37 +1,185 @@
/*********************************************************************************************************************************************************************** /***********************************************************************************************************************************************************************
* File Name: notifications.data.js * File Name: notifications.data.js
* Type of Program: Data * Type of Program: Data
* Description: Registry of notification types that have no fixed, admin- * Description: Central registry of all notification types for both admin and
* editable wording — content is entirely supplied by the caller * client (user) notifications.
* at trigger time, so there's nothing to template.
* *
* Every other system-triggered notification type (task overdue, * Each entry describes one notification type:
* 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:
* type {string} — stored in the DB 'type' column * type {string} — stored in the DB 'type' column
* scope {string} — 'admin' | 'user' | 'both' * 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 * build {function} — takes a data payload, returns the object
* ready to pass to AdminNotification.create() * ready to pass to AdminNotification.create()
* or UserNotification.create() / bulkCreate() * or UserNotification.create() / bulkCreate()
* *
* To add a new notification type:
* 1. Add an entry in the relevant section below.
* 2. Call NOTIFICATION_REGISTRY.<key>.build(data) at the trigger
* site (controller, cron, service).
* No other changes needed.
*
* Current types: * 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) * Both : broadcast (admin-composed, sent via notification_broadcasts CRUD)
* *
* Author: Kenneth Obsequio (@lash0000) * Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 19, 2026 * 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'; 'use strict';
const { fmtDate } = require('../utils/datetime.util');
const NOTIFICATION_REGISTRY = { 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 — title/message come from the achievement definition itself ─
achievement: { achievement: {
type: '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: { announcement: {
type: 'announcement', type: 'announcement',
scope: 'user', scope: 'user',
@@ -68,12 +309,27 @@ const NOTIFICATION_REGISTRY = {
type: 'announcement', type: 'announcement',
scope: 'both', scope: 'both',
trigger: 'manual', 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 { return {
type: 'announcement', type: 'announcement',
title, title,
message, 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 },
}; };
}, },
}, },
@@ -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.
},
};
@@ -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');
},
};
@@ -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 },
});
},
};
@@ -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');
},
};
@@ -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');
},
};
@@ -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');
},
};
@@ -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');
},
};
@@ -33,6 +33,10 @@ const Advertisement = sequelize.define("Advertisement", {
}, },
// ─── Content ────────────────────────────────────────────────────────────── // ─── 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 }, badge_label: { type: DataTypes.STRING(100), label: "Badge Label", order: 4 },
headline: { type: DataTypes.STRING(255), label: "Headline", order: 5 }, headline: { type: DataTypes.STRING(255), label: "Headline", order: 5 },
description: { type: DataTypes.TEXT, label: "Description", order: 6 }, 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 // [{ label, link }, ...] — 0-2 entries depending on type
ctas: { type: DataTypes.JSONB, allowNull: false, defaultValue: [], label: "CTAs", order: 7 }, 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 ─────────────────────────────────────────────────────────── // ─── Scheduling ───────────────────────────────────────────────────────────
start_date: { type: DataTypes.DATE, label: "Start Date", order: 8 }, start_date: { type: DataTypes.DATE, label: "Start Date", order: 8 },
end_date: { type: DataTypes.DATE, label: "End Date", order: 9 }, end_date: { type: DataTypes.DATE, label: "End Date", order: 9 },
@@ -12,19 +12,10 @@
// in controllers/admin/advertisements.controller.js) — type is never accepted // in controllers/admin/advertisements.controller.js) — type is never accepted
// from the client once a placement is set. // from the client once a placement is set.
// TODO(ads-1): Re-categorize placements — Hero -> Dashboard, Banner -> Tier Plans.
// Remove the "popup" and "sidebar" formats entirely (dashboard.popup,
// course_details.sidebar). Keep in sync with the frontend mirror at
// new_starr_app/src/data/placement.data.js. This also feeds the Step 1
// "Placement" picker in the Add Advertisement wizard (TODO(ads-6)), which
// should only offer Dashboard / Tier Plans / Course Details.
const PLACEMENTS = [ const PLACEMENTS = [
{ key: "dashboard.hero", format: "hero", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Hero (top of page)" }, { key: "dashboard.hero", format: "hero", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Hero (top of page)" },
{ key: "dashboard.popup", format: "popup", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Popup (on load)" }, { key: "tier_plans.banner", format: "banner", page: "tier_plans", pageLabel: "Tier Plans", slotLabel: "Banner (above plan cards)" },
{ key: "course_list.banner", format: "banner", page: "course_list", pageLabel: "Courses", slotLabel: "Banner (above course grid)" },
{ key: "course_details.banner", format: "banner", page: "course_details", pageLabel: "Course Details", slotLabel: "Banner (below hero)" }, { 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])); const PLACEMENT_MAP = Object.fromEntries(PLACEMENTS.map((p) => [p.key, p]));
+5 -5
View File
@@ -11,11 +11,11 @@ const Asset = sequelize.define("Asset", {
// ─── File info ──────────────────────────────────────────────────────────── // ─── File info ────────────────────────────────────────────────────────────
original_name: { type: DataTypes.STRING(255), allowNull: false, label: "Original Name", order: 0, hidden: true }, 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_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 }, 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 }, 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 }, checksum: { type: DataTypes.STRING(64), label: "Checksum", order: 0, hidden: true },
// ─── Classification ─────────────────────────────────────────────────────── // ─── Classification ───────────────────────────────────────────────────────
@@ -57,9 +57,9 @@ const Asset = sequelize.define("Asset", {
}, },
// ── Audit trails ──────────────────────────────────────────────────────────── // ── Audit trails ────────────────────────────────────────────────────────────
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By", filterable: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" }, updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By", filterable: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" }, deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By", filterable: true },
}, { }, {
tableName: "assets", tableName: "assets",
timestamps: true, // createdAt, updatedAt timestamps: true, // createdAt, updatedAt
@@ -42,6 +42,33 @@ const AdminNotification = sequelize.define('AdminNotification', {
defaultValue: false, 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: { data: {
type: DataTypes.JSONB, type: DataTypes.JSONB,
allowNull: true, allowNull: true,
@@ -15,6 +15,20 @@ const NotificationBroadcast = sequelize.define("NotificationBroadcast", {
// When set, the client's "view full content" dialog shows an "Open Link" // 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. // 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 }, 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 ────────────────────────────────────────────────────────── // ─── Visibility ──────────────────────────────────────────────────────────
// Determines where a delivered announcement shows up for recipients. // Determines where a delivered announcement shows up for recipients.
@@ -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;
@@ -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;
@@ -48,6 +48,33 @@ const UserNotification = sequelize.define('UserNotification', {
defaultValue: false, 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: { data: {
type: DataTypes.JSONB, type: DataTypes.JSONB,
allowNull: true, allowNull: true,
+1 -4
View File
@@ -43,7 +43,6 @@ const notificationBroadcastRoutes = require('./notificationBroadcasts.routes');
const notificationSettingsRoutes = require('./notificationSettings.routes'); const notificationSettingsRoutes = require('./notificationSettings.routes');
const mediaRoutes = require('./media.routes'); const mediaRoutes = require('./media.routes');
const achievementsRoutes = require('./achievements.routes'); const achievementsRoutes = require('./achievements.routes');
const notificationTemplatesRoutes = require('./notification_templates.routes');
const activityCtrl = require('../../controllers/admin/user_activity.controller'); const activityCtrl = require('../../controllers/admin/user_activity.controller');
// ── Guards — applied to ALL admin routes ────────────────────────────────────── // ── Guards — applied to ALL admin routes ──────────────────────────────────────
@@ -68,11 +67,9 @@ router.use('/advertisements', advertisementRoutes);
router.use('/notifications', notificationRoutes); router.use('/notifications', notificationRoutes);
router.use('/notification-broadcasts', notificationBroadcastRoutes); router.use('/notification-broadcasts', notificationBroadcastRoutes);
router.use('/notification-settings', notificationSettingsRoutes); router.use('/notification-settings', notificationSettingsRoutes);
router.use('/notification-templates', notificationTemplatesRoutes); // Announcements (alias routes for notification broadcasts/settings)
// Announcements (alias routes for notification broadcasts/templates/settings)
router.use('/announcements', notificationBroadcastRoutes); router.use('/announcements', notificationBroadcastRoutes);
router.use('/announcement-settings', notificationSettingsRoutes); router.use('/announcement-settings', notificationSettingsRoutes);
router.use('/announcement-templates', notificationTemplatesRoutes);
router.use('/media', mediaRoutes); router.use('/media', mediaRoutes);
router.use('/achievements', achievementsRoutes); router.use('/achievements', achievementsRoutes);
@@ -8,6 +8,8 @@ router.get('/archived', controller.getArchivedBroadcasts);
router.delete('/bulk', sensitiveOpsLimiter, controller.archiveBroadcasts); router.delete('/bulk', sensitiveOpsLimiter, controller.archiveBroadcasts);
router.patch('/bulk-restore', controller.restoreBroadcasts); router.patch('/bulk-restore', controller.restoreBroadcasts);
router.delete('/bulk/permanent', sensitiveOpsLimiter, controller.permanentlyDeleteBroadcasts); router.delete('/bulk/permanent', sensitiveOpsLimiter, controller.permanentlyDeleteBroadcasts);
router.get('/sticky-banner', controller.getStickyBannerSetting);
router.patch('/sticky-banner', sensitiveOpsLimiter, controller.updateStickyBannerSetting);
// ─── Collection ─────────────────────────────────────────────────────────────── // ─── Collection ───────────────────────────────────────────────────────────────
router.get('/', controller.getBroadcasts); router.get('/', controller.getBroadcasts);
@@ -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;
+3
View File
@@ -11,6 +11,9 @@ router.get('/active-batch', controller.getActiveAdvertisements);
// ─── GET /api/client/advertisements/active-list?placement=dashboard.hero ────── // ─── GET /api/client/advertisements/active-list?placement=dashboard.hero ──────
router.get('/active-list', controller.getActiveAdvertisementList); router.get('/active-list', controller.getActiveAdvertisementList);
// ─── GET /api/client/advertisements/uuid/:uuid ─────────────────────────────────
router.get('/uuid/:uuid', controller.getAdvertisementByUuid);
// ─── POST /api/client/advertisements/:advertisementId/click ─────────────────── // ─── POST /api/client/advertisements/:advertisementId/click ───────────────────
router.post('/:advertisementId/click', controller.trackClick); router.post('/:advertisementId/click', controller.trackClick);
-52
View File
@@ -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 };
+12
View File
@@ -7,6 +7,13 @@ const ENUM_SORT_ORDER = {
reg_type: ["system", "google"], 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. * 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]; 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) => const conditions = values.map((v) =>
id.startsWith("personal_info.") id.startsWith("personal_info.")
? Sequelize.where( ? Sequelize.where(
+10 -5
View File
@@ -30,25 +30,30 @@ const getFieldValues = (Model, logTag, options = {}) => async (req, res) => {
return R.error(res, "Invalid or restricted field.", 400); return R.error(res, "Invalid or restricted field.", 400);
if (auditByFields.includes(field)) { 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 tableName = Model.getTableName();
const [rows] = selfJoin const [rows] = selfJoin
? await sequelize.query(` ? 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 FROM "${tableName}" u1
JOIN "${tableName}" u2 ON u2.user_id = u1."${field}" JOIN "${tableName}" u2 ON u2.user_id = u1."${field}"
WHERE u1."${field}" IS NOT NULL WHERE u1."${field}" IS NOT NULL
AND u2."personal_info"->'name'->>'full_name' IS NOT NULL AND u2."personal_info"->'name'->>'full_name' IS NOT NULL
ORDER BY value ASC ORDER BY label ASC
`) `)
: await sequelize.query(` : 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 FROM "${tableName}" t
JOIN users u ON u.user_id = t."${field}" JOIN users u ON u.user_id = t."${field}"
WHERE t."${field}" IS NOT NULL WHERE t."${field}" IS NOT NULL
AND u."personal_info"->'name'->>'full_name' 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)) { if (dateFields.includes(field)) {
+25
View File
@@ -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 };